1import type { CreateRule, ESTree, Visitor } from "@oxlint/plugins"
2
3const SCHEMA_SOURCES = new Set(["effect", "effect/Schema"])
4const SCHEMA_NAMESPACE_SOURCES = new Set(["effect/Schema"])
5
6const rule: CreateRule = {
7 meta: {
8 type: "problem",
9 docs: { description: "Disallow instance members in Schema.Opaque classes" }
10 },
11 create(context) {
12 // Track identifiers that point to Schema or Opaque imported from Schema.
13 // Local names that refer to the Schema module (Schema or namespace import).
14 // Example: `import { Schema } from "effect"` or `import * as S from "effect/Schema"`.
15 const schemaIdentifiers = new Set<string>()
16 // Local names that refer to Opaque imported directly from the Schema module.
17 // Example: `import { Opaque as MyOpaque } from "effect/Schema"`.
18 const opaqueIdentifiers = new Set<string>()
19
20 // Match `class X extends Schema.Opaque(...)` or `class X extends Opaque(...)` when
21 // the identifiers are tied to the Schema module via imports.
22 function isSchemaOpaqueExtension(node: ESTree.Class): boolean {
23 const sc = node.superClass
24 if (!sc || sc.type !== "CallExpression") return false
25 const inner = sc.callee
26 if (!inner || inner.type !== "CallExpression") return false
27 return isOpaqueCallee(inner.callee)
28 }
29
30 // Validate the outer `Opaque` call, allowing either `Opaque` or `<SchemaNamespace>.Opaque`.
31 function isOpaqueCallee(node: ESTree.Expression | null | undefined): boolean {
32 if (!node) return false
33 if (node.type === "Identifier") return opaqueIdentifiers.has(node.name)
34 if (node.type !== "MemberExpression") return false
35 if (node.property?.type !== "Identifier" || node.property.name !== "Opaque") return false
36 return isSchemaObject(node.object)
37 }
38
39 // Ensure `<SchemaNamespace>.Opaque` is actually Schema (imported from Schema module).
40 function isSchemaObject(node: ESTree.Expression | null | undefined): boolean {
41 if (!node) return false
42 if (node.type === "Identifier") return schemaIdentifiers.has(node.name)
43 return false
44 }
45
46 function checkClass(node: ESTree.Class) {
47 if (!isSchemaOpaqueExtension(node)) return
48 for (const element of node.body.body) {
49 if (element.type === "PropertyDefinition" && !element.static) {
50 context.report({
51 node: element,
52 message: "Classes extending Schema.Opaque must not have instance members"
53 })
54 } else if (element.type === "MethodDefinition" && !element.static) {
55 context.report({
56 node: element,
57 message: "Classes extending Schema.Opaque must not have instance members"
58 })
59 }
60 }
61 }
62
63 return {
64 // Record identifiers for Schema/Opaque imports so we don't match unrelated modules.
65 ImportDeclaration(node: ESTree.ImportDeclaration) {
66 if (node.importKind === "type") return
67 const source = node.source.value
68 if (typeof source !== "string" || !SCHEMA_SOURCES.has(source)) return
69
70 for (const specifier of node.specifiers) {
71 if (specifier.type === "ImportNamespaceSpecifier") {
72 if (SCHEMA_NAMESPACE_SOURCES.has(source)) {
73 schemaIdentifiers.add(specifier.local.name)
74 }
75 } else if (specifier.type === "ImportSpecifier" && specifier.importKind !== "type") {
76 if (specifier.imported.type !== "Identifier") continue
77 const importedName = specifier.imported.name
78 if (importedName === "Schema") {
79 schemaIdentifiers.add(specifier.local.name)
80 } else if (importedName === "Opaque") {
81 opaqueIdentifiers.add(specifier.local.name)
82 }
83 }
84 }
85 },
86 ClassDeclaration: checkClass,
87 ClassExpression: checkClass
88 } as Visitor
89 }
90}
91
92export default rule