@starci/eslint-canon-be 1.2.2 → 1.7.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.
- package/CHANGELOG.md +43 -0
- package/README.md +82 -7
- package/async-discipline.mjs +83 -0
- 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/error-handling.mjs +207 -0
- package/idempotency.mjs +101 -0
- package/index.mjs +34 -4
- 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 +60 -0
- package/log-safety.mjs +148 -0
- package/module-layering.mjs +87 -105
- package/module-shape.mjs +202 -0
- package/package.json +1 -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/type-safety.mjs +98 -57
- package/user-copy.mjs +106 -0
- package/exception-identity.mjs +0 -200
- package/exceptions.mjs +0 -262
package/testing.mjs
CHANGED
|
@@ -43,21 +43,17 @@
|
|
|
43
43
|
import { statSync } from "node:fs"
|
|
44
44
|
import { normalizePath } from "./lib/path.mjs"
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
const isUnitSpec = (filename) =>
|
|
48
|
-
const file = normalizePath(filename)
|
|
49
|
-
if (!/\.spec\.ts$/.test(file)) return false
|
|
50
|
-
return !/\.(?:e2e|int|harness)-spec\.ts$/.test(file)
|
|
51
|
-
}
|
|
46
|
+
/** Test kind 1 of exactly two: a unit spec, `<name>.spec.ts` beside its subject. */
|
|
47
|
+
const isUnitSpec = (filename) => /\.spec\.ts$/.test(normalizePath(filename))
|
|
52
48
|
|
|
53
|
-
/**
|
|
49
|
+
/** Test kind 2 of exactly two: `*.e2e-spec.ts`. */
|
|
54
50
|
const isE2eSpec = (filename) => /\.e2e-spec\.ts$/.test(normalizePath(filename))
|
|
55
51
|
|
|
56
|
-
/** The
|
|
57
|
-
const
|
|
52
|
+
/** The opt-in live e2e filter (`test:e2e:live`): e2e specs under `src/tests/e2e/live/` that need live third-party accounts. */
|
|
53
|
+
const isLiveE2eSpec = (filename) => isE2eSpec(filename) && /\/src\/tests\/e2e\/live\//.test(normalizePath(filename))
|
|
58
54
|
|
|
59
|
-
/** Test-only helpers whose only authority is the
|
|
60
|
-
const
|
|
55
|
+
/** Test-only environment helpers whose only authority is the live e2e filter. */
|
|
56
|
+
const isLiveE2eHelper = (filename) => /\/src\/tests\/e2e\/live\//.test(normalizePath(filename)) && !isE2eSpec(filename)
|
|
61
57
|
|
|
62
58
|
/**
|
|
63
59
|
* Matchers that assert a CALL happened rather than what came out of it.
|
|
@@ -136,7 +132,7 @@ export const noCallOnlySpec = {
|
|
|
136
132
|
// -- TESTING-7 -------------------------------------------------------------------------------------
|
|
137
133
|
|
|
138
134
|
const UNIT_TEST_BUCKET = /\/(?:src\/tests|tests?\/unit)(?:\/|$)/
|
|
139
|
-
const HFS_TEST_CATEGORY = /\/src\/tests\/(?:
|
|
135
|
+
const HFS_TEST_CATEGORY = /\/src\/tests\/(?:fixtures|e2e)\//
|
|
140
136
|
const SUBJECT_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs"]
|
|
141
137
|
|
|
142
138
|
/** A structural test in an HFS test category may test a source file beside it. */
|
|
@@ -219,7 +215,7 @@ const PROVIDER_PACKAGES = /^(?:@anthropic-ai\/|openai$|openai\/|ollama$|@google\
|
|
|
219
215
|
*/
|
|
220
216
|
const HARNESS_MODEL_HELPERS = /helpers\/models(?:\.service)?$/
|
|
221
217
|
|
|
222
|
-
/** An e2e never calls a model;
|
|
218
|
+
/** An e2e never calls a model; only a live e2e (src/tests/e2e/live/) may reach a provider. */
|
|
223
219
|
export const noModelCallInE2e = {
|
|
224
220
|
meta: {
|
|
225
221
|
type: "problem",
|
|
@@ -227,11 +223,12 @@ export const noModelCallInE2e = {
|
|
|
227
223
|
schema: [],
|
|
228
224
|
messages: {
|
|
229
225
|
provider:
|
|
230
|
-
"`{{source}}` reaches a model provider from an e2e. A model call costs money, takes seconds and answers differently every time - so this makes the flow suite expensive, slow and flaky at once, and the assertion has to be loosened until it stops catching anything. Stub the model and assert what can actually break: the entitlement, the quota, the persisted answer. Judging the answer itself belongs in
|
|
226
|
+
"`{{source}}` reaches a model provider from an e2e. A model call costs money, takes seconds and answers differently every time - so this makes the flow suite expensive, slow and flaky at once, and the assertion has to be loosened until it stops catching anything. Stub the model and assert what can actually break: the entitlement, the quota, the persisted answer. Judging the answer itself belongs in a live e2e under src/tests/e2e/live/.",
|
|
231
227
|
},
|
|
232
228
|
},
|
|
233
229
|
create(context) {
|
|
234
|
-
|
|
230
|
+
const filename = context.filename || context.getFilename()
|
|
231
|
+
if (!isE2eSpec(filename) || isLiveE2eSpec(filename)) return {}
|
|
235
232
|
return {
|
|
236
233
|
ImportDeclaration(node) {
|
|
237
234
|
const source = node.source && node.source.value
|
|
@@ -279,6 +276,16 @@ const aiInvokePickToken = (sourceCode) => {
|
|
|
279
276
|
return null
|
|
280
277
|
}
|
|
281
278
|
|
|
279
|
+
/** A file declares itself a model harness with the comment `@harness-kind model`; any other kind is not one. */
|
|
280
|
+
const DECLARED_MODEL_HARNESS = /@harness-kind\s+model\b/
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* A model harness is identified by what it does, never by where it lives: it imports an LLM provider
|
|
284
|
+
* SDK, reaches for a house model helper or gateway symbol, or declares `@harness-kind model`. A live
|
|
285
|
+
* e2e for an identity provider (Keycloak), a payment gateway or any other third party sits in the same
|
|
286
|
+
* `src/tests/e2e/live/` folder and is not a model harness, so this rule leaves it alone.
|
|
287
|
+
*/
|
|
288
|
+
|
|
282
289
|
/** A harness calls one explicit provider SDK and never disguises it as `AiInvokeService`. */
|
|
283
290
|
export const harnessCallsProviderDirectly = {
|
|
284
291
|
meta: {
|
|
@@ -287,7 +294,7 @@ export const harnessCallsProviderDirectly = {
|
|
|
287
294
|
schema: [],
|
|
288
295
|
messages: {
|
|
289
296
|
missingProvider:
|
|
290
|
-
"This harness imports no approved provider SDK. A model-quality harness calls the declared provider client directly; a house helper, tier or gateway override can make it green about a model production does not use.",
|
|
297
|
+
"This model harness (it reaches for a house model helper or gateway, or declares `@harness-kind model`) imports no approved provider SDK. A model-quality harness calls the declared provider client directly; a house helper, tier or gateway override can make it green about a model production does not use.",
|
|
291
298
|
gateway:
|
|
292
299
|
"`{{name}}` impersonates or replaces the production AI gateway from a harness. Reuse the production prompt builder and parser around a direct provider SDK call instead.",
|
|
293
300
|
helper:
|
|
@@ -298,17 +305,19 @@ export const harnessCallsProviderDirectly = {
|
|
|
298
305
|
},
|
|
299
306
|
create(context) {
|
|
300
307
|
const filename = context.filename || context.getFilename()
|
|
301
|
-
const harness =
|
|
302
|
-
const authScope = harness ||
|
|
308
|
+
const harness = isLiveE2eSpec(filename)
|
|
309
|
+
const authScope = harness || isLiveE2eHelper(filename)
|
|
303
310
|
if (!authScope) return {}
|
|
304
311
|
|
|
305
312
|
let hasProviderImport = false
|
|
313
|
+
let modelSignal = false
|
|
306
314
|
return {
|
|
307
315
|
ImportDeclaration(node) {
|
|
308
316
|
const source = node.source && node.source.value
|
|
309
317
|
if (typeof source !== "string") return
|
|
310
318
|
if (harness && DIRECT_PROVIDER_PACKAGES.test(source)) hasProviderImport = true
|
|
311
319
|
if (harness && FORBIDDEN_HARNESS_HELPERS.test(source.replace(/\.(?:ts|js)$/, ""))) {
|
|
320
|
+
modelSignal = true
|
|
312
321
|
context.report({ node, messageId: "helper", data: { source } })
|
|
313
322
|
}
|
|
314
323
|
if (!harness) return
|
|
@@ -317,6 +326,7 @@ export const harnessCallsProviderDirectly = {
|
|
|
317
326
|
const local = specifier.local && specifier.local.name
|
|
318
327
|
const name = imported || local
|
|
319
328
|
if (!FORBIDDEN_HARNESS_SYMBOLS.has(name)) continue
|
|
329
|
+
modelSignal = true
|
|
320
330
|
context.report({ node: specifier, messageId: "gateway", data: { name } })
|
|
321
331
|
}
|
|
322
332
|
},
|
|
@@ -341,9 +351,11 @@ export const harnessCallsProviderDirectly = {
|
|
|
341
351
|
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
342
352
|
const pickToken = aiInvokePickToken(sourceCode)
|
|
343
353
|
if (pickToken) {
|
|
354
|
+
modelSignal = true
|
|
344
355
|
context.report({ node: pickToken, messageId: "gateway", data: { name: 'Pick<AiInvokeService, "run">' } })
|
|
345
356
|
}
|
|
346
|
-
|
|
357
|
+
const declared = sourceCode.getAllComments().some((comment) => DECLARED_MODEL_HARNESS.test(comment.value))
|
|
358
|
+
if ((modelSignal || declared) && !hasProviderImport) context.report({ node, messageId: "missingProvider" })
|
|
347
359
|
},
|
|
348
360
|
}
|
|
349
361
|
},
|
package/type-safety.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* (TYPE-3, rule 4, extended; see the export below for its `recommended` level and the measurement
|
|
15
15
|
* that set it), the const enum
|
|
16
16
|
* (TYPE-4, rule 5), the unguarded cast off an `unknown` value (TYPE-1's second half, rule 2), and
|
|
17
|
-
*
|
|
17
|
+
* a way of switching the
|
|
18
18
|
* compiler off -- or of leaving it turned further down than the law asks -- that looks locally
|
|
19
19
|
* reasonable, which is exactly the class a rule is for.
|
|
20
20
|
*
|
|
@@ -28,7 +28,7 @@ import { normalizePath } from "./lib/path.mjs"
|
|
|
28
28
|
/** The spec family and the test tree may build a deliberately wrong value on purpose. */
|
|
29
29
|
const isTestFile = (filename) => {
|
|
30
30
|
const file = normalizePath(filename)
|
|
31
|
-
return /\.(?:spec|test|e2e-spec
|
|
31
|
+
return /\.(?:spec|test|e2e-spec)\.ts$/.test(file) || file.includes("/src/tests/")
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** A parameter property (`private readonly x: T`) wraps the parameter it declares. */
|
|
@@ -387,77 +387,114 @@ export const noConstEnum = {
|
|
|
387
387
|
},
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
-
// --
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
390
|
+
// -- R72 -------------------------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
/** `as never` asserts that a value is of no type at all, which satisfies every parameter and checks nothing. */
|
|
393
|
+
export const noNeverCast = {
|
|
394
|
+
meta: {
|
|
395
|
+
type: "problem",
|
|
396
|
+
docs: { description: "No `x as never` outside the test lanes." },
|
|
397
|
+
schema: [],
|
|
398
|
+
messages: {
|
|
399
|
+
neverCast:
|
|
400
|
+
"`as never` makes this value assignable to anything, so the compiler stops checking the call it is passed to. It hides a real mismatch between the value and the parameter. Give the value the type the parameter wants, narrow it with a guard, or fix the signature.",
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
create(context) {
|
|
404
|
+
if (isTestFile(context.filename || context.getFilename())) return {}
|
|
405
|
+
return {
|
|
406
|
+
TSAsExpression(node) {
|
|
407
|
+
if (node.typeAnnotation?.type === "TSNeverKeyword") context.report({ node, messageId: "neverCast" })
|
|
408
|
+
},
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** `x!` claims a value is present without checking. */
|
|
414
|
+
export const noNonNullAssertion = {
|
|
415
|
+
meta: {
|
|
416
|
+
type: "problem",
|
|
417
|
+
docs: { description: "No non-null assertion (`x!`) outside the test lanes." },
|
|
418
|
+
schema: [],
|
|
419
|
+
messages: {
|
|
420
|
+
nonNull:
|
|
421
|
+
"`!` claims this value is present and checks nothing; when it is not, the failure is a `TypeError` far from here. Narrow with a check that returns or throws a typed error, or change the type so absence is impossible.",
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
create(context) {
|
|
425
|
+
if (isTestFile(context.filename || context.getFilename())) return {}
|
|
426
|
+
return {
|
|
427
|
+
TSNonNullExpression(node) {
|
|
428
|
+
context.report({ node, messageId: "nonNull" })
|
|
429
|
+
},
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
}
|
|
404
433
|
|
|
405
|
-
// --
|
|
434
|
+
// -- R75 -------------------------------------------------------------------------------------------
|
|
406
435
|
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
"no-double-cast",
|
|
410
|
-
"no-inline-param-type",
|
|
411
|
-
"no-inline-object-type",
|
|
412
|
-
"no-const-enum",
|
|
413
|
-
"no-unguarded-unknown-cast",
|
|
414
|
-
]
|
|
436
|
+
const HANDLER_DECORATORS = new Set(["Query", "Mutation", "Subscription", "ResolveField", "Get", "Post", "Put", "Patch", "Delete", "MessagePattern", "EventPattern", "Cron", "Interval"])
|
|
437
|
+
const SURFACE_CLASS_DECORATORS = new Set(["Injectable", "Resolver", "Controller"])
|
|
415
438
|
|
|
416
|
-
const
|
|
439
|
+
const decoratorIdentifier = (decorator) => {
|
|
440
|
+
const expression = decorator?.expression
|
|
441
|
+
if (!expression) return null
|
|
442
|
+
if (expression.type === "Identifier") return expression.name
|
|
443
|
+
if (expression.type === "CallExpression" && expression.callee.type === "Identifier") return expression.callee.name
|
|
444
|
+
return null
|
|
445
|
+
}
|
|
417
446
|
|
|
418
|
-
/**
|
|
419
|
-
|
|
420
|
-
*
|
|
421
|
-
* `no-double-cast`'s test-lane exemption already lives in `isTestFile`, consulted once, so every
|
|
422
|
-
* place the exit is in force is derivable from that one function. A per-line `eslint-disable` for
|
|
423
|
-
* any rule this law publishes is therefore never the lane speaking -- it is either redundant (the
|
|
424
|
-
* lane already covers the file) or an undeclared second exit (the file needed one and reached for
|
|
425
|
-
* a comment instead of a lane). Either way `type-safety.md` names this exact failure: "a per-line
|
|
426
|
-
* suppression standing in for a lane-wide exit".
|
|
427
|
-
*
|
|
428
|
-
* Deliberately scoped to only the five rule names this law publishes -- a rule that policed every
|
|
429
|
-
* `eslint-disable` in the repository would be reaching into every other family's lane, which is
|
|
430
|
-
* outside this file's boundary.
|
|
431
|
-
*/
|
|
432
|
-
export const noLineSuppression = {
|
|
447
|
+
/** A method that a caller or the framework reaches states what it returns. */
|
|
448
|
+
export const explicitHandlerReturnType = {
|
|
433
449
|
meta: {
|
|
434
450
|
type: "problem",
|
|
435
|
-
docs: {
|
|
436
|
-
description:
|
|
437
|
-
"No inline `eslint-disable` for a type-safety rule; the lane is the only sanctioned exit (TYPE-6, law 7).",
|
|
438
|
-
},
|
|
451
|
+
docs: { description: "Handlers and public methods of an Injectable, Resolver or Controller declare a return type." },
|
|
439
452
|
schema: [],
|
|
440
453
|
messages: {
|
|
441
|
-
|
|
442
|
-
"This
|
|
454
|
+
handler:
|
|
455
|
+
"This handler declares no return type, so the transport contract is whatever the body happens to return today and a change of the body silently changes the wire. Declare the return type (`Promise<Type>`).",
|
|
456
|
+
publicMethod:
|
|
457
|
+
"This public method declares no return type, so its callers depend on an inferred shape that changes with the body. Declare the return type.",
|
|
443
458
|
},
|
|
444
459
|
},
|
|
445
460
|
create(context) {
|
|
446
|
-
|
|
461
|
+
if (isTestFile(context.filename || context.getFilename())) return {}
|
|
447
462
|
return {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
463
|
+
MethodDefinition(node) {
|
|
464
|
+
if (node.kind !== "method" || node.static || !node.value.body || node.value.returnType) return
|
|
465
|
+
if (node.computed || node.key.type !== "Identifier") return
|
|
466
|
+
const isHandler = (node.decorators ?? []).some((decorator) => HANDLER_DECORATORS.has(decoratorIdentifier(decorator)))
|
|
467
|
+
if (isHandler) {
|
|
468
|
+
context.report({ node: node.key, messageId: "handler" })
|
|
469
|
+
return
|
|
455
470
|
}
|
|
471
|
+
if (node.accessibility === "private" || node.accessibility === "protected" || node.override) return
|
|
472
|
+
const klass = node.parent?.parent
|
|
473
|
+
if (!klass || klass.type !== "ClassDeclaration") return
|
|
474
|
+
const exported = klass.parent?.type === "ExportNamedDeclaration" || klass.parent?.type === "ExportDefaultDeclaration"
|
|
475
|
+
const decorators = [...(klass.decorators ?? []), ...(klass.parent?.decorators ?? [])].map(decoratorIdentifier)
|
|
476
|
+
if (!exported || !decorators.some((name) => SURFACE_CLASS_DECORATORS.has(name))) return
|
|
477
|
+
context.report({ node: node.key, messageId: "publicMethod" })
|
|
456
478
|
},
|
|
457
479
|
}
|
|
458
480
|
},
|
|
459
481
|
}
|
|
460
482
|
|
|
483
|
+
// -- TYPE-5 (rule 6) -- ATTEMPTED, NOT SHIPPED ------------------------------------------------------
|
|
484
|
+
//
|
|
485
|
+
// A rule was written here: two-or-more `is`/`has`-prefixed booleans sharing a type literal with a
|
|
486
|
+
// field that is optional and not itself boolean. It was measured against the reference backend
|
|
487
|
+
// before shipping, per this canon's own standard -- and both of its two real firings were wrong.
|
|
488
|
+
// `GlobalSearchItem` (`isEnrolled` / `isFree` / `isPremium` beside an unrelated optional
|
|
489
|
+
// `parentPath`) and `AdvertisementSeedItem` (`isHouseAd` / `isActive` beside an unrelated optional
|
|
490
|
+
// `ctaText`) are both genuinely independent facts about one entity, not a product of states -- the
|
|
491
|
+
// exact "transport type" and "independent booleans" exceptions `type-safety.md` already names. A
|
|
492
|
+
// rule reporting a defect on 2 of its 2 real firings is not a rule with debt to burn down; it is
|
|
493
|
+
// wrong, and shipping it at any level would teach a reader to distrust the machine rather than the
|
|
494
|
+
// code. TYPE-5 stays `documented`, exactly as the law's own `Layer held` table already says: seeing
|
|
495
|
+
// past a transport DTO to whether its flags are truly one situation needs the meaning of the code,
|
|
496
|
+
// which is a reader's job, not a decidable question of syntax.
|
|
497
|
+
|
|
461
498
|
/** The rules this law contributes to the plugin. */
|
|
462
499
|
export const rules = {
|
|
463
500
|
"no-double-cast": noDoubleCast,
|
|
@@ -465,7 +502,9 @@ export const rules = {
|
|
|
465
502
|
"no-inline-object-type": noInlineObjectType,
|
|
466
503
|
"no-const-enum": noConstEnum,
|
|
467
504
|
"no-unguarded-unknown-cast": noUnguardedUnknownCast,
|
|
468
|
-
"no-
|
|
505
|
+
"no-never-cast": noNeverCast,
|
|
506
|
+
"no-non-null-assertion": noNonNullAssertion,
|
|
507
|
+
"explicit-handler-return-type": explicitHandlerReturnType,
|
|
469
508
|
}
|
|
470
509
|
|
|
471
510
|
/**
|
|
@@ -501,7 +540,9 @@ export const recommended = {
|
|
|
501
540
|
"starci-be/no-inline-object-type": "error",
|
|
502
541
|
"starci-be/no-const-enum": "error",
|
|
503
542
|
"starci-be/no-unguarded-unknown-cast": "warn", // no=3 -- see type-safety.mjs's doc comment
|
|
504
|
-
"starci-be/no-
|
|
543
|
+
"starci-be/no-never-cast": "error",
|
|
544
|
+
"starci-be/no-non-null-assertion": "error",
|
|
545
|
+
"starci-be/explicit-handler-return-type": "error",
|
|
505
546
|
"@typescript-eslint/no-explicit-any": "error",
|
|
506
547
|
"@typescript-eslint/array-type": ["error", {
|
|
507
548
|
default: "generic",
|
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/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
|
-
}
|