1import rule from "@effect/oxc/oxlint/rules/no-opaque-instance-fields"
2import { describe, expect, it } from "vitest"
3import { createTestContext, runRule } from "./utils.ts"
4
5function runRuleWithNodes(nodes: Array<{ visitor: string; node: unknown }>) {
6 const { context, errors } = createTestContext()
7 const visitors = rule.create(context as never)
8 for (const { visitor, node } of nodes) {
9 const handler = visitors[visitor as keyof typeof visitors]
10 if (handler) {
11 ;(handler as (node: unknown) => void)(node)
12 }
13 }
14 return errors
15}
16
17describe("no-opaque-instance-fields", () => {
18 const createOpaqueClass = (
19 callee: { type: string; name?: string; object?: unknown; property?: unknown },
20 members: Array<{ type: string; static: boolean }>
21 ) => ({
22 type: "ClassDeclaration",
23 superClass: {
24 type: "CallExpression",
25 callee: {
26 type: "CallExpression",
27 callee
28 }
29 },
30 body: {
31 body: members
32 }
33 })
34
35 const createSchemaOpaqueClass = (members: Array<{ type: string; static: boolean }>) =>
36 createOpaqueClass({
37 type: "MemberExpression",
38 object: { type: "Identifier", name: "Schema" },
39 property: { type: "Identifier", name: "Opaque" }
40 }, members)
41
42 const createSchemaImport = () => ({
43 type: "ImportDeclaration",
44 source: { type: "Literal", value: "effect" },
45 specifiers: [{
46 type: "ImportSpecifier",
47 imported: { type: "Identifier", name: "Schema" },
48 local: { type: "Identifier", name: "Schema" },
49 importKind: "value"
50 }],
51 importKind: "value"
52 })
53
54 const createOpaqueImport = (localName = "Opaque") => ({
55 type: "ImportDeclaration",
56 source: { type: "Literal", value: "effect/Schema" },
57 specifiers: [{
58 type: "ImportSpecifier",
59 imported: { type: "Identifier", name: "Opaque" },
60 local: { type: "Identifier", name: localName },
61 importKind: "value"
62 }],
63 importKind: "value"
64 })
65
66 const createSchemaNamespaceImport = (localName = "S") => ({
67 type: "ImportDeclaration",
68 source: { type: "Literal", value: "effect/Schema" },
69 specifiers: [{
70 type: "ImportNamespaceSpecifier",
71 local: { type: "Identifier", name: localName }
72 }],
73 importKind: "value"
74 })
75
76 const createOtherOpaqueImport = (localName = "Opaque") => ({
77 type: "ImportDeclaration",
78 source: { type: "Literal", value: "other" },
79 specifiers: [{
80 type: "ImportSpecifier",
81 imported: { type: "Identifier", name: "Opaque" },
82 local: { type: "Identifier", name: localName },
83 importKind: "value"
84 }],
85 importKind: "value"
86 })
87
88 const createOtherNamespaceImport = (localName = "S") => ({
89 type: "ImportDeclaration",
90 source: { type: "Literal", value: "other" },
91 specifiers: [{
92 type: "ImportNamespaceSpecifier",
93 local: { type: "Identifier", name: localName }
94 }],
95 importKind: "value"
96 })
97
98 const createRegularClass = (members: Array<{ type: string; static: boolean }>) => ({
99 type: "ClassDeclaration",
100 superClass: {
101 type: "Identifier",
102 name: "SomeClass"
103 },
104 body: {
105 body: members
106 }
107 })
108
109 it("should not report for class without instance fields", () => {
110 const node = createSchemaOpaqueClass([])
111 const errors = runRuleWithNodes([
112 { visitor: "ImportDeclaration", node: createSchemaImport() },
113 { visitor: "ClassDeclaration", node }
114 ])
115 expect(errors).toHaveLength(0)
116 })
117
118 it("should not report for static fields in Schema.Opaque class", () => {
119 const node = createSchemaOpaqueClass([
120 { type: "PropertyDefinition", static: true }
121 ])
122 const errors = runRuleWithNodes([
123 { visitor: "ImportDeclaration", node: createSchemaImport() },
124 { visitor: "ClassDeclaration", node }
125 ])
126 expect(errors).toHaveLength(0)
127 })
128
129 it("should report for instance fields in Schema.Opaque class", () => {
130 const node = createSchemaOpaqueClass([
131 { type: "PropertyDefinition", static: false }
132 ])
133 const errors = runRuleWithNodes([
134 { visitor: "ImportDeclaration", node: createSchemaImport() },
135 { visitor: "ClassDeclaration", node }
136 ])
137 expect(errors).toHaveLength(1)
138 expect(errors[0].message).toBe("Classes extending Schema.Opaque must not have instance members")
139 })
140
141 it("should not report when Schema.Opaque has no Schema import", () => {
142 const node = createSchemaOpaqueClass([
143 { type: "PropertyDefinition", static: false }
144 ])
145 const errors = runRule(rule, "ClassDeclaration", node)
146 expect(errors).toHaveLength(0)
147 })
148
149 it("should report for instance fields in Opaque class", () => {
150 const node = createOpaqueClass(
151 { type: "Identifier", name: "Opaque" },
152 [{ type: "PropertyDefinition", static: false }]
153 )
154 const errors = runRuleWithNodes([
155 { visitor: "ImportDeclaration", node: createOpaqueImport() },
156 { visitor: "ClassDeclaration", node }
157 ])
158 expect(errors).toHaveLength(1)
159 })
160
161 it("should report for instance fields in aliased Opaque class", () => {
162 const node = createOpaqueClass(
163 {
164 type: "MemberExpression",
165 object: { type: "Identifier", name: "S" },
166 property: { type: "Identifier", name: "Opaque" }
167 },
168 [{ type: "PropertyDefinition", static: false }]
169 )
170 const errors = runRuleWithNodes([
171 { visitor: "ImportDeclaration", node: createSchemaNamespaceImport() },
172 { visitor: "ClassDeclaration", node }
173 ])
174 expect(errors).toHaveLength(1)
175 })
176
177 it("should not report for Opaque import from other module", () => {
178 const node = createOpaqueClass(
179 { type: "Identifier", name: "Opaque" },
180 [{ type: "PropertyDefinition", static: false }]
181 )
182 const errors = runRuleWithNodes([
183 { visitor: "ImportDeclaration", node: createOtherOpaqueImport() },
184 { visitor: "ClassDeclaration", node }
185 ])
186 expect(errors).toHaveLength(0)
187 })
188
189 it("should not report for namespace Opaque from other module", () => {
190 const node = createOpaqueClass(
191 {
192 type: "MemberExpression",
193 object: { type: "Identifier", name: "S" },
194 property: { type: "Identifier", name: "Opaque" }
195 },
196 [{ type: "PropertyDefinition", static: false }]
197 )
198 const errors = runRuleWithNodes([
199 { visitor: "ImportDeclaration", node: createOtherNamespaceImport() },
200 { visitor: "ClassDeclaration", node }
201 ])
202 expect(errors).toHaveLength(0)
203 })
204
205 it("should report for multiple instance fields", () => {
206 const node = createSchemaOpaqueClass([
207 { type: "PropertyDefinition", static: false },
208 { type: "PropertyDefinition", static: true },
209 { type: "PropertyDefinition", static: false }
210 ])
211 const errors = runRuleWithNodes([
212 { visitor: "ImportDeclaration", node: createSchemaImport() },
213 { visitor: "ClassDeclaration", node }
214 ])
215 expect(errors).toHaveLength(2)
216 })
217
218 it("should report for instance methods", () => {
219 const node = createSchemaOpaqueClass([
220 { type: "MethodDefinition", static: false }
221 ])
222 const errors = runRuleWithNodes([
223 { visitor: "ImportDeclaration", node: createSchemaImport() },
224 { visitor: "ClassDeclaration", node }
225 ])
226 expect(errors).toHaveLength(1)
227 })
228
229 it("should not report for non-Schema.Opaque classes", () => {
230 const node = createRegularClass([
231 { type: "PropertyDefinition", static: false }
232 ])
233 const errors = runRule(rule, "ClassDeclaration", node)
234 expect(errors).toHaveLength(0)
235 })
236
237 it("should work with ClassExpression", () => {
238 const node = {
239 ...createSchemaOpaqueClass([
240 { type: "PropertyDefinition", static: false }
241 ]),
242 type: "ClassExpression"
243 }
244 const errors = runRuleWithNodes([
245 { visitor: "ImportDeclaration", node: createSchemaImport() },
246 { visitor: "ClassExpression", node }
247 ])
248 expect(errors).toHaveLength(1)
249 })
250
251 it("should not report for class without superClass", () => {
252 const node = {
253 type: "ClassDeclaration",
254 body: {
255 body: [{ type: "PropertyDefinition", static: false }]
256 }
257 }
258 const errors = runRule(rule, "ClassDeclaration", node)
259 expect(errors).toHaveLength(0)
260 })
261
262 it("should not report for class with non-CallExpression superClass", () => {
263 const node = {
264 type: "ClassDeclaration",
265 superClass: {
266 type: "Identifier",
267 name: "BaseClass"
268 },
269 body: {
270 body: [{ type: "PropertyDefinition", static: false }]
271 }
272 }
273 const errors = runRule(rule, "ClassDeclaration", node)
274 expect(errors).toHaveLength(0)
275 })
276})