@starci/eslint-canon-be 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The rules that hold `data-access.md`.
3
+ *
4
+ * Three rules, all reading a constructor parameter or a decorator, all exact - there is no
5
+ * heuristic here and no judgement to get wrong, which is why all three can be switched on without
6
+ * a burn-down in a codebase that already follows them.
7
+ *
8
+ * The two rules canon does NOT try to enforce are worth naming so nobody adds them badly. Whether a
9
+ * helper was handed the caller's transactional manager (DATA-4) needs the call graph, and whether a
10
+ * relation should have been asked for at the call site (DATA-5) needs to know what the answer is
11
+ * for. Both are read by a person; a rule that guessed would fire on correct code.
12
+ */
13
+
14
+ /** Forward-slash form of a filename, so Windows paths compare like every other path. */
15
+ const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
16
+
17
+ /** A parameter property (`private readonly x: T`) wraps the parameter it declares. */
18
+ const unwrapParam = (param) => (param.type === "TSParameterProperty" ? param.parameter : param)
19
+
20
+ /** The type name a parameter is annotated with, or null. */
21
+ const paramTypeName = (param) => {
22
+ const annotation = param.typeAnnotation && param.typeAnnotation.typeAnnotation
23
+ if (!annotation || annotation.type !== "TSTypeReference" || annotation.typeName.type !== "Identifier") return null
24
+ return annotation.typeName.name
25
+ }
26
+
27
+ /** Decorator names on a parameter, including ones carried by the parameter property wrapping it. */
28
+ const paramDecorators = (original) => {
29
+ const carriers = [original]
30
+ if (original.type === "TSParameterProperty") carriers.push(original.parameter)
31
+ const names = []
32
+ for (const carrier of carriers) {
33
+ for (const decorator of carrier.decorators || []) {
34
+ const expression = decorator.expression
35
+ if (expression.type === "CallExpression" && expression.callee.type === "Identifier") {
36
+ names.push(expression.callee.name)
37
+ } else if (expression.type === "Identifier") {
38
+ names.push(expression.name)
39
+ }
40
+ }
41
+ }
42
+ return names
43
+ }
44
+
45
+ /** Every parameter of a constructor definition. */
46
+ const constructorParams = (node) =>
47
+ node.kind === "constructor" && node.value && node.value.params ? node.value.params : []
48
+
49
+ /** Repository types that bind a handle to one entity. */
50
+ const REPOSITORY_TYPES = /^(?:Repository|TreeRepository|MongoRepository)$/
51
+
52
+ /** The house decorator family that names a datasource. */
53
+ const NAMES_DATASOURCE = /^Inject\w*EntityManager$/
54
+
55
+ // -- DATA-1 ----------------------------------------------------------------------------------------
56
+
57
+ /** An injected manager names the datasource it belongs to. */
58
+ export const mustInjectEntityManager = {
59
+ meta: {
60
+ type: "problem",
61
+ docs: { description: "An injected `EntityManager` names its datasource through a decorator." },
62
+ schema: [],
63
+ messages: {
64
+ undecorated:
65
+ "`EntityManager` is injected without an `@Inject*EntityManager()` decorator. The type alone does not say WHICH datasource this is, and this application has more than one - so this reads correctly while pointing at the wrong database. Name the connection at the injection site.",
66
+ },
67
+ },
68
+ create(context) {
69
+ return {
70
+ MethodDefinition(node) {
71
+ for (const original of constructorParams(node)) {
72
+ const param = unwrapParam(original)
73
+ if (paramTypeName(param) !== "EntityManager") continue
74
+ if (paramDecorators(original).some((name) => NAMES_DATASOURCE.test(name))) continue
75
+ context.report({ node: param, messageId: "undecorated" })
76
+ }
77
+ },
78
+ }
79
+ },
80
+ }
81
+
82
+ // -- DATA-2 ----------------------------------------------------------------------------------------
83
+
84
+ /** Persistence never arrives as a repository. */
85
+ export const noInjectedRepository = {
86
+ meta: {
87
+ type: "problem",
88
+ docs: { description: "Persistence goes through `EntityManager`, never an injected repository." },
89
+ schema: [],
90
+ messages: {
91
+ repo:
92
+ "Inject `EntityManager` instead of a repository. A repository is bound to ONE entity, so it cannot carry a transaction into a second table - and a use case that grows a second write then has to be rewritten rather than extended.",
93
+ },
94
+ },
95
+ create(context) {
96
+ return {
97
+ MethodDefinition(node) {
98
+ for (const original of constructorParams(node)) {
99
+ const param = unwrapParam(original)
100
+ const byDecorator = paramDecorators(original).includes("InjectRepository")
101
+ const byType = REPOSITORY_TYPES.test(paramTypeName(param) || "")
102
+ if (byDecorator || byType) context.report({ node: param, messageId: "repo" })
103
+ }
104
+ },
105
+ }
106
+ },
107
+ }
108
+
109
+ // -- DATA-3 ----------------------------------------------------------------------------------------
110
+
111
+ /** An entity names its table, so a class rename cannot become a dropped table. */
112
+ export const requireEntityTableName = {
113
+ meta: {
114
+ type: "problem",
115
+ docs: { description: "`@Entity()` names its table explicitly." },
116
+ schema: [],
117
+ messages: {
118
+ inferred:
119
+ "`@Entity()` here lets the ORM infer the table name from the class name, so renaming the class renames the table - which `synchronize` performs as a DROP and CREATE rather than a migration. A class rename is a refactor; a dropped table is an outage. Name the table.",
120
+ },
121
+ },
122
+ create(context) {
123
+ /** Both `@Entity("t")` and `@Entity({ name: "t" })` name the table. */
124
+ const isTableName = (argument) =>
125
+ (argument.type === "Literal" && typeof argument.value === "string") || argument.type === "TemplateLiteral"
126
+ return {
127
+ Decorator(node) {
128
+ const expression = node.expression
129
+ if (expression.type !== "CallExpression") return
130
+ if (expression.callee.type !== "Identifier" || expression.callee.name !== "Entity") return
131
+ // The options form is not a stylistic variant to discourage: it is the ONLY form that can
132
+ // also carry a schema qualifier, so rejecting it would push an author to delete the schema
133
+ // to satisfy the rule - a worse outcome than the inferred name this exists to prevent.
134
+ const named = expression.arguments.some(
135
+ (argument) =>
136
+ isTableName(argument)
137
+ || (argument.type === "ObjectExpression"
138
+ && argument.properties.some(
139
+ (property) =>
140
+ property.type === "Property"
141
+ && !property.computed
142
+ && (property.key.name === "name" || property.key.value === "name")
143
+ && isTableName(property.value),
144
+ )),
145
+ )
146
+ if (!named) context.report({ node, messageId: "inferred" })
147
+ },
148
+ }
149
+ },
150
+ }
151
+
152
+ /** The rules this law contributes to the plugin. */
153
+ export const rules = {
154
+ "must-inject-entity-manager": mustInjectEntityManager,
155
+ "no-injected-repository": noInjectedRepository,
156
+ "require-entity-table-name": requireEntityTableName,
157
+ }
158
+
159
+ /**
160
+ * The level this law asks for, as the plugin's own opinion.
161
+ *
162
+ * All three measured at zero debt in the reference repository, so all three ship at `error`. A
163
+ * repository adopting them into an existing tree measures first and lands anything above zero at
164
+ * `warn` with the count beside it.
165
+ */
166
+ export const recommended = {
167
+ "starci-be/must-inject-entity-manager": "error",
168
+ "starci-be/no-injected-repository": "error",
169
+ "starci-be/require-entity-table-name": "error",
170
+ }
171
+
172
+ /** Path helper shared with the other backend law modules. */
173
+ export { normalizePath }
package/e2e-flow.mjs ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The rules that hold `e2e-flow.md`.
3
+ *
4
+ * FIVE OF THE TWELVE RULES HAVE A MACHINE-CHECKABLE HALF HERE. That is the honest number rather
5
+ * than a gap somebody should close. A rule earns its place by being exact: it must fire on a syntactic shape, not on a
6
+ * judgement, or it becomes something authors learn to disable and the law is worse off than when
7
+ * nothing enforced it.
8
+ *
9
+ * What was measured and left alone, so the next reader does not "finish the job":
10
+ *
11
+ * - E2E-1 (one file, one flow, named for the sentence). A filename cannot be checked against a
12
+ * business sentence. `course-purchase.e2e-spec.ts` and `resolver-group.e2e-spec.ts` are the same
13
+ * shape to a parser and opposite things to a reader.
14
+ * - E2E-2 (named steps, not one long case). Counting `it` blocks would refuse a flow that is
15
+ * genuinely one step, and a rule whose first false positive is legitimate teaches authors that
16
+ * the rule is wrong rather than that they are.
17
+ * - E2E-5, E2E-6 and E2E-9 turn on WHAT is asserted or WHO is acting, which is meaning.
18
+ * - E2E-8 (one place stands the world up) is a fact about a repository's fixtures, not about a
19
+ * file, so it belongs to a gate that can see the tree rather than to a rule that sees one file.
20
+ * - E2E-10 (a flow logs nothing) is already held: `no-console` and `no-framework-logger` in the
21
+ * observability law cover every call site, and a second rule saying the same thing in this lane
22
+ * would double every report.
23
+ *
24
+ * The rules below enforce only exact syntax: transport bypass, absence of any persisted-state read,
25
+ * direct provider imports, sleeps and branches. They do not pretend to judge business meaning.
26
+ */
27
+
28
+ /** Files this law governs. A flow is a named lane, not every file that happens to touch a database. */
29
+ const isE2eSpec = (filename) => /\.e2e-spec\.ts$/.test(String(filename || "").replace(/\\/g, "/"))
30
+
31
+ /** Names a repository sleeps by. Awaiting any of them is waiting for a duration instead of a state. */
32
+ const SLEEPERS = new Set(["sleep", "delay", "wait", "pause", "setTimeout"])
33
+
34
+ /** E2E enters through production transport and never invokes an internal actor directly. */
35
+ export const e2eUsesProductionTransport = {
36
+ meta: {
37
+ type: "problem",
38
+ docs: { description: "Operational E2E keeps the production transport boundary in the proof." },
39
+ schema: [],
40
+ messages: {
41
+ busImport: "`{{name}}` is an internal dispatcher. Enter through GraphQL, HTTP, socket, broker or scheduler.",
42
+ direct: "Direct `.{{method}}()` skips production routing and delivery semantics.",
43
+ actor: "Calling `{{name}}` directly bypasses the broker/CQRS boundary that owns it.",
44
+ },
45
+ },
46
+ create(context) {
47
+ if (!isE2eSpec(context.filename || context.getFilename())) return {}
48
+ const buses = new Set(["CommandBus", "QueryBus", "EventBus"])
49
+ return {
50
+ ImportDeclaration(node) {
51
+ if (node.source.value !== "@nestjs/cqrs") return
52
+ for (const specifier of node.specifiers) {
53
+ if (specifier.type !== "ImportSpecifier") continue
54
+ const imported = specifier.imported.name || specifier.imported.value
55
+ if (buses.has(imported)) context.report({ node: specifier, messageId: "busImport", data: { name: imported } })
56
+ }
57
+ },
58
+ CallExpression(node) {
59
+ const callee = node.callee
60
+ if (callee.type !== "MemberExpression" || callee.computed) return
61
+ const method = callee.property.name
62
+ if (method === "execute" || method === "process") {
63
+ context.report({ node: callee.property, messageId: "direct", data: { method } })
64
+ return
65
+ }
66
+ let receiver = callee.object
67
+ while (receiver && ["TSAsExpression", "TSTypeAssertion", "ChainExpression"].includes(receiver.type)) receiver = receiver.expression
68
+ if (receiver && receiver.type === "Identifier" && /(?:Worker|Handler)$/.test(receiver.name)) {
69
+ context.report({ node: callee.property, messageId: "actor", data: { name: receiver.name } })
70
+ }
71
+ },
72
+ }
73
+ },
74
+ }
75
+
76
+ /** E2E reads the consequence from persistent state rather than only its response envelope. */
77
+ export const e2eAssertsPersistedState = {
78
+ meta: {
79
+ type: "problem",
80
+ docs: { description: "E2E reads a persisted business consequence." },
81
+ schema: [],
82
+ messages: { state: "This E2E never reads persisted state back; an envelope alone is not a business consequence." },
83
+ },
84
+ create(context) {
85
+ if (!isE2eSpec(context.filename || context.getFilename())) return {}
86
+ let readsState = false
87
+ const readers = /^(?:entityManager|dataSource|EntityManager|DataSource|getRepository|queryRunner)$/
88
+ return {
89
+ Identifier(node) { if (readers.test(node.name)) readsState = true },
90
+ "Program:exit"(node) { if (!readsState) context.report({ node, messageId: "state" }) },
91
+ }
92
+ },
93
+ }
94
+
95
+ /** Model quality belongs in harness; E2E scripts only the provider boundary. */
96
+ export const noModelCallInE2e = {
97
+ meta: {
98
+ type: "problem",
99
+ docs: { description: "E2E never reaches a paid or nondeterministic model provider." },
100
+ schema: [],
101
+ messages: { provider: "`{{source}}` reaches a model provider from E2E. Script the external result and keep internal policy real." },
102
+ },
103
+ create(context) {
104
+ if (!isE2eSpec(context.filename || context.getFilename())) return {}
105
+ const providers = /^(?:@anthropic-ai\/|openai$|openai\/|ollama$|@google\/generative-ai|@mistralai\/|cohere-ai)/
106
+ return {
107
+ ImportDeclaration(node) {
108
+ const source = node.source.value
109
+ if (typeof source === "string" && providers.test(source)) context.report({ node, messageId: "provider", data: { source } })
110
+ },
111
+ }
112
+ },
113
+ }
114
+
115
+ // -- E2E-3 ----------------------------------------------------------------------------------------
116
+
117
+ /** A flow polls until the state settles; it never waits for a duration. */
118
+ export const noSleepInFlow = {
119
+ meta: {
120
+ type: "problem",
121
+ docs: { description: "A flow polls for the state it needs, with a deadline. It never sleeps." },
122
+ schema: [],
123
+ messages: {
124
+ sleep:
125
+ "`{{name}}` waits for a DURATION, and a duration is a guess about somebody else's machine. It is too long on the machine that passes - every run pays it - and too short on the one that matters, where it fails as a flake nobody can reproduce. Poll for the state you are actually waiting for, with a deadline, so the test says what it wanted rather than how long it was willing to sit there.",
126
+ timer:
127
+ "A promise around `setTimeout` is a sleep with the name taken off. The objection is the same: nothing here says which state the flow is waiting for, so the failure message when it expires names a timeout instead of the thing that never happened. Poll the state, with a deadline.",
128
+ },
129
+ },
130
+ create(context) {
131
+ if (!isE2eSpec(context.filename || context.getFilename())) return {}
132
+ return {
133
+ CallExpression(node) {
134
+ const callee = node.callee
135
+ if (!callee || callee.type !== "Identifier" || !SLEEPERS.has(callee.name)) return
136
+ // A `setTimeout` wrapped in `new Promise` is reported once, as `timer`, by the branch below.
137
+ // Reporting it here as well would put two findings on one sleep and teach nobody anything.
138
+ for (let current = node.parent; current; current = current.parent) {
139
+ if (current.type === "NewExpression" && current.callee && current.callee.name === "Promise") return
140
+ }
141
+ context.report({ node, messageId: "sleep", data: { name: callee.name } })
142
+ },
143
+ NewExpression(node) {
144
+ if (!node.callee || node.callee.name !== "Promise") return
145
+ const source = context.sourceCode || context.getSourceCode()
146
+ if (/setTimeout/.test(source.getText(node))) context.report({ node, messageId: "timer" })
147
+ },
148
+ }
149
+ },
150
+ }
151
+
152
+ // -- E2E-7 ----------------------------------------------------------------------------------------
153
+
154
+ /** A step asserts one outcome. A branch means the test is prepared for either, which is no assertion. */
155
+ export const noBranchInFlowStep = {
156
+ meta: {
157
+ type: "problem",
158
+ docs: { description: "A flow step takes no branch: it asserts the one outcome the business promises." },
159
+ schema: [],
160
+ messages: {
161
+ branch:
162
+ "A branch inside a step means this test passes down either path, so a green run stops being evidence that the business worked - it only proves the code reached the end. The flow knows which outcome it is asserting: state it. If two outcomes are both legitimate, they are two steps, or two flows.",
163
+ },
164
+ },
165
+ create(context) {
166
+ if (!isE2eSpec(context.filename || context.getFilename())) return {}
167
+ const source = context.sourceCode || context.getSourceCode()
168
+
169
+ /** True when this node sits inside the callback of an `it(...)`. */
170
+ const insideStep = (node) => {
171
+ for (let current = node.parent; current; current = current.parent) {
172
+ if (current.type !== "CallExpression") continue
173
+ const callee = current.callee
174
+ const name = callee && (callee.name || (callee.object && callee.object.name))
175
+ if (name === "it" || name === "test") return true
176
+ }
177
+ return false
178
+ }
179
+
180
+ const report = (node) => {
181
+ if (insideStep(node)) context.report({ node, messageId: "branch" })
182
+ }
183
+
184
+ return {
185
+ IfStatement: report,
186
+ ConditionalExpression: report,
187
+ SwitchStatement: report,
188
+ LogicalExpression(node) {
189
+ // `a && b` used as a STATEMENT is a hidden if; the same operator inside an assertion is not
190
+ if (node.parent && node.parent.type === "ExpressionStatement") report(node)
191
+ // touching the source keeps the helper honest if the shape ever changes
192
+ void source
193
+ },
194
+ }
195
+ },
196
+ }
197
+
198
+ /** The rules this law contributes to the plugin. */
199
+ export const rules = {
200
+ "e2e-uses-production-transport": e2eUsesProductionTransport,
201
+ "e2e-asserts-persisted-state": e2eAssertsPersistedState,
202
+ "no-model-call-in-e2e": noModelCallInE2e,
203
+ "no-sleep-in-flow": noSleepInFlow,
204
+ "no-branch-in-flow-step": noBranchInFlowStep,
205
+ }
206
+
207
+ /**
208
+ * The level this law asks for, as the plugin's own opinion.
209
+ *
210
+ * Both are exact and both fire on a syntactic shape rather than on a judgement, so neither carries
211
+ * the false-positive risk that would justify adopting it at `warn`.
212
+ */
213
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-be/${name}`, "error"]))
@@ -0,0 +1,40 @@
1
+ const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
2
+
3
+ /** The NATS bridge rejects self-echo and claims redelivery before local fan-out. */
4
+ export const natsBridgeDeliveryContract = {
5
+ meta: {
6
+ type: "problem",
7
+ docs: { description: "The central NATS bridge preserves cross-instance delivery invariants." },
8
+ schema: [],
9
+ messages: {
10
+ origin: "Drop envelopes whose producer id matches this instance before local emit.",
11
+ digest: "Claim/check the envelope digest before local emit so redelivery is idempotent.",
12
+ },
13
+ },
14
+ create(context) {
15
+ if (!normalizePath(context.filename || context.getFilename()).endsWith(
16
+ "/event/nats/nats-bridge.service.ts",
17
+ )) return {}
18
+ return {
19
+ "Program:exit"(node) {
20
+ const source = context.sourceCode || context.getSourceCode()
21
+ const text = source.getText()
22
+ const originIndex = text.search(/parsed\.id\s*===\s*this\.instanceService\.getId\(\)/)
23
+ const digestIndex = text.indexOf("parsed.digest")
24
+ const emitIndex = text.indexOf("this.eventEmitter.emit")
25
+ if (originIndex < 0 || emitIndex < 0 || originIndex > emitIndex) {
26
+ context.report({ node, messageId: "origin" })
27
+ }
28
+ if (digestIndex < 0 || emitIndex < 0 || digestIndex > emitIndex) {
29
+ context.report({ node, messageId: "digest" })
30
+ }
31
+ },
32
+ }
33
+ },
34
+ }
35
+
36
+ export const rules = { "nats-bridge-delivery-contract": natsBridgeDeliveryContract }
37
+
38
+ export const recommended = {
39
+ "starci-be/nats-bridge-delivery-contract": "error",
40
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The rules that hold `exception-identity.md`.
3
+ *
4
+ * THREE RULES OVER ONE DECLARATION, AND THE FIRST IS WHAT MAKES THE OTHER GATES REAL. The
5
+ * metadata-object rule, the base-class rule and the folder rule in `exceptions.mjs` all key on a name
6
+ * ending in `Exception`, and the throw-site rule recognises only `Error` and the framework names. A
7
+ * failure declared as `SomethingError` therefore sits inside the errors folder, extends the house
8
+ * base, and is reported by none of them; the gate says nothing and that reads as agreement.
9
+ *
10
+ * The second rule is the one a client feels. The code is what Apollo stamps onto every GraphQL error
11
+ * (`formatError` in the monolithic server module says so in as many words: code matching, not status,
12
+ * is the front end's primary signal), so a code that drifts from its class is a wire contract nobody
13
+ * can predict from the name - and a code copied from the exception above it makes two unrelated
14
+ * failures arrive identical, which is the exact defect `EXCEPTION-1` refuses framework exceptions for.
15
+ *
16
+ * The third keeps the payload's type named after the failure it belongs to, so the shape a throw site
17
+ * must satisfy is reachable from the exception's own name.
18
+ */
19
+
20
+ /** The house base every exception extends. */
21
+ const EXCEPTION_BASE = "AbstractException"
22
+
23
+ /** Anything named like an exception. */
24
+ const EXCEPTION_NAME = /Exception$/
25
+
26
+ /** A class declaration that extends the house base - the only shape these rules govern. */
27
+ const isHouseException = (node) =>
28
+ Boolean(node.id) && Boolean(node.superClass) && node.superClass.type === "Identifier" && node.superClass.name === EXCEPTION_BASE
29
+
30
+ /** The constructor of a class body, when it declares one. */
31
+ const constructorOf = (node) =>
32
+ node.body.body.find((member) => member.type === "MethodDefinition" && member.kind === "constructor")
33
+
34
+ /** The `super(...)` call inside a constructor, which is where the message and the code are passed. */
35
+ const superCallOf = (ctor) => {
36
+ const found = []
37
+ const visit = (statements) => {
38
+ for (const statement of statements ?? []) {
39
+ if (
40
+ statement.type === "ExpressionStatement" &&
41
+ statement.expression.type === "CallExpression" &&
42
+ statement.expression.callee.type === "Super"
43
+ ) {
44
+ found.push(statement.expression)
45
+ }
46
+ }
47
+ }
48
+ visit(ctor.value.body?.body)
49
+ return found[0]
50
+ }
51
+
52
+ /** Letters only, upper case: `COURSE_NOT_FOUND_EXCEPTION` and `CourseNotFoundException` compare equal. */
53
+ const letters = (text) => String(text).replace(/_/g, "").toUpperCase()
54
+
55
+ // -- IDENTITY-1 ------------------------------------------------------------------------------------
56
+
57
+ /** The class name ends in `Exception`, because that suffix is what every other exception rule sees. */
58
+ export const exceptionNameEndsInException = {
59
+ meta: {
60
+ type: "problem",
61
+ docs: { description: "A class extending `AbstractException` is named `*Exception`." },
62
+ schema: [],
63
+ messages: {
64
+ suffix:
65
+ "`{{name}}` extends `AbstractException` but is not named `*Exception`. Every rule guarding exceptions matches on that suffix - the throw-site rule, the metadata-object rule, the base-class rule and the folder rule all skip this class, so it is a failure NOTHING checks while the gate stays green. Rename it `{{suggestion}}`.",
66
+ },
67
+ },
68
+ create(context) {
69
+ return {
70
+ ClassDeclaration(node) {
71
+ if (!isHouseException(node) || EXCEPTION_NAME.test(node.id.name)) return
72
+ context.report({
73
+ node: node.id,
74
+ messageId: "suffix",
75
+ data: {
76
+ name: node.id.name,
77
+ suggestion: `${node.id.name.replace(/Error$/, "")}Exception`,
78
+ },
79
+ })
80
+ },
81
+ }
82
+ },
83
+ }
84
+
85
+ // -- IDENTITY-2 ------------------------------------------------------------------------------------
86
+
87
+ /** The code is the class name, so nothing has to be looked up and nothing can be quietly reused. */
88
+ export const exceptionCodeMatchesClassName = {
89
+ meta: {
90
+ type: "problem",
91
+ docs: { description: "The code passed to `super()` spells the class name." },
92
+ schema: [],
93
+ messages: {
94
+ mismatch:
95
+ "`{{name}}` reports the code `{{code}}`. The code is what a client matches on, so it is derived from the class name and nothing else: `{{expected}}`. A code that says something different cannot be predicted from the name, cannot be found by searching for the class, and - when it was copied from the exception above it - makes two unrelated failures arrive at the client identical.",
96
+ notLiteral:
97
+ "`{{name}}` builds its code instead of stating one. A code assembled at runtime cannot be found by searching for it, which is the one thing every consumer of it does - the client matching on it, the alert grouping by it, the reader asking where this failure comes from. Pass the literal `{{expected}}`.",
98
+ },
99
+ },
100
+ create(context) {
101
+ return {
102
+ ClassDeclaration(node) {
103
+ if (!isHouseException(node)) return
104
+ const ctor = constructorOf(node)
105
+ if (!ctor) return
106
+ const call = superCallOf(ctor)
107
+ if (!call || call.arguments.length < 2) return
108
+ const code = call.arguments[1]
109
+ const expected = `${node.id.name
110
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
111
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
112
+ .toUpperCase()}`
113
+ if (code.type !== "Literal" || typeof code.value !== "string") {
114
+ context.report({ node: code, messageId: "notLiteral", data: { name: node.id.name, expected } })
115
+ return
116
+ }
117
+ // Underscore placement is not the ruling - `GRAPHQL_DATA` and `GRAPH_QL_DATA` name the same
118
+ // class, and an acronym has no single correct split. The letters are the ruling.
119
+ if (letters(code.value) === letters(node.id.name)) return
120
+ context.report({
121
+ node: code,
122
+ messageId: "mismatch",
123
+ data: { name: node.id.name, code: code.value, expected },
124
+ })
125
+ },
126
+ }
127
+ },
128
+ }
129
+
130
+ // -- IDENTITY-3 ------------------------------------------------------------------------------------
131
+
132
+ /** The metadata type carries the exception's name, so the payload shape is reachable from it. */
133
+ export const exceptionMetadataTypeNamedForClass = {
134
+ meta: {
135
+ type: "problem",
136
+ docs: { description: "The constructor's metadata parameter is typed `<Class>Metadata`." },
137
+ schema: [],
138
+ messages: {
139
+ untyped:
140
+ "`{{name}}`'s metadata parameter states no type. The parameter is the whole contract a throw site has to satisfy, and an untyped one accepts every object - including the one missing the id the failure exists to carry. Type it `{{expected}}`.",
141
+ named:
142
+ "`{{name}}` types its metadata as `{{actual}}`. Name the type after the exception - `{{expected}}` - so the payload is reachable from the failure's own name, and so the day this failure gains its first field there is already a place to put it that no call site has to learn.",
143
+ },
144
+ },
145
+ create(context) {
146
+ return {
147
+ ClassDeclaration(node) {
148
+ if (!isHouseException(node)) return
149
+ const ctor = constructorOf(node)
150
+ if (!ctor) return
151
+ // `{ ... }: Metadata = {}` parses as an assignment pattern wrapping the destructuring, and
152
+ // reading only the outer node skipped exactly the declarations that had given up on a named
153
+ // type - the ones this rule exists for.
154
+ const declared = ctor.value.params[0]
155
+ const param = declared?.type === "AssignmentPattern" ? declared.left : declared
156
+ if (!param || param.type !== "ObjectPattern") return
157
+ const expected = `${node.id.name}Metadata`
158
+ const annotation = param.typeAnnotation?.typeAnnotation
159
+ if (!annotation) {
160
+ context.report({ node: param, messageId: "untyped", data: { name: node.id.name, expected } })
161
+ return
162
+ }
163
+ if (annotation.type !== "TSTypeReference" || annotation.typeName.type !== "Identifier") return
164
+ if (annotation.typeName.name === expected) return
165
+ context.report({
166
+ node: annotation,
167
+ messageId: "named",
168
+ data: { name: node.id.name, actual: annotation.typeName.name, expected },
169
+ })
170
+ },
171
+ }
172
+ },
173
+ }
174
+
175
+ /** The rules this law contributes to the plugin. */
176
+ export const rules = {
177
+ "exception-name-ends-in-exception": exceptionNameEndsInException,
178
+ "exception-code-matches-class-name": exceptionCodeMatchesClassName,
179
+ "exception-metadata-type-named-for-class": exceptionMetadataTypeNamedForClass,
180
+ }
181
+
182
+ /**
183
+ * The levels this law asks for.
184
+ *
185
+ * All three measured with debt when they landed and all three are at zero now, which is the only
186
+ * state that earns `error`: a rule switched on over existing offenders trains its readers to scroll
187
+ * past it, and a reader who has learned to scroll past one rule reads none of them. Each arrived at
188
+ * `warn` with a ledger entry naming its offenders and flipped as that entry closed - metadata types
189
+ * first, because renaming one costs no caller anything, then the codes and the class names, which
190
+ * are wire-visible and were measured against every client before being moved.
191
+ *
192
+ * What that measurement found is worth keeping: across three front ends, five exception codes are
193
+ * matched in total, and none of them belonged to a drifted declaration. The assumption that a code
194
+ * change means a coordinated release was expensive to believe and cheap to check.
195
+ */
196
+ export const recommended = {
197
+ "starci-be/exception-name-ends-in-exception": "error", // debt paid 2026-08-14
198
+ "starci-be/exception-code-matches-class-name": "error", // debt paid 2026-08-14
199
+ "starci-be/exception-metadata-type-named-for-class": "error", // debt paid 2026-08-14
200
+ }