@starci/eslint-canon-be 1.2.2 → 1.7.1
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.
- package/CHANGELOG.md +47 -0
- package/README.md +46 -7
- package/async-discipline.mjs +83 -0
- package/authorization.mjs +106 -106
- package/comments.mjs +7 -55
- package/config-owner.mjs +252 -0
- package/cqrs.mjs +7 -141
- package/data-access.mjs +151 -5
- package/default-deny.mjs +116 -0
- package/e2e-flow.mjs +222 -222
- package/error-handling.mjs +207 -0
- package/idempotency.mjs +101 -0
- package/index.mjs +34 -8
- package/input-bounds.mjs +84 -0
- package/lib/ast.mjs +81 -0
- package/lib/config.mjs +49 -0
- package/lib/path.mjs +15 -0
- package/lib/slots.mjs +83 -0
- package/log-safety.mjs +148 -0
- package/module-layering.mjs +87 -105
- package/module-shape.mjs +202 -0
- package/observability.mjs +275 -275
- package/package.json +4 -1
- package/query-safety.mjs +274 -0
- package/resilience.mjs +178 -0
- package/schema-authority.mjs +187 -0
- package/size-budget.mjs +106 -0
- package/spec-quality.mjs +132 -0
- package/suppression.mjs +60 -0
- package/temporal.mjs +71 -0
- package/testing.mjs +31 -19
- package/transport.mjs +168 -168
- package/type-safety.mjs +98 -57
- package/user-copy.mjs +106 -0
- package/cdc.mjs +0 -150
- package/event-delivery.mjs +0 -93
- package/exception-identity.mjs +0 -200
- package/exceptions.mjs +0 -262
package/user-copy.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds the messages catalog (catalog R78 `BE_USER_COPY_LITERAL`).
|
|
3
|
+
*
|
|
4
|
+
* Text a user reads - an exception's message, a notification's subject or body, a response's
|
|
5
|
+
* `message` or `description` - lives in a per-capability `messages` catalog (Vietnamese and
|
|
6
|
+
* English), reached through the typed `platform/i18n` `MessageCatalog` port by a key with named
|
|
7
|
+
* interpolation. A literal in source cannot be translated, cannot be found by the key a test
|
|
8
|
+
* asserts on, and drifts the moment one call site is edited and the others are not.
|
|
9
|
+
*
|
|
10
|
+
* - `user-copy-through-catalog` refuses a literal string in the three places copy is written by
|
|
11
|
+
* hand: the message argument of a `*Error`/`*Exception` constructor, a `subject`/`title`/
|
|
12
|
+
* `body`/`text`/`message` property of an object passed to `notify`/`send`/`publish`, and a
|
|
13
|
+
* `message`/`description`/`title` property of an object literal built in a `transport/` file.
|
|
14
|
+
*
|
|
15
|
+
* A short, code-shaped literal (`UPPER_SNAKE`, or a single bare word with no space or punctuation -
|
|
16
|
+
* `"PLAN_NOT_FOUND"`, `"pending"`) is a key or an enum value, not copy, and is left alone; so is a
|
|
17
|
+
* template literal, which this reads one file's syntax for and cannot split into its static and
|
|
18
|
+
* interpolated halves without guessing. `no-non-ascii-source` already refuses Vietnamese text
|
|
19
|
+
* anywhere; this rule is the English half, scoped to the three places copy is written.
|
|
20
|
+
*/
|
|
21
|
+
import { keyName, staticText } from "./lib/ast.mjs"
|
|
22
|
+
import { isDeclarationFile, isTestLane, normalizePath } from "./lib/path.mjs"
|
|
23
|
+
|
|
24
|
+
const BUILTIN_ERRORS = new Set(["Error", "TypeError", "RangeError", "SyntaxError", "EvalError", "URIError", "ReferenceError", "AggregateError"])
|
|
25
|
+
const NOTIFY_METHODS = new Set(["notify", "send", "publish"])
|
|
26
|
+
const NOTIFY_TEXT_PROPS = new Set(["subject", "title", "body", "text", "message"])
|
|
27
|
+
const RESPONSE_TEXT_PROPS = new Set(["message", "description", "title"])
|
|
28
|
+
|
|
29
|
+
/** A literal that reads as prose rather than a code, an enum value or a key. */
|
|
30
|
+
const looksLikeCopy = (text) => {
|
|
31
|
+
const trimmed = text.trim()
|
|
32
|
+
if (!trimmed) return false
|
|
33
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(trimmed)) return false // a code: PLAN_NOT_FOUND
|
|
34
|
+
if (/^[A-Za-z][A-Za-z0-9_-]*$/.test(trimmed)) return false // a single bare word: pending, plan-not-found
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isNotifyCall = (node) =>
|
|
39
|
+
node?.type === "CallExpression"
|
|
40
|
+
&& node.callee.type === "MemberExpression"
|
|
41
|
+
&& !node.callee.computed
|
|
42
|
+
&& node.callee.property.type === "Identifier"
|
|
43
|
+
&& NOTIFY_METHODS.has(node.callee.property.name)
|
|
44
|
+
|
|
45
|
+
/** User-facing text is looked up in the catalog by key, never written where it is used. */
|
|
46
|
+
export const userCopyThroughCatalog = {
|
|
47
|
+
meta: {
|
|
48
|
+
type: "problem",
|
|
49
|
+
docs: { description: "A literal exception message, notification text or response copy comes from the messages catalog, not from source." },
|
|
50
|
+
schema: [],
|
|
51
|
+
messages: {
|
|
52
|
+
exception: "`{{name}}`'s message is a literal string. A user-facing exception message comes from the per-capability messages catalog (`messages.get('<key>', params)`) through the typed `MessageCatalog` port, in Vietnamese and English, not written in source.",
|
|
53
|
+
notification: "`{{property}}` of this {{call}} call is a literal string. Notification copy comes from the messages catalog through the `MessageCatalog` port, not written at the call site.",
|
|
54
|
+
response: "`{{property}}` of this response is a literal string. Response copy comes from the messages catalog through the `MessageCatalog` port, not written in source.",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
create(context) {
|
|
58
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
59
|
+
if (isDeclarationFile(filename) || isTestLane(filename)) return {}
|
|
60
|
+
const inTransport = filename.includes("/transport/")
|
|
61
|
+
return {
|
|
62
|
+
NewExpression(node) {
|
|
63
|
+
const name = node.callee.type === "Identifier" ? node.callee.name : null
|
|
64
|
+
if (!name || BUILTIN_ERRORS.has(name) || !/(?:Error|Exception)$/.test(name)) return
|
|
65
|
+
const first = node.arguments[0]
|
|
66
|
+
if (!first) return
|
|
67
|
+
const text = staticText(first)
|
|
68
|
+
if (text !== null && looksLikeCopy(text)) context.report({ node: first, messageId: "exception", data: { name } })
|
|
69
|
+
},
|
|
70
|
+
CallExpression(node) {
|
|
71
|
+
if (!isNotifyCall(node)) return
|
|
72
|
+
const callName = node.callee.property.name
|
|
73
|
+
const objectArg = node.arguments.find((argument) => argument.type === "ObjectExpression")
|
|
74
|
+
if (!objectArg) return
|
|
75
|
+
for (const property of objectArg.properties) {
|
|
76
|
+
if (property.type !== "Property") continue
|
|
77
|
+
const key = keyName(property.key)
|
|
78
|
+
if (!key || !NOTIFY_TEXT_PROPS.has(key)) continue
|
|
79
|
+
const text = staticText(property.value)
|
|
80
|
+
if (text !== null && looksLikeCopy(text)) {
|
|
81
|
+
context.report({ node: property.value, messageId: "notification", data: { property: key, call: callName } })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
Property(node) {
|
|
86
|
+
if (!inTransport) return
|
|
87
|
+
const key = keyName(node.key)
|
|
88
|
+
if (!key || !RESPONSE_TEXT_PROPS.has(key)) return
|
|
89
|
+
const text = staticText(node.value)
|
|
90
|
+
if (text === null || !looksLikeCopy(text)) return
|
|
91
|
+
if (isNotifyCall(node.parent?.parent)) return // already judged as notification copy
|
|
92
|
+
context.report({ node: node.value, messageId: "response", data: { property: key } })
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The rule this law contributes to the plugin. */
|
|
99
|
+
export const rules = {
|
|
100
|
+
"user-copy-through-catalog": userCopyThroughCatalog,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Starts at error: a hand-written user-facing string is never a warning. */
|
|
104
|
+
export const recommended = {
|
|
105
|
+
"starci-be/user-copy-through-catalog": "error",
|
|
106
|
+
}
|
package/cdc.mjs
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
const isProjectionListener = (filename) => /projection\.listener\.ts$/.test(
|
|
2
|
-
String(filename || "").replace(/\\/g, "/"),
|
|
3
|
-
) && !String(filename || "").replace(/\\/g, "/").endsWith("/abstract-projection.listener.ts")
|
|
4
|
-
|
|
5
|
-
const isProjectionService = (filename) => /-projection\.service\.ts$/.test(
|
|
6
|
-
String(filename || "").replace(/\\/g, "/"),
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
const memberName = (member) => member.key && (member.key.name || member.key.value)
|
|
10
|
-
|
|
11
|
-
/** A groupId literal is safe: a plain string, or a template with no `${}` substitution. */
|
|
12
|
-
const isStaticStringValue = (node) => {
|
|
13
|
-
if (!node) return false
|
|
14
|
-
if (node.type === "Literal" && typeof node.value === "string") return true
|
|
15
|
-
if (node.type === "TemplateLiteral" && node.expressions.length === 0) return true
|
|
16
|
-
return false
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Flatten a function's parameter list to the names it binds, through destructuring/defaults/rest. */
|
|
20
|
-
const collectParamNames = (params, names = []) => {
|
|
21
|
-
for (const param of params || []) {
|
|
22
|
-
if (!param) continue
|
|
23
|
-
if (param.type === "Identifier") {
|
|
24
|
-
names.push(param.name)
|
|
25
|
-
} else if (param.type === "AssignmentPattern") {
|
|
26
|
-
collectParamNames([param.left], names)
|
|
27
|
-
} else if (param.type === "RestElement") {
|
|
28
|
-
collectParamNames([param.argument], names)
|
|
29
|
-
} else if (param.type === "ObjectPattern") {
|
|
30
|
-
for (const prop of param.properties || []) {
|
|
31
|
-
if (prop.type === "Property" && prop.key) {
|
|
32
|
-
names.push(prop.key.name || prop.key.value)
|
|
33
|
-
} else if (prop.type === "RestElement") {
|
|
34
|
-
collectParamNames([prop.argument], names)
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return names
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const DELTA_LIKE_PARAMS = new Set(["amount", "delta", "points", "increment"])
|
|
43
|
-
|
|
44
|
-
/** A projection listener declares mapping while AbstractProjectionListener owns Kafka lifecycle. */
|
|
45
|
-
export const projectionListenerContract = {
|
|
46
|
-
meta: {
|
|
47
|
-
type: "problem",
|
|
48
|
-
docs: { description: "Projection CDC listeners share one Kafka lifecycle contract." },
|
|
49
|
-
schema: [],
|
|
50
|
-
messages: {
|
|
51
|
-
base: "A projection CDC listener extends AbstractProjectionListener; the shared base owns subscription, envelope parsing and failure isolation.",
|
|
52
|
-
member: "A projection CDC listener must declare `{{name}}`.",
|
|
53
|
-
lifecycle: "A concrete projection listener must not override `onModuleInit`; Kafka lifecycle belongs to AbstractProjectionListener.",
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
create(context) {
|
|
57
|
-
if (!isProjectionListener(context.filename || context.getFilename())) return {}
|
|
58
|
-
return {
|
|
59
|
-
ClassDeclaration(node) {
|
|
60
|
-
if (!node.superClass || node.superClass.name !== "AbstractProjectionListener") {
|
|
61
|
-
context.report({ node: node.id || node, messageId: "base" })
|
|
62
|
-
}
|
|
63
|
-
const members = node.body.body || []
|
|
64
|
-
const names = new Set(members.map(memberName))
|
|
65
|
-
for (const name of ["groupId", "topics", "deriveTargets", "recomputeTarget"]) {
|
|
66
|
-
if (!names.has(name)) {
|
|
67
|
-
context.report({ node: node.id || node, messageId: "member", data: { name } })
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
const lifecycle = members.find((member) => memberName(member) === "onModuleInit")
|
|
71
|
-
if (lifecycle) context.report({ node: lifecycle.key, messageId: "lifecycle" })
|
|
72
|
-
},
|
|
73
|
-
}
|
|
74
|
-
},
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** CDC Rule 3 (CDC-2): a projection's consumer group id is a constant, not computed at boot. */
|
|
78
|
-
export const noDynamicProjectionGroupId = {
|
|
79
|
-
meta: {
|
|
80
|
-
type: "problem",
|
|
81
|
-
docs: {
|
|
82
|
-
description: "CDC Rule 3 (CDC-2): `groupId` is a stable string literal, not a value computed at boot.",
|
|
83
|
-
},
|
|
84
|
-
schema: [],
|
|
85
|
-
messages: {
|
|
86
|
-
dynamic: "CDC Rule 3 (CDC-2): `groupId` must be a constant string literal. A template substitution, a call (`Date.now()`, `randomUUID()`, `hostname()`), or an env/instance read makes the consumer group unstable across restarts, so each restart replays the whole history in a fresh group instead of resuming from the committed offset.",
|
|
87
|
-
},
|
|
88
|
-
},
|
|
89
|
-
create(context) {
|
|
90
|
-
if (!isProjectionListener(context.filename || context.getFilename())) return {}
|
|
91
|
-
return {
|
|
92
|
-
PropertyDefinition(node) {
|
|
93
|
-
if (memberName(node) !== "groupId") return
|
|
94
|
-
if (node.value && !isStaticStringValue(node.value)) {
|
|
95
|
-
context.report({ node: node.value, messageId: "dynamic" })
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
}
|
|
99
|
-
},
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** CDC Rules 1 and 6 (CDC-4): recompute rebuilds from source by UPSERT, never by applying a delta. */
|
|
103
|
-
export const projectionRecomputeMustUpsert = {
|
|
104
|
-
meta: {
|
|
105
|
-
type: "problem",
|
|
106
|
-
docs: {
|
|
107
|
-
description: "CDC Rules 1 and 6 (CDC-4): a projection recomputes from source rows by UPSERT; it is never updated by applying an event's delta.",
|
|
108
|
-
},
|
|
109
|
-
schema: [],
|
|
110
|
-
messages: {
|
|
111
|
-
delta: "CDC Rules 1 and 6 (CDC-4): `recompute` must not take a `{{name}}` parameter. Recompute takes only the target's identity and rebuilds the value from authoritative source rows -- a delta/amount/points parameter means a duplicate delivery doubles the projection and a lost delivery never heals.",
|
|
112
|
-
upsert: "CDC Rules 1 and 6 (CDC-4): a projection service with a `recompute` method must write through an UPSERT (`ON CONFLICT`). A value assembled without one is not safe to replay, and replay is the one guarantee a broker actually offers.",
|
|
113
|
-
},
|
|
114
|
-
},
|
|
115
|
-
create(context) {
|
|
116
|
-
if (!isProjectionService(context.filename || context.getFilename())) return {}
|
|
117
|
-
let hasRecompute = false
|
|
118
|
-
return {
|
|
119
|
-
MethodDefinition(node) {
|
|
120
|
-
if (memberName(node) !== "recompute") return
|
|
121
|
-
hasRecompute = true
|
|
122
|
-
const params = collectParamNames(node.value.params)
|
|
123
|
-
for (const name of params) {
|
|
124
|
-
if (DELTA_LIKE_PARAMS.has(String(name).toLowerCase())) {
|
|
125
|
-
context.report({ node, messageId: "delta", data: { name } })
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
},
|
|
129
|
-
"Program:exit"(node) {
|
|
130
|
-
if (!hasRecompute) return
|
|
131
|
-
const source = context.sourceCode || context.getSourceCode()
|
|
132
|
-
if (!/on\s*conflict/i.test(source.getText())) {
|
|
133
|
-
context.report({ node, messageId: "upsert" })
|
|
134
|
-
}
|
|
135
|
-
},
|
|
136
|
-
}
|
|
137
|
-
},
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export const rules = {
|
|
141
|
-
"projection-listener-contract": projectionListenerContract,
|
|
142
|
-
"no-dynamic-projection-group-id": noDynamicProjectionGroupId,
|
|
143
|
-
"projection-recompute-must-upsert": projectionRecomputeMustUpsert,
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export const recommended = {
|
|
147
|
-
"starci-be/projection-listener-contract": "error",
|
|
148
|
-
"starci-be/no-dynamic-projection-group-id": "error",
|
|
149
|
-
"starci-be/projection-recompute-must-upsert": "error",
|
|
150
|
-
}
|
package/event-delivery.mjs
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { normalizePath } from "./lib/path.mjs"
|
|
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
|
-
const isEmitCallOnEventEmitterService = (node) =>
|
|
37
|
-
node.callee.type === "MemberExpression" &&
|
|
38
|
-
!node.callee.computed &&
|
|
39
|
-
node.callee.property.type === "Identifier" &&
|
|
40
|
-
node.callee.property.name === "emit" &&
|
|
41
|
-
node.callee.object.type === "MemberExpression" &&
|
|
42
|
-
!node.callee.object.computed &&
|
|
43
|
-
node.callee.object.property.type === "Identifier" &&
|
|
44
|
-
node.callee.object.property.name === "eventEmitterService"
|
|
45
|
-
|
|
46
|
-
const findObjectProperty = (objectExpression, name) => {
|
|
47
|
-
if (!objectExpression || objectExpression.type !== "ObjectExpression") return null
|
|
48
|
-
return objectExpression.properties.find(
|
|
49
|
-
(property) => property.type === "Property" &&
|
|
50
|
-
!property.computed &&
|
|
51
|
-
(property.key.name === name || property.key.value === name),
|
|
52
|
-
) || null
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** DELIVERY Rule 2 (DELIVERY-2): transport is declared per event in one config, never at the call site. */
|
|
56
|
-
export const noCallSiteTransportOverride = {
|
|
57
|
-
meta: {
|
|
58
|
-
type: "problem",
|
|
59
|
-
docs: {
|
|
60
|
-
description: "DELIVERY Rule 2 (DELIVERY-2): transport is declared per event in the central config; it is never chosen at the call site.",
|
|
61
|
-
},
|
|
62
|
-
schema: [],
|
|
63
|
-
messages: {
|
|
64
|
-
callsite: "DELIVERY Rule 2 (DELIVERY-2): do not pass `options.useLocal`/`options.useNats` to `eventEmitterService.emit(...)`. Transport is part of the event's definition in the central config, not a per-call decision -- a call site choosing it is a promise the config never made and a reader has to guess at.",
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
create(context) {
|
|
68
|
-
return {
|
|
69
|
-
CallExpression(node) {
|
|
70
|
-
if (!isEmitCallOnEventEmitterService(node)) return
|
|
71
|
-
const args = node.arguments[0]
|
|
72
|
-
const optionsProperty = findObjectProperty(args, "options")
|
|
73
|
-
if (!optionsProperty) return
|
|
74
|
-
const optionsValue = optionsProperty.value
|
|
75
|
-
const hasUseLocal = findObjectProperty(optionsValue, "useLocal")
|
|
76
|
-
const hasUseNats = findObjectProperty(optionsValue, "useNats")
|
|
77
|
-
if (hasUseLocal || hasUseNats) {
|
|
78
|
-
context.report({ node: optionsProperty, messageId: "callsite" })
|
|
79
|
-
}
|
|
80
|
-
},
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export const rules = {
|
|
86
|
-
"nats-bridge-delivery-contract": natsBridgeDeliveryContract,
|
|
87
|
-
"no-call-site-transport-override": noCallSiteTransportOverride,
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export const recommended = {
|
|
91
|
-
"starci-be/nats-bridge-delivery-contract": "error",
|
|
92
|
-
"starci-be/no-call-site-transport-override": "error",
|
|
93
|
-
}
|
package/exception-identity.mjs
DELETED
|
@@ -1,200 +0,0 @@
|
|
|
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
|
-
}
|