@starci/eslint-canon-be 1.2.0 → 1.2.2

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.
@@ -1,200 +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
- }
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
+ }