@starci/hfs 4.0.10 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/emit/operations.mjs +1 -1
  3. package/emit/type-schema.mjs +2 -1
  4. package/lint/run.mjs +65 -24
  5. package/package.json +1 -1
  6. package/report/order.mjs +2 -0
  7. package/runtime/config.example.yaml +194 -0
  8. package/runtime/engine/by-code-unit.mjs +12 -0
  9. package/runtime/engine/config.mjs +222 -239
  10. package/runtime/engine/invalid-config.mjs +5 -5
  11. package/runtime/engine/model-config.mjs +90 -0
  12. package/runtime/engine/orca-config.mjs +3 -1
  13. package/runtime/engine/release-config.mjs +28 -0
  14. package/runtime/engine/removed-vocabulary.mjs +28 -0
  15. package/runtime/engine/resources-config.mjs +52 -0
  16. package/runtime/engine/runtime-root.mjs +17 -0
  17. package/runtime/engine/secrets.mjs +35 -31
  18. package/runtime/engine/sonar-config.mjs +21 -0
  19. package/runtime/engine/temp-root.mjs +32 -0
  20. package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
  21. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  22. package/runtime/knowledge/hfs/rules.yaml +161 -65
  23. package/runtime/knowledge/hfs/slots.yaml +11 -14
  24. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  25. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  26. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  27. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  28. package/runtime/modules/kernel/failure-codes.yaml +1 -41
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +268 -0
  30. package/runtime/modules/models/registry.yaml +25 -359
  31. package/runtime/modules/models/runtimes.yaml +126 -265
  32. package/runtime/modules/models/tiers.yaml +66 -0
  33. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  34. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  35. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  36. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  37. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  38. package/runtime/scripts/api/git/lib.mjs +34 -1
  39. package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
  40. package/runtime/scripts/api/process/run-program.mjs +2 -1
  41. package/runtime/scripts/api/sops/lib.mjs +198 -120
  42. package/runtime/scripts/hfs/allows.mjs +3 -3
  43. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  44. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  45. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  46. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  47. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  48. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  49. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  50. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  51. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  52. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  53. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  54. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  55. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  56. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  57. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  58. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  59. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  60. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  61. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  62. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  63. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  64. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  65. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  66. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  67. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  68. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
  69. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  70. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  71. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  72. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  73. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  74. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  75. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  76. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  77. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  78. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  79. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  80. package/runtime/scripts/hfs/architecture/index.mjs +164 -104
  81. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  82. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  83. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  84. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  85. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  86. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  87. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  88. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  89. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  90. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  91. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  92. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  93. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  94. package/runtime/scripts/hfs/architecture/required-files.mjs +121 -70
  95. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  96. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  97. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  98. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  99. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  100. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  101. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  102. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  103. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  104. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  105. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  106. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  107. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  108. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  109. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  110. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  111. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  112. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  113. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  114. package/runtime/scripts/hfs/architecture.mjs +3 -4
  115. package/runtime/scripts/hfs/catalog-once.mjs +15 -0
  116. package/runtime/scripts/hfs/check.mjs +63 -71
  117. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  118. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  119. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  120. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  121. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  122. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  123. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  124. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  125. package/runtime/scripts/hfs/repo-identity.mjs +21 -4
  126. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  127. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  128. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  129. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  130. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  131. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  132. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  133. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  134. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  135. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  136. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  137. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  138. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  139. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  140. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  141. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  142. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  143. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  144. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  145. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  146. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  147. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  148. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  149. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  150. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  151. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  152. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  153. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  154. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  155. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  156. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  157. package/runtime/scripts/hfs/secret.mjs +59 -48
  158. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  159. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  160. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  161. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  162. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  163. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  164. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  165. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  166. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  167. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  168. package/runtime/scripts/hfs/slots.mjs +74 -695
  169. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  170. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  171. package/runtime/scripts/hfs/tree.mjs +19 -14
  172. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  173. package/runtime/scripts/hfs/view.mjs +1 -1
  174. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  175. package/runtime/scripts/lib/env.mjs +10 -0
  176. package/runtime/scripts/lib/event-contract.mjs +17 -13
  177. package/runtime/scripts/lib/git.mjs +2 -2
  178. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  179. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  180. package/runtime/scripts/lib/i18n.mjs +3 -2
  181. package/runtime/scripts/lib/in-order.mjs +71 -0
  182. package/runtime/scripts/lib/language.mjs +3 -3
  183. package/runtime/scripts/lib/list.mjs +8 -1
  184. package/runtime/scripts/lib/path-key.mjs +38 -5
  185. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  186. package/runtime/scripts/lib/regex.mjs +1 -1
  187. package/runtime/scripts/lib/same-text.mjs +1 -1
  188. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  189. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  190. package/runtime/scripts/lib/sops-envelope.mjs +95 -48
  191. package/runtime/scripts/lib/stack-services.mjs +1 -2
  192. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  193. package/runtime/scripts/lib/walk.mjs +6 -1
  194. package/runtime/scripts/lib/yaml-cached.mjs +14 -0
  195. package/scaffold/add-cli-lite.mjs +2 -1
  196. package/scaffold/add-table.mjs +2 -2
  197. package/scaffold/add.mjs +3 -2
  198. package/scaffold/app.mjs +4 -2
  199. package/scaffold/edition-gate.mjs +25 -25
  200. package/scaffold/lite-exports.mjs +2 -1
  201. package/scaffold/service.mjs +5 -4
  202. package/sync/hygiene.mjs +15 -9
  203. package/sync/index.mjs +7 -3
  204. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  205. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  206. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  207. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  208. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  209. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  210. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  211. package/templates/app/starciwork.gitignore +1 -1
  212. package/templates/be/image/api/Dockerfile +1 -1
  213. package/templates/be/image/cli/Dockerfile +1 -1
  214. package/templates/be/image/worker/Dockerfile +1 -1
  215. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  216. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  217. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  218. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  219. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  220. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  221. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  222. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  223. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  224. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  225. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  226. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  227. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  228. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  229. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  230. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  231. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  232. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  233. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  234. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  235. package/templates/fe/image/next/Dockerfile +2 -1
  236. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  237. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  238. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  239. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  240. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  241. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  242. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  243. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  244. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  245. package/upgrade/index.mjs +9 -2
  246. package/runtime/engine/admission.mjs +0 -308
  247. package/runtime/engine/canonical-json.mjs +0 -10
  248. package/runtime/engine/db/blob.mjs +0 -315
  249. package/runtime/engine/db/ledger-paths.mjs +0 -83
  250. package/runtime/engine/db/ledger.mjs +0 -1118
  251. package/runtime/engine/db/machine-connection.mjs +0 -135
  252. package/runtime/engine/db/machine-schema.mjs +0 -84
  253. package/runtime/engine/db/machine.mjs +0 -1367
  254. package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
  255. package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
  256. package/runtime/engine/db/provider-reservations.mjs +0 -101
  257. package/runtime/engine/digest.mjs +0 -16
  258. package/runtime/engine/refuse.mjs +0 -11
  259. package/runtime/modules/ops/_labels.yaml +0 -53
  260. package/runtime/scripts/api/node/lib.mjs +0 -14
  261. package/runtime/scripts/api/node/spawn-node.mjs +0 -6
  262. package/runtime/scripts/api/process/lib.mjs +0 -111
  263. package/runtime/scripts/api/process/owned-process.mjs +0 -78
  264. package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
  265. package/runtime/scripts/connectors/lib.mjs +0 -488
  266. package/runtime/scripts/lib/clip.mjs +0 -19
  267. package/runtime/scripts/lib/display-names.mjs +0 -259
  268. package/runtime/scripts/lib/example-refs.mjs +0 -158
  269. package/runtime/scripts/lib/json-schema.mjs +0 -52
  270. package/runtime/scripts/lib/process-identity.mjs +0 -6
  271. package/runtime/scripts/lib/read-yaml.mjs +0 -18
  272. package/runtime/scripts/lib/redact.mjs +0 -161
  273. package/runtime/scripts/lib/sleep.mjs +0 -7
  274. package/runtime/scripts/lib/source-phrases.mjs +0 -34
  275. package/runtime/scripts/lib/sqlite.mjs +0 -21
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import { machineKit } from './machine-ast.mjs';
3
+ import { callName } from './supabase-ast.mjs';
3
4
  import { isTestWorldSlot } from '../test-world-slot.mjs';
5
+ import { byCodeUnit } from '../../lib/list.mjs';
4
6
 
5
7
  /**
6
8
  * R45 `register-once` (BE_MODULE_SHAPE), the module graph read from the app roots (owner rule of 2026-09-30, the
@@ -21,19 +23,7 @@ export const REGISTER_ONCE_RULE_IDS = ['BE_MODULE_SHAPE'];
21
23
  const RULE = 'BE_MODULE_SHAPE';
22
24
  const CAPABILITY_TIERS = new Set(['domain', 'platform', 'integrations']);
23
25
 
24
- export function checkRegisterOnce(input) {
25
- const { config, graph } = input;
26
- const kit = machineKit(input);
27
- const { ts } = kit;
28
- const violations = [];
29
- const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
30
- const isAppRoot = file => Boolean(file.slot?.startsWith('be.app.')) && path.posix.basename(file.rel) === 'app.module.ts';
31
- // The test world (slot be.tests.world) is the test composition root: useTestWorld registers capability modules with
32
- // `isGlobal: true` the way an app root does. A spec (integration, contract, e2e) is not a composition root.
33
- const isWorld = file => isTestWorldSlot(file.slot);
34
- const appOf = file => config.apps.find(app => file.rel === `apps/${app.name}/src/app.module.ts`)?.name ?? null;
35
-
36
- // Every @Module class of the program.
26
+ function moduleDeclarations(graph, kit, ts) {
37
27
  const modules = new Map(); // declaration -> {name, file, capability}
38
28
  for (const file of graph.files.values()) {
39
29
  const checker = kit.checkerOf(file.sourceFile);
@@ -47,57 +37,81 @@ export function checkRegisterOnce(input) {
47
37
  if (isModule) modules.set(statement, { name: statement.name.text, file, capability: CAPABILITY_TIERS.has(file.tier) });
48
38
  }
49
39
  }
50
- const targetOf = (checker, node) => {
51
- for (const declaration of kit.declarationsOf(checker, node)) if (modules.has(declaration)) return { declaration, ...modules.get(declaration) };
52
- return null;
53
- };
54
- const enclosingModule = node => {
55
- for (let current = node.parent; current; current = current.parent) if (ts.isClassDeclaration(current) && modules.has(current)) return current;
56
- return null;
57
- };
40
+ return modules;
41
+ }
42
+
43
+ function targetOf(kit, modules, checker, node) {
44
+ for (const declaration of kit.declarationsOf(checker, node)) {
45
+ if (modules.has(declaration)) return { declaration, ...modules.get(declaration) };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function enclosingModule(ts, modules, node) {
51
+ for (let current = node.parent; current; current = current.parent) {
52
+ if (ts.isClassDeclaration(current) && modules.has(current)) return current;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /** Records the reference an element of an imports list, or a register call, makes to a module. */
58
+ function noteReference(scan, element, viaImports) {
59
+ const { ts, kit, modules, checker, file, seen, references, isAppRoot } = scan;
60
+ const call = callName(ts, element) === 'register';
61
+ if (ts.isCallExpression(element) && !call) return;
62
+ const target = targetOf(kit, modules, checker, call ? element.expression.expression : element);
63
+ if (!target) return;
64
+ seen.add(element);
65
+ const owner = enclosingModule(ts, modules, element);
66
+ references.push({ file, node: element, target, register: call, viaImports, importer: owner ?? file.rel, importerOwner: file.owner?.root ?? null,
67
+ options: call ? element.arguments[0] : null, root: isAppRoot(file) });
68
+ }
69
+
70
+ const isImportsList = (scan, node) => scan.ts.isPropertyAssignment(node) && scan.kit.propertyNameText(node.name) === 'imports' && scan.ts.isArrayLiteralExpression(node.initializer);
71
+
72
+ const isStrayGlobal = (scan, node) => scan.ts.isPropertyAssignment(node) && scan.kit.propertyNameText(node.name) === 'isGlobal'
73
+ && node.initializer.kind === scan.ts.SyntaxKind.TrueKeyword && !scan.isAppRoot(scan.file) && !scan.isWorld(scan.file);
58
74
 
75
+ function visitReferenceNode(scan, node) {
76
+ if (isImportsList(scan, node)) {
77
+ for (const element of node.initializer.elements) noteReference(scan, element, true);
78
+ } else if (callName(scan.ts, node) === 'register' && !scan.seen.has(node)) {
79
+ noteReference(scan, node, false);
80
+ }
81
+ if (isStrayGlobal(scan, node)) {
82
+ scan.report(scan.file, node, '`isGlobal: true` appears only in the app root (apps/<app>/src/app.module.ts), where the representative module of a capability is registered; a module never decides its own globality.');
83
+ }
84
+ return true;
85
+ }
86
+
87
+ function moduleReferences(graph, kit, ts, modules, isWorld, isAppRoot, report) {
59
88
  // The references to modules: importer (the enclosing module class, else the file), target, node, whether it is a register call.
60
89
  const references = [];
61
90
  for (const file of graph.files.values()) {
62
91
  if (isWorld(file)) continue; // the test composition root assembles modules; it is not a module importer
63
- const checker = kit.checkerOf(file.sourceFile);
64
- const seen = new Set();
65
- const note = (element, viaImports) => {
66
- const call = ts.isCallExpression(element) && ts.isPropertyAccessExpression(element.expression) && element.expression.name.text === 'register';
67
- if (ts.isCallExpression(element) && !call) return;
68
- const target = targetOf(checker, call ? element.expression.expression : element);
69
- if (!target) return;
70
- seen.add(element);
71
- const owner = enclosingModule(element);
72
- references.push({ file, node: element, target, register: call, viaImports, importer: owner ?? file.rel, importerOwner: file.owner?.root ?? null,
73
- options: call ? element.arguments[0] : null, root: isAppRoot(file) });
74
- };
75
- kit.walk(file.sourceFile, node => {
76
- if (ts.isPropertyAssignment(node) && kit.propertyNameText(node.name) === 'imports' && ts.isArrayLiteralExpression(node.initializer)) {
77
- for (const element of node.initializer.elements) note(element, true);
78
- } else if (ts.isCallExpression(node) && !seen.has(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register') {
79
- note(node, false);
80
- }
81
- if (ts.isPropertyAssignment(node) && kit.propertyNameText(node.name) === 'isGlobal' && node.initializer.kind === ts.SyntaxKind.TrueKeyword && !isAppRoot(file) && !isWorld(file)) {
82
- report(file, node, '`isGlobal: true` appears only in the app root (apps/<app>/src/app.module.ts), where the representative module of a capability is registered; a module never decides its own globality.');
83
- }
84
- return true;
85
- });
92
+ const scan = { ts, kit, modules, file, isWorld, isAppRoot, report, references, checker: kit.checkerOf(file.sourceFile), seen: new Set() };
93
+ kit.walk(file.sourceFile, node => visitReferenceNode(scan, node));
86
94
  }
95
+ return references;
96
+ }
87
97
 
88
- const trueGlobal = options => Boolean(options) && ts.isObjectLiteralExpression(options) && (() => {
89
- const property = kit.propertyOf(options, 'isGlobal');
90
- return Boolean(property) && ts.isPropertyAssignment(property) && property.initializer.kind === ts.SyntaxKind.TrueKeyword;
91
- })();
98
+ function isTrueGlobal(kit, ts, options) {
99
+ if (!options || !ts.isObjectLiteralExpression(options)) return false;
100
+ const property = kit.propertyOf(options, 'isGlobal');
101
+ return Boolean(property) && ts.isPropertyAssignment(property) && property.initializer.kind === ts.SyntaxKind.TrueKeyword;
102
+ }
92
103
 
93
- // App roots: each module is listed once, capability modules are registered with isGlobal true.
94
- const rootReferences = references.filter(item => item.root);
104
+ function appRegistrations(rootReferences, appOf) {
95
105
  const inApps = new Map(); // module declaration -> Set(app names)
96
106
  for (const item of rootReferences) {
97
107
  const app = appOf(item.file) ?? item.file.rel;
98
108
  if (!inApps.has(item.target.declaration)) inApps.set(item.target.declaration, new Set());
99
109
  inApps.get(item.target.declaration).add(app);
100
110
  }
111
+ return inApps;
112
+ }
113
+
114
+ function reportDuplicateAppModules(config, rootReferences, appOf, report) {
101
115
  for (const app of config.apps) {
102
116
  const seenInApp = new Map();
103
117
  for (const item of rootReferences.filter(entry => appOf(entry.file) === app.name)) {
@@ -106,35 +120,70 @@ export function checkRegisterOnce(input) {
106
120
  } else seenInApp.set(item.target.declaration, item.node);
107
121
  }
108
122
  }
123
+ }
124
+
125
+ function reportCapabilityRegistrations(rootReferences, kit, ts, report) {
109
126
  for (const item of rootReferences) {
110
127
  if (!item.target.capability || !item.viaImports && !item.register) continue;
111
128
  if (!item.register) report(item.file, item.node, `${item.target.name} is listed in imports as a bare class; the representative module of a capability is registered in the app root as ${item.target.name}.register({ isGlobal: true, ...options }).`, { module: item.target.name });
112
- else if (!trueGlobal(item.options)) report(item.file, item.node, `${item.target.name} is registered in the app root without the literal \`isGlobal: true\`; write ${item.target.name}.register({ isGlobal: true, ...options }).`, { module: item.target.name });
129
+ else if (!isTrueGlobal(kit, ts, item.options)) report(item.file, item.node, `${item.target.name} is registered in the app root without the literal \`isGlobal: true\`; write ${item.target.name}.register({ isGlobal: true, ...options }).`, { module: item.target.name });
113
130
  }
131
+ }
114
132
 
115
- // Non-app importers: no importer of an app-registered module, exactly one importer otherwise (the feature case excepted).
133
+ /** The references that import a module from a non-app module, grouped by the imported module declaration. */
134
+ function importersByModule(references) {
116
135
  const importersOf = new Map(); // module declaration -> [reference]
117
136
  for (const item of references.filter(entry => !entry.root && entry.viaImports)) {
118
137
  if (!importersOf.has(item.target.declaration)) importersOf.set(item.target.declaration, []);
119
138
  importersOf.get(item.target.declaration).push(item);
120
139
  }
121
- for (const [declaration, list] of importersOf) {
140
+ return importersOf;
141
+ }
142
+
143
+ function reportSharedSubModule(target, list, report) {
144
+ const distinct = [...new Set(list.map(item => item.importer))];
145
+ const featureCase = target.file.tier === 'feature' && target.file.owner && list.every(item => item.importerOwner === target.file.owner.root);
146
+ if (distinct.length <= 1 || featureCase) return;
147
+ for (const item of list.filter(entry => entry.importer !== list[0].importer)) {
148
+ report(item.file, item.node, `${target.name} is imported by ${distinct.length} modules; a sub-module has exactly one importer, its parent. Another capability's module is consumed through its Inject*() decorators, never imported.`, { module: target.name });
149
+ }
150
+ }
151
+
152
+ function reportNonAppImporters(references, modules, inApps, report) {
153
+ // Non-app importers: no importer of an app-registered module, exactly one importer otherwise (the feature case excepted).
154
+ for (const [declaration, list] of importersByModule(references)) {
122
155
  const target = modules.get(declaration);
123
156
  const apps = inApps.get(declaration);
124
- if (apps) {
125
- for (const item of list) {
126
- report(item.file, item.node, `${target.name} is registered in the root of app ${[...apps].sort().join(', ')}, so no other module imports it; consume it through its Inject*() decorators.`, { module: target.name });
127
- }
157
+ if (!apps) {
158
+ reportSharedSubModule(target, list, report);
128
159
  continue;
129
160
  }
130
- const distinct = [...new Set(list.map(item => item.importer))];
131
- const featureCase = target.file.tier === 'feature' && target.file.owner && list.every(item => item.importerOwner === target.file.owner.root);
132
- if (distinct.length > 1 && !featureCase) {
133
- for (const item of list.filter(entry => entry.importer !== list[0].importer)) {
134
- report(item.file, item.node, `${target.name} is imported by ${distinct.length} modules; a sub-module has exactly one importer, its parent. Another capability's module is consumed through its Inject*() decorators, never imported.`, { module: target.name });
135
- }
161
+ for (const item of list) {
162
+ report(item.file, item.node, `${target.name} is registered in the root of app ${[...apps].sort(byCodeUnit).join(', ')}, so no other module imports it; consume it through its Inject*() decorators.`, { module: target.name });
136
163
  }
137
164
  }
165
+ }
166
+
167
+ export function checkRegisterOnce(input) {
168
+ const { config, graph } = input;
169
+ const kit = machineKit(input);
170
+ const { ts } = kit;
171
+ const violations = [];
172
+ const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
173
+ const isAppRoot = file => Boolean(file.slot?.startsWith('be.app.')) && path.posix.basename(file.rel) === 'app.module.ts';
174
+ // The test world (slot be.tests.world) is the test composition root: useTestWorld registers capability modules with
175
+ // `isGlobal: true` the way an app root does. A spec (integration, contract, e2e) is not a composition root.
176
+ const isWorld = file => isTestWorldSlot(file.slot);
177
+ const appOf = file => config.apps.find(app => file.rel === `apps/${app.name}/src/app.module.ts`)?.name ?? null;
178
+ const modules = moduleDeclarations(graph, kit, ts);
179
+ const references = moduleReferences(graph, kit, ts, modules, isWorld, isAppRoot, report);
180
+ const rootReferences = references.filter(item => item.root);
181
+ const inApps = appRegistrations(rootReferences, appOf);
182
+
183
+ // App roots: each module is listed once, capability modules are registered with isGlobal true.
184
+ reportDuplicateAppModules(config, rootReferences, appOf, report);
185
+ reportCapabilityRegistrations(rootReferences, kit, ts, report);
186
+ reportNonAppImporters(references, modules, inApps, report);
138
187
 
139
188
  return { violations, coverage: { status: 'checked', apps: config.apps.filter(app => kit.appRoot(app.name)).length, modules: modules.size,
140
189
  capabilityModules: [...modules.values()].filter(item => item.capability).length, registrations: references.filter(item => item.register).length,
@@ -1,8 +1,9 @@
1
1
  import path from 'node:path';
2
2
  import { canonical } from './config.mjs';
3
3
  import { referencedExports, relativePath, unwrapExpression } from './typescript.mjs';
4
- import { commonJsRequireReasons, decoratorCallee, moduleExportsOf, mutableDecoratorKind, normalizedSymbol, normalizedSymbolValue, programSourcesOf, selectedNode, valueSymbol } from './ast-walks.mjs';
4
+ import { commonJsRequireReasons, decoratorCallee, moduleExportsOf, mutableDecoratorKind, programSourcesOf, selectedNode, valueSymbol } from './ast-walks.mjs';
5
5
  import { sourceLocation } from '../../lib/ts-ast.mjs';
6
+ import { byCodeUnit } from '../../lib/list.mjs';
6
7
 
7
8
  const REGISTRATION_RULE_IDS = ['BE_MODULE_HANDLER_REGISTRATION', 'BE_MODULE_PROVIDER_REREGISTRATION'];
8
9
  const FRAMEWORK = new Map([
@@ -27,28 +28,33 @@ const mutableFrameworkAlias = (ts, checker, decorator, targets) => {
27
28
  return mutableDecoratorKind(ts, checker, decorator, bySymbol);
28
29
  };
29
30
 
31
+ function collectFrameworkStatement(config, context, checker, sourceFile, statement, targets, reasons) {
32
+ const bound = moduleExportsOf(context.ts, checker, statement);
33
+ if (!bound || ![...FRAMEWORK.values()].includes(bound.specifier)) return;
34
+ const expected = new Set([...FRAMEWORK].filter(([, packageName]) => packageName === bound.specifier).map(([name]) => name));
35
+ const selected = referencedExports(context.ts, statement, expected);
36
+ if (!selected.length) return;
37
+ if (!bound.symbol) {
38
+ reasons.push(`${relativePath(config.root, sourceFile.fileName)} cannot resolve ${bound.specifier}`);
39
+ return;
40
+ }
41
+ for (const name of selected) {
42
+ const target = bound.exports.get(name);
43
+ if (!target) {
44
+ reasons.push(`${relativePath(config.root, sourceFile.fileName)} cannot resolve ${name} from ${bound.specifier}`);
45
+ continue;
46
+ }
47
+ targets.set(name, target);
48
+ }
49
+ }
50
+
30
51
  function frameworkTargets(config, context, checker, localFiles) {
31
52
  const targets = new Map();
32
53
  const reasons = [];
33
54
  const files = programSourcesOf(context, checker, localFiles);
34
55
  if (!files) return { targets, reasons: ['a TypeScript program checker could not be associated with its source'] };
35
56
  for (const sourceFile of files) {
36
- for (const statement of sourceFile.statements) {
37
- const bound = moduleExportsOf(context.ts, checker, statement);
38
- if (!bound || ![...FRAMEWORK.values()].includes(bound.specifier)) continue;
39
- const expected = new Set([...FRAMEWORK].filter(([, packageName]) => packageName === bound.specifier).map(([name]) => name));
40
- const selected = referencedExports(context.ts, statement, expected);
41
- if (!selected.length) continue;
42
- if (!bound.symbol) {
43
- reasons.push(`${relativePath(config.root, sourceFile.fileName)} cannot resolve ${bound.specifier}`);
44
- continue;
45
- }
46
- for (const name of selected) {
47
- const target = bound.exports.get(name);
48
- if (target) targets.set(name, target);
49
- else reasons.push(`${relativePath(config.root, sourceFile.fileName)} cannot resolve ${name} from ${bound.specifier}`);
50
- }
51
- }
57
+ for (const statement of sourceFile.statements) collectFrameworkStatement(config, context, checker, sourceFile, statement, targets, reasons);
52
58
  commonJsRequireReasons(context.ts, checker, sourceFile, (specifier) => [...FRAMEWORK.values()].includes(specifier),
53
59
  reasons, relativePath(config.root, sourceFile.fileName), 'whose decorator identity cannot be proved', 'Nest framework binding');
54
60
  }
@@ -73,28 +79,35 @@ function propertyName(ts, property) {
73
79
  return null;
74
80
  }
75
81
 
82
+ function inspectTokenElement(ts, checker, element, localFiles, field, tokens) {
83
+ if (ts.isSpreadElement(element)) return true;
84
+ let key;
85
+ let node = element;
86
+ if (ts.isObjectLiteralExpression(element)) {
87
+ if (element.properties.some(property => ts.isSpreadAssignment(property))) return true;
88
+ const providers = element.properties.filter(property => propertyName(ts, property) === 'provide');
89
+ if (providers.length !== 1 || !ts.isPropertyAssignment(providers[0])) return true;
90
+ node = providers[0].initializer;
91
+ key = localClassKey(ts, checker, node, localFiles);
92
+ } else {
93
+ const token = classToken(ts, checker, element, localFiles);
94
+ key = token.key;
95
+ if (!key) return field === 'providers' && !token.external;
96
+ }
97
+ if (key) {
98
+ if (!tokens.has(key)) tokens.set(key, []);
99
+ tokens.get(key).push(node);
100
+ }
101
+ return false;
102
+ }
103
+
76
104
  function tokenEntries(ts, checker, initializer, localFiles, field) {
77
105
  initializer = unwrapExpression(ts, initializer);
78
106
  if (!ts.isArrayLiteralExpression(initializer)) return { tokens: new Map(), unknown: true };
79
107
  const tokens = new Map();
80
108
  let unknown = false;
81
- const add = (key, node) => {
82
- if (!tokens.has(key)) tokens.set(key, []);
83
- tokens.get(key).push(node);
84
- };
85
109
  for (const element of initializer.elements) {
86
- if (ts.isSpreadElement(element)) { unknown = true; continue; }
87
- if (ts.isObjectLiteralExpression(element)) {
88
- if (element.properties.some(property => ts.isSpreadAssignment(property))) { unknown = true; continue; }
89
- const providers = element.properties.filter(property => propertyName(ts, property) === 'provide');
90
- if (providers.length !== 1 || !ts.isPropertyAssignment(providers[0])) { unknown = true; continue; }
91
- const key = localClassKey(ts, checker, providers[0].initializer, localFiles);
92
- if (key) add(key, providers[0].initializer);
93
- continue;
94
- }
95
- const token = classToken(ts, checker, element, localFiles);
96
- if (token.key) add(token.key, element);
97
- else if (field === 'providers' && !token.external) unknown = true;
110
+ if (inspectTokenElement(ts, checker, element, localFiles, field, tokens)) unknown = true;
98
111
  }
99
112
  return { tokens, unknown };
100
113
  }
@@ -131,114 +144,161 @@ function registrationFinding(module, node, ruleId, message, extra = {}) {
131
144
  return { ruleId, path: module.path, ...sourceLocation(node.getSourceFile(), node), module: module.name, message, ...extra };
132
145
  }
133
146
 
134
- /** A module belongs to an app only when its runtime source is reachable from that app's composition root. */
135
- function applicationGraphs(config, context, modules) {
136
- const roots = modules.filter(module => /^apps\/[^/]+\/src\/app\.module\.ts$/.test(module.path))
137
- .sort((a, b) => a.path.localeCompare(b.path));
138
- // Small architecture fixtures without app composition retain the original single-graph relation.
139
- if (!roots.length) return [modules];
147
+ const modulesByFileOf = (config, modules) => {
140
148
  const byFile = new Map();
141
149
  for (const module of modules) {
142
150
  const file = canonical(path.resolve(config.root, ...module.path.split('/')));
143
151
  if (!byFile.has(file)) byFile.set(file, []);
144
152
  byFile.get(file).push(module);
145
153
  }
146
- const bound = new Set();
147
- const graphs = roots.map(root => {
148
- const queue = [canonical(path.resolve(config.root, ...root.path.split('/')))];
149
- const seen = new Set();
150
- const reachable = [];
151
- for (let index = 0; index < queue.length; index += 1) {
152
- const file = queue[index];
153
- if (seen.has(file)) continue;
154
- seen.add(file);
155
- for (const module of byFile.get(file) ?? []) {
156
- reachable.push(module);
157
- bound.add(module);
158
- }
159
- for (const edge of context.edges.get(file) ?? []) if (edge.runtime && !seen.has(edge.to)) queue.push(edge.to);
154
+ return byFile;
155
+ };
156
+
157
+ /** The modules whose file the runtime import edges reach from `rootFile`, in first-visit order; each is added to `bound`. */
158
+ const reachableModules = (context, byFile, rootFile, bound) => {
159
+ const queue = [rootFile];
160
+ const seen = new Set();
161
+ const reachable = [];
162
+ for (const file of queue) {
163
+ if (seen.has(file)) continue;
164
+ seen.add(file);
165
+ for (const module of byFile.get(file) ?? []) {
166
+ reachable.push(module);
167
+ bound.add(module);
160
168
  }
161
- return reachable;
162
- });
169
+ for (const edge of context.edges.get(file) ?? []) if (edge.runtime && !seen.has(edge.to)) queue.push(edge.to);
170
+ }
171
+ return reachable;
172
+ };
173
+
174
+ /** A module belongs to an app only when its runtime source is reachable from that app's composition root. */
175
+ function applicationGraphs(config, context, modules) {
176
+ const roots = modules.filter(module => /^apps\/[^/]+\/src\/app\.module\.ts$/.test(module.path))
177
+ .sort((a, b) => a.path.localeCompare(b.path));
178
+ // Small architecture fixtures without app composition retain the original single-graph relation.
179
+ if (!roots.length) return [modules];
180
+ const byFile = modulesByFileOf(config, modules);
181
+ const bound = new Set();
182
+ const graphs = roots.map(root => reachableModules(context, byFile, canonical(path.resolve(config.root, ...root.path.split('/'))), bound));
163
183
  // A module outside every proven app graph can still contain a duplicate in its own metadata.
164
184
  return [...graphs, ...modules.filter(module => !bound.has(module)).map(module => [module])];
165
185
  }
166
186
 
167
- /** Prove only static exported class-token identity and selected CQRS handler registration. */
168
- export function checkModuleRegistration(config, context) {
169
- const selected = config.backend.moduleRegistration;
170
- if (!selected) return { violations: [], coverage: { status: 'unavailable', reason: 'architecture.json does not select Nest module-registration identity checks' } };
171
- const ts = context.ts;
172
- const localFiles = new Set(context.files.map(file => canonical(file.fileName)));
187
+ function decoratorKinds(ts, checker, node, framework) {
188
+ const decorators = ts.canHaveDecorators(node) ? ts.getDecorators(node) ?? [] : node.decorators ?? [];
189
+ return decorators.map(decorator => ({ decorator, kind: frameworkDecorator(ts, checker, decorator, framework.targets) }));
190
+ }
191
+
192
+ function recordMutableDecoratorReasons(config, ts, checker, sourceFile, kinds, framework, reasons) {
193
+ for (const { decorator, kind } of kinds) {
194
+ const dynamic = kind ? null : mutableFrameworkAlias(ts, checker, decorator, framework.targets);
195
+ if (dynamic) reasons.push(`${relativePath(config.root, sourceFile.fileName)} uses mutable ${dynamic} decorator identity`);
196
+ }
197
+ }
198
+
199
+ function recordModuleDecorator(site, node, kinds, found) {
200
+ const { config, context, sourceFile, checker, localFiles } = site;
201
+ const { modules, reasons } = found;
202
+ const moduleDecorators = kinds.filter(item => item.kind === 'Module');
203
+ if (moduleDecorators.length === 1) modules.push(moduleRecord(config, context, sourceFile, node, moduleDecorators[0].decorator, checker, localFiles));
204
+ else if (moduleDecorators.length > 1) reasons.push(`${relativePath(config.root, sourceFile.fileName)} has multiple resolved @Module decorators`);
205
+ }
206
+
207
+ function recordHandlerDecorator(site, node, kinds, found) {
208
+ const { config, context, sourceFile, checker, localFiles } = site;
209
+ const { handlers, reasons } = found;
210
+ const { ts } = context;
211
+ const handlerDecorators = kinds.filter(item => item.kind === 'CommandHandler' || item.kind === 'QueryHandler');
212
+ if (handlerDecorators.length === 1) handlers.push({
213
+ key: node.name ? localClassKey(ts, checker, node.name, localFiles) : null,
214
+ name: node.name?.text ?? '(anonymous)',
215
+ kind: handlerDecorators[0].kind,
216
+ path: relativePath(config.root, sourceFile.fileName),
217
+ ...sourceLocation(sourceFile, node.name ?? node),
218
+ });
219
+ else if (handlerDecorators.length > 1) reasons.push(`${relativePath(config.root, sourceFile.fileName)} has multiple resolved CQRS handler decorators`);
220
+ }
221
+
222
+ function inspectRegistrationClass(site, node, found) {
223
+ const { config, context, sourceFile, checker, framework } = site;
224
+ const { ts } = context;
225
+ if (!ts.isClassDeclaration(node)) return;
226
+ const kinds = decoratorKinds(ts, checker, node, framework);
227
+ recordMutableDecoratorReasons(config, ts, checker, sourceFile, kinds, framework, found.reasons);
228
+ recordModuleDecorator(site, node, kinds, found);
229
+ recordHandlerDecorator(site, node, kinds, found);
230
+ }
231
+
232
+ function discoverRegistrations(config, context, localFiles) {
173
233
  const modules = [];
174
234
  const handlers = [];
175
235
  const reasons = [];
236
+ const found = { modules, handlers, reasons };
176
237
  const frameworkByChecker = new Map();
177
238
  for (const sourceFile of context.files) {
178
239
  const checker = context.checkerFor(sourceFile.fileName);
179
240
  if (!frameworkByChecker.has(checker)) frameworkByChecker.set(checker, frameworkTargets(config, context, checker, localFiles));
180
241
  const framework = frameworkByChecker.get(checker);
181
242
  reasons.push(...framework.reasons);
243
+ const site = { config, context, sourceFile, checker, localFiles, framework };
182
244
  const visit = node => {
183
- if (ts.isClassDeclaration(node)) {
184
- const decorators = ts.canHaveDecorators(node) ? ts.getDecorators(node) ?? [] : node.decorators ?? [];
185
- const kinds = decorators.map(decorator => ({ decorator, kind: frameworkDecorator(ts, checker, decorator, framework.targets) }));
186
- const recognized = kinds.filter(item => item.kind);
187
- for (const { decorator, kind } of kinds) {
188
- const dynamic = kind ? null : mutableFrameworkAlias(ts, checker, decorator, framework.targets);
189
- if (dynamic) reasons.push(`${relativePath(config.root, sourceFile.fileName)} uses mutable ${dynamic} decorator identity`);
190
- }
191
- const moduleDecorators = recognized.filter(item => item.kind === 'Module');
192
- if (moduleDecorators.length === 1) modules.push(moduleRecord(config, context, sourceFile, node, moduleDecorators[0].decorator, checker, localFiles));
193
- else if (moduleDecorators.length > 1) reasons.push(`${relativePath(config.root, sourceFile.fileName)} has multiple resolved @Module decorators`);
194
- const handlerDecorators = recognized.filter(item => item.kind === 'CommandHandler' || item.kind === 'QueryHandler');
195
- if (handlerDecorators.length === 1) handlers.push({
196
- key: node.name ? localClassKey(ts, checker, node.name, localFiles) : null,
197
- name: node.name?.text ?? '(anonymous)',
198
- kind: handlerDecorators[0].kind,
199
- path: relativePath(config.root, sourceFile.fileName),
200
- ...sourceLocation(sourceFile, node.name ?? node),
201
- });
202
- else if (handlerDecorators.length > 1) reasons.push(`${relativePath(config.root, sourceFile.fileName)} has multiple resolved CQRS handler decorators`);
203
- }
204
- ts.forEachChild(node, visit);
245
+ inspectRegistrationClass(site, node, found);
246
+ context.ts.forEachChild(node, visit);
205
247
  };
206
248
  visit(sourceFile);
207
249
  }
208
- for (const module of modules) if (module.unknown) reasons.push(`${module.path} has dynamic or unresolvable @Module providers/exports metadata`);
209
- for (const handler of handlers) {
210
- if (!handler.key) reasons.push(`${handler.path} handler class identity is not resolvable`);
211
- if (!selected.handlerDecorators.includes(handler.kind)) reasons.push(`${handler.path} uses unselected ${handler.kind}`);
212
- }
213
- const violations = [];
250
+ return { modules, handlers, reasons };
251
+ }
252
+
253
+ function providerOwnersOf(modules) {
214
254
  const providerOwners = new Map();
215
- for (const module of modules) for (const key of module.providers.keys()) if (module.exports.has(key)) {
216
- if (!providerOwners.has(key)) providerOwners.set(key, []);
217
- providerOwners.get(key).push(module);
255
+ for (const module of modules) {
256
+ for (const key of module.providers.keys()) {
257
+ if (!module.exports.has(key)) continue;
258
+ if (!providerOwners.has(key)) providerOwners.set(key, []);
259
+ providerOwners.get(key).push(module);
260
+ }
218
261
  }
262
+ return providerOwners;
263
+ }
264
+
265
+ const byModulePath = (a, b) => a.module.path.localeCompare(b.module.path) || a.node.getStart() - b.node.getStart();
266
+
267
+ /** The registrations of `key` in a graph that follow the first one of its owner module: the owner registers it once, every other is a duplicate. */
268
+ const duplicateRegistrations = (graph, key, owner) => {
269
+ const registrations = graph.flatMap(module => (module.providers.get(key) ?? []).map(node => ({ module, node })))
270
+ .sort(byModulePath);
271
+ let keptOwnerRegistration = false;
272
+ return registrations.filter(registration => {
273
+ if (registration.module !== owner || keptOwnerRegistration) return true;
274
+ keptOwnerRegistration = true;
275
+ return false;
276
+ });
277
+ };
278
+
279
+ function reregistrationViolations(config, context, modules, providerOwners) {
280
+ const violations = [];
219
281
  const reportedRegistrations = new Set();
282
+ const report = (key, owner, duplicate) => {
283
+ const identity = `${key}\0${duplicate.module.path}\0${duplicate.node.getStart()}`;
284
+ if (reportedRegistrations.has(identity)) return;
285
+ reportedRegistrations.add(identity);
286
+ violations.push(registrationFinding(duplicate.module, duplicate.node,
287
+ 'BE_MODULE_PROVIDER_REREGISTRATION',
288
+ `Class-token provider exported by ${owner.name} is registered again by ${duplicate.module.name} in one application graph; import the owning module and preserve one class-token registration.`,
289
+ { ownerModule: owner.name, ownerPath: owner.path }));
290
+ };
220
291
  for (const graph of applicationGraphs(config, context, modules)) for (const [key] of providerOwners) {
221
292
  const owners = graph.filter(module => module.providers.has(key) && module.exports.has(key))
222
293
  .sort((a, b) => a.path.localeCompare(b.path));
223
294
  if (!owners.length) continue;
224
- const owner = owners[0];
225
- const registrations = graph.flatMap(module => (module.providers.get(key) ?? []).map(node => ({ module, node })))
226
- .sort((a, b) => a.module.path.localeCompare(b.module.path) || a.node.getStart() - b.node.getStart());
227
- let keptOwnerRegistration = false;
228
- for (const duplicate of registrations.filter(registration => {
229
- if (registration.module !== owner || keptOwnerRegistration) return true;
230
- keptOwnerRegistration = true;
231
- return false;
232
- })) {
233
- const identity = `${key}\0${duplicate.module.path}\0${duplicate.node.getStart()}`;
234
- if (reportedRegistrations.has(identity)) continue;
235
- reportedRegistrations.add(identity);
236
- violations.push(registrationFinding(duplicate.module, duplicate.node,
237
- 'BE_MODULE_PROVIDER_REREGISTRATION',
238
- `Class-token provider exported by ${owner.name} is registered again by ${duplicate.module.name} in one application graph; import the owning module and preserve one class-token registration.`,
239
- { ownerModule: owner.name, ownerPath: owner.path }));
240
- }
295
+ for (const duplicate of duplicateRegistrations(graph, key, owners[0])) report(key, owners[0], duplicate);
241
296
  }
297
+ return violations;
298
+ }
299
+
300
+ function handlerRegistrationViolations(modules, handlers, selected) {
301
+ const violations = [];
242
302
  for (const handler of handlers.filter(item => item.key && selected.handlerDecorators.includes(item.kind))) {
243
303
  const registrations = modules.flatMap(module => (module.providers.get(handler.key) ?? []).map(node => ({ module, node })));
244
304
  if (registrations.length !== 1) violations.push({
@@ -251,8 +311,25 @@ export function checkModuleRegistration(config, context) {
251
311
  ...sourceLocation(registration.node.getSourceFile(), registration.node) })),
252
312
  });
253
313
  }
314
+ return violations;
315
+ }
316
+
317
+ /** Prove only static exported class-token identity and selected CQRS handler registration. */
318
+ export function checkModuleRegistration(config, context) {
319
+ const selected = config.backend.moduleRegistration;
320
+ if (!selected) return { violations: [], coverage: { status: 'unavailable', reason: 'hfs.json does not select Nest module-registration identity checks' } };
321
+ const localFiles = new Set(context.files.map(file => canonical(file.fileName)));
322
+ const { modules, handlers, reasons } = discoverRegistrations(config, context, localFiles);
323
+ for (const module of modules) if (module.unknown) reasons.push(`${module.path} has dynamic or unresolvable @Module providers/exports metadata`);
324
+ for (const handler of handlers) {
325
+ if (!handler.key) reasons.push(`${handler.path} handler class identity is not resolvable`);
326
+ if (!selected.handlerDecorators.includes(handler.kind)) reasons.push(`${handler.path} uses unselected ${handler.kind}`);
327
+ }
328
+ const providerOwners = providerOwnersOf(modules);
329
+ const violations = reregistrationViolations(config, context, modules, providerOwners);
330
+ violations.push(...handlerRegistrationViolations(modules, handlers, selected));
254
331
  const coverage = reasons.length
255
- ? { status: 'unavailable', reason: 'one or more Nest module-registration relations are not statically provable', details: [...new Set(reasons)].sort() }
332
+ ? { status: 'unavailable', reason: 'one or more Nest module-registration relations are not statically provable', details: [...new Set(reasons)].sort(byCodeUnit) }
256
333
  : { status: 'checked', modules: modules.length, exportedClassTokenProviders: providerOwners.size, handlers: handlers.length,
257
334
  selectedHandlerDecorators: selected.handlerDecorators };
258
335
  return { violations, coverage };