@starci/hfs 4.0.9 → 4.1.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 (247) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/LICENSE +21 -0
  3. package/emit/operations.mjs +1 -1
  4. package/emit/type-schema.mjs +2 -1
  5. package/lint/run.mjs +82 -8
  6. package/package.json +3 -2
  7. package/report/order.mjs +2 -0
  8. package/runtime/config.example.yaml +187 -0
  9. package/runtime/engine/by-code-unit.mjs +12 -0
  10. package/runtime/engine/config.mjs +247 -273
  11. package/runtime/engine/invalid-config.mjs +5 -5
  12. package/runtime/engine/model-config.mjs +90 -0
  13. package/runtime/engine/orca-config.mjs +3 -1
  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 +134 -0
  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 +18 -18
  21. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  22. package/runtime/knowledge/hfs/rules.yaml +102 -69
  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 -1
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
  30. package/runtime/modules/models/registry.yaml +25 -397
  31. package/runtime/modules/models/runtimes.yaml +92 -262
  32. package/runtime/modules/models/tiers.yaml +65 -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/lib.mjs +6 -0
  37. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  38. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  39. package/runtime/scripts/api/git/lib.mjs +36 -1
  40. package/runtime/scripts/api/process/resolve-real-tool.mjs +52 -0
  41. package/runtime/scripts/api/process/run-program.mjs +8 -0
  42. package/runtime/scripts/api/sops/decrypt.mjs +9 -16
  43. package/runtime/scripts/api/sops/lib.mjs +309 -11
  44. package/runtime/scripts/api/sops/seal.mjs +8 -4
  45. package/runtime/scripts/hfs/allows.mjs +3 -3
  46. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  47. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  48. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  49. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  50. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  51. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  52. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  53. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  54. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  55. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  56. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  57. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  58. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  59. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  60. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  61. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  62. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  63. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  64. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  65. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  66. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  67. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  68. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  69. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  70. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  71. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +3 -2
  72. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  73. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  74. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  75. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  76. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  77. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  78. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  79. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  80. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  81. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  82. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  83. package/runtime/scripts/hfs/architecture/index.mjs +158 -102
  84. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  85. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  86. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  87. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  88. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  89. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  90. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  91. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  92. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  93. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  94. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  95. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  96. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  97. package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
  98. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  99. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  100. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  101. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  102. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  103. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  104. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  105. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  106. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  107. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  108. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  109. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  110. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  111. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  112. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  113. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  114. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  115. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  116. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  117. package/runtime/scripts/hfs/architecture.mjs +3 -4
  118. package/runtime/scripts/hfs/check.mjs +61 -70
  119. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  120. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  121. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  122. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  123. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  124. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  125. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  126. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  127. package/runtime/scripts/hfs/repo-identity.mjs +5 -2
  128. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  129. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  130. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  131. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  132. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  133. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  134. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  135. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  136. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  137. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  138. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  139. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  140. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  141. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  142. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  143. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  144. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  145. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  146. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  147. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  148. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  149. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  150. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  151. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  152. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  153. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  154. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  155. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  156. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  157. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  158. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  159. package/runtime/scripts/hfs/secret.mjs +90 -45
  160. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  161. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  162. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  163. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  164. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  165. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  166. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  167. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  168. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  169. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  170. package/runtime/scripts/hfs/slots.mjs +72 -693
  171. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  172. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  173. package/runtime/scripts/hfs/tree.mjs +19 -14
  174. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  175. package/runtime/scripts/hfs/view.mjs +1 -1
  176. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  177. package/runtime/scripts/lib/event-contract.mjs +17 -13
  178. package/runtime/scripts/lib/fs-kind.mjs +14 -4
  179. package/runtime/scripts/lib/git.mjs +2 -2
  180. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  181. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  182. package/runtime/scripts/lib/i18n.mjs +3 -2
  183. package/runtime/scripts/lib/in-order.mjs +71 -0
  184. package/runtime/scripts/lib/language.mjs +3 -3
  185. package/runtime/scripts/lib/list.mjs +8 -1
  186. package/runtime/scripts/lib/mutation-fence.mjs +15 -0
  187. package/runtime/scripts/lib/path-key.mjs +42 -9
  188. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  189. package/runtime/scripts/lib/regex.mjs +1 -1
  190. package/runtime/scripts/lib/same-text.mjs +1 -1
  191. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  192. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  193. package/runtime/scripts/lib/sops-envelope.mjs +103 -2
  194. package/runtime/scripts/lib/stack-services.mjs +1 -2
  195. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  196. package/runtime/scripts/lib/walk.mjs +6 -1
  197. package/scaffold/add-cli-lite.mjs +2 -1
  198. package/scaffold/add-table.mjs +2 -2
  199. package/scaffold/add.mjs +3 -2
  200. package/scaffold/app.mjs +4 -2
  201. package/scaffold/edition-gate.mjs +25 -25
  202. package/scaffold/lite-exports.mjs +2 -1
  203. package/scaffold/service.mjs +5 -4
  204. package/sync/hygiene.mjs +15 -9
  205. package/sync/index.mjs +7 -3
  206. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  207. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  208. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  209. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  210. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  211. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  212. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  213. package/templates/app/starciwork.gitignore +1 -1
  214. package/templates/be/image/api/Dockerfile +1 -1
  215. package/templates/be/image/cli/Dockerfile +1 -1
  216. package/templates/be/image/worker/Dockerfile +1 -1
  217. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  218. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  219. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  220. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  221. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  222. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  223. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  224. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  225. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  226. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  227. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  228. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  229. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  230. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  231. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  232. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  233. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  234. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  235. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  236. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  237. package/templates/fe/image/next/Dockerfile +2 -1
  238. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  239. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  240. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  241. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  242. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  243. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  244. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  245. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  246. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  247. package/upgrade/index.mjs +9 -2
@@ -0,0 +1,96 @@
1
+ import { readTextFile } from '../../lib/read-text.mjs';
2
+
3
+ // Owner ruling 2026-09-29 (layout 2026-09-30): integration, e2e and contract run MANUALLY only. No hook, default typecheck,
4
+ // coverage run or automatic CI trigger may include those trees or run those projects. Linting the e2e files is not running them: ESLint reads them
5
+ // as syntax in the one repository-wide lint run (the factory's e2e block), so no `lint:e2e` command exists to judge.
6
+ const E2E_COMMAND = new RegExp([
7
+ String.raw`\btest:(?:e2e|integration|contract)\b|`,
8
+ String.raw`\btypecheck:tests\b|`,
9
+ String.raw`--selectProjects\s+(?:e2e|integration|contract)\b|`,
10
+ String.raw`src\/tests\/(?:world|integration|e2e|contract)\b|`,
11
+ String.raw`jest[^\n|&;]*(?:e2e|integration|contract)`,
12
+ ].join(''), 'u');
13
+ const UNIT_RUN_SCRIPTS = ['test', 'test:unit', 'test:ci', 'test:affected', 'test:coverage', 'test:cov'];
14
+
15
+ const runsE2e = text => E2E_COMMAND.test(String(text).replace(/--ignore-pattern[= ]+(?:"[^"]*"|'[^']*'|\S+)/gu, ''));
16
+ const withoutComments = text => text.split('\n').filter(line => !/^\s*#/u.test(line)).join('\n');
17
+
18
+ function inspectHooks(state) {
19
+ for (const hook of ['.husky/pre-commit', '.husky/pre-push']) {
20
+ const text = readTextFile(state.root, hook);
21
+ if (text === null) continue;
22
+ const body = withoutComments(text);
23
+ if (runsE2e(body)) state.finding(state.rule, hook, `${hook} runs integration, e2e or contract. They are manual only; hooks run unit, lint and typecheck.`);
24
+ for (const match of body.matchAll(/npm\s+run\s+([\w:.-]+)/gu)) state.pending.push(match[1]);
25
+ }
26
+ }
27
+
28
+ function inspectLintStaged(state) {
29
+ const lintStaged = state.pkg?.['lint-staged'];
30
+ if (lintStaged && runsE2e(Object.values(lintStaged).flat().join('\n')))
31
+ state.finding(state.rule, 'package.json', 'lint-staged runs an integration, e2e or contract command. They are manual only.');
32
+ }
33
+
34
+ function inspectHookScripts(state) {
35
+ for (const name of state.pending) {
36
+ if (state.called.has(name)) continue;
37
+ state.called.add(name);
38
+ const command = state.scripts[name];
39
+ if (typeof command !== 'string') continue;
40
+ if (runsE2e(command)) state.finding(state.rule, 'package.json', `Script ${name} is run by a husky hook and touches integration, e2e or contract. They are manual only.`);
41
+ for (const match of command.matchAll(/npm\s+run\s+([\w:.-]+)/gu)) state.pending.push(match[1]);
42
+ }
43
+ }
44
+
45
+ function inspectUnitGates(state) {
46
+ if (!state.tree.hasFile(state.jestConfig)) return;
47
+ for (const name of UNIT_RUN_SCRIPTS) {
48
+ const command = state.scripts[name];
49
+ if (typeof command === 'string' && /\bjest\b/u.test(command) && !/--selectProjects\s+unit\b/u.test(command))
50
+ state.finding(state.rule, 'package.json', `Script ${name} runs jest without --selectProjects unit and would run the integration, e2e or contract project.`);
51
+ }
52
+ const jestText = readTextFile(state.root, state.jestConfig) ?? '';
53
+ if (/collectCoverageFrom/u.test(jestText) && !/!src\/tests\/(?:\*\*|e2e)/u.test(jestText))
54
+ state.finding(state.rule, state.jestConfig, 'collectCoverageFrom must exclude src/tests/** so no integration, e2e or contract file counts toward coverage.');
55
+ }
56
+
57
+ function inspectWorkflows(state) {
58
+ for (const file of state.tree.files().filter(entry => /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(entry))) {
59
+ const text = readTextFile(state.root, file);
60
+ if (text === null) continue;
61
+ const trigger = /^on:.*(?:\n(?:[ \t].*)?$)*/mu.exec(text)?.[0] ?? '';
62
+ if (!/\b(?:push|pull_request)\b/u.test(trigger)) continue;
63
+ if (runsE2e(withoutComments(text)))
64
+ state.finding(state.rule, file, `${file} runs e2e on push or pull_request. Move the e2e job to its own workflow with on: workflow_dispatch only.`);
65
+ }
66
+ }
67
+
68
+ /** Check hooks, transitive hook scripts, unit scripts, coverage and automatic workflows. */
69
+ export function e2eInAutomaticGate({ root, tree, jestConfig, finding }) {
70
+ const rule = 'HFS_E2E_IN_AUTOMATIC_GATE';
71
+ let pkg = null;
72
+ try { pkg = JSON.parse(readTextFile(root, 'package.json') ?? ''); } catch { /* the package checks own invalid JSON */ }
73
+ const state = { root, tree, jestConfig, finding, rule, pkg, scripts: pkg?.scripts ?? {}, pending: [], called: new Set() };
74
+ inspectHooks(state);
75
+ inspectLintStaged(state);
76
+ inspectHookScripts(state);
77
+ inspectUnitGates(state);
78
+ inspectWorkflows(state);
79
+ }
80
+
81
+ /** The be side's default tsconfig excludes the world, integration, e2e and contract trees (they are checked by src/tests/tsconfig.json). */
82
+ export function testTreesOutOfDefaultProgram({ root, tree, finding }) {
83
+ const rule = 'HFS_E2E_IN_AUTOMATIC_GATE';
84
+ const tsconfigText = readTextFile(root, 'tsconfig.json');
85
+ let tsconfig = null;
86
+ try { tsconfig = tsconfigText === null ? null : JSON.parse(tsconfigText); } catch { /* the typecheck itself owns parsing */ }
87
+ if (tsconfig) {
88
+ const excludedText = JSON.stringify(tsconfig.exclude ?? []);
89
+ const excludesTestTrees = ['world', 'integration', 'e2e', 'contract'].every(tree => excludedText.includes(`src/tests/${tree}`));
90
+ const defaultAll = tsconfig.include === undefined && tsconfig.files === undefined;
91
+ const files = tree.files();
92
+ if (files.some(file => /^src\/tests\/(?:world|integration|e2e|contract)\/.+\.[cm]?tsx?$/u.test(file)) && !excludesTestTrees &&
93
+ (defaultAll || JSON.stringify(tsconfig.include ?? []).includes('src')))
94
+ finding(rule, 'tsconfig.json', 'The default tsconfig includes src/tests/{world,integration,e2e,contract}/**. Exclude those trees and check them with src/tests/tsconfig.json (typecheck:tests).');
95
+ }
96
+ }
@@ -3,11 +3,21 @@ import { isInside } from './config.mjs';
3
3
  import { slotAdmitsFile } from '../allows.mjs';
4
4
  import { reachableViolation, relativePath } from './typescript.mjs';
5
5
  import { sourceLocation } from '../../lib/ts-ast.mjs';
6
+ import { byCodeUnit } from '../../lib/list.mjs';
6
7
 
7
8
  const FORBIDDEN_APP_ROLE = /(?:^|\.)(?:service|provider|providers|resolver|controller|handler|repository|entity|use-case|command|query|listener|consumer|processor)\.[cm]?[jt]sx?$/i;
8
9
  const FORBIDDEN_DECLARATION = /(?:Service|Provider|Resolver|Controller|Handler|Repository|Entity|UseCase|Command|Query|Listener|Consumer|Processor)$/;
9
10
  const FORBIDDEN_DECORATORS = new Set(['Controller', 'Resolver', 'Injectable', 'Processor', 'WebSocketGateway']);
10
- const TRANSPORT_PACKAGES = /^(?:@nestjs\/(?:graphql|microservices|platform-[^/]+|websockets)(?:\/|$)|@apollo\/|apollo-server(?:\/|$)|express(?:\/|$)|fastify(?:\/|$)|graphql(?:\/|$)|class-validator(?:\/|$)|class-transformer(?:\/|$))/;
11
+ const TRANSPORT_PACKAGES = new RegExp(`^(?:${[
12
+ '@nestjs/(?:graphql|microservices|platform-[^/]+|websockets)(?:/|$)',
13
+ '@apollo/',
14
+ 'apollo-server(?:/|$)',
15
+ 'express(?:/|$)',
16
+ 'fastify(?:/|$)',
17
+ 'graphql(?:/|$)',
18
+ 'class-validator(?:/|$)',
19
+ 'class-transformer(?:/|$)',
20
+ ].join('|')})`);
11
21
  const NEST_COMMON_TRANSPORT = new Set(['Controller', 'Get', 'Post', 'Put', 'Patch', 'Delete', 'Options', 'Head', 'Body', 'Param', 'Query', 'Req', 'Request', 'Res', 'Response', 'Headers', 'Header', 'HttpCode', 'Redirect', 'Render', 'Sse', 'UploadedFile', 'UploadedFiles', 'UseGuards', 'UseInterceptors', 'UsePipes']);
12
22
 
13
23
  function absolute(root, relative) {
@@ -72,111 +82,142 @@ function roleEvidence(ts, sourceFile) {
72
82
  return found;
73
83
  }
74
84
 
75
- function transportFrameworkEvidence(ts, sourceFile, checker) {
76
- const found = [];
77
- // The destructured binding elements naming a transport export (or a computed binding, which cannot be
78
- // proved to exclude one): `const { Controller, [x]: y } = ...`. `rest` elements are always opaque.
79
- const destructuredBindings = (elements, detail) => elements.flatMap(element => {
80
- const selected = element.dotDotDotToken
81
- ? null
82
- : ts.isIdentifier(element.propertyName ?? element.name)
83
- ? (element.propertyName ?? element.name).text
84
- : ts.isStringLiteralLike(element.propertyName)
85
- ? element.propertyName.text
86
- : null;
85
+ // The destructured binding elements naming a transport export (or a computed binding, which cannot be
86
+ // proved to exclude one): `const { Controller, [x]: y } = ...`. `rest` elements are always opaque.
87
+ function destructuredTransportBindings(ts, elements, detail) {
88
+ return elements.flatMap(element => {
89
+ let selected = null;
90
+ if (!element.dotDotDotToken) {
91
+ const imported = element.propertyName ?? element.name;
92
+ if (ts.isIdentifier(imported) || ts.isStringLiteralLike(imported)) selected = imported.text;
93
+ }
87
94
  return selected === null || NEST_COMMON_TRANSPORT.has(selected) ? [{ node: element, detail: selected ?? detail }] : [];
88
95
  });
89
- const namespaceUsages = binding => {
90
- const usages = [];
91
- const alias = binding.text;
92
- const bindingSymbol = checker?.getSymbolAtLocation(binding);
93
- const isAlias = node => ts.isIdentifier(node) && node.text === alias
94
- && (!bindingSymbol || checker?.getSymbolAtLocation(node) === bindingSymbol);
95
- const visit = node => {
96
- if (ts.isPropertyAccessExpression(node) && isAlias(node.expression)
97
- && NEST_COMMON_TRANSPORT.has(node.name.text)) usages.push({ node: node.name, detail: node.name.text });
98
- if (ts.isElementAccessExpression(node) && isAlias(node.expression)) {
99
- const selected = ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
100
- if (selected === null || NEST_COMMON_TRANSPORT.has(selected)) usages.push({ node: node.argumentExpression, detail: selected ?? 'computed @nestjs/common namespace access' });
101
- }
102
- if (ts.isVariableDeclaration(node) && isAlias(node.initializer)) {
103
- if (!ts.isObjectBindingPattern(node.name)) {
104
- usages.push({ node: node.name, detail: 'escaped @nestjs/common namespace access' });
105
- } else {
106
- usages.push(...destructuredBindings(node.name.elements, 'computed @nestjs/common namespace destructuring'));
107
- }
108
- }
109
- ts.forEachChild(node, visit);
110
- };
111
- visit(sourceFile);
112
- return usages;
96
+ }
97
+
98
+ const namespaceMemberUsages = (ts, node, isAlias) => {
99
+ if (ts.isPropertyAccessExpression(node) && isAlias(node.expression)
100
+ && NEST_COMMON_TRANSPORT.has(node.name.text)) return [{ node: node.name, detail: node.name.text }];
101
+ if (ts.isElementAccessExpression(node) && isAlias(node.expression)) {
102
+ const selected = ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
103
+ if (selected === null || NEST_COMMON_TRANSPORT.has(selected)) return [{ node: node.argumentExpression, detail: selected ?? 'computed @nestjs/common namespace access' }];
104
+ }
105
+ return [];
106
+ };
107
+
108
+ const namespaceEscapeUsages = (ts, node, isAlias) => {
109
+ if (!ts.isVariableDeclaration(node) || !isAlias(node.initializer)) return [];
110
+ if (!ts.isObjectBindingPattern(node.name)) return [{ node: node.name, detail: 'escaped @nestjs/common namespace access' }];
111
+ return destructuredTransportBindings(ts, node.name.elements, 'computed @nestjs/common namespace destructuring');
112
+ };
113
+
114
+ function namespaceTransportUsages(ts, sourceFile, checker, binding) {
115
+ const usages = [];
116
+ const alias = binding.text;
117
+ const bindingSymbol = checker?.getSymbolAtLocation(binding);
118
+ const isAlias = node => ts.isIdentifier(node) && node.text === alias
119
+ && (!bindingSymbol || checker?.getSymbolAtLocation(node) === bindingSymbol);
120
+ const visit = node => {
121
+ usages.push(...namespaceMemberUsages(ts, node, isAlias), ...namespaceEscapeUsages(ts, node, isAlias));
122
+ ts.forEachChild(node, visit);
113
123
  };
124
+ visit(sourceFile);
125
+ return usages;
126
+ }
127
+
128
+ function importSpecifierOf(ts, statement) {
129
+ const importEqualsSpecifier = ts.isImportEqualsDeclaration(statement)
130
+ && ts.isExternalModuleReference(statement.moduleReference)
131
+ && statement.moduleReference.expression
132
+ && ts.isStringLiteralLike(statement.moduleReference.expression)
133
+ ? statement.moduleReference.expression
134
+ : null;
135
+ let importSpecifier = importEqualsSpecifier;
136
+ if (ts.isImportDeclaration(statement) && ts.isStringLiteralLike(statement.moduleSpecifier)) importSpecifier = statement.moduleSpecifier;
137
+ return importSpecifier;
138
+ }
139
+
140
+ function addCommonImportEvidence(ts, statement, importSpecifier, sourceFile, checker, found) {
141
+ if (ts.isImportEqualsDeclaration(statement)) {
142
+ found.push(...namespaceTransportUsages(ts, sourceFile, checker, statement.name).map(item => ({ ...item, specifier: importSpecifier.text })));
143
+ return;
144
+ }
145
+ if (!statement.importClause) return;
146
+ const bindings = statement.importClause.namedBindings;
147
+ if (bindings && ts.isNamespaceImport(bindings)) {
148
+ found.push(...namespaceTransportUsages(ts, sourceFile, checker, bindings.name).map(item => ({ ...item, specifier: importSpecifier.text })));
149
+ return;
150
+ }
151
+ if (!bindings || !ts.isNamedImports(bindings)) return;
152
+ for (const element of bindings.elements) {
153
+ const imported = element.propertyName?.text ?? element.name.text;
154
+ if (NEST_COMMON_TRANSPORT.has(imported)) found.push({ node: element, specifier: importSpecifier.text, detail: imported });
155
+ }
156
+ }
157
+
158
+ function importTransportEvidence(ts, sourceFile, checker, found) {
114
159
  for (const statement of sourceFile.statements) {
115
- const importEqualsSpecifier = ts.isImportEqualsDeclaration(statement)
116
- && ts.isExternalModuleReference(statement.moduleReference)
117
- && statement.moduleReference.expression
118
- && ts.isStringLiteralLike(statement.moduleReference.expression)
119
- ? statement.moduleReference.expression
120
- : null;
121
- const importSpecifier = ts.isImportDeclaration(statement) && ts.isStringLiteralLike(statement.moduleSpecifier)
122
- ? statement.moduleSpecifier
123
- : importEqualsSpecifier;
160
+ const importSpecifier = importSpecifierOf(ts, statement);
124
161
  if (!importSpecifier) continue;
125
162
  const specifier = importSpecifier.text;
126
163
  if (TRANSPORT_PACKAGES.test(specifier)) {
127
164
  found.push({ node: importSpecifier, specifier, detail: specifier });
128
165
  continue;
129
166
  }
130
- if (specifier !== '@nestjs/common') continue;
131
- if (ts.isImportEqualsDeclaration(statement)) {
132
- found.push(...namespaceUsages(statement.name).map(item => ({ ...item, specifier })));
133
- continue;
134
- }
135
- if (!statement.importClause) continue;
136
- const bindings = statement.importClause.namedBindings;
137
- if (bindings && ts.isNamespaceImport(bindings)) {
138
- found.push(...namespaceUsages(bindings.name).map(item => ({ ...item, specifier })));
139
- continue;
140
- }
141
- if (!bindings || !ts.isNamedImports(bindings)) continue;
142
- for (const element of bindings.elements) {
143
- const imported = element.propertyName?.text ?? element.name.text;
144
- if (NEST_COMMON_TRANSPORT.has(imported)) found.push({ node: element, specifier, detail: imported });
145
- }
167
+ if (specifier === '@nestjs/common') addCommonImportEvidence(ts, statement, importSpecifier, sourceFile, checker, found);
146
168
  }
147
- const visitRequire = node => {
148
- const requiredSpecifier = call => ts.isCallExpression(call)
149
- && ts.isIdentifier(call.expression) && call.expression.text === 'require'
150
- && call.arguments.length === 1 && ts.isStringLiteralLike(call.arguments[0])
151
- ? call.arguments[0].text
152
- : null;
153
- if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && ts.isCallExpression(node.expression)) {
154
- const specifier = requiredSpecifier(node.expression);
155
- const selected = ts.isPropertyAccessExpression(node)
156
- ? node.name.text
157
- : ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
158
- if (specifier && TRANSPORT_PACKAGES.test(specifier)) {
159
- found.push({ node: ts.isPropertyAccessExpression(node) ? node.name : node.argumentExpression, specifier, detail: `${selected ?? 'computed member'} from ${specifier}` });
160
- } else if (specifier === '@nestjs/common' && (selected === null || NEST_COMMON_TRANSPORT.has(selected))) {
161
- found.push({ node: ts.isPropertyAccessExpression(node) ? node.name : node.argumentExpression, specifier, detail: selected ?? 'computed @nestjs/common require access' });
162
- }
163
- }
164
- if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)
165
- && requiredSpecifier(node.initializer)) {
166
- const specifier = requiredSpecifier(node.initializer);
167
- if (TRANSPORT_PACKAGES.test(specifier)) {
168
- found.push({ node: node.initializer.arguments[0], specifier, detail: specifier });
169
- } else if (specifier === '@nestjs/common') {
170
- if (ts.isIdentifier(node.name)) {
171
- found.push(...namespaceUsages(node.name).map(item => ({ ...item, specifier })));
172
- } else if (ts.isObjectBindingPattern(node.name)) {
173
- found.push(...destructuredBindings(node.name.elements, 'computed @nestjs/common require destructuring').map(item => ({ ...item, specifier })));
174
- }
175
- }
169
+ }
170
+
171
+ function requiredSpecifier(ts, call) {
172
+ return ts.isCallExpression(call)
173
+ && ts.isIdentifier(call.expression) && call.expression.text === 'require'
174
+ && call.arguments.length === 1 && ts.isStringLiteralLike(call.arguments[0])
175
+ ? call.arguments[0].text
176
+ : null;
177
+ }
178
+
179
+ const requiredMemberSelection = (ts, node) => {
180
+ if (ts.isPropertyAccessExpression(node)) return node.name.text;
181
+ return ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
182
+ };
183
+
184
+ function requiredMemberEvidence(ts, node, found) {
185
+ if (!(ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) || !ts.isCallExpression(node.expression)) return;
186
+ const specifier = requiredSpecifier(ts, node.expression);
187
+ const selected = requiredMemberSelection(ts, node);
188
+ const member = ts.isPropertyAccessExpression(node) ? node.name : node.argumentExpression;
189
+ if (specifier && TRANSPORT_PACKAGES.test(specifier)) {
190
+ found.push({ node: member, specifier, detail: `${selected ?? 'computed member'} from ${specifier}` });
191
+ } else if (specifier === '@nestjs/common' && (selected === null || NEST_COMMON_TRANSPORT.has(selected))) {
192
+ found.push({ node: member, specifier, detail: selected ?? 'computed @nestjs/common require access' });
193
+ }
194
+ }
195
+
196
+ function requiredVariableEvidence(ts, node, sourceFile, checker, found) {
197
+ if (!ts.isVariableDeclaration(node) || !node.initializer || !ts.isCallExpression(node.initializer)
198
+ || !requiredSpecifier(ts, node.initializer)) return;
199
+ const specifier = requiredSpecifier(ts, node.initializer);
200
+ if (TRANSPORT_PACKAGES.test(specifier)) {
201
+ found.push({ node: node.initializer.arguments[0], specifier, detail: specifier });
202
+ } else if (specifier === '@nestjs/common') {
203
+ if (ts.isIdentifier(node.name)) {
204
+ found.push(...namespaceTransportUsages(ts, sourceFile, checker, node.name).map(item => ({ ...item, specifier })));
205
+ } else if (ts.isObjectBindingPattern(node.name)) {
206
+ found.push(...destructuredTransportBindings(ts, node.name.elements, 'computed @nestjs/common require destructuring').map(item => ({ ...item, specifier })));
176
207
  }
177
- ts.forEachChild(node, visitRequire);
178
- };
179
- visitRequire(sourceFile);
208
+ }
209
+ }
210
+
211
+ function requiredTransportEvidence(ts, node, sourceFile, checker, found) {
212
+ requiredMemberEvidence(ts, node, found);
213
+ requiredVariableEvidence(ts, node, sourceFile, checker, found);
214
+ ts.forEachChild(node, child => requiredTransportEvidence(ts, child, sourceFile, checker, found));
215
+ }
216
+
217
+ function transportFrameworkEvidence(ts, sourceFile, checker) {
218
+ const found = [];
219
+ importTransportEvidence(ts, sourceFile, checker, found);
220
+ requiredTransportEvidence(ts, sourceFile, sourceFile, checker, found);
180
221
  return found;
181
222
  }
182
223
 
@@ -207,7 +248,7 @@ function selectedReexportSurface(ts, declaration, selected) {
207
248
  return next.size ? next : undefined;
208
249
  }
209
250
 
210
- function externalTransportReexport(ts, sourceFile, selected) {
251
+ function importedTransportBindings(ts, sourceFile) {
211
252
  const imported = new Map();
212
253
  for (const statement of sourceFile.statements) {
213
254
  if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier) || !statement.importClause) continue;
@@ -217,60 +258,92 @@ function externalTransportReexport(ts, sourceFile, selected) {
217
258
  for (const element of bindings.elements) imported.set(element.name.text, { specifier, imported: element.propertyName?.text ?? element.name.text });
218
259
  } else if (bindings && ts.isNamespaceImport(bindings)) imported.set(bindings.name.text, { specifier, imported: null });
219
260
  }
261
+ return imported;
262
+ }
263
+
264
+ const unnamedReexport = (statement, specifier, selected) => {
265
+ if (specifier !== '@nestjs/common' || selected === null || [...selected].some(name => NEST_COMMON_TRANSPORT.has(name))) {
266
+ return { node: statement.moduleSpecifier, specifier, detail: selected === null ? `unbounded ${specifier} re-export` : `re-exported ${[...selected].join(', ')}` };
267
+ }
268
+ return null;
269
+ };
270
+
271
+ const namedReexport = (clause, specifier, selected) => {
272
+ for (const element of clause.elements) {
273
+ if (selected !== null && !selected.has(element.name.text)) continue;
274
+ const original = element.propertyName?.text ?? element.name.text;
275
+ if (specifier !== '@nestjs/common' || NEST_COMMON_TRANSPORT.has(original)) return { node: element, specifier, detail: `${element.name.text} re-exported from ${specifier}` };
276
+ }
277
+ return null;
278
+ };
279
+
280
+ function directTransportReexport(ts, statement, specifier, selected) {
281
+ const clause = statement.exportClause;
282
+ if (!clause) return unnamedReexport(statement, specifier, selected);
283
+ if (ts.isNamespaceExport(clause)) {
284
+ return selected === null || selected.has(clause.name.text) ? { node: clause.name, specifier, detail: `namespace re-export from ${specifier}` } : null;
285
+ }
286
+ return ts.isNamedExports(clause) ? namedReexport(clause, specifier, selected) : null;
287
+ }
288
+
289
+ /** True for an import binding of a transport package, or of a transport symbol of @nestjs/common. */
290
+ const isTransportBinding = binding => (binding.specifier === '@nestjs/common'
291
+ ? binding.imported !== null && NEST_COMMON_TRANSPORT.has(binding.imported)
292
+ : TRANSPORT_PACKAGES.test(binding.specifier));
293
+
294
+ function localTransportReexport(ts, statement, selected, imported) {
295
+ const clause = statement.exportClause;
296
+ if (!clause || !ts.isNamedExports(clause)) return null;
297
+ for (const element of clause.elements) {
298
+ if (selected !== null && !selected.has(element.name.text)) continue;
299
+ const binding = imported.get(element.propertyName?.text ?? element.name.text);
300
+ if (!binding || !isTransportBinding(binding)) continue;
301
+ return { node: element, specifier: binding.specifier, detail: `${element.name.text} re-exported from ${binding.specifier}` };
302
+ }
303
+ return null;
304
+ }
305
+
306
+ const statementTransportReexport = (ts, statement, selected, imported) => {
307
+ const specifier = statement.moduleSpecifier && ts.isStringLiteralLike(statement.moduleSpecifier) ? statement.moduleSpecifier.text : null;
308
+ if (specifier && (specifier === '@nestjs/common' || TRANSPORT_PACKAGES.test(specifier))) return directTransportReexport(ts, statement, specifier, selected);
309
+ return specifier ? null : localTransportReexport(ts, statement, selected, imported);
310
+ };
311
+
312
+ function externalTransportReexport(ts, sourceFile, selected) {
313
+ const imported = importedTransportBindings(ts, sourceFile);
220
314
  for (const statement of sourceFile.statements) {
221
315
  if (!ts.isExportDeclaration(statement)) continue;
222
- const clause = statement.exportClause;
223
- const specifier = statement.moduleSpecifier && ts.isStringLiteralLike(statement.moduleSpecifier) ? statement.moduleSpecifier.text : null;
224
- if (specifier && (specifier === '@nestjs/common' || TRANSPORT_PACKAGES.test(specifier))) {
225
- if (!clause) {
226
- if (specifier !== '@nestjs/common' || selected === null || [...selected].some(name => NEST_COMMON_TRANSPORT.has(name))) {
227
- return { node: statement.moduleSpecifier, specifier, detail: selected === null ? `unbounded ${specifier} re-export` : `re-exported ${[...selected].join(', ')}` };
228
- }
229
- continue;
230
- }
231
- if (ts.isNamespaceExport(clause)) {
232
- if (selected === null || selected.has(clause.name.text)) return { node: clause.name, specifier, detail: `namespace re-export from ${specifier}` };
233
- continue;
234
- }
235
- if (!ts.isNamedExports(clause)) continue;
236
- for (const element of clause.elements) {
237
- if (selected !== null && !selected.has(element.name.text)) continue;
238
- const original = element.propertyName?.text ?? element.name.text;
239
- if (specifier !== '@nestjs/common' || NEST_COMMON_TRANSPORT.has(original)) return { node: element, specifier, detail: `${element.name.text} re-exported from ${specifier}` };
240
- }
241
- continue;
242
- }
243
- if (specifier || !clause || !ts.isNamedExports(clause)) continue;
244
- for (const element of clause.elements) {
245
- if (selected !== null && !selected.has(element.name.text)) continue;
246
- const local = element.propertyName?.text ?? element.name.text;
247
- const binding = imported.get(local);
248
- if (!binding) continue;
249
- if (binding.specifier !== '@nestjs/common' && !TRANSPORT_PACKAGES.test(binding.specifier)) continue;
250
- if (binding.specifier === '@nestjs/common' && (binding.imported === null || !NEST_COMMON_TRANSPORT.has(binding.imported))) continue;
251
- return { node: element, specifier: binding.specifier, detail: `${element.name.text} re-exported from ${binding.specifier}` };
252
- }
316
+ const evidence = statementTransportReexport(ts, statement, selected, imported);
317
+ if (evidence) return evidence;
253
318
  }
254
319
  return null;
255
320
  }
256
321
 
322
+ function reexportQueueKey(current) {
323
+ return `${current.file}\0${current.selected === null ? '*' : [...current.selected].sort(byCodeUnit).join(',')}`;
324
+ }
325
+
326
+ function addReexportEdges(ts, context, current, queue) {
327
+ for (const edge of context.edges.get(current.file) ?? []) {
328
+ if (!edge.reexport) continue;
329
+ const selected = selectedReexportSurface(ts, edge.declaration, current.selected);
330
+ if (selected !== undefined) queue.push({ file: edge.to, selected, chain: [...current.chain, edge.to] });
331
+ }
332
+ }
333
+
257
334
  function reexportedTransportEvidence(ts, context, sourceFiles, firstEdge) {
258
335
  const queue = [{ file: firstEdge.to, selected: importedSurface(ts, firstEdge.declaration), chain: [firstEdge.from, firstEdge.to] }];
259
336
  const visited = new Set();
260
337
  while (queue.length) {
261
338
  const current = queue.shift();
262
- const key = `${current.file}\0${current.selected === null ? '*' : [...current.selected].sort().join(',')}`;
339
+ const key = reexportQueueKey(current);
263
340
  if (visited.has(key)) continue;
264
341
  visited.add(key);
265
342
  const sourceFile = sourceFiles.get(path.resolve(current.file));
266
343
  if (!sourceFile) continue;
267
344
  const evidence = externalTransportReexport(ts, sourceFile, current.selected);
268
345
  if (evidence) return { evidence, chain: current.chain };
269
- for (const edge of context.edges.get(current.file) ?? []) {
270
- if (!edge.reexport) continue;
271
- const selected = selectedReexportSurface(ts, edge.declaration, current.selected);
272
- if (selected !== undefined) queue.push({ file: edge.to, selected, chain: [...current.chain, edge.to] });
273
- }
346
+ addReexportEdges(ts, context, current, queue);
274
347
  }
275
348
  return null;
276
349
  }
@@ -288,56 +361,69 @@ function finding(config, edge, ruleId, message, chain) {
288
361
  };
289
362
  }
290
363
 
364
+ function checkApplicationComposition(config, ts, sourceFile, fileName, app, violations) {
365
+ const evidence = roleEvidence(ts, sourceFile);
366
+ if (evidence) {
367
+ violations.push({
368
+ ruleId: 'BE_APP_BUSINESS_ROLE',
369
+ path: relativePath(config.root, fileName),
370
+ ...sourceLocation(sourceFile, evidence.node),
371
+ message: `Application composition source contains measurable business/provider role ${evidence.detail}. Move that role under src/features or src/modules.`,
372
+ });
373
+ } else if (slotAdmitsFile(config.hfs, relativePath(config.root, fileName)) !== true) {
374
+ violations.push({
375
+ ruleId: FORBIDDEN_APP_ROLE.test(path.posix.basename(app.relative)) ? 'BE_APP_BUSINESS_ROLE' : 'BE_APP_COMPOSITION_ONLY',
376
+ path: relativePath(config.root, fileName),
377
+ line: 1,
378
+ column: 1,
379
+ message: `Application source ${app.relative} is not a file its app slot requires or allows (main.ts, app.module.ts, <app>.options.ts).`,
380
+ });
381
+ }
382
+ }
383
+
384
+ function checkApplicationTransport(config, context, sourceFile, fileName, violations) {
385
+ for (const evidence of transportFrameworkEvidence(context.ts, sourceFile, context.checkerFor(fileName))) {
386
+ violations.push({
387
+ ruleId: 'BE_APPLICATION_TRANSPORT_FRAMEWORK',
388
+ path: relativePath(config.root, fileName),
389
+ ...sourceLocation(sourceFile, evidence.node),
390
+ specifier: evidence.specifier,
391
+ message: `Feature application code imports transport framework surface ${evidence.detail}. Keep protocol decorators, request/response types, validation DTOs, and generated transport types under transport/.`,
392
+ });
393
+ }
394
+ }
395
+
396
+ function checkFeatureTransportEdges(config, context, sourceFiles, featureRoots, fileName, violations) {
397
+ for (const edge of context.edges.get(fileName) ?? []) {
398
+ const reexported = reexportedTransportEvidence(context.ts, context, sourceFiles, edge);
399
+ if (reexported) violations.push(finding(config, edge, 'BE_APPLICATION_TRANSPORT_FRAMEWORK', `Feature application code imports protocol surface ${reexported.evidence.detail} through an internal re-export. Keep protocol decorators and types under transport/.`, reexported.chain));
400
+ const transportChain = reachableViolation(context.edges, edge, target => insideFeatureLayer(featureRoots, target, 'transport'));
401
+ if (transportChain) violations.push(finding(config, edge, 'BE_APPLICATION_IMPORTS_TRANSPORT', 'Feature application code cannot depend on transport adapters or DTOs, including through a type import or barrel.', transportChain));
402
+ }
403
+ }
404
+
405
+ function checkBackendSource(config, context, sourceFiles, roots, sourceFile, violations) {
406
+ const { moduleRoots, featureRoots, nonAppRoots } = roots;
407
+ const fileName = path.resolve(sourceFile.fileName);
408
+ const fromModules = insideAny(moduleRoots, fileName);
409
+ const fromFeatures = insideAny(featureRoots, fileName);
410
+ const fromApplication = fromFeatures && insideFeatureLayer(featureRoots, fileName, 'application');
411
+ const app = !fromModules && !fromFeatures ? appSource(config, fileName, nonAppRoots) : null;
412
+ if (app) checkApplicationComposition(config, context.ts, sourceFile, fileName, app, violations);
413
+ if (!fromModules && !fromFeatures) return;
414
+ if (fromApplication) checkApplicationTransport(config, context, sourceFile, fileName, violations);
415
+ if (fromApplication) checkFeatureTransportEdges(config, context, sourceFiles, featureRoots, fileName, violations);
416
+ }
417
+
291
418
  /** Enforce backend direction and keep executable apps as measurable composition roots. */
292
419
  export function checkBackend(config, context) {
293
420
  const violations = [];
294
421
  const moduleRoots = roots(config.root, config.backend.modules);
295
422
  const featureRoots = roots(config.root, config.backend.features);
296
- const nonAppRoots = [...moduleRoots, ...featureRoots];
423
+ const sourceRoots = { moduleRoots, featureRoots, nonAppRoots: [...moduleRoots, ...featureRoots] };
297
424
  const sourceFiles = new Map(context.files.map(file => [path.resolve(file.fileName), file]));
298
425
  for (const sourceFile of context.files) {
299
- const fileName = path.resolve(sourceFile.fileName);
300
- const fromModules = insideAny(moduleRoots, fileName);
301
- const fromFeatures = insideAny(featureRoots, fileName);
302
- const fromApplication = fromFeatures && insideFeatureLayer(featureRoots, fileName, 'application');
303
- const app = !fromModules && !fromFeatures ? appSource(config, fileName, nonAppRoots) : null;
304
- if (app) {
305
- const evidence = roleEvidence(context.ts, sourceFile);
306
- if (evidence) {
307
- violations.push({
308
- ruleId: 'BE_APP_BUSINESS_ROLE',
309
- path: relativePath(config.root, fileName),
310
- ...sourceLocation(sourceFile, evidence.node),
311
- message: `Application composition source contains measurable business/provider role ${evidence.detail}. Move that role under src/features or src/modules.`,
312
- });
313
- } else if (slotAdmitsFile(config.hfs, relativePath(config.root, fileName)) !== true) {
314
- violations.push({
315
- ruleId: FORBIDDEN_APP_ROLE.test(path.posix.basename(app.relative)) ? 'BE_APP_BUSINESS_ROLE' : 'BE_APP_COMPOSITION_ONLY',
316
- path: relativePath(config.root, fileName),
317
- line: 1,
318
- column: 1,
319
- message: `Application source ${app.relative} is not a file its app slot requires or allows (main.ts, app.module.ts, <app>.options.ts).`,
320
- });
321
- }
322
- }
323
- if (!fromModules && !fromFeatures) continue;
324
- if (fromApplication) {
325
- for (const evidence of transportFrameworkEvidence(context.ts, sourceFile, context.checkerFor(fileName))) violations.push({
326
- ruleId: 'BE_APPLICATION_TRANSPORT_FRAMEWORK',
327
- path: relativePath(config.root, fileName),
328
- ...sourceLocation(sourceFile, evidence.node),
329
- specifier: evidence.specifier,
330
- message: `Feature application code imports transport framework surface ${evidence.detail}. Keep protocol decorators, request/response types, validation DTOs, and generated transport types under transport/.`,
331
- });
332
- }
333
- for (const edge of context.edges.get(fileName) ?? []) {
334
- if (fromApplication) {
335
- const reexported = reexportedTransportEvidence(context.ts, context, sourceFiles, edge);
336
- if (reexported) violations.push(finding(config, edge, 'BE_APPLICATION_TRANSPORT_FRAMEWORK', `Feature application code imports protocol surface ${reexported.evidence.detail} through an internal re-export. Keep protocol decorators and types under transport/.`, reexported.chain));
337
- const transportChain = reachableViolation(context.edges, edge, target => insideFeatureLayer(featureRoots, target, 'transport'));
338
- if (transportChain) violations.push(finding(config, edge, 'BE_APPLICATION_IMPORTS_TRANSPORT', 'Feature application code cannot depend on transport adapters or DTOs, including through a type import or barrel.', transportChain));
339
- }
340
- }
426
+ checkBackendSource(config, context, sourceFiles, sourceRoots, sourceFile, violations);
341
427
  }
342
428
  return violations;
343
429
  }