@starci/eslint-canon-be 1.0.1 → 1.2.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.
@@ -1,16 +1,28 @@
1
1
  /**
2
2
  * The rules that hold `module-layering.mjs`'s law file, `module-layering.md`.
3
3
  *
4
- * Two rules, both reading import specifiers against the path of the file doing the importing. They
5
- * are cheap and exact, which is the whole reason they can be `error`.
4
+ * Five rules, all reading import specifiers, export specifiers, decorators or a filename against
5
+ * the path of the file being linted. They are cheap and exact -- none of them touches disk -- which
6
+ * is the whole reason they can be `error`:
7
+ * - `must-deep-module-import` / `no-self-module-alias` hold LAYERING-1 and LAYERING-2.
8
+ * - `no-folder-reexport` holds LAYERING-5 / Law 7 (no file re-exports a folder).
9
+ * - `no-self-global-module` holds the decidable slice of LAYERING-4 / Law 6 (a capability may not
10
+ * declare itself `@Global()`; see that rule's own doc comment for what is deliberately still
11
+ * left undecided).
12
+ * - `no-relative-capability-escape` holds Law 8 (a relative import may not walk out of its own
13
+ * capability -- crossing a boundary always goes through the declared public alias).
6
14
  *
7
15
  * LAYERING-3 (a capability module importing a sibling capability) is NOT here, and the omission is
8
16
  * deliberate rather than an oversight: deciding whether an imported module is a sibling capability
9
17
  * or a nested child needs the module graph, and a rule reading one file at a time cannot see it. In
10
18
  * the reference repository that rule exists and is scoped by path glob; a repository adopting this
11
- * law should port it as a gate that walks the tree, not as a per-file rule that guesses.
19
+ * law should port it as a gate that walks the tree, not as a per-file rule that guesses. The same
20
+ * reasoning is why `no-self-global-module` stops at the `@Global()` decorator and does not also
21
+ * chase a hardcoded `isGlobal: true` passed into a `.register(...)` call -- this repository passes
22
+ * that option through at every level of legitimate nesting, and only the module graph can tell a
23
+ * nested child from a foreign capability.
12
24
  *
13
- * THE META-ROOT IS THE TRAP IN BOTH RULES. Some capabilities sit behind a category folder
25
+ * THE META-ROOT IS THE TRAP IN MOST OF THESE. Some capabilities sit behind a category folder
14
26
  * (`platform/`, `lib/`, `integrations/`), so the capability name is the SECOND segment there and
15
27
  * the first everywhere else. Get that wrong and `@modules/platform/exceptions` reads as a
16
28
  * capability-with-file rather than as a barrel.
@@ -152,20 +164,269 @@ export const noSelfModuleAlias = {
152
164
  },
153
165
  }
154
166
 
167
+ // -- shared path arithmetic --------------------------------------------------------------------
168
+ //
169
+ // No fs anywhere below: every check in this file, old and new, reads a specifier string against a
170
+ // filename string. Resolving a relative specifier therefore has to be done the same way -- pure
171
+ // segment arithmetic against the importer's own path, never a disk lookup.
172
+
173
+ /** POSIX-style dirname of an already-normalized (forward-slash) path. */
174
+ const dirnameOf = (file) => {
175
+ const at = file.lastIndexOf("/")
176
+ return at === -1 ? "" : file.slice(0, at)
177
+ }
178
+
179
+ /**
180
+ * Resolves a relative specifier against the file that imports it, without touching disk.
181
+ *
182
+ * `..` pops a segment, `.` is a no-op, everything else pushes. Whether the importer's directory
183
+ * was rooted (a leading `/`) is preserved, so a POSIX-style absolute path stays POSIX-style after
184
+ * resolution; a Windows drive-letter path (`D:/repo/...`) already carries its root as its first
185
+ * segment and needs nothing extra.
186
+ */
187
+ const resolveRelativeSpecifier = (filename, specifier) => {
188
+ const dir = dirnameOf(normalizePath(filename))
189
+ const rooted = dir.startsWith("/")
190
+ const stack = dir.split("/").filter(Boolean)
191
+ for (const part of specifier.split("/")) {
192
+ if (part === "" || part === ".") continue
193
+ if (part === "..") {
194
+ stack.pop()
195
+ continue
196
+ }
197
+ stack.push(part)
198
+ }
199
+ return (rooted ? "/" : "") + stack.join("/")
200
+ }
201
+
202
+ // -- LAYERING-5 / Law 7 -------------------------------------------------------------------------
203
+
204
+ /** This repository's own convention, and the law's Anchor: an `index.*` file at any depth. */
205
+ const INDEX_FILE_RE = /(^|\/)index\.(ts|tsx|js|jsx|mjs|cjs)$/
206
+
207
+ /** A specifier so bare it can only ever name a directory: `.`, `./`, `..`, `../`, or trailing `/`. */
208
+ const isBareFolderSpecifier = (specifier) =>
209
+ specifier === "." || specifier === "./" || specifier === ".." || specifier === "../" || specifier.endsWith("/")
210
+
211
+ /**
212
+ * LAYERING-5 / module-layering.md Law 7: no file re-exports a folder.
213
+ *
214
+ * Two certain signs, neither needing a disk lookup:
215
+ * - a bare-dot or trailing-slash specifier names a directory by construction -- no file path ever
216
+ * looks like that, aliased or relative, so there is nothing to guess;
217
+ * - a file literally named `index.*` whose entire top-level body is re-export statements is the
218
+ * exact shape the law's own Anchor measures ("Zero index.ts files in the entire source tree").
219
+ *
220
+ * What this deliberately does NOT flag: a single bridging re-export naming a real file
221
+ * (`export { AiInvokeService } from '@modules/ai/ai-invoke.service'`), which LAYERING-1's own
222
+ * common-business-situations list keeps legitimate, and a relative barrel that names a real
223
+ * multi-segment path with no file extension (`export * from './services'`) -- telling that apart
224
+ * from a real file with an implicit extension needs the filesystem, which no rule in this canon
225
+ * touches; see the module doc comment for why that gap is left to LAYERING-1's alias-only reach
226
+ * plus this filename check rather than guessed at.
227
+ */
228
+ export const noFolderReexport = {
229
+ meta: {
230
+ type: "problem",
231
+ docs: {
232
+ description:
233
+ "LAYERING-5 / module-layering.md Law 7: no file re-exports a folder -- a bare-directory specifier or an index barrel turns a capability's surface into a list nobody reads.",
234
+ },
235
+ schema: [],
236
+ messages: {
237
+ bareSpecifier:
238
+ "`{{specifier}}` names a directory, not a file. LAYERING-5: a capability's public surface is the files call sites actually import -- re-exporting a whole folder makes that surface a list nobody reads instead. Export the specific file.",
239
+ indexBarrel:
240
+ "This file is named `{{name}}` and its entire content is re-export statements -- the exact shape of an index barrel. LAYERING-5: this repository keeps zero `index.*` files so a barrel specifier never has anything to resolve to. Delete it and have callers import the real files directly.",
241
+ },
242
+ },
243
+ create(context) {
244
+ const filename = normalizePath(context.filename || context.getFilename())
245
+
246
+ const checkSpecifier = (node, specifier) => {
247
+ if (typeof specifier !== "string") return
248
+ if (isBareFolderSpecifier(specifier)) {
249
+ context.report({ node, messageId: "bareSpecifier", data: { specifier } })
250
+ }
251
+ }
252
+
253
+ return {
254
+ ImportDeclaration(node) {
255
+ checkSpecifier(node.source, node.source.value)
256
+ },
257
+ ExportNamedDeclaration(node) {
258
+ if (node.source) checkSpecifier(node.source, node.source.value)
259
+ },
260
+ ExportAllDeclaration(node) {
261
+ if (node.source) checkSpecifier(node.source, node.source.value)
262
+ },
263
+ "Program:exit"(node) {
264
+ if (!INDEX_FILE_RE.test(filename)) return
265
+ const body = node.body
266
+ const reexports = body.filter(
267
+ (stmt) =>
268
+ (stmt.type === "ExportNamedDeclaration" && stmt.source) || stmt.type === "ExportAllDeclaration",
269
+ )
270
+ if (reexports.length === 0 || reexports.length !== body.length) return
271
+ context.report({
272
+ node,
273
+ messageId: "indexBarrel",
274
+ data: { name: filename.slice(filename.lastIndexOf("/") + 1) },
275
+ })
276
+ },
277
+ }
278
+ },
279
+ }
280
+
281
+ // -- LAYERING-4 / Law 6 (partial) ----------------------------------------------------------------
282
+
283
+ /** Files that build their own throwaway test graph rather than wiring a production capability. */
284
+ const TEST_FILE_RE = /\.(spec|test)\.(ts|tsx|js|jsx|mjs|cjs)$/
285
+
286
+ /**
287
+ * LAYERING-4 / module-layering.md Law 6, the one slice of it decidable without whole-application
288
+ * knowledge: a capability declaring `@Global()` on itself.
289
+ *
290
+ * The rest of Law 6 -- which capabilities exist at all, and what starts before what -- has no rule
291
+ * here, and the omission is deliberate, not an oversight. Both need knowledge a single file does
292
+ * not have: "which capabilities exist" is a fact about the whole application graph, and "what
293
+ * starts first" is recorded today as an English comment (`apps/core/src/main.ts`'s side-effect
294
+ * import), which a rule can only pattern-match on wording and would be guessing at meaning, not
295
+ * reading syntax.
296
+ *
297
+ * `isGlobal` passed as a plain, caller-supplied OPTION (`.register({ isGlobal: false })`,
298
+ * `.setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal
299
+ * }))`) is deliberately OUT of scope too, and measuring the reference repository is why: it passes
300
+ * that literal option at every level of nesting, including a capability's own top module wiring its
301
+ * own nested children (`CoreModule.register({ isGlobal: true })` inside `features/api/api.module.ts`,
302
+ * which is nesting, not the sideways declaration Law 6 refuses). Telling "a nested child" from "a
303
+ * foreign capability" needs the module graph -- the exact reason LAYERING-3 stays a documented code
304
+ * rather than an enforced one, and the same reason applies here. `@Global()` carries none of that
305
+ * ambiguity: it declares the module global unconditionally, for every caller, which is precisely
306
+ * the fact Law 6 says only a composition root gets to state.
307
+ */
308
+ export const noSelfGlobalModule = {
309
+ meta: {
310
+ type: "problem",
311
+ docs: {
312
+ description:
313
+ "LAYERING-4 / module-layering.md Law 6: which capabilities are global is stated only at the composition root -- a capability's own file may not declare itself `@Global()`.",
314
+ },
315
+ schema: [],
316
+ messages: {
317
+ global:
318
+ "`@Global()` declares this module global from inside its own capability. LAYERING-4: whether a capability is global is a fact about the APPLICATION, decided per-application at the composition root (`AiModule.register({ isGlobal: true })` in `apps/*/src/app.module.ts`), not a fact a capability gets to state about itself. Remove the decorator and pass `isGlobal` through as a caller-supplied option instead.",
319
+ },
320
+ },
321
+ create(context) {
322
+ const filename = normalizePath(context.filename || context.getFilename())
323
+ if (TEST_FILE_RE.test(filename)) return {}
324
+ const self = selfAliases(filename)
325
+ if (!self || self.prefix === "@tests/") return {}
326
+
327
+ return {
328
+ Decorator(node) {
329
+ const expr = node.expression
330
+ const name =
331
+ expr.type === "CallExpression"
332
+ ? expr.callee.type === "Identifier"
333
+ ? expr.callee.name
334
+ : null
335
+ : expr.type === "Identifier"
336
+ ? expr.name
337
+ : null
338
+ if (name === "Global") {
339
+ context.report({ node, messageId: "global" })
340
+ }
341
+ },
342
+ }
343
+ },
344
+ }
345
+
346
+ // -- Law 8 ("moved to another repository") -------------------------------------------------------
347
+
348
+ /**
349
+ * Law 8 / the law's own moved-capability test, read as a rule: a relative specifier may never leave
350
+ * the capability it is written in. `LAYERING-2` already says the path must be relative INSIDE a
351
+ * capability; this is the fact `LAYERING-2` does not check -- that a relative path can just as
352
+ * easily walk OUT (`../../other-capability/thing`), reaching a sibling capability without ever
353
+ * naming it through the declared public alias `LAYERING-1` requires. A file doing that could not be
354
+ * moved to another repository with its capability and still resolve: the relative path assumes the
355
+ * sibling is sitting right there on disk.
356
+ *
357
+ * Certain because it never touches disk either -- both sides of the comparison (the importer's own
358
+ * path, and the specifier resolved against it) run through the exact same `selfAliases` lookup this
359
+ * file already uses for `LAYERING-2`, so "different capability" is read off two path strings, not
360
+ * guessed from specifier text.
361
+ */
362
+ export const noRelativeCapabilityEscape = {
363
+ meta: {
364
+ type: "problem",
365
+ docs: {
366
+ description:
367
+ "Law 8 (module-layering.md): a capability moved to another repository still resolves every import it declares -- a relative specifier may not walk out of its own capability.",
368
+ },
369
+ schema: [],
370
+ messages: {
371
+ escape:
372
+ "`{{specifier}}` is a relative import that leaves the `{{from}}` capability and reaches `{{to}}`. Law 8: this capability could not be moved to another repository and still resolve that path -- cross a capability boundary through its declared public alias (LAYERING-1), never by walking out with `../`.",
373
+ },
374
+ },
375
+ create(context) {
376
+ const filename = normalizePath(context.filename || context.getFilename())
377
+ const self = selfAliases(filename)
378
+ if (!self) return {}
379
+
380
+ const check = (node, specifier) => {
381
+ if (typeof specifier !== "string") return
382
+ if (!specifier.startsWith("./") && !specifier.startsWith("../")) return
383
+ const resolved = resolveRelativeSpecifier(filename, specifier)
384
+ const target = selfAliases(resolved)
385
+ if (!target) return
386
+ if (target.prefix === self.prefix && target.keys[0] === self.keys[0]) return
387
+ context.report({
388
+ node,
389
+ messageId: "escape",
390
+ data: { specifier, from: self.keys[0], to: target.keys[0] },
391
+ })
392
+ }
393
+ return {
394
+ ImportDeclaration(node) {
395
+ check(node.source, node.source.value)
396
+ },
397
+ ExportNamedDeclaration(node) {
398
+ if (node.source) check(node.source, node.source.value)
399
+ },
400
+ ExportAllDeclaration(node) {
401
+ if (node.source) check(node.source, node.source.value)
402
+ },
403
+ }
404
+ },
405
+ }
406
+
155
407
  /** The rules this law contributes to the plugin. */
156
408
  export const rules = {
157
409
  "must-deep-module-import": mustDeepModuleImport,
158
410
  "no-self-module-alias": noSelfModuleAlias,
411
+ "no-folder-reexport": noFolderReexport,
412
+ "no-self-global-module": noSelfGlobalModule,
413
+ "no-relative-capability-escape": noRelativeCapabilityEscape,
159
414
  }
160
415
 
161
416
  /**
162
417
  * The level this law asks for, as the plugin's own opinion.
163
418
  *
164
- * Both measured at zero debt in the reference repository. A repository adopting them into an
165
- * existing tree will not be at zero: barrels are the default in most codebases, so measure first
166
- * and land above zero at `warn` with the count, exactly as the burn-down procedure says.
419
+ * All five measured at zero debt in the reference repository -- the three added alongside
420
+ * `no-folder-reexport`, `no-self-global-module` and `no-relative-capability-escape` fired zero
421
+ * times across the repository's ~4700 `src/**` TypeScript files, verified against a harness that
422
+ * DOES fire on synthetic violations of each (so the zero is measured, not a broken rule going
423
+ * quiet). A repository adopting any of these five into an existing tree will not be at zero: land
424
+ * above zero at `warn` with the count, exactly as the burn-down procedure says.
167
425
  */
168
426
  export const recommended = {
169
427
  "starci-be/must-deep-module-import": "error",
170
428
  "starci-be/no-self-module-alias": "error",
429
+ "starci-be/no-folder-reexport": "error",
430
+ "starci-be/no-self-global-module": "error",
431
+ "starci-be/no-relative-capability-escape": "error",
171
432
  }
package/naming.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * The rules that hold `naming.md`.
3
3
  *
4
- * Two rules, which are the only two parts of naming a parser can actually judge: whether a name
5
- * encodes a schema VERSION, and whether an exported function is a bare verb.
4
+ * Three rules, which are the parts of naming a parser can actually judge: whether a name encodes a
5
+ * schema VERSION, whether an exported function is a bare verb, and whether a `.module.ts` file's
6
+ * OWN static factory borrows a vendor dynamic-module's exact name instead of this tree's `register`.
6
7
  *
7
- * A THIRD RULE WAS WRITTEN AND DELETED, and the deletion is the useful part. It demanded that a
8
+ * A FOURTH RULE WAS WRITTEN AND DELETED, and the deletion is the useful part. It demanded that a
8
9
  * file name spell out the class it declares, and measured 616 offenders in 4430 files -- because
9
10
  * the convention is the opposite of what it assumed: the PATH carries the role and the scope
10
11
  * (`parsers/content.service.ts` and `path/content.service.ts` declare `ContentParserService` and
@@ -12,6 +13,14 @@
12
13
  * convention, not debt. Canon records what the code does; a rule that disagrees with the code at
13
14
  * that scale is the rule being wrong.
14
15
  *
16
+ * The module-factory rule (NAME-1) stays narrow on purpose: it visits DECLARATIONS only -- a static
17
+ * method on a class this repository declares, decorated `@Module(...)` -- and never a CallExpression,
18
+ * so `BullModule.forRoot(...)`, `ConfigModule.forRoot(...)`, `TypeOrmModule.forFeature(...)` and
19
+ * `SentryCoreModule.forRoot(...)` are calls into vendor code and are never reported. Its forbidden
20
+ * set is closed to the four literal vendor names (`forRoot`, `forRootAsync`, `forFeature`,
21
+ * `forFeatureAsync`); a repository-invented auxiliary factory like `registerQueue` does not collide
22
+ * with a vendor shape and is left alone.
23
+ *
15
24
  * The rest of the law is not enforced and should not be faked. Whether a name describes a folder
16
25
  * (NAME-3), a mechanism (NAME-4) or its first caller (NAME-7) needs to know what the thing IS --
17
26
  * `VolumeService` reads perfectly well until you know the folder was renamed, and no parser knows
@@ -46,6 +55,72 @@ const BARE_VERBS = new Set([
46
55
  "apply",
47
56
  ])
48
57
 
58
+ // -- NAME-1 ----------------------------------------------------------------------------------------
59
+
60
+ /** Decorator names carried by a class declaration. */
61
+ const decoratorNames = (node) =>
62
+ (node.decorators || []).map((decorator) => {
63
+ const expression = decorator.expression
64
+ if (expression.type === "CallExpression" && expression.callee.type === "Identifier") return expression.callee.name
65
+ if (expression.type === "Identifier") return expression.name
66
+ return null
67
+ }).filter(Boolean)
68
+
69
+ /**
70
+ * Vendor dynamic-module factory names, each mapped to the house name a `.module.ts` file's own
71
+ * factory declares instead: `register`, or `registerAsync` for its async companion.
72
+ *
73
+ * This is a CLOSED list of the exact method names Nest's own modules publish
74
+ * (`ConfigModule.forRoot`, `TypeOrmModule.forFeature`, `BullModule.forRootAsync`,
75
+ * `SentryCoreModule.forRoot`). A repository class that declares its OWN static factory under one of
76
+ * these names is not calling the vendor - it is shaped like the vendor, which is exactly what makes
77
+ * `PrimaryPostgreSQLModule.forFeature()` unreadable at a glance from `TypeOrmModule.forFeature()`:
78
+ * same literal name, two different owners, and nothing at the call site says which.
79
+ */
80
+ const VENDOR_FACTORY_NAMES = new Map([
81
+ ["forRoot", "register"],
82
+ ["forRootAsync", "registerAsync"],
83
+ ["forFeature", "register"],
84
+ ["forFeatureAsync", "registerAsync"],
85
+ ])
86
+
87
+ /** A `.module.ts` file's own static factory is named `register`, never a vendor's factory shape. */
88
+ export const noVendorModuleFactoryName = {
89
+ meta: {
90
+ type: "problem",
91
+ docs: {
92
+ description:
93
+ "NAME-1: a `.module.ts` file's own static factory is named `register` (`registerAsync` for its async companion), never a vendor dynamic-module name it does not own.",
94
+ },
95
+ schema: [],
96
+ messages: {
97
+ vendorShaped:
98
+ "`{{name}}` is a module declared IN THIS REPOSITORY, but its static factory is named `{{method}}` - the exact name Nest's own vendor modules publish (`ConfigModule.forRoot`, `TypeOrmModule.forFeature`, `BullModule.forRootAsync`). A reader hitting `{{name}}.{{method}}(...)` in an `imports:` list cannot tell, from the name alone, whether that call lands in this class or forwards straight into a vendor's. Thirty-nine other modules in this tree already answer that question by calling this factory `{{suggestion}}` instead - a name no vendor already owns. Rename `{{method}}` to `{{suggestion}}`.",
99
+ },
100
+ },
101
+ create(context) {
102
+ return {
103
+ MethodDefinition(node) {
104
+ if (!node.static) return
105
+ if (!node.key || node.key.type !== "Identifier") return
106
+ const suggestion = VENDOR_FACTORY_NAMES.get(node.key.name)
107
+ if (!suggestion) return
108
+
109
+ const classBody = node.parent
110
+ const classNode = classBody && classBody.parent
111
+ if (!classNode || !decoratorNames(classNode).includes("Module")) return
112
+
113
+ const name = (classNode.id && classNode.id.name) || "this module"
114
+ context.report({
115
+ node: node.key,
116
+ messageId: "vendorShaped",
117
+ data: { name, method: node.key.name, suggestion },
118
+ })
119
+ },
120
+ }
121
+ },
122
+ }
123
+
49
124
  // -- NAME-2 ----------------------------------------------------------------------------------------
50
125
 
51
126
  /** A name says what a thing is, never which schema generation it was built for. */
@@ -126,6 +201,7 @@ export const noBareVerbExport = {
126
201
  export const rules = {
127
202
  "no-version-in-name": noVersionInName,
128
203
  "no-bare-verb-export": noBareVerbExport,
204
+ "no-vendor-module-factory-name": noVendorModuleFactoryName,
129
205
  }
130
206
 
131
207
  /**
@@ -140,4 +216,5 @@ export const rules = {
140
216
  export const recommended = {
141
217
  "starci-be/no-version-in-name": "warn",
142
218
  "starci-be/no-bare-verb-export": "warn",
219
+ "starci-be/no-vendor-module-factory-name": "warn",
143
220
  }
package/observability.mjs CHANGED
@@ -102,21 +102,154 @@ export const noInterpolatedLogMessage = {
102
102
  },
103
103
  }
104
104
 
105
+ // -- OBSERVABILITY-5 -------------------------------------------------------------------------------
106
+
107
+ /** Whether `node` is a bare reference to the caught error, by name. */
108
+ const isIdentifierNamed = (node, name) => Boolean(node) && node.type === "Identifier" && node.name === name
109
+
110
+ /** How each wording shape reads back to the author, keyed by the `kind` pushed in `scanErrorUsage`. */
111
+ const WORDING_SHAPES = {
112
+ message: (name) => `\`${name}.message\``,
113
+ stringWrap: (name) => `\`String(${name})\``,
114
+ template: (name) => `a template literal that interpolates \`${name}\` directly`,
115
+ }
116
+
117
+ /**
118
+ * Walk a log call's data arguments for two things beside the caught error: a reference that carries
119
+ * only its rendered WORDING (`.message`, `String(error)`, `${error}` inside a template), and a
120
+ * reference that carries its IDENTITY (`.code`). The walk is a shallow recursion over the expression
121
+ * shapes a data object is normally built from - object properties, array elements, template
122
+ * expressions, ternary branches, boolean joins. It does not need a type checker: these three wording
123
+ * shapes and the one identity shape are syntactic, not semantic, which is exactly the boundary
124
+ * OBSERVABILITY-5 draws between what a parser can hold and what needs a reader.
125
+ */
126
+ const scanErrorUsage = (node, errorName, found) => {
127
+ if (!node || typeof node !== "object" || typeof node.type !== "string") return
128
+ switch (node.type) {
129
+ case "MemberExpression":
130
+ if (!node.computed && isIdentifierNamed(node.object, errorName) && node.property.type === "Identifier") {
131
+ if (node.property.name === "message") found.wording.push({ node, kind: "message" })
132
+ if (node.property.name === "code") found.identity.push(node)
133
+ }
134
+ scanErrorUsage(node.object, errorName, found)
135
+ break
136
+ case "CallExpression":
137
+ if (
138
+ node.callee.type === "Identifier" && node.callee.name === "String"
139
+ && node.arguments.length === 1 && isIdentifierNamed(node.arguments[0], errorName)
140
+ ) {
141
+ found.wording.push({ node, kind: "stringWrap" })
142
+ }
143
+ for (const arg of node.arguments) scanErrorUsage(arg, errorName, found)
144
+ break
145
+ case "TemplateLiteral":
146
+ for (const expr of node.expressions) {
147
+ if (isIdentifierNamed(expr, errorName)) found.wording.push({ node: expr, kind: "template" })
148
+ else scanErrorUsage(expr, errorName, found)
149
+ }
150
+ break
151
+ case "ObjectExpression":
152
+ for (const prop of node.properties) scanErrorUsage(prop, errorName, found)
153
+ break
154
+ case "Property":
155
+ scanErrorUsage(node.value, errorName, found)
156
+ break
157
+ case "ArrayExpression":
158
+ for (const el of node.elements) scanErrorUsage(el, errorName, found)
159
+ break
160
+ case "ConditionalExpression":
161
+ scanErrorUsage(node.consequent, errorName, found)
162
+ scanErrorUsage(node.alternate, errorName, found)
163
+ break
164
+ case "LogicalExpression":
165
+ case "BinaryExpression":
166
+ scanErrorUsage(node.left, errorName, found)
167
+ scanErrorUsage(node.right, errorName, found)
168
+ break
169
+ default:
170
+ break
171
+ }
172
+ }
173
+
174
+ /**
175
+ * A failure log's data carries the exception's `code`, not only its rendered message. Scoped to log
176
+ * calls sitting inside a `catch`, and to references to that `catch`'s OWN error identifier - a field
177
+ * that happens to be named `error` elsewhere is not this rule's business, and neither is a call
178
+ * outside a `catch`, since OBSERVABILITY-5 only binds where the line is what an alert groups by.
179
+ */
180
+ export const noErrorWordingAsLogIdentity = {
181
+ meta: {
182
+ type: "problem",
183
+ docs: {
184
+ description:
185
+ "OBSERVABILITY-5 (Rule 5): a failure log's data carries the exception's `code`, so improving the wording never splits an alert in two.",
186
+ },
187
+ schema: [],
188
+ messages: {
189
+ wordingOnly:
190
+ "This failure log carries {{shape}} but never `{{errorName}}.code`. Wording is what a person reads; code is what a dashboard groups by. Fuse the two and the alert lives or dies on how the sentence happens to be phrased today - reword it tomorrow for clarity and the old alert goes silent while a new one opens under the reworded text. Add `{{errorName}}.code` (and any other identity metadata) to the data, and keep the message only as a secondary field beside it.",
191
+ },
192
+ },
193
+ create(context) {
194
+ const catchStack = []
195
+ return {
196
+ CatchClause(node) {
197
+ catchStack.push(node.param && node.param.type === "Identifier" ? node.param.name : null)
198
+ },
199
+ "CatchClause:exit"() {
200
+ catchStack.pop()
201
+ },
202
+ CallExpression(node) {
203
+ const errorName = catchStack[catchStack.length - 1]
204
+ if (!errorName) return
205
+ const callee = node.callee
206
+ if (callee.type !== "MemberExpression" || callee.computed) return
207
+ const method = callee.property.name
208
+ if (!LOG_METHODS.has(method)) return
209
+ if (!isLoggerReceiver(callee.object)) return
210
+
211
+ // the event name (first argument) is OBSERVABILITY-2's business, not this rule's
212
+ const dataArgs = node.arguments.slice(1)
213
+ if (dataArgs.length === 0) return
214
+
215
+ const found = { wording: [], identity: [] }
216
+ for (const arg of dataArgs) scanErrorUsage(arg, errorName, found)
217
+ if (found.wording.length > 0 && found.identity.length === 0) {
218
+ const first = found.wording[0]
219
+ context.report({
220
+ node: first.node,
221
+ messageId: "wordingOnly",
222
+ data: { errorName, shape: WORDING_SHAPES[first.kind](errorName) },
223
+ })
224
+ }
225
+ },
226
+ }
227
+ },
228
+ }
229
+
105
230
  /** The rules this law contributes to the plugin. */
106
231
  export const rules = {
107
232
  "no-framework-logger": noFrameworkLogger,
108
233
  "no-interpolated-log-message": noInterpolatedLogMessage,
234
+ "no-error-wording-as-log-identity": noErrorWordingAsLogIdentity,
109
235
  }
110
236
 
111
237
  /**
112
238
  * The level this law asks for, as the plugin's own opinion.
113
239
  *
114
- * Both measured at zero debt in the reference repository ONCE the sanctioned exit is scoped:
115
- * `no-framework-logger` reports four sites under the standalone-agent folder, and those are the
116
- * exit rather than the debt. An agent running outside the request lifecycle has no request to
117
- * correlate and no transport configured, so the house service would give it a dependency and
118
- * nothing else. Scope that folder off in the consuming config -- once, by path -- rather than
119
- * letting per-line suppressions accumulate until nobody can see how wide the exception has grown.
240
+ * `no-framework-logger` and `no-interpolated-log-message` are both measured at zero debt in the
241
+ * reference repository ONCE the sanctioned exit is scoped: `no-framework-logger` reports four sites
242
+ * under the standalone-agent folder, and those are the exit rather than the debt. An agent running
243
+ * outside the request lifecycle has no request to correlate and no transport configured, so the
244
+ * house service would give it a dependency and nothing else. Scope that folder off in the consuming
245
+ * config -- once, by path -- rather than letting per-line suppressions accumulate until nobody can
246
+ * see how wide the exception has grown.
247
+ *
248
+ * `no-error-wording-as-log-identity` measures at 64 offenders across 39 files in the reference
249
+ * repository (`src/**`, probed against source directly since the published package predates this
250
+ * rule) -- real debt, not zero, so it ships at `warn` with that count on record rather than `error`.
251
+ * Flip to `error` once the count reaches zero; shipping `error` over standing debt is what teaches an
252
+ * author to scroll past a rule instead of fixing it.
120
253
  *
121
254
  * `no-console` is the standard rule rather than a house one, listed here so a repository switches
122
255
  * all three on together: it is the third way out of the pipeline, and leaving it off makes the
@@ -125,6 +258,7 @@ export const rules = {
125
258
  export const recommended = {
126
259
  "starci-be/no-framework-logger": "error",
127
260
  "starci-be/no-interpolated-log-message": "error",
261
+ "starci-be/no-error-wording-as-log-identity": "warn",
128
262
  "no-console": "error",
129
263
  }
130
264
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-be",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "The StarCi back-end lint canon: every rule its laws publish, gathered as one flat-config plugin.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",