@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
@@ -15,8 +15,8 @@ import { relativePath } from './typescript.mjs';
15
15
  * graph.ownerRoots Map<unitKey, {slot, root, tier}> of every owner instance that holds a graph file
16
16
  * graph.abs(file) the repository-relative path of an absolute file the graph holds, else null
17
17
  */
18
- export function buildHfsGraph(config, context) {
19
- const resolver = config.hfs;
18
+ // Every production source file the TypeScript context loaded, as a graph.files node keyed by its repository-relative path.
19
+ const collectFiles = (config, context, resolver) => {
20
20
  const files = new Map();
21
21
  const absolute = new Map();
22
22
  for (const sourceFile of context.files) {
@@ -35,6 +35,11 @@ export function buildHfsGraph(config, context) {
35
35
  });
36
36
  absolute.set(file, rel);
37
37
  }
38
+ return { files, absolute };
39
+ };
40
+
41
+ // Every import, re-export and type-only import between two graphed files, as rel-path edges.
42
+ const collectEdges = (context, absolute) => {
38
43
  const edges = [];
39
44
  for (const [file, list] of context.edges) {
40
45
  const from = absolute.get(file);
@@ -45,6 +50,13 @@ export function buildHfsGraph(config, context) {
45
50
  edges.push({ from, to, runtime: edge.runtime, line: edge.line, column: edge.column, specifier: edge.specifier, reexport: Boolean(edge.reexport), edge });
46
51
  }
47
52
  }
53
+ return edges;
54
+ };
55
+
56
+ export function buildHfsGraph(config, context) {
57
+ const resolver = config.hfs;
58
+ const { files, absolute } = collectFiles(config, context, resolver);
59
+ const edges = collectEdges(context, absolute);
48
60
  const unit = rel => {
49
61
  const node = files.get(rel);
50
62
  if (!node) return null;
@@ -1,13 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { lsFiles } from '../../api/git/ls-files.mjs'; import { revParseQuery } from '../../api/git/rev-parse-query.mjs'; import { configGet } from '../../api/git/config-get.mjs'; import { gitOutputOf } from '../../lib/git.mjs';
4
- import { repositoryName } from '../repo-identity.mjs';
5
4
  import { braceVariants } from '../../lib/glob.mjs';
6
5
  import { createSlotResolver, loadSlotManifest, openHfs } from '../slots.mjs';
7
6
  import { isFeTestPath } from '../rules/fe-no-tests.mjs';
8
- import { readTextFile } from '../../lib/read-text.mjs';
9
- import { managedScriptNames } from './managed-scripts.mjs';
10
-
7
+ import { byCodeUnit } from '../../lib/list.mjs';
8
+ import { checkRepoPresentationImpl } from './presentation.mjs';
9
+ import { e2eInAutomaticGate, testTreesOutOfDefaultProgram } from './automatic-gates.mjs';
11
10
  /**
12
11
  * HFS repository-tree check (knowledge/hfs/README.md): every StarCi repository is an
13
12
  * apps/<app>/ monorepo on npm with a fixed root-entry allowlist; backend composition lives in
@@ -43,15 +42,20 @@ export const HFS_RULE_IDS = [
43
42
  ];
44
43
 
45
44
  const NON_NPM_ENTRIES = new Set(['pnpm-lock.yaml', 'pnpm-workspace.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']);
46
- const RUNTIME_ROOT_MARKDOWN = new Set(['README.md', 'CONTEXT.md', 'CONTRIBUTING.md', 'CHANGELOG.md', 'THIRD_PARTY_NOTICES.md']);
47
- const PRODUCT_ROOT_MARKDOWN = new Set(['README.md']);
48
- const README_SECTIONS = ['Overview', 'Stack', 'Repository layout', 'Development'];
45
+ const HUSKY_HOOKS_PATH = /^\.husky(?:\/_)?\/?$/u;
49
46
  const BACKEND_SRC_CHILDREN = new Set(['features', 'modules', 'tests']);
50
47
  const moduleTiersOf = resolver => new Set(resolver.slots().filter(slot => slot.profiles.includes('be')).flatMap(slot => braceVariants(slot.path)).map(variant => /^src\/modules\/([a-z][a-z-]*)\//.exec(variant)?.[1]).filter(Boolean)); // the folders of src/modules/ the slot manifest knows (domain, platform, integrations, events, queues, projections)
51
48
  // Owner test layout 2026-09-30: unit `<name>.spec.ts`, integration, e2e and contract by folder and suffix; int-spec and harness-spec stay banned.
52
- const RETIRED_TEST_SUFFIX = /\.(?:int|harness)-spec\.[cm]?[jt]sx?$/u;
53
- const RETIRED_TEST_FOLDER = /^src\/tests\/(?:harness|live|e2e\/live)(?:\/|$)/u;
54
- const EXTRA_TEST_CONFIG = /(?:^|\/)(?:jest[.-][^/]*(?:config\.[cm]?[jt]s|\.json)|jest-(?:e2e|int|integration|harness)[^/]*)$/u;
49
+ const UNSUPPORTED_TEST_SUFFIX = /\.(?:int|harness)-spec\.[cm]?[jt]sx?$/u;
50
+ const UNSUPPORTED_TEST_FOLDER = /^src\/tests\/(?:harness|live|e2e\/live)(?:\/|$)/u;
51
+ const JEST_CONFIG_PREFIX = /^jest[.-]/u;
52
+ const JEST_CONFIG_SUFFIX = /(?:config\.[cm]?[jt]s|\.json)$/u;
53
+ const JEST_LANE_PREFIX = /^jest-(?:e2e|int|harness)/u;
54
+ /** A per-lane jest config file name: the last path segment is `jest[.-]...config.<ext>`, `jest[.-]....json` or `jest-<lane>...`. */
55
+ const isExtraTestConfig = file => {
56
+ const base = file.slice(file.lastIndexOf('/') + 1);
57
+ return (JEST_CONFIG_PREFIX.test(base) && JEST_CONFIG_SUFFIX.test(base.slice('jest-'.length))) || JEST_LANE_PREFIX.test(base);
58
+ };
55
59
  const NODE_ENTRIES_SKIPPED = new Set(['node_modules', '.git']);
56
60
 
57
61
  /** The slot resolver of a side folder: the caller's, else the side view its app's hfs.json declares, else the profile's slots with no app declared. */
@@ -109,6 +113,17 @@ function ownsGitTopLevel(root) {
109
113
  }
110
114
  }
111
115
 
116
+ /** A directory that is not the top level of its own work tree has no hooks of its own: the check is not applicable there. */
117
+ function hooksPathNotRedirected({ root, finding }) {
118
+ if (!ownsGitTopLevel(root)) return { status: 'not-applicable', reason: 'the checked root is not the top level of its own Git work tree, so it has no hooks of its own' };
119
+ const set = configGet(root, 'core.hooksPath', { local: true });
120
+ if (!set.ok) return { status: 'checked' }; // key not set: nothing is redirected
121
+ const value = set.stdout;
122
+ if (value && !HUSKY_HOOKS_PATH.test(value.replaceAll('\\', '/')))
123
+ finding('HFS_HOOKS_PATH_REDIRECTED', '.git/config', `core.hooksPath is set to ${value} in this clone. Unset it (git config --local --unset core.hooksPath) and let husky own the hooks; a redirected path skips the pre-commit and pre-push gates.`);
124
+ return { status: 'checked' };
125
+ }
126
+
112
127
  /** The tracked paths under the given root, relative to it: the pathspec keeps a nested directory from listing the paths of its enclosing clone. */
113
128
  function gitPaths(root) {
114
129
  try {
@@ -182,214 +197,109 @@ function treeView(root) {
182
197
  };
183
198
  }
184
199
 
185
- // `hostname` is a WHATWG URL hostname: a name never holds ':', so after the brackets go only an IPv6 literal does.
186
- function privateHost(hostname) {
187
- const host = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
188
- if (host === 'localhost' || host === '::1' || host === '0.0.0.0' ||
189
- /(?:\.localhost|\.local|\.internal|\.lan)$/u.test(host)) return true;
190
- if (host.includes(':')) return /^(?:::|f[cd][0-9a-f]*:|fe[89ab][0-9a-f]*:)/iu.test(host);
191
- if (!host.includes('.')) return true;
192
- const octets = host.split('.');
193
- if (octets.length !== 4 || !octets.every(part => /^\d{1,3}$/u.test(part) && Number(part) <= 255)) return false;
194
- const [a, b] = octets.map(Number);
195
- return a === 0 || a === 10 || a === 127 || a === 169 && b === 254 ||
196
- a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127;
200
+ /** Presentation checks shared by the product HFS gate and this runtime's own standalone gate. */
201
+ export function checkRepoPresentation({ root, runtime = false, tree = treeView(root), profile = 'app', edition = 'full' }) {
202
+ return checkRepoPresentationImpl({ root, runtime, tree, profile, edition, nonNpmEntries: NON_NPM_ENTRIES });
203
+ }
204
+ function checkSideRootEntry(state, entry) {
205
+ const { profile, frontend, backend, allowed, finding } = state;
206
+ if (NON_NPM_ENTRIES.has(entry) || /\.md$/iu.test(entry)) return;
207
+ if (entry === '.starcistacks') { finding('HFS_STACKS_IN_SIDE', entry, `The ${profile} side must not hold .starcistacks; stack declarations and sealed custody live in the app root .starcistacks.`); return; }
208
+ if (frontend && !backend) {
209
+ if (entry === '.starciwork') { finding('HFS_WORK_IN_FE', entry, 'The fe side must not hold a .starciwork tree; Work records live in the app root .starciwork.'); return; }
210
+ if (entry === 'src') { finding('HFS_ROOT_SRC_FORBIDDEN_FE', entry, 'The fe side keeps source only under apps/<app>/src; the fe/src/ tree must move.'); return; }
211
+ if (isFeTestPath(entry) || isFeTestPath(`${entry}/x`)) return;
212
+ }
213
+ if (!allowed.has(entry)) finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} of the ${profile} side is not in the slots of the ${profile} side.`);
197
214
  }
198
215
 
199
- // The scripts the README Development section shows; each is required only when the managed package-scripts template of the
200
- // profile (packages/hfs/templates/<profile>/package-scripts/package.json, the one source of the managed script names) has it.
201
- const DEVELOPMENT_SCRIPTS = ['typecheck', 'lint', 'build', 'test'];
202
- /** The README command that runs a managed script: `npm test` for test, `npm run <name>` for the others (never a longer script name that starts with it). */
203
- function scriptCommand(name) {
204
- const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
205
- return new RegExp(name === 'test' ? 'npm (?:run test|test)(?![\\w:-])' : `npm run ${escaped}(?![\\w:-])`, 'u');
216
+ function checkSideRootEntries(state) {
217
+ // The README, the hooks, the workflows and the scripts are the app root's (checkAppRoot); a side folder is judged as the old
218
+ // repository root it stands for, less those.
219
+ for (const entry of [...state.tree.top].sort(byCodeUnit)) checkSideRootEntry(state, entry);
206
220
  }
207
221
 
208
- /** Presentation checks shared by the product HFS gate and this runtime's own standalone gate. */
209
- export function checkRepoPresentation({ root, runtime = false, tree = treeView(root), profile = 'app', edition = 'full' }) {
210
- const violations = [];
211
- const finding = (ruleId, entry, message, line = 1) => violations.push({ ruleId, path: entry, line, column: 1, message });
212
- for (const entry of tree.top) {
213
- if (/\.md$/iu.test(entry) && !(runtime ? RUNTIME_ROOT_MARKDOWN : PRODUCT_ROOT_MARKDOWN).has(entry))
214
- finding('HFS_ROOT_MARKDOWN_FORBIDDEN', entry, `Root Markdown ${entry} belongs under docs/ or the owning Work record.`);
215
- if (NON_NPM_ENTRIES.has(entry))
216
- finding('HFS_PACKAGE_MANAGER_MIXED', entry, `${entry} contradicts the npm package manager contract.`);
217
- }
218
- for (const entry of ['.gitattributes', 'README.md']) {
219
- if (!tree.hasFile(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `Repository presentation requires root ${entry}.`);
220
- }
221
- if (tree.hasFile('package.json')) {
222
- try {
223
- const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
224
- if (pkg.packageManager && !/^npm@\d/u.test(pkg.packageManager))
225
- finding('HFS_PACKAGE_MANAGER_MIXED', 'package.json', `packageManager ${pkg.packageManager} contradicts the npm package-lock.json contract.`);
226
- } catch { /* The repository's package/config checks own unreadable or invalid JSON. */ }
227
- }
228
- if (!tree.hasFile('README.md')) return { violations, coverage: { status: 'checked', source: tree.source } };
229
- let readme;
230
- try { readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8'); }
231
- catch {
232
- finding('HFS_ROOT_ENTRY_MISSING', 'README.md', 'Tracked README.md is not readable.');
233
- return { violations, coverage: { status: 'checked', source: tree.source } };
234
- }
235
- const lines = readme.split(/\r?\n/u);
236
- const name = runtime ? 'StarCi' : repositoryName(root);
237
- if (lines[0].trim().toLowerCase() !== `# ${name}`.toLowerCase())
238
- finding('HFS_README_TITLE_INVALID', 'README.md', `README.md must start with # ${name}.`);
239
- const description = lines.slice(1).find(line => line.trim());
240
- if (!description || /^\s*(?:#|!\[|\[!\[)/u.test(description) || description.trim().length > 240)
241
- finding('HFS_README_DESCRIPTION_INVALID', 'README.md', 'Place one concise description line directly below the repository name.');
242
- const headings = lines.map((line, index) => ({ name: /^## (.+?)\s*$/u.exec(line)?.[1], index })).filter(item => item.name);
243
- const required = [...README_SECTIONS, ...(tree.hasDir('.starciwork') ? ['Work'] : [])];
244
- let previous = -1;
245
- for (const section of required) {
246
- const found = headings.find(item => item.name === section);
247
- if (!found) finding('HFS_README_SECTION_MISSING', 'README.md', `README.md requires a ## ${section} section.`);
248
- else if (found.index <= previous) finding('HFS_README_SECTION_ORDER', 'README.md', `## ${section} must follow the preceding standard section.`, found.index + 1);
249
- else previous = found.index;
250
- }
251
- const sectionBody = section => {
252
- const start = headings.find(item => item.name === section)?.index;
253
- if (start === undefined) return '';
254
- const end = headings.find(item => item.index > start)?.index ?? lines.length;
255
- return lines.slice(start + 1, end).join('\n');
256
- };
257
- if (!runtime && headings.some(item => item.name === 'Development')) {
258
- const development = sectionBody('Development');
259
- const scripts = DEVELOPMENT_SCRIPTS.filter(name => managedScriptNames(profile, edition).has(name));
260
- const commands = [/npm (?:ci|install)/u, ...scripts.map(scriptCommand)];
261
- if (commands.some(command => !command.test(development)))
262
- finding('HFS_README_DEVELOPMENT_INCOMPLETE', 'README.md',
263
- `Development must show npm install and the managed script commands: ${scripts.map(name => (name === 'test' ? 'npm test' : `npm run ${name}`)).join(', ')}.`);
222
+ function checkRequiredRootEntries(state) {
223
+ for (const entry of [...requiredRootEntries(state.resolver)].sort(byCodeUnit)) {
224
+ if (entry === 'apps') continue;
225
+ if (!state.tree.top.includes(entry)) state.finding('HFS_ROOT_ENTRY_MISSING', entry, `The ${state.profile} side requires root entry ${entry}.`);
264
226
  }
265
- if (tree.hasDir('.starciwork') && headings.some(item => item.name === 'Work') &&
266
- !/\.starciwork\b/u.test(sectionBody('Work')))
267
- finding('HFS_README_WORK_POINTER_MISSING', 'README.md', 'Work must point at the backend .starciwork tree.');
268
- let fenced = false;
269
- lines.forEach((line, index) => {
270
- if (/^\s*```/u.test(line)) { fenced = !fenced; return; }
271
- if (fenced) return;
272
- const prose = line.replace(/`[^`]*`/gu, '');
273
- for (const match of prose.matchAll(/https?:\/\/[^\s<>)"']+/giu)) {
274
- const value = match[0].replace(/[.,;!?]+$/u, '');
275
- try {
276
- if (privateHost(new URL(value).hostname))
277
- finding('HFS_README_PRIVATE_URL', 'README.md', `README URL ${value} points at a local or private host.`, index + 1);
278
- } catch { /* Malformed URLs are outside this presentation rule. */ }
279
- }
280
- const badgeTargets = [
281
- ...[...prose.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/gu)].map(match => ({ alt: match[1], target: match[2] })),
282
- ...[...prose.matchAll(/<img\b[^>]*>/giu)].map(match => ({
283
- alt: /\balt=["']([^"']*)["']/iu.exec(match[0])?.[1] ?? '',
284
- target: /\bsrc=["']([^"']*)["']/iu.exec(match[0])?.[1] ?? '',
285
- })),
286
- ];
287
- for (const { alt, target: rawTarget } of badgeTargets) {
288
- const target = rawTarget.trim().replace(/^<|>$/gu, '');
289
- if (!/badge/iu.test(alt) && !/badge|shields\.io|badgen\.net/iu.test(target)) continue;
290
- try {
291
- const url = new URL(target);
292
- if (url.protocol !== 'https:' || privateHost(url.hostname) ||
293
- /^(?:img\.shields\.io|badgen\.net)$/iu.test(url.hostname) && /^\/badge\//u.test(url.pathname))
294
- finding('HFS_README_BADGE_NOT_LIVE', 'README.md', `Badge ${target} must represent a live external HTTPS service.`, index + 1);
295
- } catch { finding('HFS_README_BADGE_NOT_LIVE', 'README.md', `Badge ${target} must use a live external HTTPS service.`, index + 1); }
296
- }
297
- });
298
- return { violations, coverage: { status: 'checked', source: tree.source } };
299
227
  }
300
228
 
301
- // Owner ruling 2026-09-29 (layout 2026-09-30): integration, e2e and contract run MANUALLY only. No hook, default typecheck,
302
- // 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
303
- // as syntax in the one repository-wide lint run (the factory's e2e block), so no `lint:e2e` command exists to judge.
304
- const E2E_COMMAND = /\btest:(?:e2e|integration|contract)\b|\btypecheck:tests\b|--selectProjects\s+(?:e2e|integration|contract)\b|src\/tests\/(?:world|integration|e2e|contract)\b|jest[^\n|&;]*(?:e2e|integration|contract)/u;
305
- const UNIT_RUN_SCRIPTS = ['test', 'test:unit', 'test:ci', 'test:affected', 'test:coverage', 'test:cov'];
306
- // An --ignore-pattern names the e2e tree to keep it OUT of a command; it is not a run of e2e.
307
- const runsE2e = text => E2E_COMMAND.test(String(text).replace(/--ignore-pattern[= ]+(?:"[^"]*"|'[^']*'|\S+)/gu, ''));
308
- const withoutComments = text => text.split('\n').filter(line => !/^\s*#/u.test(line)).join('\n');
229
+ function missingBackendFiles(state, app) {
230
+ const { tree, resolver } = state;
231
+ const missing = [];
232
+ // A back-end app must hold exactly what the slot of its kind requires (each kind's slot names its own requires).
233
+ for (const required of resolver.requiredFiles(`apps/${app}/src/main.ts`)) {
234
+ const directory = required.endsWith('/');
235
+ if (!(directory ? tree.hasDir(required.slice(0, -1)) : tree.hasFile(required))) missing.push(required.slice(`apps/${app}/`.length));
236
+ }
237
+ return missing;
238
+ }
309
239
 
310
- const readText = readTextFile;
240
+ function missingFrontendFiles(state, app) {
241
+ const { tree, resolver } = state;
242
+ const missing = [];
243
+ // next-env.d.ts is generated by Next and commonly ignored by Git; it is not
244
+ // a reliable tracked-tree input.
245
+ // A front-end app holds the files its slot requires at its own root (next.config.ts, tsconfig.json, ...; it has no package.json).
246
+ for (const required of resolver.requiredFiles(`apps/${app}/next.config.ts`).filter(entry => !entry.slice(`apps/${app}/`.length).includes('/'))) {
247
+ if (!tree.hasFile(required)) missing.push(required.slice(`apps/${app}/`.length));
248
+ }
249
+ return missing;
250
+ }
311
251
 
312
- // A repository-local core.hooksPath that points anywhere but husky's own directory switches the commit and push
313
- // hooks off for that clone (a lane once redirected it to skip husky), so the gate every other clone runs never ran.
314
- // Husky itself sets core.hooksPath to .husky/_ ; that value and .husky are the only ones allowed.
315
- const HUSKY_HOOKS_PATH = /^\.husky(?:\/_)?\/?$/u;
252
+ function checkApplicationRequiredFiles(state, app) {
253
+ const { tree, backend, frontend, finding } = state;
254
+ if (!tree.hasDir(`apps/${app}`)) {
255
+ finding('HFS_APP_LAYOUT_INVALID', `apps/${app}`, `Application entry apps/${app} must be a directory.`);
256
+ return;
257
+ }
258
+ const missing = [];
259
+ if (!tree.hasDir(`apps/${app}/src`)) missing.push('src/');
260
+ if (backend) missing.push(...missingBackendFiles(state, app));
261
+ if (frontend) missing.push(...missingFrontendFiles(state, app));
262
+ if (missing.length) finding('HFS_APP_LAYOUT_INVALID', `apps/${app}`, `Application apps/${app} lacks ${missing.join(', ')} required by the HFS app layout.`);
263
+ }
316
264
 
317
- /** A directory that is not the top level of its own work tree has no hooks of its own: the check is not applicable there. */
318
- function hooksPathNotRedirected({ root, finding }) {
319
- if (!ownsGitTopLevel(root)) return { status: 'not-applicable', reason: 'the checked root is not the top level of its own Git work tree, so it has no hooks of its own' };
320
- const set = configGet(root, 'core.hooksPath', { local: true });
321
- if (!set.ok) return { status: 'checked' }; // key not set: nothing is redirected
322
- const value = set.stdout;
323
- if (value && !HUSKY_HOOKS_PATH.test(value.replaceAll('\\', '/')))
324
- finding('HFS_HOOKS_PATH_REDIRECTED', '.git/config', `core.hooksPath is set to ${value} in this clone. Unset it (git config --local --unset core.hooksPath) and let husky own the hooks; a redirected path skips the pre-commit and pre-push gates.`);
325
- return { status: 'checked' };
265
+ function checkApplications(state) {
266
+ const { tree, finding } = state;
267
+ const apps = tree.children('apps').sort(byCodeUnit);
268
+ if (!tree.hasDir('apps') || apps.length === 0)
269
+ finding('HFS_APPS_REQUIRED', 'apps', 'Every HFS repository is an apps/<app>/ monorepo; apps/ must hold at least one application.');
270
+ for (const app of apps) checkApplicationRequiredFiles(state, app);
271
+ return apps;
326
272
  }
327
273
 
328
- /**
329
- * The app root's automatic gates never run integration, e2e or contract: the husky hooks and the root scripts they call, the unit
330
- * scripts over the be side's jest configuration (`jestConfig`, app-relative), and the workflows that start on push or pull_request.
331
- */
332
- function e2eInAutomaticGate({ root, tree, jestConfig, finding }) {
333
- const rule = 'HFS_E2E_IN_AUTOMATIC_GATE';
334
- let pkg = null;
335
- try { pkg = JSON.parse(readText(root, 'package.json') ?? ''); } catch { /* the package checks own invalid JSON */ }
336
- const scripts = pkg?.scripts ?? {};
337
- // 1. Husky hooks and the scripts they call (transitively through `npm run <script>`).
338
- const pending = [];
339
- for (const hook of ['.husky/pre-commit', '.husky/pre-push']) {
340
- const text = readText(root, hook);
341
- if (text === null) continue;
342
- const body = withoutComments(text);
343
- if (runsE2e(body)) finding(rule, hook, `${hook} runs integration, e2e or contract. They are manual only; hooks run unit, lint and typecheck.`);
344
- for (const match of body.matchAll(/npm\s+run\s+([\w:.-]+)/gu)) pending.push(match[1]);
274
+ function checkBackendSourceLayout(state) {
275
+ if (!state.backend) return;
276
+ const { tree, resolver, finding } = state;
277
+ for (const child of tree.children('src').sort(byCodeUnit)) {
278
+ if (!BACKEND_SRC_CHILDREN.has(child))
279
+ finding('HFS_SRC_LAYOUT_INVALID', `src/${child}`, `Backend src/ holds only features/, modules/ and tests/; ${child} must move to its owner.`);
345
280
  }
346
- const lintStaged = pkg?.['lint-staged'];
347
- if (lintStaged && runsE2e(Object.values(lintStaged).flat().join('\n'))) finding(rule, 'package.json', 'lint-staged runs an integration, e2e or contract command. They are manual only.');
348
- const called = new Set();
349
- for (const name of pending) {
350
- if (called.has(name)) continue;
351
- called.add(name);
352
- const command = scripts[name];
353
- if (typeof command !== 'string') continue;
354
- if (runsE2e(command)) finding(rule, 'package.json', `Script ${name} is run by a husky hook and touches integration, e2e or contract. They are manual only.`);
355
- for (const match of command.matchAll(/npm\s+run\s+([\w:.-]+)/gu)) pending.push(match[1]);
281
+ for (const tier of tree.children('src/modules').sort(byCodeUnit)) {
282
+ if (!moduleTiersOf(resolver).has(tier))
283
+ finding('HFS_MODULE_TIER_INVALID', `src/modules/${tier}`, `Module tier ${tier} is not one of ${[...moduleTiersOf(resolver)].sort(byCodeUnit).join(', ')}.`);
356
284
  }
357
- // 2. Unit and coverage scripts over the be side's jest configuration select the unit project only and exclude src/tests.
358
- if (tree.hasFile(jestConfig)) {
359
- for (const name of UNIT_RUN_SCRIPTS) {
360
- const command = scripts[name];
361
- if (typeof command === 'string' && /\bjest\b/u.test(command) && !/--selectProjects\s+unit\b/u.test(command))
362
- finding(rule, 'package.json', `Script ${name} runs jest without --selectProjects unit and would run the integration, e2e or contract project.`);
363
- }
364
- const jestText = readText(root, jestConfig) ?? '';
365
- if (/collectCoverageFrom/u.test(jestText) && !/!src\/tests\/(?:\*\*|e2e)/u.test(jestText))
366
- finding(rule, jestConfig, 'collectCoverageFrom must exclude src/tests/** so no integration, e2e or contract file counts toward coverage.');
367
- }
368
- // 3. A workflow that starts on push or pull_request never runs e2e.
369
- for (const file of tree.files().filter(entry => /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(entry))) {
370
- const text = readText(root, file);
371
- if (text === null) continue;
372
- const trigger = /^on:.*(?:\n(?:[ \t]+.*|)$)*/mu.exec(text)?.[0] ?? '';
373
- if (!/\b(?:push|pull_request)\b/u.test(trigger)) continue;
374
- if (runsE2e(withoutComments(text)))
375
- finding(rule, file, `${file} runs e2e on push or pull_request. Move the e2e job to its own workflow with on: workflow_dispatch only.`);
285
+ const testChildren = slotTestChildren(resolver);
286
+ for (const child of tree.children('src/tests').sort(byCodeUnit)) {
287
+ // A file directly below src/tests/ that a slot owns (src/tests/tsconfig.json, be.tool-config) is that slot's, not a stray folder.
288
+ if (!testChildren.has(child) && resolver.classifyPath(`src/tests/${child}`).status !== 'owned')
289
+ finding('HFS_SRC_LAYOUT_INVALID', `src/tests/${child}`, `Backend src/tests/ holds only ${[...testChildren].join(', ')} and the files a slot owns there; ${child} must move.`);
376
290
  }
377
291
  }
378
292
 
379
- /** The be side's default tsconfig excludes the world, integration, e2e and contract trees (they are checked by src/tests/tsconfig.json). */
380
- function testTreesOutOfDefaultProgram({ root, tree, finding }) {
381
- const rule = 'HFS_E2E_IN_AUTOMATIC_GATE';
382
- const tsconfigText = readText(root, 'tsconfig.json');
383
- let tsconfig = null;
384
- try { tsconfig = tsconfigText === null ? null : JSON.parse(tsconfigText); } catch { /* the typecheck itself owns parsing */ }
385
- if (tsconfig) {
386
- const excludedText = JSON.stringify(tsconfig.exclude ?? []);
387
- const excludesTestTrees = ['world', 'integration', 'e2e', 'contract'].every(tree => excludedText.includes(`src/tests/${tree}`));
388
- const defaultAll = tsconfig.include === undefined && tsconfig.files === undefined;
389
- const files = tree.files();
390
- if (files.some(file => /^src\/tests\/(?:world|integration|e2e|contract)\/.+\.[cm]?tsx?$/u.test(file)) && !excludesTestTrees &&
391
- (defaultAll || JSON.stringify(tsconfig.include ?? []).includes('src')))
392
- 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).');
293
+ function checkTestTopology(state) {
294
+ // Test kinds and folders that do not exist: int-spec, harness-spec, src/tests/harness and live/;
295
+ // a backend spec sits by its kind (beside its subject, or under src/tests/{integration,e2e,contract}/).
296
+ for (const file of state.tree.files()) {
297
+ if (UNSUPPORTED_TEST_SUFFIX.test(file))
298
+ state.finding('BE_TEST_TOPOLOGY', file, `${file} uses an unsupported test kind. Only unit *.spec.ts, *.integration-spec.ts, *.e2e-spec.ts and *.contract-spec.ts exist, each in its own folder under src/tests/.`);
299
+ else if (state.backend && UNSUPPORTED_TEST_FOLDER.test(file))
300
+ state.finding('BE_TEST_TOPOLOGY', file, `${file} sits in an unsupported test folder. Unit specs sit beside their subject, flows go under src/tests/e2e/<area>/ and test infrastructure under src/tests/world/.`);
301
+ else if (state.backend && file.startsWith('src/tests/') && isExtraTestConfig(file))
302
+ state.finding('BE_TEST_TOPOLOGY', file, `${file} is a per-lane test config. One root jest.config.js declares exactly the unit, integration, e2e and contract projects.`);
393
303
  }
394
304
  }
395
305
 
@@ -403,94 +313,16 @@ export function checkHfs(config) {
403
313
  const finding = (ruleId, entry, message) => violations.push({ ruleId, path: entry, line: 1, column: 1, message });
404
314
  const profile = backend ? 'be' : 'fe';
405
315
  const resolver = resolverOf(config.root, profile, config.hfs);
406
- // The README, the hooks, the workflows and the scripts are the app root's (checkAppRoot); a side folder is judged as the old
407
- // repository root it stands for, less those.
408
- const allowed = slotRootEntries(resolver, profile);
409
- for (const entry of [...tree.top].sort()) {
410
- if (NON_NPM_ENTRIES.has(entry) || /\.md$/iu.test(entry)) continue;
411
- if (entry === '.starcistacks') { finding('HFS_STACKS_IN_SIDE', entry, `The ${profile} side must not hold .starcistacks; stack declarations and sealed custody live in the app root .starcistacks.`); continue; }
412
- if (frontend && !backend) {
413
- if (entry === '.starciwork') { finding('HFS_WORK_IN_FE', entry, 'The fe side must not hold a .starciwork tree; Work records live in the app root .starciwork.'); continue; }
414
- if (entry === 'src') { finding('HFS_ROOT_SRC_FORBIDDEN_FE', entry, 'The fe side keeps source only under apps/<app>/src; the fe/src/ tree must move.'); continue; }
415
- }
416
- if (frontend && !backend && (isFeTestPath(entry) || isFeTestPath(`${entry}/x`))) continue; // a test entry of a front end is FE_NO_TESTS's, the one finding of that path
417
- if (!allowed.has(entry)) {
418
- finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} of the ${profile} side is not in the slots of the ${profile} side.`);
419
- }
420
- }
421
-
422
- for (const entry of [...requiredRootEntries(resolver)].sort()) {
423
- if (entry === 'apps') continue;
424
- if (!tree.top.includes(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `The ${profile} side requires root entry ${entry}.`);
425
- }
426
-
427
- const apps = tree.children('apps').sort();
428
- if (!tree.hasDir('apps') || apps.length === 0) {
429
- finding('HFS_APPS_REQUIRED', 'apps', 'Every HFS repository is an apps/<app>/ monorepo; apps/ must hold at least one application.');
430
- }
431
- for (const app of apps) {
432
- const missing = [];
433
- if (!tree.hasDir(`apps/${app}`)) {
434
- finding('HFS_APP_LAYOUT_INVALID', `apps/${app}`, `Application entry apps/${app} must be a directory.`);
435
- continue;
436
- }
437
- if (!tree.hasDir(`apps/${app}/src`)) missing.push('src/');
438
- // A back-end app must hold exactly what the slot of its kind requires (each kind's slot names its own requires).
439
- if (backend) for (const required of resolver.requiredFiles(`apps/${app}/src/main.ts`)) {
440
- const directory = required.endsWith('/');
441
- if (!(directory ? tree.hasDir(required.slice(0, -1)) : tree.hasFile(required))) missing.push(required.slice(`apps/${app}/`.length));
442
- }
443
- // next-env.d.ts is generated by Next and commonly ignored by Git; it is not
444
- // a reliable tracked-tree input.
445
- // A front-end app holds the files its slot requires at its own root (next.config.ts, tsconfig.json, ...; it has no package.json).
446
- if (frontend) for (const required of resolver.requiredFiles(`apps/${app}/next.config.ts`).filter(entry => !entry.slice(`apps/${app}/`.length).includes('/'))) {
447
- if (!tree.hasFile(required)) missing.push(required.slice(`apps/${app}/`.length));
448
- }
449
- if (missing.length) finding('HFS_APP_LAYOUT_INVALID', `apps/${app}`, `Application apps/${app} lacks ${missing.join(', ')} required by the HFS app layout.`);
450
- }
451
-
452
- if (backend) {
453
- for (const child of tree.children('src').sort()) {
454
- if (!BACKEND_SRC_CHILDREN.has(child)) {
455
- finding('HFS_SRC_LAYOUT_INVALID', `src/${child}`, `Backend src/ holds only features/, modules/ and tests/; ${child} must move to its owner.`);
456
- }
457
- }
458
- for (const tier of tree.children('src/modules').sort()) {
459
- if (!moduleTiersOf(resolver).has(tier)) {
460
- finding('HFS_MODULE_TIER_INVALID', `src/modules/${tier}`, `Module tier ${tier} is not one of ${[...moduleTiersOf(resolver)].sort().join(', ')}.`);
461
- }
462
- }
463
- const testChildren = slotTestChildren(resolver);
464
- for (const child of tree.children('src/tests').sort()) {
465
- // A file directly below src/tests/ that a slot owns (src/tests/tsconfig.json, be.tool-config) is that slot's, not a stray folder.
466
- if (!testChildren.has(child) && resolver.classifyPath(`src/tests/${child}`).status !== 'owned') {
467
- finding('HFS_SRC_LAYOUT_INVALID', `src/tests/${child}`, `Backend src/tests/ holds only ${[...testChildren].join(', ')} and the files a slot owns there; ${child} must move.`);
468
- }
469
- }
470
- }
471
-
472
- // Retired test kinds and folders: int-spec and harness-spec are gone, so are src/tests/harness and
473
- // live/; a backend spec sits by its kind (beside its subject, or under src/tests/{integration,e2e,contract}/).
474
- for (const file of tree.files()) {
475
- if (RETIRED_TEST_SUFFIX.test(file))
476
- finding('BE_TEST_TOPOLOGY', file, `${file} uses a retired test kind. Only unit *.spec.ts, *.integration-spec.ts, *.e2e-spec.ts and *.contract-spec.ts exist, each in its own folder under src/tests/.`);
477
- else if (backend && RETIRED_TEST_FOLDER.test(file))
478
- finding('BE_TEST_TOPOLOGY', file, `${file} sits in a retired test folder. Unit specs sit beside their subject, flows go under src/tests/e2e/<area>/ and test infrastructure under src/tests/world/.`);
479
- else if (backend && /^src\/tests\//u.test(file) && EXTRA_TEST_CONFIG.test(file))
480
- finding('BE_TEST_TOPOLOGY', file, `${file} is a per-lane test config. One root jest.config.js declares exactly the unit, integration, e2e and contract projects.`);
481
- }
482
-
316
+ const state = { config, tree, violations, finding, backend, frontend, profile, resolver, allowed: slotRootEntries(resolver, profile) };
317
+ checkSideRootEntries(state);
318
+ checkRequiredRootEntries(state);
319
+ const apps = checkApplications(state);
320
+ checkBackendSourceLayout(state);
321
+ checkTestTopology(state);
483
322
  if (backend) testTreesOutOfDefaultProgram({ root: config.root, tree, finding });
484
-
485
323
  return {
486
324
  violations,
487
- coverage: {
488
- status: 'checked',
489
- source: tree.source,
490
- rootEntries: tree.top.length,
491
- apps,
492
- ruleIds: [...HFS_RULE_IDS],
493
- },
325
+ coverage: { status: 'checked', source: tree.source, rootEntries: tree.top.length, apps, ruleIds: [...HFS_RULE_IDS] },
494
326
  };
495
327
  }
496
328
 
@@ -504,11 +336,11 @@ export function checkAppRoot({ root, resolver, tree = treeView(root) }) {
504
336
  const finding = (ruleId, entry, message) => violations.push({ ruleId, path: entry, line: 1, column: 1, message });
505
337
  violations.push(...checkRepoPresentation({ root, tree, profile: 'app', edition: resolver.repo?.edition ?? 'full' }).violations);
506
338
  const allowed = slotRootEntries(resolver, 'app');
507
- for (const entry of [...tree.top].sort()) {
339
+ for (const entry of [...tree.top].sort(byCodeUnit)) {
508
340
  if (NON_NPM_ENTRIES.has(entry) || /\.md$/iu.test(entry)) continue;
509
341
  if (!allowed.has(entry)) finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} is not in the slots of the app root.`);
510
342
  }
511
- for (const entry of [...requiredRootEntries(resolver)].sort()) {
343
+ for (const entry of [...requiredRootEntries(resolver)].sort(byCodeUnit)) {
512
344
  if (entry === 'README.md' || entry === '.gitattributes') continue; // checkRepoPresentation reports these two
513
345
  if (!tree.top.includes(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `The app root requires root entry ${entry}.`);
514
346
  }