| 1 | import type { CreateRule, ESTree, Visitor } from "@oxlint/plugins" |
| 2 | |
| 3 | const SCHEMA_SOURCES = new Set(["effect", "effect/Schema"]) |
| 4 | const SCHEMA_NAMESPACE_SOURCES = new Set(["effect/Schema"]) |
| 5 | |
| 6 | const 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 | |
| 92 | export default rule |