@starci/eslint-canon-be 3.3.1 → 3.4.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -2
  3. package/app-composition.mjs +348 -3
  4. package/authorization.mjs +68 -2
  5. package/builders.mjs +2 -2
  6. package/config-owner.mjs +44 -15
  7. package/default-deny.mjs +80 -9
  8. package/edge-hardening.mjs +139 -0
  9. package/feature-thin.mjs +78 -2
  10. package/function-budget.mjs +49 -0
  11. package/health-ready.mjs +539 -0
  12. package/index.mjs +37 -0
  13. package/infra-owner.mjs +3 -0
  14. package/jwt-alg.mjs +45 -0
  15. package/lib/subject-values.mjs +55 -0
  16. package/lib/unit-spec.mjs +0 -3
  17. package/lib/webhook-handoff-inbox.mjs +260 -0
  18. package/lib/webhook-handoff-intake.mjs +140 -0
  19. package/lib/why.mjs +13 -0
  20. package/module-shape.mjs +147 -51
  21. package/operations.mjs +165 -1
  22. package/package.json +1 -1
  23. package/realtime.mjs +59 -22
  24. package/resilience.mjs +45 -14
  25. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  26. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  27. package/runtime/knowledge/hfs/rules.yaml +3401 -152
  28. package/runtime/knowledge/hfs/slots.yaml +344 -62
  29. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  30. package/runtime/knowledge/sonar-gate.yaml +1 -1
  31. package/runtime/modules/models/runtimes.yaml +1 -1
  32. package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
  33. package/runtime/scripts/api/git/lib.mjs +3 -0
  34. package/runtime/scripts/api/git/merge-base.mjs +9 -0
  35. package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
  36. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  37. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  38. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  39. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  40. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  41. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  42. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  43. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  44. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  45. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  46. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  47. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  48. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  49. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  50. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  51. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  52. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  53. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  54. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  55. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  56. package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
  57. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  58. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  59. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  60. package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
  61. package/runtime/scripts/hfs/project-rule.mjs +3 -1
  62. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  63. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  64. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  65. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  66. package/runtime/scripts/hfs/slots.mjs +1 -0
  67. package/runtime/scripts/lib/env.mjs +20 -0
  68. package/runtime/scripts/lib/event-contract.mjs +107 -0
  69. package/runtime/scripts/lib/json-clone.mjs +61 -0
  70. package/runtime/scripts/lib/list.mjs +4 -0
  71. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  72. package/runtime/scripts/lib/stack-services.mjs +1 -1
  73. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  74. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  75. package/service-isolation.mjs +3 -0
  76. package/size-budget.mjs +9 -5
  77. package/third-party-owner.mjs +149 -0
  78. package/transport.mjs +11 -29
  79. package/unit-spec.mjs +270 -84
  80. package/webhooks.mjs +169 -0
package/module-shape.mjs CHANGED
@@ -12,11 +12,15 @@
12
12
  * capability's representative module is reached through its `Inject*()` decorators, never by import. One owner may
13
13
  * import its own modules (a transport module its feature's application module, a representative its sub-modules);
14
14
  * the app composes every owner.
15
- * - `capability-module-shape` holds the files of a capability: its representative `<c>.module.ts` extends the
16
- * `ConfigurableModuleClass` of its own `<c>.module-definition.ts` (and may override only
17
- * `static register(options: typeof OPTIONS_TYPE): DynamicModule`); that definition is a typed
18
- * `ConfigurableModuleBuilder` with `.setExtras({ isGlobal: false }, ...)` and `.build()`; every sub-module and every
19
- * feature module is a plain `@Module` class, and a feature has no module definition.
15
+ * - `capability-module-shape` holds the definition of every module (owner ruling module-definition-everywhere): the representative
16
+ * `<c>.module-definition.ts` of a capability is a typed `ConfigurableModuleBuilder` with `.setExtras({ isGlobal: false }, ...)`
17
+ * and `.build()`; every other module's definition (a sub-module, a feature, a transport, a cli group) is
18
+ * `.setClassMethodName("register").build()` with no extras; only a representative may override
19
+ * `static register(options: typeof OPTIONS_TYPE): DynamicModule`.
20
+ * - `module-definition-required` holds that every module has one: a `<x>.module.ts` of a feature, capability or projection has a
21
+ * sibling `<x>.module-definition.ts` and `<x>.options.ts`, its class extends the `ConfigurableModuleClass` of that definition,
22
+ * and a module of the product appears in an `imports` array only as `X.register(...)`, never as a bare class
23
+ * (BE_MODULE_DEFINITION_MISSING).
20
24
  * - `typed-module-definition` requires `ConfigurableModuleBuilder<Options>` to state its options type.
21
25
  * - `static-module-register` allows one factory name, `register`, and requires it `static`: no `forRoot`, `forFeature`
22
26
  * or `registerAsync`, because options are parsed once in `main.ts`.
@@ -25,6 +29,8 @@
25
29
  * - `no-module-let` refuses mutable module-level state (`let` and `var` at the top of a file).
26
30
  * - `one-module-per-file` allows one `@Module` class in a file, so one transport or application module has one home.
27
31
  */
32
+ import { existsSync } from "node:fs"
33
+ import { dirname, join } from "node:path"
28
34
  import ts from "typescript"
29
35
  import { decoratorName, keyName, walk } from "./lib/ast.mjs"
30
36
  import { importsFrom } from "./lib/declared.mjs"
@@ -294,7 +300,9 @@ export const oneModulePerFile = {
294
300
  },
295
301
  }
296
302
 
297
- const CAPABILITY_TIERS = new Set(["domain", "platform", "integrations"])
303
+ const CAPABILITY_TIERS = new Set(["domain", "platform", "integrations", "projections"])
304
+ /** The tiers whose `<x>.module.ts` files are modules of the product: every one of them has a module definition. */
305
+ const MODULE_TIERS = new Set(["feature", "domain", "platform", "integrations", "projections", "queues", "events"])
298
306
 
299
307
  /** `{ inside, name }`: the owner-relative path of a file and the owner's directory name, or null outside an owner. */
300
308
  const insideOwner = (hfs, filename) => {
@@ -330,84 +338,170 @@ const builderChain = (newExpression) => {
330
338
  return calls
331
339
  }
332
340
 
341
+ /** True when the argument is the string literal `register`. */
342
+ const isRegisterName = (node) => node?.type === "Literal" && node.value === "register"
343
+
344
+ /** The reasons a builder chain is not the module definition of its kind (an empty list when it is). */
345
+ const chainFaults = (calls, representative) => {
346
+ const missing = []
347
+ const extras = calls.find((entry) => entry.name === "setExtras")
348
+ const named = calls.find((entry) => entry.name === "setClassMethodName")
349
+ if (representative) {
350
+ const defaults = extras?.call.arguments[0]
351
+ const localByDefault =
352
+ defaults?.type === "ObjectExpression" &&
353
+ defaults.properties.some((item) => item.type === "Property" && keyName(item.key) === "isGlobal" && item.value.type === "Literal" && item.value.value === false)
354
+ if (!localByDefault) missing.push("`.setExtras({ isGlobal: false }, ...)` is missing or does not default `isGlobal` to a literal `false`")
355
+ if (named && !isRegisterName(named.call.arguments[0])) missing.push('`.setClassMethodName(...)` names a factory other than "register"')
356
+ } else {
357
+ if (extras) missing.push("a sub-module, feature or transport module is never global, so it has no `.setExtras(...)`")
358
+ if (!named || !isRegisterName(named.call.arguments[0])) missing.push('`.setClassMethodName("register")` is missing')
359
+ }
360
+ if (calls.at(-1)?.name !== "build") missing.push("the chain does not end in `.build()`")
361
+ return missing
362
+ }
363
+
364
+ /** The module scope of a file: its kind, tier and name when it is a `<x>.module.ts` or `<x>.module-definition.ts` of a product tier, else null. */
365
+ const moduleFileOf = (hfs, filename) => {
366
+ const base = filename.split("/").at(-1)
367
+ const suffix = base.endsWith(".module-definition.ts") ? ".module-definition.ts" : base.endsWith(".module.ts") ? ".module.ts" : null
368
+ if (!suffix) return null
369
+ const tier = hfs.tierOf(filename)
370
+ if (!MODULE_TIERS.has(tier)) return null
371
+ const name = base.slice(0, base.length - suffix.length)
372
+ const where = insideOwner(hfs, filename)
373
+ const representative = CAPABILITY_TIERS.has(tier) && where !== null && where.inside === `${where.name}${suffix}`
374
+ return { kind: suffix === ".module.ts" ? "module" : "definition", tier, name, representative }
375
+ }
376
+
333
377
  /**
334
- * The shape of a capability's module files (BE-CONVENTION 1.2): the representative `<c>.module.ts` extends the
335
- * `ConfigurableModuleClass` of its own `<c>.module-definition.ts`; that definition builds a typed
336
- * `ConfigurableModuleBuilder` with `.setExtras({ isGlobal: false }, ...)` and `.build()`; every other module of the
337
- * capability, and every feature module, is a plain static `@Module` class with no definition and no `register`.
378
+ * The shape of a module's definition (BE-CONVENTION 1.2, owner ruling module-definition-everywhere): the representative
379
+ * module of a capability builds `new ConfigurableModuleBuilder<XOptions>().setExtras({ isGlobal: false }, ...).build()` and
380
+ * may override only `static register(options: typeof OPTIONS_TYPE): DynamicModule`; every other module (a sub-module, a feature,
381
+ * a transport, a cli group) builds `new ConfigurableModuleBuilder<XOptions>().setClassMethodName("register").build()` and
382
+ * declares nothing but its `@Module` metadata.
338
383
  */
339
384
  export const capabilityModuleShape = {
340
385
  meta: {
341
386
  type: "problem",
342
- docs: { description: "A capability's representative module extends its module definition's `ConfigurableModuleClass`; sub-modules and feature modules are plain." },
387
+ docs: { description: "A module definition is a typed `ConfigurableModuleBuilder` chain of its kind; only a capability's representative module overrides `register`." },
343
388
  schema: [],
344
389
  messages: {
345
- extendsBase: "The representative module of `{{name}}` must be `@Module({...}) export class X extends ConfigurableModuleClass {}` with `ConfigurableModuleClass` imported from `./{{name}}.module-definition`.",
346
390
  registerSignature: "The only `register` a capability module may declare is the override `static register(options: typeof OPTIONS_TYPE): DynamicModule`, with `OPTIONS_TYPE` from `./{{name}}.module-definition`.",
347
- plain: "`{{class}}` is a sub-module or a feature module: a plain static `@Module` class with no base class, no `register` and no module definition. Only the capability's representative module `{{name}}.module.ts` is configurable.",
348
- chain: "The module definition must be `new ConfigurableModuleBuilder<XOptions>().setExtras({ isGlobal: false }, (d, e) => ({ ...d, global: e.isGlobal })).build()`: {{missing}}.",
349
- featureDefinition: "A feature has no module definition: feature modules are static. Delete this file and register the feature's providers in its `@Module`.",
391
+ plain: "`{{class}}` is a sub-module, feature module, transport module or cli group: it declares its providers in `@Module({...})` and has no `register` of its own. Only the representative module of a capability may override `register`.",
392
+ chain: "The module definition of this kind must be {{shape}}: {{missing}}.",
350
393
  },
351
394
  },
352
395
  create(context) {
353
396
  const filename = normalizePath(context.filename || context.getFilename())
354
- if (isDeclarationFile(filename)) return {}
355
- const hfs = hfsOf(context)
356
- const where = insideOwner(hfs, filename)
357
- if (!where) return {}
358
- const base = filename.split("/").at(-1)
359
- const isDefinition = base.endsWith(".module-definition.ts")
360
- const isModuleFile = base.endsWith(".module.ts")
361
- if (where.tier === "feature" && isDefinition) return { Program: (program) => context.report({ node: program, loc: { line: 1, column: 0 }, messageId: "featureDefinition" }) }
362
- if (CAPABILITY_TIERS.has(where.tier) && isDefinition) {
397
+ if (isDeclarationFile(filename) || isSpecFile(filename)) return {}
398
+ const scope = moduleFileOf(hfsOf(context), filename)
399
+ if (!scope) return {}
400
+ if (scope.kind === "definition") {
401
+ const shape = scope.representative
402
+ ? "`new ConfigurableModuleBuilder<XOptions>().setExtras({ isGlobal: false }, (d, e) => ({ ...d, global: e.isGlobal })).build()`"
403
+ : '`new ConfigurableModuleBuilder<XOptions>().setClassMethodName("register").build()`'
363
404
  return {
364
405
  Program(program) {
365
406
  let found = false
366
407
  walk(program, (node) => {
367
408
  if (node.type !== "NewExpression" || node.callee.type !== "Identifier" || node.callee.name !== "ConfigurableModuleBuilder") return
368
409
  found = true
369
- const calls = builderChain(node)
370
- const extras = calls.find((entry) => entry.name === "setExtras")
371
- const defaults = extras?.call.arguments[0]
372
- const localByDefault =
373
- defaults?.type === "ObjectExpression" &&
374
- defaults.properties.some((item) => item.type === "Property" && keyName(item.key) === "isGlobal" && item.value.type === "Literal" && item.value.value === false)
375
- const missing = []
376
- if (!localByDefault) missing.push("`.setExtras({ isGlobal: false }, ...)` is missing or does not default `isGlobal` to a literal `false`")
377
- if (calls.at(-1)?.name !== "build") missing.push("the chain does not end in `.build()`")
378
- if (missing.length > 0) context.report({ node, messageId: "chain", data: { missing: missing.join("; ") } })
410
+ const missing = chainFaults(builderChain(node), scope.representative)
411
+ if (missing.length > 0) context.report({ node, messageId: "chain", data: { shape, missing: missing.join("; ") } })
379
412
  })
380
- if (!found) context.report({ node: program, loc: { line: 1, column: 0 }, messageId: "chain", data: { missing: "no `new ConfigurableModuleBuilder<XOptions>()` is built here" } })
413
+ if (!found) context.report({ node: program, loc: { line: 1, column: 0 }, messageId: "chain", data: { shape, missing: "no `new ConfigurableModuleBuilder<XOptions>()` is built here" } })
381
414
  },
382
415
  }
383
416
  }
384
- if (!isModuleFile || (where.tier !== "feature" && !CAPABILITY_TIERS.has(where.tier))) return {}
385
- const importedFrom = new Map()
386
- const representative = CAPABILITY_TIERS.has(where.tier) && where.inside === `${where.name}.module.ts`
387
417
  return {
388
418
  ClassDeclaration(node) {
389
419
  if (!isModuleClass(node)) return
390
- if (!representative) {
391
- const hasRegister = node.body.body.some((member) => member.type === "MethodDefinition" && member.key.type === "Identifier" && member.key.name === "register")
392
- if (node.superClass || hasRegister) context.report({ node: node.id ?? node, messageId: "plain", data: { class: node.id?.name ?? "This class", name: where.name } })
393
- return
394
- }
395
- const base = node.superClass
396
- const imported = base?.type === "Identifier" ? importedFrom.get(base.name) : null
397
- if (!imported || imported.name !== "ConfigurableModuleClass" || imported.source !== `./${where.name}.module-definition`) {
398
- context.report({ node: node.id ?? node, messageId: "extendsBase", data: { name: where.name } })
399
- }
400
420
  for (const member of node.body.body) {
401
- if (member.type === "MethodDefinition" && member.key.type === "Identifier" && member.key.name === "register" && !isRegisterOverride(member)) {
402
- context.report({ node: member, messageId: "registerSignature", data: { name: where.name } })
403
- }
421
+ if (member.type !== "MethodDefinition" || member.key.type !== "Identifier" || member.key.name !== "register") continue
422
+ if (!scope.representative) context.report({ node: member, messageId: "plain", data: { class: node.id?.name ?? "This class" } })
423
+ else if (!isRegisterOverride(member)) context.report({ node: member, messageId: "registerSignature", data: { name: scope.name } })
404
424
  }
405
425
  },
426
+ }
427
+ },
428
+ }
429
+
430
+ /** True when a class declaration carries the Nest `Module` decorator. */
431
+ const isModuleDeclaration = (declaration) => {
432
+ if (!ts.isClassDeclaration(declaration)) return false
433
+ return (ts.getDecorators(declaration) ?? []).some((decorator) => {
434
+ const expression = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression
435
+ return ts.isIdentifier(expression) && expression.text === "Module"
436
+ })
437
+ }
438
+
439
+ /** The array of an `imports` property (the `@Module({ imports })` metadata or a dynamic module literal), else null. */
440
+ const importsArrayOf = (property) => (!property.computed && keyName(property.key) === "imports" && property.value.type === "ArrayExpression" ? property.value : null)
441
+
442
+ /** The PascalCase options type name a module file name implies: `sepay-feed-http` gives `SepayFeedHttpOptions`. */
443
+ const optionsTypeOf = (name) => `${name.replace(/(?:^|-)(\w)/g, (_, letter) => letter.toUpperCase())}Options`
444
+
445
+ /**
446
+ * Every Nest module has a module definition and is registered through it (owner ruling module-definition-everywhere): its
447
+ * `<x>.module.ts` has a sibling `<x>.module-definition.ts` and `<x>.options.ts`, its class extends the `ConfigurableModuleClass`
448
+ * of that definition, and a module of the product is listed in an `imports` array only as `X.register(...)`, never as a bare
449
+ * class. The `AppModule` of an app root and the test world are composition roots and are not judged here.
450
+ */
451
+ export const moduleDefinitionRequired = {
452
+ meta: {
453
+ type: "problem",
454
+ docs: { description: "A module has a sibling module definition and options file, extends its `ConfigurableModuleClass`, and is imported only as `X.register(...)`." },
455
+ schema: [],
456
+ messages: {
457
+ missingDefinition: '`{{name}}.module.ts` has no sibling `{{name}}.module-definition.ts`. Every module builds its base class: `export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } = new ConfigurableModuleBuilder<{{type}}>().setClassMethodName("register").build()`.',
458
+ missingOptions: "`{{name}}.module.ts` has no sibling `{{name}}.options.ts`. Declare `export type {{type}} = Record<string, never>` there (with a one-line doc saying the module takes no options yet), or its real options.",
459
+ extendsBase: "`{{class}}` must be `@Module({...}) export class {{class}} extends ConfigurableModuleClass {}` with `ConfigurableModuleClass` imported from `./{{name}}.module-definition`.",
460
+ unregistered: "`{{name}}` is imported as a bare class. Every module is registered through its definition: write `{{name}}.register({...})` (`{}` when it takes no options).",
461
+ },
462
+ },
463
+ create(context) {
464
+ const filename = normalizePath(context.filename || context.getFilename())
465
+ if (isDeclarationFile(filename) || isSpecFile(filename)) return {}
466
+ const hfs = hfsOf(context)
467
+ if (inTestWorld(hfs, filename)) return {}
468
+ const scope = moduleFileOf(hfs, filename)
469
+ const importedFrom = new Map()
470
+ const { checker, toTs } = typed(context)
471
+ const isModuleClassIdentifier = (identifier) => {
472
+ const tsNode = toTs(identifier)
473
+ let symbol = tsNode ? checker.getSymbolAtLocation(tsNode) : undefined
474
+ if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol)
475
+ return (symbol?.declarations ?? []).some((declaration) => !/[\\/]node_modules[\\/]/.test(declaration.getSourceFile().fileName) && isModuleDeclaration(declaration))
476
+ }
477
+ return {
406
478
  ImportDeclaration(node) {
407
479
  for (const specifier of node.specifiers) {
408
480
  if (specifier.type === "ImportSpecifier") importedFrom.set(specifier.local.name, { name: specifier.imported.name ?? specifier.imported.value, source: String(node.source.value) })
409
481
  }
410
482
  },
483
+ Program(program) {
484
+ if (scope?.kind !== "module") return
485
+ const siblings = dirname(filename)
486
+ const type = optionsTypeOf(scope.name)
487
+ if (!existsSync(join(siblings, `${scope.name}.module-definition.ts`))) context.report({ node: program, loc: { line: 1, column: 0 }, messageId: "missingDefinition", data: { name: scope.name, type } })
488
+ if (!existsSync(join(siblings, `${scope.name}.options.ts`))) context.report({ node: program, loc: { line: 1, column: 0 }, messageId: "missingOptions", data: { name: scope.name, type } })
489
+ },
490
+ ClassDeclaration(node) {
491
+ if (scope?.kind !== "module" || !isModuleClass(node)) return
492
+ const base = node.superClass
493
+ const imported = base?.type === "Identifier" ? importedFrom.get(base.name) : null
494
+ if (!imported || imported.name !== "ConfigurableModuleClass" || imported.source !== `./${scope.name}.module-definition`) {
495
+ context.report({ node: node.id ?? node, messageId: "extendsBase", data: { class: node.id?.name ?? "This class", name: scope.name } })
496
+ }
497
+ },
498
+ Property(node) {
499
+ const array = importsArrayOf(node)
500
+ if (!array) return
501
+ for (const entry of array.elements) {
502
+ if (entry?.type === "Identifier" && isModuleClassIdentifier(entry)) context.report({ node: entry, messageId: "unregistered", data: { name: entry.name } })
503
+ }
504
+ },
411
505
  }
412
506
  },
413
507
  }
@@ -418,6 +512,7 @@ export const rules = {
418
512
  "is-global-only-in-app": isGlobalOnlyInApp,
419
513
  "no-cross-owner-module-import": noCrossOwnerModuleImport,
420
514
  "capability-module-shape": capabilityModuleShape,
515
+ "module-definition-required": moduleDefinitionRequired,
421
516
  "typed-module-definition": typedModuleDefinition,
422
517
  "static-module-register": staticModuleRegister,
423
518
  "no-new-injectable": noNewInjectable,
@@ -431,6 +526,7 @@ export const recommended = {
431
526
  "starci-be/is-global-only-in-app": "error",
432
527
  "starci-be/no-cross-owner-module-import": "error",
433
528
  "starci-be/capability-module-shape": "error",
529
+ "starci-be/module-definition-required": "error",
434
530
  "starci-be/typed-module-definition": "error",
435
531
  "starci-be/static-module-register": "error",
436
532
  "starci-be/no-new-injectable": "error",
package/operations.mjs CHANGED
@@ -11,8 +11,9 @@
11
11
  * a path pattern.
12
12
  */
13
13
  import ts from "typescript"
14
+ import { keyName, walk } from "./lib/ast.mjs"
14
15
  import { hfsOf } from "./lib/hfs.mjs"
15
- import { decoratorCallee, isImportedFrom } from "./lib/import-source.mjs"
16
+ import { decoratorArguments, decoratorCallee, isImportedFrom } from "./lib/import-source.mjs"
16
17
  import { isTransportSlot } from "./lib/transport-slots.mjs"
17
18
  import { typed } from "./lib/types.mjs"
18
19
 
@@ -155,6 +156,167 @@ const unwrapPromise = (annotation) => {
155
156
  return node ?? null
156
157
  }
157
158
 
159
+ /** The canon request and reply annotations of one HTTP method. */
160
+ const operationReferences = (context, method) => {
161
+ const hfs = hfsOf(context)
162
+ const { checker, toTs } = typed(context)
163
+ const params = method.value.params.filter((param) => (param.decorators ?? []).some((decorator) => isImportedFrom(context, decoratorCallee(decorator), "@nestjs/common", "Body")))
164
+ return {
165
+ request: params.map((param) => canonReference(hfs, checker, toTs, param.typeAnnotation ?? param.parameter?.typeAnnotation)).find((reference) => reference?.name === "OperationRequest") ?? null,
166
+ reply: canonReference(hfs, checker, toTs, unwrapPromise(method.value.returnType)),
167
+ }
168
+ }
169
+
170
+ /** A route's table must be the nonempty result of the canon defineOperations, including an imported app table. */
171
+ export const operationRouteEvidence = (context) => {
172
+ const hfs = hfsOf(context)
173
+ const { program, checker } = typed(context)
174
+ let tables
175
+ const declaredTables = () => {
176
+ if (tables) return tables
177
+ tables = new Set()
178
+ const visit = (node) => {
179
+ if (ts.isCallExpression(node) && isCanonType(hfs, checker, checker.getSymbolAtLocation(node.expression), "defineOperations")) {
180
+ const table = checker.getTypeAtLocation(node)
181
+ if (checker.getPropertiesOfType(table).length > 0) tables.add(table)
182
+ }
183
+ ts.forEachChild(node, visit)
184
+ }
185
+ for (const source of program.getSourceFiles()) {
186
+ if (!source.isDeclarationFile && hfs.slotOf(source.fileName)) visit(source)
187
+ }
188
+ return tables
189
+ }
190
+ return (method) => {
191
+ const { request, reply } = operationReferences(context, method)
192
+ return request?.table != null && reply?.name === "OperationReply" && request.table === reply.table && declaredTables().has(request.table)
193
+ }
194
+ }
195
+
196
+ /** A symbol declared by the app's domain identity capability, rather than a same-named local decorator or enum. */
197
+ const identityExport = (context, node, name) => {
198
+ if (!node) return false
199
+ const hfs = hfsOf(context)
200
+ const { checker, toTs } = typed(context)
201
+ const target = toTs(node)
202
+ if (!target) return false
203
+ return declarationsOfSymbol(checker, checker.getSymbolAtLocation(target)).some((declaration) => {
204
+ const file = declaration.getSourceFile().fileName
205
+ return declaration.name?.text === name && hfs.tierOf(file) === "domain" && hfs.ownerOf(file)?.split("/").pop() === "identity"
206
+ })
207
+ }
208
+
209
+ const REST_REASONS = new Set(["Health", "AuthHandshake", "SignedWebhook"])
210
+ const FILE_INTERCEPTORS = ["FileInterceptor", "FilesInterceptor", "AnyFilesInterceptor", "FileFieldsInterceptor"]
211
+
212
+ /** A stream actually returned by this method; importing or mentioning a stream constructor proves nothing. */
213
+ const returnsBytes = (context, method) => {
214
+ const seen = new Set()
215
+ const isBytes = (expression) => {
216
+ if (!expression || seen.has(expression)) return false
217
+ seen.add(expression)
218
+ if (["AwaitExpression", "TSAsExpression", "TSTypeAssertion", "TSNonNullExpression"].includes(expression.type)) return isBytes(expression.argument ?? expression.expression)
219
+ if (expression.type === "NewExpression" && isImportedFrom(context, expression.callee, "@nestjs/common", "StreamableFile")) return true
220
+ if (expression.type === "CallExpression" && isImportedFrom(context, expression.callee, ["node:fs", "fs"], "createReadStream")) return true
221
+ if (expression.type !== "Identifier") return false
222
+ for (let scope = context.sourceCode.getScope(expression); scope; scope = scope.upper) {
223
+ const variable = scope.set.get(expression.name)
224
+ if (variable) return variable.defs.some((definition) => definition.type === "Variable" && isBytes(definition.node.init))
225
+ }
226
+ return false
227
+ }
228
+ let found = false
229
+ walk(method.value.body, (node) => {
230
+ if (node.type === "ReturnStatement" && isBytes(node.argument)) found = true
231
+ }, { intoFunctions: false })
232
+ return found
233
+ }
234
+
235
+ /** True when a class declaration implements the `CanActivate` of `@nestjs/common` (resolved by symbol, not by spelling). */
236
+ const implementsCanActivate = (checker, declaration) =>
237
+ (declaration.heritageClauses ?? []).some((clause) => clause.token === ts.SyntaxKind.ImplementsKeyword && clause.types.some((type) => {
238
+ const symbol = checker.getSymbolAtLocation(type.expression)
239
+ return declarationsOfSymbol(checker, symbol).some((found) => found.name?.text === "CanActivate" && /[\/]@nestjs[\/]common[\/]/.test(String(found.getSourceFile().fileName)))
240
+ }))
241
+
242
+ /**
243
+ * True when the identity capability that declares `Credential` also declares the canon `CredentialGuard`, a class implementing
244
+ * `CanActivate`. The decorator only selects the credential kind; this guard is what authenticates it.
245
+ */
246
+ const credentialGuardDeclared = (context, decoratorCallee_) => {
247
+ const hfs = hfsOf(context)
248
+ const { program, checker, toTs } = typed(context)
249
+ const target = toTs(decoratorCallee_)
250
+ const home = declarationsOfSymbol(checker, target ? checker.getSymbolAtLocation(target) : undefined).find((declaration) => declaration.name?.text === "Credential")
251
+ const owner = home && hfs.ownerOf(home.getSourceFile().fileName)
252
+ if (!owner) return false
253
+ for (const source of program.getSourceFiles()) {
254
+ if (source.isDeclarationFile || hfs.ownerOf(source.fileName) !== owner) continue
255
+ const found = source.statements.some((statement) => ts.isClassDeclaration(statement) && statement.name?.text === "CredentialGuard" && implementsCanActivate(checker, statement))
256
+ if (found) return true
257
+ }
258
+ return false
259
+ }
260
+
261
+ /**
262
+ * Credential-guarded route (BE_REST_DOOR_EVIDENCE, owner delegation 2026-10-16): the route, or its controller class, carries
263
+ * `@Credential({ kind: "<literal>" })` imported from the domain identity capability, and that capability declares a
264
+ * `CredentialGuard implements CanActivate`. A same-named local decorator, a `@UseGuards` of any other guard and a non-literal kind prove nothing.
265
+ */
266
+ const credentialGuardEvidence = (context, method) => {
267
+ const carriers = [...(method.decorators ?? []), ...(method.parent?.parent?.decorators ?? [])]
268
+ for (const decorator of carriers) {
269
+ const callee = decoratorCallee(decorator)
270
+ if (!identityExport(context, callee, "Credential")) continue
271
+ const options = decoratorArguments(decorator)[0]
272
+ const kind = options?.type === "ObjectExpression" ? options.properties.find((property) => property.type === "Property" && keyName(property.key) === "kind")?.value : null
273
+ if (kind?.type === "Literal" && typeof kind.value === "string" && kind.value !== "" && credentialGuardDeclared(context, callee)) return true
274
+ }
275
+ return false
276
+ }
277
+
278
+ /** Route-level evidence for probes, auth handshakes, signed webhooks, redirects, byte transfers and credential-guarded routes. */
279
+ export const restExceptionEvidence = (context, method) => {
280
+ if (credentialGuardEvidence(context, method)) return true
281
+ for (const decorator of method.decorators ?? []) {
282
+ const callee = decoratorCallee(decorator)
283
+ if (isImportedFrom(context, callee, "@nestjs/common", "Redirect")) return true
284
+ if (identityExport(context, callee, "Public")) {
285
+ const options = decoratorArguments(decorator)[0]
286
+ const reason = options?.type === "ObjectExpression" ? options.properties.find((property) => property.type === "Property" && keyName(property.key) === "reason")?.value : null
287
+ if (reason?.type === "MemberExpression" && !reason.computed && REST_REASONS.has(keyName(reason.property)) && identityExport(context, reason.object, "PublicReason")) return true
288
+ }
289
+ if (isImportedFrom(context, callee, "@nestjs/common", "UseInterceptors") && decoratorArguments(decorator).some((argument) => argument.type === "CallExpression" && isImportedFrom(context, argument.callee, "@nestjs/platform-express", FILE_INTERCEPTORS))) return true
290
+ }
291
+ return returnsBytes(context, method)
292
+ }
293
+
294
+ /** Public REST JSON is exposed through the app's declared operation table. */
295
+ export const restRouteDeclared = {
296
+ meta: {
297
+ type: "problem",
298
+ docs: { description: "Every REST route outside probes, auth handshakes, signed webhooks and byte transfers uses a declared operation table." },
299
+ schema: [],
300
+ messages: {
301
+ undeclared: "[BE_REST_ROUTE_UNDECLARED] This REST route is absent from the app's defineOperations table. Serve public v1 JSON through @Body() request: OperationRequest<Table> and Promise<OperationReply<Table>> of the same declared table; otherwise expose it through GraphQL.",
302
+ },
303
+ },
304
+ create(context) {
305
+ const hfs = hfsOf(context)
306
+ if (!isTransportSlot(hfs.slotOf(context.filename))) return {}
307
+ const declared = operationRouteEvidence(context)
308
+ return {
309
+ MethodDefinition(node) {
310
+ if (node.kind !== "method" || node.static) return
311
+ if (!(node.decorators ?? []).some((decorator) => isImportedFrom(context, decoratorCallee(decorator), "@nestjs/common", HTTP_ROUTES))) return
312
+ const controller = node.parent?.parent
313
+ if (!(controller?.decorators ?? []).some((decorator) => isImportedFrom(context, decoratorCallee(decorator), "@nestjs/common", "Controller"))) return
314
+ if (!restExceptionEvidence(context, node) && !declared(node)) context.report({ node: node.key, messageId: "undeclared" })
315
+ },
316
+ }
317
+ },
318
+ }
319
+
158
320
  /** An operation route takes the table's `OperationRequest` and answers its `OperationReply`, of one table. */
159
321
  export const operationRouteDrivenByTable = {
160
322
  meta: {
@@ -192,10 +354,12 @@ export const operationRouteDrivenByTable = {
192
354
  export const rules = {
193
355
  "operation-contract-decidable": operationContractDecidable,
194
356
  "operation-route-driven-by-table": operationRouteDrivenByTable,
357
+ "rest-route-declared": restRouteDeclared,
195
358
  }
196
359
 
197
360
  /** Every rule of this law at `error`. */
198
361
  export const recommended = {
199
362
  "starci-be/operation-contract-decidable": "error",
200
363
  "starci-be/operation-route-driven-by-table": "error",
364
+ "starci-be/rest-route-declared": "error",
201
365
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-be",
3
- "version": "3.3.1",
3
+ "version": "3.4.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": "MIT",
package/realtime.mjs CHANGED
@@ -5,13 +5,15 @@
5
5
  * subscription, slot `be.feature.realtime`) READS and PUSHES. It never writes: the push is fed by a reactor that calls the `RealtimeHub`
6
6
  * of `platform/realtime`, and the door only subscribes a client to a topic of that hub.
7
7
  *
8
- * - `realtime-shape` (R184): a `.gateway.ts` holds one `@WebSocketGateway` class whose handlers are `@SubscribeMessage`; a
8
+ * - `realtime-shape` (R184): a `.gateway.ts` holds one `@WebSocketGateway` class whose handlers are `@SubscribeMessage`, or a
9
+ * push-only gateway whose `@WebSocketServer` streams take the server's typed principal; a
9
10
  * `.subscription.ts` holds one `@Resolver` class whose operations are `@Subscription`. Each is the door its file role names, and
10
11
  * neither carries a query, a mutation or a REST route.
11
12
  * - `realtime-read-only` (R185): the only value injected into a realtime door is the `RealtimeHub` of `platform/realtime`. No
12
13
  * `EntityManager`, bus, queue, event bus or domain service reaches it.
13
14
  * - `realtime-topic-scope` (R186): the topic passed to `hub.subscribe` is built from the principal parameter of the handler
14
- * (`@CurrentPrincipal()` of `domain/identity`), so a client can only listen to what the principal owns.
15
+ * (`@CurrentPrincipal()` of `domain/identity`, or `Principal` of `platform/cqrs` for a push-only stream), so a client
16
+ * can only listen to what the principal owns.
15
17
  *
16
18
  * What a receiver is comes from its TYPE, what a decorator is from the import that binds it, and where a parameter comes
17
19
  * from from the declaration its name resolves to. No rule here tests a path pattern or a variable name.
@@ -46,15 +48,37 @@ const doorClasses = (context, program, role) => {
46
48
  return found
47
49
  }
48
50
 
51
+ /** The framework supplies this server property; no inbound message supplies a stream's principal. */
52
+ const hasSocketServer = (context, node) => node.body.body.some((member) =>
53
+ member.type === "PropertyDefinition" && hasClassDecorator(context, member, "@nestjs/websockets", "WebSocketServer"))
54
+
55
+ /** The authenticated principal port the socket middleware passes to a push-only stream. */
56
+ const isStreamPrincipal = (context, param) => isOwnedType(context, param, { name: "Principal", capability: "cqrs", tier: "platform" })
57
+
58
+ /** Subscriptions on the platform's hub, including those inside closures. */
59
+ const hubSubscriptions = (context, method) => callsOf(method).filter(({ receiver, method: called }) =>
60
+ called === "subscribe" && receiver !== null && isHubType(context, receiver))
61
+
62
+ /** The nonstatic methods a push-only gateway uses to feed its socket server. */
63
+ const streamMethods = (context, node) => {
64
+ if (!hasSocketServer(context, node)) return []
65
+ return node.body.body.filter((member) => member.type === "MethodDefinition" && member.kind === "method" && !member.static
66
+ && hubSubscriptions(context, member).length > 0)
67
+ }
68
+
69
+ /** An inbound handler or a push-only stream taking the server's authenticated principal. */
70
+ const hasOperation = (context, node, role) => methodsDecoratedBy(context, node, ...ROLES[role].operation).length > 0
71
+ || (role === "gateway" && streamMethods(context, node).some((method) => parametersOf(method).some((param) => isStreamPrincipal(context, param))))
72
+
49
73
  /** A realtime file is the one door its role names, and nothing else. */
50
74
  export const realtimeShape = {
51
75
  meta: {
52
76
  type: "problem",
53
- docs: { description: "A realtime file is one door: a `.gateway.ts` holds one `@WebSocketGateway` class with `@SubscribeMessage` handlers, a `.subscription.ts` holds one `@Resolver` class with `@Subscription` operations, and neither carries a route, query or mutation." },
77
+ docs: { description: "A realtime file is one door: a `.gateway.ts` holds one `@WebSocketGateway` class with `@SubscribeMessage` handlers or principal-typed push streams through `@WebSocketServer`, a `.subscription.ts` holds one `@Resolver` class with `@Subscription` operations, and neither carries a route, query or mutation." },
54
78
  schema: [],
55
79
  messages: {
56
80
  door: "A `<channel>.{{role}}.ts` file declares exactly one `{{decorator}}` class (this one declares {{count}}). One file is one door.",
57
- operations: "This {{role}} door has no `@{{operation}}` handler. A realtime door exists to serve a client one: it subscribes the client to a topic of the `RealtimeHub`.",
81
+ operations: "This {{role}} door has no `@{{operation}}` handler or principal-typed push stream through `@WebSocketServer`. A realtime door subscribes the client to a topic of the `RealtimeHub`.",
58
82
  foreign: "`@{{name}}` has no place in a realtime {{role}} door. A realtime door only subscribes and pushes; a query, a mutation, a field resolver or a REST route belongs in an api feature, and the other realtime protocol in its own file.",
59
83
  },
60
84
  },
@@ -70,7 +94,7 @@ export const realtimeShape = {
70
94
  context.report({ node: program, messageId: "door", data: { role, decorator: `@${spec.classDecorator[1]}`, count: String(doors.length) } })
71
95
  return
72
96
  }
73
- if (methodsDecoratedBy(context, doors[0], ...spec.operation).length === 0) context.report({ node: doors[0], messageId: "operations", data: { role, operation: spec.operation[1] } })
97
+ if (!hasOperation(context, doors[0], role)) context.report({ node: doors[0], messageId: "operations", data: { role, operation: spec.operation[1] } })
74
98
  walk(program, (node) => {
75
99
  if (node.type !== "Decorator") return
76
100
  for (const [source, names] of spec.foreign) {
@@ -128,14 +152,37 @@ const resolvesTo = (context, identifier, parameters) => {
128
152
  return false
129
153
  }
130
154
 
155
+ /** A method's principal parameters, declared by the identity decorator or the push stream's platform type. */
156
+ const principalParameters = (context, hfs, method, push) => {
157
+ const principals = new Set()
158
+ for (const param of parametersOf(method)) {
159
+ if (!isPrincipalParameter(context, hfs, param) && !(push && isStreamPrincipal(context, param))) continue
160
+ const target = param.type === "AssignmentPattern" ? param.left : param
161
+ if (target.type === "Identifier") principals.add(target)
162
+ }
163
+ return principals
164
+ }
165
+
166
+ /** The hub topic must use the method's principal declaration rather than a shadowing identifier. */
167
+ const checkMethodTopics = (context, hfs, method, push) => {
168
+ const principals = principalParameters(context, hfs, method, push)
169
+ for (const { call } of hubSubscriptions(context, method)) {
170
+ let scoped = false
171
+ walk(call.arguments[0], (child) => {
172
+ if (child.type === "Identifier" && resolvesTo(context, child, principals)) scoped = true
173
+ })
174
+ if (!scoped) context.report({ node: call, messageId: "scope" })
175
+ }
176
+ }
177
+
131
178
  /** The topic a client subscribes to is built from the principal the handler receives. */
132
179
  export const realtimeTopicScope = {
133
180
  meta: {
134
181
  type: "problem",
135
- docs: { description: "The topic passed to `RealtimeHub.subscribe` is built from the handler's `@CurrentPrincipal()` parameter." },
182
+ docs: { description: "The topic passed to `RealtimeHub.subscribe` is built from the handler's `@CurrentPrincipal()` parameter or the push-only stream's platform `Principal` parameter." },
136
183
  schema: [],
137
184
  messages: {
138
- scope: "The topic of this `subscribe` is not built from the principal. A realtime handler takes `@CurrentPrincipal() principal` and subscribes to a topic made from `principal.id` (plus any verified id), so one client cannot listen to another's channel. A topic taken only from client input is a leak.",
185
+ scope: "The topic of this `subscribe` is not built from the principal. A realtime handler takes `@CurrentPrincipal() principal`, or a push-only stream takes the platform `Principal`, and subscribes to a topic made from `principal.id` (plus any verified id), so one client cannot listen to another's channel. A topic taken only from client input is a leak.",
139
186
  },
140
187
  },
141
188
  create(context) {
@@ -145,21 +192,11 @@ export const realtimeTopicScope = {
145
192
  const [source, name] = ROLES[role].operation
146
193
  return {
147
194
  ClassDeclaration(node) {
148
- for (const method of methodsDecoratedBy(context, node, source, name)) {
149
- const principals = new Set()
150
- for (const param of parametersOf(method)) {
151
- if (!isPrincipalParameter(context, hfs, param)) continue
152
- const target = param.type === "AssignmentPattern" ? param.left : param
153
- if (target.type === "Identifier") principals.add(target)
154
- }
155
- for (const { call, receiver, method: called } of callsOf(method)) {
156
- if (called !== "subscribe" || receiver === null || !isHubType(context, receiver)) continue
157
- let scoped = false
158
- walk(call.arguments[0], (child) => {
159
- if (child.type === "Identifier" && resolvesTo(context, child, principals)) scoped = true
160
- })
161
- if (!scoped) context.report({ node: call, messageId: "scope" })
162
- }
195
+ const handlers = methodsDecoratedBy(context, node, source, name)
196
+ for (const method of handlers) checkMethodTopics(context, hfs, method, false)
197
+ if (role !== "gateway") return
198
+ for (const method of streamMethods(context, node)) {
199
+ if (!handlers.includes(method)) checkMethodTopics(context, hfs, method, true)
163
200
  }
164
201
  },
165
202
  }