@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.
- package/README.md +69 -69
- package/cdc.mjs +107 -1
- package/comments.mjs +147 -1
- package/cqrs.mjs +103 -7
- package/data-access.mjs +144 -10
- package/e2e-flow.mjs +222 -213
- package/event-delivery.mjs +54 -1
- package/index.mjs +19 -2
- package/module-layering.mjs +268 -7
- package/naming.mjs +80 -3
- package/observability.mjs +140 -6
- package/package.json +1 -1
- package/testing.mjs +147 -46
- package/transport.mjs +65 -0
- package/type-safety.mjs +242 -8
package/module-layering.mjs
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The rules that hold `module-layering.mjs`'s law file, `module-layering.md`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* are cheap and exact
|
|
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
|
|
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
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
-
*
|
|
115
|
-
* `no-framework-logger` reports four sites
|
|
116
|
-
* exit rather than the debt. An agent running
|
|
117
|
-
* correlate and no transport configured, so the
|
|
118
|
-
* nothing else. Scope that folder off in the consuming
|
|
119
|
-
* letting per-line suppressions accumulate until nobody can
|
|
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