@starci/eslint-canon-be 3.0.8 → 3.0.10

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 (151) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/LICENSE +21 -0
  3. package/README.md +12 -6
  4. package/ambient-id.mjs +90 -0
  5. package/cli.mjs +181 -0
  6. package/comments.mjs +24 -36
  7. package/config-owner.mjs +20 -4
  8. package/connections.mjs +12 -12
  9. package/cookies.mjs +106 -0
  10. package/cqrs.mjs +22 -26
  11. package/data-access.mjs +9 -28
  12. package/default-deny.mjs +2 -1
  13. package/e2e-flow.mjs +4 -4
  14. package/event-bus.mjs +237 -0
  15. package/feature-thin.mjs +237 -0
  16. package/idempotency.mjs +57 -6
  17. package/index.mjs +22 -1
  18. package/infra-owner.mjs +1 -1
  19. package/injection.mjs +67 -22
  20. package/input-bounds.mjs +2 -1
  21. package/jobs.mjs +214 -0
  22. package/lib/ast.mjs +7 -0
  23. package/lib/config.mjs +1 -1
  24. package/lib/db-writes.mjs +1 -1
  25. package/lib/doors.mjs +81 -0
  26. package/lib/edition.mjs +45 -0
  27. package/lib/entity-writes.mjs +35 -0
  28. package/lib/path.mjs +0 -6
  29. package/lib/persistence.mjs +11 -18
  30. package/lib/ports.mjs +5 -2
  31. package/lib/specifier.mjs +27 -0
  32. package/lib/transactions.mjs +37 -0
  33. package/lib/transport-slots.mjs +8 -3
  34. package/lib/unit-spec.mjs +63 -2
  35. package/module-layering.mjs +3 -24
  36. package/module-shape.mjs +2 -2
  37. package/naming.mjs +2 -2
  38. package/package.json +5 -2
  39. package/project-graph.mjs +5 -2
  40. package/projections.mjs +117 -0
  41. package/query-safety.mjs +1 -1
  42. package/realtime.mjs +181 -0
  43. package/runtime/engine/config.mjs +486 -0
  44. package/runtime/engine/invalid-config.mjs +15 -0
  45. package/runtime/engine/orca-config.mjs +27 -0
  46. package/runtime/engine/secrets.mjs +130 -0
  47. package/runtime/knowledge/hfs/canon-pins.yaml +162 -31
  48. package/runtime/knowledge/hfs/rules.yaml +2059 -0
  49. package/runtime/knowledge/hfs/slots.yaml +912 -120
  50. package/runtime/knowledge/patterns/fe/folder.yaml +131 -25
  51. package/runtime/knowledge/sonar-gate.yaml +52 -18
  52. package/runtime/modules/models/registry.yaml +553 -0
  53. package/runtime/modules/models/runtimes.yaml +695 -0
  54. package/runtime/packages/hfs/templates/app/package-scripts/package.json +3 -10
  55. package/runtime/scripts/api/git/config-get.mjs +10 -0
  56. package/runtime/scripts/api/git/lib.mjs +18 -26
  57. package/runtime/scripts/api/git/ls-files.mjs +6 -0
  58. package/runtime/scripts/api/git/remote.mjs +5 -0
  59. package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
  60. package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
  61. package/runtime/scripts/hfs/architecture/backend.mjs +16 -23
  62. package/runtime/scripts/hfs/architecture/background-unowned.mjs +16 -34
  63. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +3 -1
  64. package/runtime/scripts/hfs/architecture/clones.mjs +4 -4
  65. package/runtime/scripts/hfs/architecture/config.mjs +9 -6
  66. package/runtime/scripts/hfs/architecture/connection-map.mjs +20 -8
  67. package/runtime/scripts/hfs/architecture/context-coupling.mjs +88 -0
  68. package/runtime/scripts/hfs/architecture/context-map.mjs +237 -0
  69. package/runtime/scripts/hfs/architecture/context-owner.mjs +82 -0
  70. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +47 -0
  71. package/runtime/scripts/hfs/architecture/context-transaction.mjs +60 -0
  72. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +4 -10
  73. package/runtime/scripts/hfs/architecture/contracts.mjs +33 -162
  74. package/runtime/scripts/hfs/architecture/dead-exports.mjs +58 -29
  75. package/runtime/scripts/hfs/architecture/doc-language.mjs +1 -1
  76. package/runtime/scripts/hfs/architecture/entrypoint.mjs +2 -1
  77. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -2
  78. package/runtime/scripts/hfs/architecture/feature-shape.mjs +5 -2
  79. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  80. package/runtime/scripts/hfs/architecture/frontend.mjs +17 -43
  81. package/runtime/scripts/hfs/architecture/hfs.mjs +24 -39
  82. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +1 -1
  83. package/runtime/scripts/hfs/architecture/index.mjs +17 -4
  84. package/runtime/scripts/hfs/architecture/machine-ast.mjs +44 -11
  85. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
  86. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +30 -6
  87. package/runtime/scripts/hfs/architecture/next-data.mjs +22 -63
  88. package/runtime/scripts/hfs/architecture/owners.mjs +35 -15
  89. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -1
  90. package/runtime/scripts/hfs/architecture/registration.mjs +23 -81
  91. package/runtime/scripts/hfs/architecture/required-files.mjs +20 -12
  92. package/runtime/scripts/hfs/architecture/schema-owner.mjs +17 -114
  93. package/runtime/scripts/hfs/architecture/server-action.mjs +24 -0
  94. package/runtime/scripts/hfs/architecture/side-boundary.mjs +16 -0
  95. package/runtime/scripts/hfs/architecture/source-names.mjs +27 -147
  96. package/runtime/scripts/hfs/architecture/sql-owner.mjs +59 -18
  97. package/runtime/scripts/hfs/architecture/sql-returning.mjs +64 -0
  98. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +111 -0
  99. package/runtime/scripts/hfs/architecture/supabase-be.mjs +131 -0
  100. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +42 -0
  101. package/runtime/scripts/hfs/architecture/supabase.mjs +434 -0
  102. package/runtime/scripts/hfs/architecture/surface.mjs +9 -3
  103. package/runtime/scripts/hfs/architecture/test-world-files.mjs +9 -19
  104. package/runtime/scripts/hfs/architecture/tiers.mjs +47 -5
  105. package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -3
  106. package/runtime/scripts/hfs/architecture/typescript.mjs +12 -26
  107. package/runtime/scripts/hfs/declaration-shape.mjs +62 -0
  108. package/runtime/scripts/hfs/declaration-slots.mjs +75 -0
  109. package/runtime/scripts/hfs/edition-slots.mjs +66 -0
  110. package/runtime/scripts/hfs/manifest-shape.mjs +109 -32
  111. package/runtime/scripts/hfs/param-names.mjs +18 -0
  112. package/runtime/scripts/hfs/path-findings.mjs +1 -1
  113. package/runtime/scripts/hfs/project-graph.mjs +3 -3
  114. package/runtime/scripts/hfs/repo-identity.mjs +9 -6
  115. package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
  116. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +6 -5
  117. package/runtime/scripts/hfs/rules/read.mjs +1 -1
  118. package/runtime/scripts/hfs/rules/secrets.mjs +3 -3
  119. package/runtime/scripts/hfs/rules/spec-placement.mjs +1 -1
  120. package/runtime/scripts/hfs/slots.mjs +78 -108
  121. package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
  122. package/runtime/scripts/hfs/typescript-programs.mjs +0 -5
  123. package/runtime/scripts/hfs/view.mjs +3 -2
  124. package/runtime/scripts/lib/dockerfile.mjs +124 -0
  125. package/runtime/scripts/lib/git.mjs +36 -0
  126. package/runtime/scripts/lib/i18n.mjs +86 -0
  127. package/runtime/scripts/lib/jsdoc.mjs +35 -0
  128. package/runtime/scripts/lib/json.mjs +13 -3
  129. package/runtime/scripts/lib/language.mjs +24 -5
  130. package/runtime/scripts/lib/list.mjs +20 -0
  131. package/runtime/scripts/lib/mutation-fence.mjs +15 -0
  132. package/runtime/scripts/lib/path-key.mjs +57 -2
  133. package/runtime/scripts/lib/read-text.mjs +34 -0
  134. package/runtime/scripts/lib/regex.mjs +4 -0
  135. package/runtime/scripts/lib/secret-patterns.mjs +4 -3
  136. package/runtime/scripts/lib/sops-envelope.mjs +112 -0
  137. package/runtime/scripts/lib/stack-services.mjs +7 -31
  138. package/runtime/scripts/lib/ts-ast.mjs +35 -0
  139. package/runtime/scripts/lib/walk.mjs +4 -3
  140. package/schema-authority.mjs +14 -12
  141. package/service-deps.mjs +7 -13
  142. package/service-isolation.mjs +72 -0
  143. package/size-budget.mjs +1 -1
  144. package/test-world.mjs +5 -4
  145. package/testing.mjs +27 -24
  146. package/transport.mjs +12 -9
  147. package/unit-spec.mjs +1 -1
  148. package/user-copy.mjs +5 -4
  149. package/webhooks.mjs +173 -0
  150. package/runtime/scripts/api/sops/decrypt.mjs +0 -16
  151. package/runtime/scripts/lib/test-secrets.mjs +0 -116
package/index.mjs CHANGED
@@ -17,15 +17,21 @@
17
17
  * leave two names for one rule and no way to tell which a message came from.
18
18
  */
19
19
  import { buildBeConfig } from "./lib/config.mjs"
20
+ import { pluginForEdition } from "./lib/edition.mjs"
21
+ import { recommended as ambientIdRecommended, rules as ambientIdRules } from "./ambient-id.mjs"
20
22
  import { recommended as asyncDisciplineRecommended, rules as asyncDisciplineRules } from "./async-discipline.mjs"
21
23
  import { recommended as authorizationRecommended, rules as authorizationRules } from "./authorization.mjs"
22
24
  import { recommended as buildersRecommended, rules as buildersRules } from "./builders.mjs"
23
25
  import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
24
26
  import { recommended as configOwnerRecommended, rules as configOwnerRules } from "./config-owner.mjs"
25
27
  import { recommended as connectionsRecommended, rules as connectionsRules } from "./connections.mjs"
28
+ import { recommended as cookiesRecommended, rules as cookiesRules } from "./cookies.mjs"
26
29
  import { recommended as cqrsRecommended, rules as cqrsRules } from "./cqrs.mjs"
27
30
  import { recommended as dataAccessRecommended, rules as dataAccessRules } from "./data-access.mjs"
28
31
  import { recommended as defaultDenyRecommended, rules as defaultDenyRules } from "./default-deny.mjs"
32
+ import { recommended as eventBusRecommended, rules as eventBusRules } from "./event-bus.mjs"
33
+ import { recommended as jobsRecommended, rules as jobsRules } from "./jobs.mjs"
34
+ import { recommended as projectionsRecommended, rules as projectionsRules } from "./projections.mjs"
29
35
  import { recommended as e2eFlowRecommended, rules as e2eFlowRules } from "./e2e-flow.mjs"
30
36
  import { recommended as errorHandlingRecommended, rules as errorHandlingRules } from "./error-handling.mjs"
31
37
  import { recommended as idempotencyRecommended, rules as idempotencyRules } from "./idempotency.mjs"
@@ -41,7 +47,9 @@ import { recommended as operationsRecommended, rules as operationsRules } from "
41
47
  import { recommended as querySafetyRecommended, rules as querySafetyRules } from "./query-safety.mjs"
42
48
  import { recommended as resilienceRecommended, rules as resilienceRules } from "./resilience.mjs"
43
49
  import { recommended as schemaAuthorityRecommended, rules as schemaAuthorityRules } from "./schema-authority.mjs"
50
+ import { recommended as cliRecommended, rules as cliRules } from "./cli.mjs"
44
51
  import { recommended as serviceDepsRecommended, rules as serviceDepsRules } from "./service-deps.mjs"
52
+ import { recommended as serviceIsolationRecommended, rules as serviceIsolationRules } from "./service-isolation.mjs"
45
53
  import { recommended as sizeBudgetRecommended, rules as sizeBudgetRules } from "./size-budget.mjs"
46
54
  import { recommended as specNoSkipRecommended, rules as specNoSkipRules } from "./spec-no-skip.mjs"
47
55
  import { recommended as specQualityRecommended, rules as specQualityRules } from "./spec-quality.mjs"
@@ -53,20 +61,29 @@ import { recommended as transportRecommended, rules as transportRules } from "./
53
61
  import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
54
62
  import { recommended as unitSpecRecommended, rules as unitSpecRules } from "./unit-spec.mjs"
55
63
  import { recommended as userCopyRecommended, rules as userCopyRules } from "./user-copy.mjs"
64
+ import { recommended as webhooksRecommended, rules as webhooksRules } from "./webhooks.mjs"
65
+ import { recommended as realtimeRecommended, rules as realtimeRules } from "./realtime.mjs"
66
+ import { recommended as featureThinRecommended, rules as featureThinRules } from "./feature-thin.mjs"
56
67
  import { recommended as projectGraphRecommended, rules as projectGraphRules } from "./project-graph.mjs"
57
68
 
58
69
  /** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
59
70
  const CONTRIBUTIONS = [
60
71
  { law: "project-graph", rules: projectGraphRules, recommended: projectGraphRecommended },
72
+ { law: "ambient-id", rules: ambientIdRules, recommended: ambientIdRecommended },
61
73
  { law: "async-discipline", rules: asyncDisciplineRules, recommended: asyncDisciplineRecommended },
62
74
  { law: "authorization", rules: authorizationRules, recommended: authorizationRecommended },
63
75
  { law: "builders", rules: buildersRules, recommended: buildersRecommended },
76
+ { law: "cli", rules: cliRules, recommended: cliRecommended },
64
77
  { law: "comments", rules: commentsRules, recommended: commentsRecommended },
65
78
  { law: "config-owner", rules: configOwnerRules, recommended: configOwnerRecommended },
66
79
  { law: "connections", rules: connectionsRules, recommended: connectionsRecommended },
80
+ { law: "cookies", rules: cookiesRules, recommended: cookiesRecommended },
67
81
  { law: "cqrs", rules: cqrsRules, recommended: cqrsRecommended },
68
82
  { law: "data-access", rules: dataAccessRules, recommended: dataAccessRecommended },
69
83
  { law: "default-deny", rules: defaultDenyRules, recommended: defaultDenyRecommended },
84
+ { law: "event-bus", rules: eventBusRules, recommended: eventBusRecommended },
85
+ { law: "jobs", rules: jobsRules, recommended: jobsRecommended },
86
+ { law: "projections", rules: projectionsRules, recommended: projectionsRecommended },
70
87
  { law: "e2e-flow", rules: e2eFlowRules, recommended: e2eFlowRecommended },
71
88
  { law: "error-handling", rules: errorHandlingRules, recommended: errorHandlingRecommended },
72
89
  { law: "idempotency", rules: idempotencyRules, recommended: idempotencyRecommended },
@@ -83,6 +100,7 @@ const CONTRIBUTIONS = [
83
100
  { law: "resilience", rules: resilienceRules, recommended: resilienceRecommended },
84
101
  { law: "schema-authority", rules: schemaAuthorityRules, recommended: schemaAuthorityRecommended },
85
102
  { law: "service-deps", rules: serviceDepsRules, recommended: serviceDepsRecommended },
103
+ { law: "service-isolation", rules: serviceIsolationRules, recommended: serviceIsolationRecommended },
86
104
  { law: "size-budget", rules: sizeBudgetRules, recommended: sizeBudgetRecommended },
87
105
  { law: "spec-no-skip", rules: specNoSkipRules, recommended: specNoSkipRecommended },
88
106
  { law: "spec-quality", rules: specQualityRules, recommended: specQualityRecommended },
@@ -94,6 +112,9 @@ const CONTRIBUTIONS = [
94
112
  { law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
95
113
  { law: "unit-spec", rules: unitSpecRules, recommended: unitSpecRecommended },
96
114
  { law: "user-copy", rules: userCopyRules, recommended: userCopyRecommended },
115
+ { law: "webhooks", rules: webhooksRules, recommended: webhooksRecommended },
116
+ { law: "realtime", rules: realtimeRules, recommended: realtimeRecommended },
117
+ { law: "feature-thin", rules: featureThinRules, recommended: featureThinRecommended },
97
118
  ]
98
119
 
99
120
  /** Every gathered law. */
@@ -154,7 +175,7 @@ export default plugin
154
175
  * @param {{ hfs: object }} input - The HFS view of the repository.
155
176
  * @returns {Promise<Array<object>>} The flat config.
156
177
  */
157
- export const starciBeConfig = ({ hfs }) => buildBeConfig({ hfs, plugin, recommended })
178
+ export const starciBeConfig = ({ hfs }) => buildBeConfig({ hfs, plugin: pluginForEdition({ plugin, hfs }), recommended })
158
179
 
159
180
  export { linterOptions } from "./lib/config.mjs"
160
181
  export { loadHfs } from "./lib/hfs.mjs"
package/infra-owner.mjs CHANGED
@@ -53,7 +53,7 @@ export const infraImportOwner = {
53
53
  schema: [],
54
54
  messages: {
55
55
  foreign: "`{{name}}` is owned by {{owners}}. Use that capability's port (injected with its `Inject*()` decorator) instead of the raw library, or move this code into the owner.",
56
- nowhere: "`{{name}}` is not used in a back end: its job belongs to a platform port (config through the typed `<c>.config.ts`, events through the outbox, schedules through `platform/scheduling`). Remove it.",
56
+ nowhere: "`{{name}}` is not used in a back end: its job belongs to a platform port (config through the typed `<c>.config.ts`, events through `eventBus.publish(event, tx)` of `platform/event-bus`, schedules and background jobs through a queue's BullMQ job scheduler of `platform/queue`). Remove it.",
57
57
  },
58
58
  },
59
59
  create(context) {
package/injection.mjs CHANGED
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { posix } from "node:path"
27
27
  import ts from "typescript"
28
- import { staticText } from "./lib/ast.mjs"
28
+ import { paramParts, staticText } from "./lib/ast.mjs"
29
29
  import { hfsOf } from "./lib/hfs.mjs"
30
30
  import { isImportedFrom } from "./lib/import-source.mjs"
31
31
  import { normalizePath } from "./lib/path.mjs"
@@ -133,13 +133,6 @@ const inComposition = (hfs, file) => {
133
133
  return owner !== null && posix.basename(owner) === "composition"
134
134
  }
135
135
 
136
- /** The decorators of a constructor parameter: on the parameter, its wrapper (`private readonly x`) or its inner identifier. */
137
- const decoratorsOfParam = (param) => {
138
- const inner = param.type === "TSParameterProperty" ? param.parameter : param
139
- const target = inner.type === "AssignmentPattern" ? inner.left : inner
140
- return { target, decorators: [...new Set([...(param.decorators ?? []), ...(inner.decorators ?? []), ...(target.decorators ?? [])])] }
141
- }
142
-
143
136
  /** The injected type `T` of a call typed `TypedParameterDecorator<T>`, else null. */
144
137
  const injectedTypeOf = (context, call) => {
145
138
  const { checker, toTs } = typed(context)
@@ -373,7 +366,7 @@ export const injectorTypeMatch = {
373
366
  return {
374
367
  MethodDefinition(node) {
375
368
  for (const param of constructorParams(node)) {
376
- const { target, decorators } = decoratorsOfParam(param)
369
+ const { target, decorators } = paramParts(param)
377
370
  for (const decorator of decorators) {
378
371
  if (decorator.expression.type !== "CallExpression") continue
379
372
  const injected = injectedTypeOf(context, decorator.expression)
@@ -417,7 +410,25 @@ const isContainerBuilt = (context, constructor) => {
417
410
  const declaration = constructor.parent?.parent
418
411
  if (declaration?.type !== "ClassDeclaration" && declaration?.type !== "ClassExpression") return false
419
412
  if ((declaration.decorators ?? []).some((decorator) => packageOfExport(context, decorator.expression)?.startsWith("@nestjs/"))) return true
420
- return constructorParams(constructor).some((param) => decoratorsOfParam(param).decorators.length > 0)
413
+ return constructorParams(constructor).some((param) => paramParts(param).decorators.length > 0)
414
+ }
415
+
416
+ /**
417
+ * The origin that makes an annotated type infrastructure: declared by a package, or by a `platform/*` or `integrations/*` owner other
418
+ * than `own`; null for a domain type, a type of the same owner or a language global.
419
+ */
420
+ const infraOriginOf = (context, hfs, own, annotation) => {
421
+ const origins = typeOrigins(context, annotation)
422
+ // A language global (`ReadonlyArray`, `Array`) is declared in the TypeScript lib and may be augmented by a package
423
+ // (`@types/node`): the augmentation does not make it infrastructure.
424
+ const isLanguageGlobal = (origin) => origins.some((other) => other.name === origin.name && /\/node_modules\/typescript\/lib\//.test(other.file))
425
+ return origins.find((origin) => {
426
+ if (isLanguageGlobal(origin)) return false
427
+ if (origin.module !== null) return true
428
+ if (own !== null && hfs.ownerOf(origin.file) === own) return false
429
+ const tier = hfs.tierOf(origin.file)
430
+ return tier === "platform" || tier === "integrations"
431
+ }) ?? null
421
432
  }
422
433
 
423
434
  /** A constructor parameter of an infrastructure type carries an injector. */
@@ -441,20 +452,10 @@ export const infraNeedsInjector = {
441
452
  // metadata. A plain class (a CQRS message, a factory-built adapter, a test fake) is never DI-constructed.
442
453
  if (!isContainerBuilt(context, node)) return
443
454
  for (const param of constructorParams(node)) {
444
- const { target, decorators } = decoratorsOfParam(param)
455
+ const { target, decorators } = paramParts(param)
445
456
  const annotation = target.typeAnnotation?.typeAnnotation
446
457
  if (!annotation) continue
447
- const origins = typeOrigins(context, annotation)
448
- // A language global (`ReadonlyArray`, `Array`) is declared in the TypeScript lib and may be augmented by a package
449
- // (`@types/node`): the augmentation does not make it infrastructure.
450
- const isLanguageGlobal = (origin) => origins.some((other) => other.name === origin.name && /\/node_modules\/typescript\/lib\//.test(other.file))
451
- const infra = origins.find((origin) => {
452
- if (isLanguageGlobal(origin)) return false
453
- if (origin.module !== null) return true
454
- if (own !== null && hfs.ownerOf(origin.file) === own) return false
455
- const tier = hfs.tierOf(origin.file)
456
- return tier === "platform" || tier === "integrations"
457
- })
458
+ const infra = infraOriginOf(context, hfs, own, annotation)
458
459
  if (!infra) continue
459
460
  const carries = decorators.some((decorator) => {
460
461
  const expression = decorator.expression
@@ -470,6 +471,48 @@ export const infraNeedsInjector = {
470
471
  },
471
472
  }
472
473
 
474
+ // -- injected-param-name --------------------------------------------------------------------------
475
+
476
+ /** The `ruleParams.be.paramNames` entry for an origin's declared name, or null. */
477
+ const paramEntryOf = (entries, name) => entries.find((entry) => (entry.type !== undefined ? entry.type === name : name.length > entry.typeSuffix.length && name.endsWith(entry.typeSuffix))) ?? null
478
+
479
+ /** An injected port of the table of `paramNames` is named the way every class names it. */
480
+ export const injectedParamName = {
481
+ meta: {
482
+ type: "problem",
483
+ docs: { description: "A constructor parameter that injects a port named in `ruleParams.be.paramNames` (`Clock`, `Logger`, `Cache`, `CommandBus`, `QueryBus`, `EntityManager`, `<Owner>Options`) carries the name that table gives it." },
484
+ schema: [],
485
+ messages: {
486
+ name: "`{{param}}` injects `{{type}}`, and every class names that port {{expected}}. Rename the parameter so a reader finds the same dependency under the same name in every class.",
487
+ },
488
+ },
489
+ create(context) {
490
+ const hfs = hfsOf(context)
491
+ const entries = hfs.ruleParams.paramNames
492
+ const own = hfs.ownerOf(context.filename || context.getFilename())
493
+ return {
494
+ MethodDefinition(node) {
495
+ if (!isContainerBuilt(context, node)) return
496
+ for (const param of constructorParams(node)) {
497
+ const { target, decorators } = paramParts(param)
498
+ if (target.type !== "Identifier") continue
499
+ const annotation = target.typeAnnotation?.typeAnnotation
500
+ if (!annotation) continue
501
+ const injected = decorators.some((decorator) => decorator.expression.type === "CallExpression" && (INJECT_NAME.test(calleeOf(context, decorator.expression).name ?? "") || injectedTypeOf(context, decorator.expression) !== null))
502
+ if (!injected) continue
503
+ const infra = infraOriginOf(context, hfs, own, annotation)
504
+ if (!infra) continue
505
+ const entry = paramEntryOf(entries, infra.name)
506
+ if (!entry || entry.names.includes(target.name)) continue
507
+ if (entry.nameSuffix !== undefined && target.name.length > entry.nameSuffix.length && target.name.endsWith(entry.nameSuffix)) continue
508
+ const expected = [...entry.names.map((name) => `\`${name}\``), ...(entry.nameSuffix !== undefined ? [`\`<name>${entry.nameSuffix}\``] : [])].join(" or ")
509
+ context.report({ node: target, messageId: "name", data: { param: target.name, type: infra.name, expected } })
510
+ }
511
+ },
512
+ }
513
+ },
514
+ }
515
+
473
516
  // -- no-module-ref ---------------------------------------------------------------------------------
474
517
 
475
518
  const MODULE_REF_CALLS = new Set(["get", "resolve", "create"])
@@ -585,6 +628,7 @@ export const rules = {
585
628
  "injector-shape": injectorShape,
586
629
  "injector-type-match": injectorTypeMatch,
587
630
  "infra-needs-injector": infraNeedsInjector,
631
+ "injected-param-name": injectedParamName,
588
632
  "no-module-ref": noModuleRef,
589
633
  "no-forward-ref": noForwardRef,
590
634
  "no-string-token": noStringToken,
@@ -596,6 +640,7 @@ export const recommended = {
596
640
  "starci-be/injector-shape": "error",
597
641
  "starci-be/injector-type-match": "error",
598
642
  "starci-be/infra-needs-injector": "error",
643
+ "starci-be/injected-param-name": "error",
599
644
  "starci-be/no-module-ref": "error",
600
645
  "starci-be/no-forward-ref": "error",
601
646
  "starci-be/no-string-token": "error",
package/input-bounds.mjs CHANGED
@@ -16,6 +16,7 @@
16
16
  * argument is `RateTier.Strict` of `platform/http-security`, on the method or on its class. A sign-in, sign-up,
17
17
  * password-reset or callback door on the default tier is the brute-force path the strict tier exists for.
18
18
  */
19
+ import { isTransportSlot } from "./lib/transport-slots.mjs"
19
20
  import ts from "typescript"
20
21
  import { decoratorName, keyName, staticText } from "./lib/ast.mjs"
21
22
  import { importsFrom, isOwnedEnumMember, partsOf, presentParts, typeOf } from "./lib/declared.mjs"
@@ -26,7 +27,7 @@ import { isPackageType, typed } from "./lib/types.mjs"
26
27
  /** True when the file sits in a transport slot inside a `dto/` folder, judged by the slot and the owner-relative path. */
27
28
  const isTransportDto = (hfs, filename) => {
28
29
  const slot = hfs.slotOf(filename) ?? ""
29
- if (!slot.startsWith("be.transport.") && !slot.startsWith("be.feature.transport.")) return false
30
+ if (!isTransportSlot(slot) && !slot.startsWith("be.feature.transport.")) return false
30
31
  return hfs.relative(filename).split("/").slice(0, -1).includes("dto")
31
32
  }
32
33
 
package/jobs.mjs ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The rules that hold the fenced-job pattern (catalog R157 `BE_JOB_WRITE_OUTSIDE_OWNER`, R158 `BE_JOB_FENCE_REQUIRED`,
3
+ * R159 `BE_JOB_RUN_KEY`, R160 `BE_JOB_SHAPE`).
4
+ *
5
+ * A background job is a row whose `fencingToken` the claim bumps. A worker that lost its claim (a zombie) holds a stale token, so every
6
+ * write to the row is guarded by `expectedFencingToken` and an external effect carries an idempotency key that includes the token.
7
+ * `platform/jobs` is the ONE owner of the row: it declares the entity, the claim and the guarded writes, and nothing else writes it.
8
+ *
9
+ * Everything is found by TYPE ORIGIN (the `JobClaims`, `JobStep`, `RunKey` and `FencedProcessor` declared by `platform/jobs`, the entity
10
+ * class declared by that owner, typeorm receivers and query builders) and by the slot view (`be.feature.jobs.steps`, `be.feature.jobs`); the
11
+ * only names read are the contract's own members `runKey`, `jobId` and `expectedFencingToken`.
12
+ */
13
+ import ts from "typescript"
14
+ import { walk } from "./lib/ast.mjs"
15
+ import { writtenEntityOrigins } from "./lib/entity-writes.mjs"
16
+ import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
17
+ import { baseName, isOwnedType, ownerNameOf } from "./lib/ports.mjs"
18
+ import { typeOrigins, typed } from "./lib/types.mjs"
19
+
20
+ const isJobClaimsType = (context, node) => isOwnedType(context, node, { name: "JobClaims", capability: "jobs", tier: "platform" })
21
+ const isJobStepType = (context, node) => isOwnedType(context, node, { name: "JobStep", capability: "jobs", tier: "platform" })
22
+ const isFencedProcessorType = (context, node) => isOwnedType(context, node, { name: "FencedProcessor", capability: "jobs", tier: "platform" })
23
+ const isRunKeyType = (context, node) => isOwnedType(context, node, { name: "RunKey", capability: "jobs", tier: "platform" })
24
+
25
+ /** True for a file of the owner itself, and for the test composition. */
26
+ const isOwnerOrTest = (hfs, file) => {
27
+ const tier = hfs.tierOf(file)
28
+ if (tier === "e2e" || tier === "fixtures" || inTestWorld(hfs, file)) return true
29
+ return tier === "platform" && ownerNameOf(hfs, file) === "jobs"
30
+ }
31
+
32
+ /** A write to an entity that `platform/jobs` declares (the job row) is made only by `platform/jobs`. */
33
+ export const jobWriteOwner = {
34
+ meta: {
35
+ type: "problem",
36
+ docs: { description: "The job entity is written only by `platform/jobs`: no `update`, `increment`, `save`, `insert`, `delete` or query-builder write on it elsewhere." },
37
+ schema: [],
38
+ messages: {
39
+ foreign: "`{{call}}` writes the job row outside `platform/jobs`. The fencing token and the status of a job change only through `JobClaims` (`claim`, `advance`, `complete`, `fail`), each of which requires `expectedFencingToken`; a direct write lets a zombie worker overwrite the job a newer worker owns.",
40
+ },
41
+ },
42
+ create(context) {
43
+ const hfs = hfsOf(context)
44
+ if (isOwnerOrTest(hfs, context.filename)) return {}
45
+ return {
46
+ CallExpression(node) {
47
+ const origins = writtenEntityOrigins(context, node)
48
+ if (!origins.some((origin) => hfs.tierOf(origin.file) === "platform" && ownerNameOf(hfs, origin.file) === "jobs" && hfs.slotOf(origin.file) === "be.persistence")) return
49
+ context.report({ node, messageId: "foreign", data: { call: context.sourceCode.getText(node.callee) } })
50
+ },
51
+ }
52
+ },
53
+ }
54
+
55
+ /** The names the guarded-write contract fixes: the target job and the token the writer holds. */
56
+ const TARGET = "jobId"
57
+ const TOKEN = "expectedFencingToken"
58
+ /** The property of a claimed job that holds the token it carries. */
59
+ const CARRIED = "fencingToken"
60
+
61
+ /** A property of a TypeScript type by name, or undefined. */
62
+ const propertyOf = (type, name) => type.getProperty(name)
63
+
64
+ /** True when the property is a required `number`. */
65
+ const isRequiredNumber = (checker, property, location) => {
66
+ if (!property || (property.flags & ts.SymbolFlags.Optional) !== 0) return false
67
+ const type = checker.getTypeOfSymbolAtLocation(property, location)
68
+ return (type.flags & ts.TypeFlags.Number) !== 0
69
+ }
70
+
71
+ /** Calls on the `JobClaims` port, and what a guarded write is: required token, no cast to get around it, and `JobFencedOut` is not swallowed. */
72
+ export const jobFenceRequired = {
73
+ meta: {
74
+ type: "problem",
75
+ docs: { description: "Every `JobClaims` method that writes an existing job declares a required `expectedFencingToken: number`, a call never casts around it, and a `catch` around a guarded write rethrows." },
76
+ schema: [],
77
+ messages: {
78
+ optionalToken: "`{{method}}` takes a job (`{{target}}`) but its parameter type does not require `{{token}}: number`. Every write to an existing job is guarded by the fencing token the caller holds: declare it required (non-optional, non-nullable) in the type.",
79
+ cast: "This argument of `{{call}}` uses a cast or a non-null assertion. The token a guarded write carries is the one the claim returned: pass it as typed, never `as`, `!` or `any` around it.",
80
+ swallowed: "This `catch` hides a failed guarded write: a `JobFencedOut` means a newer worker owns the job and this one must stop with no further effect. Do not catch around `JobClaims` writes in a step or processor; the `FencedProcessor` base class owns the failure path. Rethrow if you must catch.",
81
+ },
82
+ },
83
+ create(context) {
84
+ const hfs = hfsOf(context)
85
+ const owner = isOwnerOrTest(hfs, context.filename)
86
+ const { checker, toTs } = typed(context)
87
+ const claimsCall = (node) => node.type === "CallExpression" && node.callee.type === "MemberExpression" && !node.callee.computed && isJobClaimsType(context, node.callee.object)
88
+ return {
89
+ // The declaration side: in the owner, each member of the port that takes a job requires the token.
90
+ TSMethodSignature(node) {
91
+ if (!owner || hfs.tierOf(context.filename) !== "platform" || !isJobClaimsType(context, node.parent.parent?.id ?? node.parent)) return
92
+ const first = node.params?.[0]
93
+ if (!first) return
94
+ const tsNode = toTs(first)
95
+ const type = tsNode ? checker.getTypeAtLocation(tsNode) : undefined
96
+ // A parameter that already carries the token (the claimed job itself) is a derivation, not a write.
97
+ if (!type || !propertyOf(type, TARGET) || propertyOf(type, CARRIED)) return
98
+ if (!isRequiredNumber(checker, propertyOf(type, TOKEN), tsNode)) {
99
+ context.report({ node: first, messageId: "optionalToken", data: { method: node.key?.name ?? "this method", target: TARGET, token: TOKEN } })
100
+ }
101
+ },
102
+ CallExpression(node) {
103
+ if (owner || !claimsCall(node)) return
104
+ for (const argument of node.arguments) {
105
+ walk(argument, (child) => {
106
+ if (child.type === "TSAsExpression" || child.type === "TSTypeAssertion" || child.type === "TSNonNullExpression") context.report({ node: child, messageId: "cast", data: { call: context.sourceCode.getText(node.callee) } })
107
+ })
108
+ }
109
+ },
110
+ TryStatement(node) {
111
+ if (owner || !node.handler) return
112
+ let writes = false
113
+ walk(node.block, (child) => { if (claimsCall(child)) writes = true })
114
+ if (!writes) return
115
+ let rethrows = false
116
+ walk(node.handler.body, (child) => { if (child.type === "ThrowStatement") rethrows = true })
117
+ if (!rethrows) context.report({ node: node.handler, messageId: "swallowed" })
118
+ },
119
+ }
120
+ },
121
+ }
122
+
123
+ /** The class of an external effect of a step passes the key `JobClaims.runKey` made. */
124
+ export const jobRunKey = {
125
+ meta: {
126
+ type: "problem",
127
+ docs: { description: "In a job step every call on an integration port passes a `RunKey` (made by `JobClaims.runKey`, which includes the fencing token), never a cast or a literal." },
128
+ schema: [],
129
+ messages: {
130
+ missing: "`{{call}}` is an external call from a job step without a `RunKey` argument. An effect that a zombie worker may repeat must be idempotent at the provider: pass `this.claims.runKey(job, \"<step>\")` (it includes the fencing token) as the idempotency key. A read belongs in the application layer, not in a step.",
131
+ forged: "This `RunKey` is built with a cast. Only `JobClaims.runKey(job, step)` makes one, so the key always carries the fencing token of the claim.",
132
+ },
133
+ },
134
+ create(context) {
135
+ const hfs = hfsOf(context)
136
+ if (hfs.slotOf(context.filename) !== "be.feature.jobs.steps") return {}
137
+ return {
138
+ CallExpression(node) {
139
+ const callee = node.callee
140
+ if (callee.type !== "MemberExpression" || callee.computed) return
141
+ const origins = typeOrigins(context, callee.object)
142
+ if (!origins.some((origin) => origin.module === null && hfs.tierOf(origin.file) === "integrations")) return
143
+ const keyArguments = node.arguments.filter((argument) => isRunKeyType(context, argument))
144
+ if (keyArguments.length === 0) {
145
+ context.report({ node, messageId: "missing", data: { call: context.sourceCode.getText(callee) } })
146
+ return
147
+ }
148
+ for (const argument of keyArguments) {
149
+ walk(argument, (child) => { if (child.type === "TSAsExpression" || child.type === "TSTypeAssertion") context.report({ node: child, messageId: "forged" }) })
150
+ }
151
+ },
152
+ }
153
+ },
154
+ }
155
+
156
+ /** A processor extends the fenced base and is named after its job; a step implements `JobStep` in `steps/`. */
157
+ export const jobShape = {
158
+ meta: {
159
+ type: "problem",
160
+ docs: { description: "`<job>.processor.ts` of `features/jobs/<job>/transport/queue/` declares a class extending `FencedProcessor`; `steps/<step>.step.ts` declares a class implementing `JobStep`; neither role exists elsewhere." },
161
+ schema: [],
162
+ messages: {
163
+ processorPlacement: "`{{name}}` is in a `.processor.ts` file outside `features/jobs/<job>/transport/queue/`. A job processor lives only in the queue transport of its job, so the jobs kind stays one place.",
164
+ processorStem: "The processor of job `{{job}}` is `{{job}}.processor.ts`, but this file is `{{stem}}.processor.ts`.",
165
+ notFenced: "`{{name}}` is a job processor that does not extend `FencedProcessor` of `platform/jobs`. The base class claims the job with a bumped fencing token and settles it through the guarded writes; a processor without it has no fence.",
166
+ stepPlacement: "`{{name}}` implements `JobStep` outside `features/jobs/<job>/steps/<step>.step.ts`.",
167
+ notStep: "`{{name}}` is in a step file but does not implement `JobStep` of `platform/jobs`.",
168
+ },
169
+ },
170
+ create(context) {
171
+ const hfs = hfsOf(context)
172
+ const file = context.filename
173
+ const found = hfs.classify(file)
174
+ const base = baseName(file)
175
+ const isProcessorFile = base.endsWith(".processor.ts")
176
+ const isStepFile = base.endsWith(".step.ts")
177
+ const inJob = found.slot === "be.feature.jobs.queue"
178
+ const inSteps = found.slot === "be.feature.jobs.steps"
179
+ const inPlatform = hfs.tierOf(file) === "platform" && ownerNameOf(hfs, file) === "jobs"
180
+ const check = (node) => {
181
+ const name = node.id?.name ?? "this class"
182
+ const extendsFenced = node.superClass ? isFencedProcessorType(context, node.superClass) : false
183
+ const implementsStep = (node.implements ?? []).some((entry) => isJobStepType(context, entry))
184
+ if (isProcessorFile && !inPlatform) {
185
+ if (!inJob) context.report({ node: node.id ?? node, messageId: "processorPlacement", data: { name } })
186
+ else {
187
+ const stem = base.slice(0, -".processor.ts".length)
188
+ if (found.bindings?.job !== undefined && found.bindings.job !== stem) context.report({ node: node.id ?? node, messageId: "processorStem", data: { job: found.bindings.job, stem } })
189
+ if (!extendsFenced) context.report({ node: node.id ?? node, messageId: "notFenced", data: { name } })
190
+ }
191
+ }
192
+ if (isStepFile) {
193
+ if (!inSteps) context.report({ node: node.id ?? node, messageId: "stepPlacement", data: { name } })
194
+ else if (!implementsStep) context.report({ node: node.id ?? node, messageId: "notStep", data: { name } })
195
+ } else if (implementsStep && !inPlatform) context.report({ node: node.id ?? node, messageId: "stepPlacement", data: { name } })
196
+ }
197
+ return { ClassDeclaration: check, ClassExpression: check }
198
+ },
199
+ }
200
+
201
+ export const rules = {
202
+ "job-write-owner": jobWriteOwner,
203
+ "job-fence-required": jobFenceRequired,
204
+ "job-run-key": jobRunKey,
205
+ "job-shape": jobShape,
206
+ }
207
+
208
+ /** The level these laws ask for: `error`, switched off nowhere. */
209
+ export const recommended = {
210
+ "starci-be/job-write-owner": "error",
211
+ "starci-be/job-fence-required": "error",
212
+ "starci-be/job-run-key": "error",
213
+ "starci-be/job-shape": "error",
214
+ }
package/lib/ast.mjs CHANGED
@@ -54,6 +54,13 @@ export const keyName = (node) => {
54
54
  return null
55
55
  }
56
56
 
57
+ /** The decorators of a constructor parameter: on the parameter, its wrapper (`private readonly x`) or its inner identifier. */
58
+ export const paramParts = (param) => {
59
+ const inner = param.type === "TSParameterProperty" ? param.parameter : param
60
+ const target = inner.type === "AssignmentPattern" ? inner.left : inner
61
+ return { target, decorators: [...new Set([...(param.decorators ?? []), ...(inner.decorators ?? []), ...(target.decorators ?? [])])] }
62
+ }
63
+
57
64
  /** The name of a decorator (`@Foo` or `@Foo(...)`), else null. */
58
65
  export const decoratorName = (decorator) => {
59
66
  const expression = decorator?.expression
package/lib/config.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The one flat config of a StarCi back end.
3
3
  *
4
- * A repository's `eslint.config.mjs` is a managed file (R05/R16), rendered by `hfs sync` as exactly:
4
+ * A repository's `eslint.config.mjs` is a managed file (R05/R16), rendered by `starci app sync` as exactly:
5
5
  *
6
6
  * import { loadHfs, starciBeConfig } from "@starci/eslint-canon-be"
7
7
  * export default starciBeConfig({ hfs: loadHfs(import.meta.url) })
package/lib/db-writes.mjs CHANGED
@@ -6,7 +6,7 @@ import { staticText } from "./ast.mjs"
6
6
  import { isPackageType } from "./types.mjs"
7
7
 
8
8
  /** The `typeorm` types a call can write through. */
9
- export const RECEIVER_TYPES = Object.freeze(["EntityManager", "DataSource", "QueryRunner"])
9
+ const RECEIVER_TYPES = Object.freeze(["EntityManager", "DataSource", "QueryRunner"])
10
10
 
11
11
  /** Statement text that changes rows. */
12
12
  export const WRITING_SQL = /(?:^|[\s;(])(?:insert\s+into|update\s+["`\w.]+\s+set|delete\s+from|truncate\b)/i
package/lib/doors.mjs ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * What the door rules of the feature kinds (webhooks, realtime) read from a class: what is injected into it, which methods
3
+ * the framework enters, and which expression a call is made on. Every answer comes from a decorator's import, a value's
4
+ * declared type or a declaration's origin, never from a spelling.
5
+ */
6
+ import ts from "typescript"
7
+ import { walk } from "./ast.mjs"
8
+ import { decoratorCallee, importOf, isImportedFrom } from "./import-source.mjs"
9
+ import { typed } from "./types.mjs"
10
+
11
+ /** True when the class carries a decorator that is `name` exported by `source`. */
12
+ export const hasClassDecorator = (context, node, source, name) =>
13
+ (node.decorators ?? []).some((decorator) => isImportedFrom(context, decoratorCallee(decorator), source, name))
14
+
15
+ /** The decorators of a method or class that are one of `names` exported by `source`. */
16
+ export const decoratorsFrom = (context, node, source, names) =>
17
+ (node.decorators ?? []).filter((decorator) => isImportedFrom(context, decoratorCallee(decorator), source, names))
18
+
19
+ /**
20
+ * Every value injected into a class: a constructor parameter (plain or a parameter property) and a property that
21
+ * carries a decorator which injects (`Inject` of Nest, a `@nestjs/typeorm` injector or a function declared in a
22
+ * `*.decorators.ts` file).
23
+ *
24
+ * @param {object} context - The ESLint rule context.
25
+ * @param {object} classNode - A `ClassDeclaration`.
26
+ * @returns {Array<{ node: object, annotation: object | undefined }>} The injected members, each with its declared type annotation.
27
+ */
28
+ export const injectedMembers = (context, classNode) => {
29
+ const members = []
30
+ const constructor = classNode.body.body.find((member) => member.type === "MethodDefinition" && member.kind === "constructor")
31
+ for (const original of constructor?.value.params ?? []) {
32
+ const param = original.type === "TSParameterProperty" ? original.parameter : original
33
+ members.push({ node: original, annotation: param.typeAnnotation?.typeAnnotation })
34
+ }
35
+ for (const member of classNode.body.body) {
36
+ if (member.type !== "PropertyDefinition" || !(member.decorators ?? []).some((decorator) => isInjectorDecorator(context, decorator))) continue
37
+ members.push({ node: member, annotation: member.typeAnnotation?.typeAnnotation })
38
+ }
39
+ return members
40
+ }
41
+
42
+ /** True for a decorator that injects: `Inject` of Nest, a `@nestjs/typeorm` injector, or a function declared in a `*.decorators.ts` file. */
43
+ const isInjectorDecorator = (context, decorator) => {
44
+ const callee = decoratorCallee(decorator)
45
+ if (callee?.type !== "Identifier") return false
46
+ const found = importOf(context, callee)
47
+ if (found?.source === "@nestjs/common" && found.imported === "Inject") return true
48
+ if (found?.source === "@nestjs/typeorm") return true
49
+ const symbol = aliasTarget(context, callee)
50
+ return (symbol?.getDeclarations() ?? []).some((declaration) => declaration.getSourceFile().fileName.split("\\").join("/").endsWith(".decorators.ts"))
51
+ }
52
+
53
+ /** The symbol an identifier names, with an import alias resolved to its target. */
54
+ export const aliasTarget = (context, identifier) => {
55
+ const { checker, toTs } = typed(context)
56
+ const tsNode = toTs(identifier)
57
+ const symbol = tsNode && checker.getSymbolAtLocation(tsNode)
58
+ return symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(symbol) : symbol
59
+ }
60
+
61
+ /** The methods of a class (instance, non-constructor) that carry one of the decorators `names` of `source`. */
62
+ export const methodsDecoratedBy = (context, classNode, source, names) =>
63
+ classNode.body.body.filter((member) => member.type === "MethodDefinition" && member.kind === "method" && !member.static && decoratorsFrom(context, member, source, names).length > 0)
64
+
65
+ /**
66
+ * Every call made in a method body, closures included: `<receiver>.<method>(...)` as `{ call, receiver, method }`, and any
67
+ * other call as `{ call, receiver: null, method: null }`.
68
+ */
69
+ export const callsOf = (method) => {
70
+ const calls = []
71
+ walk(method.value.body, (node) => {
72
+ if (node.type !== "CallExpression") return
73
+ const callee = node.callee
74
+ const member = callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" ? callee : null
75
+ calls.push({ call: node, receiver: member?.object ?? null, method: member?.property.name ?? null })
76
+ })
77
+ return calls
78
+ }
79
+
80
+ /** The parameters of a handler method, with the pattern a default value or a destructuring wraps unwrapped. */
81
+ export const parametersOf = (method) => method.value.params.map((param) => (param.type === "TSParameterProperty" ? param.parameter : param))
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The edition gate of the back-end plugin.
3
+ *
4
+ * Edition applicability belongs to the HFS rule catalog, beside the law and its enforcer. A rule
5
+ * that the catalog does not judge in this app's edition still ships in the plugin, but its `create`
6
+ * returns no listeners. The plugin wiring therefore has one generic gate instead of edition checks
7
+ * scattered through rule bodies.
8
+ */
9
+ import { dirname, join } from "node:path"
10
+ import { fileURLToPath } from "node:url"
11
+ import { loadRuleCatalog } from "../runtime/scripts/hfs/slots.mjs"
12
+
13
+ const RUNTIME = join(dirname(fileURLToPath(import.meta.url)), "..", "runtime")
14
+ let catalogCache
15
+
16
+ /** The bundled rule catalog, loaded once per lint process. */
17
+ const bundledCatalog = () => {
18
+ catalogCache ??= loadRuleCatalog({ root: RUNTIME })
19
+ return catalogCache
20
+ }
21
+
22
+ /** A rule which intentionally registers no listeners in this edition. */
23
+ const inactive = (rule) => Object.freeze({ ...rule, create: () => ({}) })
24
+
25
+ /**
26
+ * The plugin view for one HFS edition.
27
+ *
28
+ * Full is the default edition and the catalog's superset, so it needs no runtime catalog read. This
29
+ * keeps the source checkout testable before `sync-runtime` refreshes the generated bundle; lite
30
+ * loads the package's bundled catalog in a published installation. Tests may inject a catalog to
31
+ * prove the generic filtering independently of generated copies.
32
+ */
33
+ export const pluginForEdition = ({ plugin, hfs, catalog }) => {
34
+ const edition = hfs?.edition === undefined ? "full" : hfs.edition
35
+ if (edition !== "full" && edition !== "lite") {
36
+ throw new RangeError(`Unknown HFS edition ${JSON.stringify(edition)}; expected "full" or "lite"`)
37
+ }
38
+ if (edition === "full" && catalog === undefined) return plugin
39
+ const loaded = catalog ?? bundledCatalog()
40
+ const rules = Object.fromEntries(Object.entries(plugin.rules).map(([id, rule]) => [
41
+ id,
42
+ loaded.enforcerJudgedIn("eslint-be", id, edition) ? rule : inactive(rule),
43
+ ]))
44
+ return Object.freeze({ ...plugin, rules: Object.freeze(rules) })
45
+ }