@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
@@ -18,48 +18,80 @@ const HOISTED = /^node_modules\/((?:@[^/]+\/)?[^/]+)$/;
18
18
  /** Every package.json a repository tracks: the root and each workspace's. */
19
19
  const manifestsOf = (files) => files.filter((file) => file === 'package.json' || (file.endsWith('/package.json') && !file.includes('node_modules/')));
20
20
 
21
- /** The findings of R14 over the tracked paths `files` of `repoRoot`. */
22
- export function depFindings({ repoRoot, files }) {
23
- const findings = [];
24
- const specs = new Map();
25
- for (const file of manifestsOf(files)) {
26
- const pkg = readJson(repoRoot, file);
27
- if (!pkg) continue;
28
- for (const section of SECTIONS) {
29
- for (const [name, spec] of Object.entries(pkg[section] ?? {})) {
30
- if (typeof spec !== 'string' || LINK.test(spec)) continue;
31
- if (!specs.has(name)) specs.set(name, new Map());
32
- const at = specs.get(name);
33
- if (!at.has(spec)) at.set(spec, []);
34
- at.get(spec).push(file);
35
- }
36
- }
21
+ function recordDependencySpec(specs, file, name, spec) {
22
+ if (typeof spec !== 'string' || LINK.test(spec)) return;
23
+ if (!specs.has(name)) specs.set(name, new Map());
24
+ const at = specs.get(name);
25
+ if (!at.has(spec)) at.set(spec, []);
26
+ at.get(spec).push(file);
27
+ }
28
+
29
+ function collectManifestSpecs(repoRoot, file, specs) {
30
+ const pkg = readJson(repoRoot, file);
31
+ if (!pkg) return;
32
+ for (const section of SECTIONS) {
33
+ for (const [name, spec] of Object.entries(pkg[section] ?? {})) recordDependencySpec(specs, file, name, spec);
37
34
  }
35
+ }
36
+
37
+ function versionSkewFindings(specs) {
38
+ const findings = [];
38
39
  for (const [name, bySpec] of specs) {
39
40
  if (bySpec.size < 2) continue;
40
41
  const list = [...bySpec].map(([spec, where]) => `${spec} (${where.join(', ')})`);
41
42
  findings.push(found(DEP_VERSION_SKEW, [...bySpec.values()][0][0], `${name} is declared at ${bySpec.size} versions in the workspace: ${list.join('; ')}; keep one`, { dependency: name, versions: [...bySpec.keys()] }));
42
43
  }
43
- const overrides = readJson(repoRoot, 'package.json')?.overrides;
44
+ return findings;
45
+ }
46
+
47
+ function overrideFindings(specs, overrides) {
48
+ const findings = [];
44
49
  for (const [name, pin] of Object.entries(overrides && typeof overrides === 'object' ? overrides : {})) {
45
50
  if (typeof pin !== 'string' || pin.startsWith('$') || !specs.has(name)) continue;
46
51
  const off = [...specs.get(name)].filter(([spec]) => spec !== pin);
47
- if (off.length) findings.push(found(DEP_VERSION_SKEW, off[0][1][0], `${name} is pinned to ${pin} by the root overrides but declared at ${off.map(([spec, where]) => `${spec} (${where.join(', ')})`).join('; ')}; declare the pinned version`, { dependency: name, versions: off.map(([spec]) => spec), pinned: pin }));
48
- }
49
- const lock = files.includes('package-lock.json') ? readJson(repoRoot, 'package-lock.json') : null;
50
- if (lock?.packages) {
51
- const declared = new Set(specs.keys());
52
- const hoisted = new Map();
53
- for (const [key, entry] of Object.entries(lock.packages)) {
54
- const top = HOISTED.exec(key);
55
- if (top && !entry.link) hoisted.set(top[1], entry.version);
56
- }
57
- for (const [key, entry] of Object.entries(lock.packages)) {
58
- const nested = NESTED.exec(key);
59
- if (!nested || entry.link || entry.inBundle || HOISTED.test(key) || !declared.has(nested[1])) continue;
60
- const name = nested[1];
61
- findings.push(found(DEP_VERSION_SKEW, 'package-lock.json', `${key} is a nested copy of ${name}${entry.version ? ` ${entry.version}` : ''}${hoisted.has(name) ? ` next to the hoisted ${hoisted.get(name)}` : ''}; the workspace keeps one copy (align the ranges or add a root override)`, { dependency: name, lockPath: key, version: entry.version }));
52
+ if (off.length) {
53
+ const declarations = off.map(([spec, where]) => `${spec} (${where.join(', ')})`).join('; ');
54
+ findings.push(found(DEP_VERSION_SKEW, off[0][1][0], `${name} is pinned to ${pin} by the root overrides but declared at ${declarations}; declare the pinned version`, { dependency: name, versions: off.map(([spec]) => spec), pinned: pin }));
62
55
  }
63
56
  }
64
57
  return findings;
65
58
  }
59
+
60
+ /** The versions of the top-level (hoisted) packages of a lockfile, by package name. */
61
+ function hoistedVersions(packages) {
62
+ const hoisted = new Map();
63
+ for (const [key, entry] of Object.entries(packages)) {
64
+ const top = HOISTED.exec(key);
65
+ if (top && !entry.link) hoisted.set(top[1], entry.version);
66
+ }
67
+ return hoisted;
68
+ }
69
+
70
+ /** The finding for a lockfile entry that nests a second copy of a declared dependency, or null. */
71
+ function nestedCopyFinding(key, entry, declared, hoisted) {
72
+ const nested = NESTED.exec(key);
73
+ if (!nested || entry.link || entry.inBundle || HOISTED.test(key) || !declared.has(nested[1])) return null;
74
+ const name = nested[1];
75
+ const versionNote = entry.version ? ` ${entry.version}` : '';
76
+ const hoistedNote = hoisted.has(name) ? ` next to the hoisted ${hoisted.get(name)}` : '';
77
+ return found(DEP_VERSION_SKEW, 'package-lock.json', `${key} is a nested copy of ${name}${versionNote}${hoistedNote}; the workspace keeps one copy (align the ranges or add a root override)`, { dependency: name, lockPath: key, version: entry.version });
78
+ }
79
+
80
+ function nestedLockFindings(specs, lock) {
81
+ if (!lock?.packages) return [];
82
+ const declared = new Set(specs.keys());
83
+ const hoisted = hoistedVersions(lock.packages);
84
+ return Object.entries(lock.packages).map(([key, entry]) => nestedCopyFinding(key, entry, declared, hoisted)).filter(Boolean);
85
+ }
86
+
87
+ /** The findings of R14 over the tracked paths `files` of `repoRoot`. */
88
+ export function depFindings({ repoRoot, files }) {
89
+ const specs = new Map();
90
+ for (const file of manifestsOf(files)) collectManifestSpecs(repoRoot, file, specs);
91
+ const findings = versionSkewFindings(specs);
92
+ const overrides = readJson(repoRoot, 'package.json')?.overrides;
93
+ findings.push(...overrideFindings(specs, overrides));
94
+ const lock = files.includes('package-lock.json') ? readJson(repoRoot, 'package-lock.json') : null;
95
+ findings.push(...nestedLockFindings(specs, lock));
96
+ return findings;
97
+ }
@@ -78,49 +78,52 @@ function envOf(stage) {
78
78
  return env;
79
79
  }
80
80
 
81
+ /** True when a header comment states `docker build -f <wanted> ... .`. */
82
+ const statesBuildCommand = (parsed, wanted) => parsed.comments.some((comment) => {
83
+ const parts = words(comment.text);
84
+ const build = parts.findIndex((part, index) => part === 'docker' && parts[index + 1] === 'build');
85
+ if (build < 0) return false;
86
+ const flag = parts.indexOf('-f', build);
87
+ return flag > 0 && parts[flag + 1] === wanted && parts.at(-1) === '.';
88
+ });
89
+
90
+ function copySourceFindings(file, item) {
91
+ const findings = [];
92
+ const parts = words(item.text).filter((part) => !part.startsWith('--'));
93
+ const sources = parts.slice(0, -1);
94
+ const fromStage = words(item.text).some((part) => part.startsWith('--from='));
95
+ if (fromStage) return findings;
96
+ for (const source of sources) {
97
+ if (/^[a-z]+:\/\//i.test(source)) continue;
98
+ const segments = source.split('/');
99
+ if (source.startsWith('/') || segments.includes('..')) findings.push(found(DOCKER_BUILD_CONTEXT, file, `${file}:${item.line} ${item.keyword} source ${source} leaves the build context; every source is a path inside the app root.`, { line: item.line, source }));
100
+ }
101
+ return findings;
102
+ }
103
+
81
104
  /** R187: the header states this app's build command, and the build never reaches outside its context. */
82
105
  function contextFindings(file, parsed, side, app) {
83
106
  const findings = [];
84
107
  const wanted = dockerfilePath(side, app);
85
- const named = parsed.comments.some((comment) => {
86
- const parts = words(comment.text);
87
- const build = parts.findIndex((part, index) => part === 'docker' && parts[index + 1] === 'build');
88
- if (build < 0) return false;
89
- const flag = parts.indexOf('-f', build);
90
- return flag > 0 && parts[flag + 1] === wanted && parts.at(-1) === '.';
91
- });
92
- if (!named) findings.push(found(DOCKER_BUILD_CONTEXT, file, `${file} does not state its build command in a header comment: \`docker build -f ${wanted} -t <image> .\` (the context is the app root, the folder with the one package.json and lockfile).`, { expected: wanted }));
93
- for (const item of parsed.instructions.filter((entry) => entry.keyword === 'COPY' || entry.keyword === 'ADD')) {
94
- const parts = words(item.text).filter((part) => !part.startsWith('--'));
95
- const sources = parts.slice(0, -1);
96
- const fromStage = words(item.text).some((part) => part.startsWith('--from='));
97
- if (fromStage) continue;
98
- for (const source of sources) {
99
- if (/^[a-z]+:\/\//i.test(source)) continue;
100
- const segments = source.split('/');
101
- if (source.startsWith('/') || segments.includes('..')) findings.push(found(DOCKER_BUILD_CONTEXT, file, `${file}:${item.line} ${item.keyword} source ${source} leaves the build context; every source is a path inside the app root.`, { line: item.line, source }));
102
- }
103
- }
108
+ if (!statesBuildCommand(parsed, wanted)) findings.push(found(DOCKER_BUILD_CONTEXT, file, `${file} does not state its build command in a header comment: \`docker build -f ${wanted} -t <image> .\` (the context is the app root, the folder with the one package.json and lockfile).`, { expected: wanted }));
109
+ for (const item of parsed.instructions.filter((entry) => entry.keyword === 'COPY' || entry.keyword === 'ADD')) findings.push(...copySourceFindings(file, item));
104
110
  return findings;
105
111
  }
106
112
 
107
113
  /** R188: the stages and the install, the user and the build discipline of each. */
108
- function stageFindings(file, parsed, side, kind) {
114
+ function stageInstallFindings(file, stages) {
109
115
  const findings = [];
110
- const { stages } = parsed;
111
- const names = stages.map((stage) => stage.name);
112
- if (!names.includes(BUILD_STAGE) || stages.at(-1)?.name !== RUNTIME_STAGE || stages.length < 2) {
113
- findings.push(found(DOCKER_STAGES, file, `${file} has stages ${JSON.stringify(names)}; an image is multi-stage: a stage named ${BUILD_STAGE} (installs and builds) and a last stage named ${RUNTIME_STAGE} (what ships).`, { stages: names }));
114
- if (!stages.length) return findings;
115
- }
116
116
  for (const stage of stages) {
117
117
  for (const command of commandsOf(stage)) {
118
118
  const call = npmCall(command.words);
119
119
  if (call && (call.verb === 'install' || call.verb === 'i') && !call.args.some((arg) => arg === '-g' || arg === '--global')) findings.push(found(DOCKER_STAGES, file, `${file}:${command.line} runs \`npm ${call.verb}\`; an image installs with \`npm ci\` from the lockfile.`, { line: command.line }));
120
120
  }
121
121
  }
122
- const runtime = stages.at(-1);
123
- if (runtime.name !== RUNTIME_STAGE) return findings;
122
+ return findings;
123
+ }
124
+
125
+ function runtimeStageFindings(file, runtime, side, kind) {
126
+ const findings = [];
124
127
  for (const command of commandsOf(runtime)) {
125
128
  if (isBuildCommand(command.words)) findings.push(found(DOCKER_STAGES, file, `${file}:${command.line} the ${RUNTIME_STAGE} stage runs the build (\`${command.words.join(' ')}\`); it only receives what the ${BUILD_STAGE} stage built.`, { line: command.line }));
126
129
  }
@@ -132,43 +135,79 @@ function stageFindings(file, parsed, side, kind) {
132
135
  return findings;
133
136
  }
134
137
 
138
+ function stageFindings(file, parsed, side, kind) {
139
+ const findings = [];
140
+ const { stages } = parsed;
141
+ const names = stages.map((stage) => stage.name);
142
+ if (!names.includes(BUILD_STAGE) || stages.at(-1)?.name !== RUNTIME_STAGE || stages.length < 2) {
143
+ findings.push(found(DOCKER_STAGES, file, `${file} has stages ${JSON.stringify(names)}; an image is multi-stage: a stage named ${BUILD_STAGE} (installs and builds) and a last stage named ${RUNTIME_STAGE} (what ships).`, { stages: names }));
144
+ if (!stages.length) return findings;
145
+ }
146
+ findings.push(...stageInstallFindings(file, stages));
147
+ const runtime = stages.at(-1);
148
+ if (runtime.name !== RUNTIME_STAGE) return findings;
149
+ findings.push(...runtimeStageFindings(file, runtime, side, kind));
150
+ return findings;
151
+ }
152
+
135
153
  /** The Next config's `output` property, read with TypeScript's parser: the string literal assigned to `output` in an object literal. */
136
154
  function nextOutput(ts, text) {
137
155
  return propertyText(ts, text, { file: 'next.config.ts', key: 'output' });
138
156
  }
139
157
 
158
+ function listenerHealthFindings(file, runtime, exposes, health, none) {
159
+ const findings = [];
160
+ const port = envOf(runtime).get('PORT');
161
+ if (!port || !exposes.includes(port)) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage must set \`ENV PORT=<port>\` and \`EXPOSE\` the same port (found PORT ${JSON.stringify(port ?? null)}, EXPOSE ${JSON.stringify(exposes)}); a listening app declares the port it serves.`, { port: port ?? null, exposes }));
162
+ if (!health.length || none) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage has no HEALTHCHECK; an api or Next app answers a health probe the platform can run.`, {}));
163
+ return findings;
164
+ }
165
+
166
+ function oneShotHealthFindings(file, kind, exposes, none) {
167
+ const findings = [];
168
+ if (!none) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage must say \`HEALTHCHECK NONE\`; a ${kind} app is a one-shot command, not a service.`, { kind }));
169
+ if (exposes.length) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage EXPOSEs ${exposes.join(', ')}; a ${kind} app listens on nothing.`, { exposes }));
170
+ return findings;
171
+ }
172
+
173
+ function processHealthFindings(file, kind, exposes, health, none) {
174
+ const findings = [];
175
+ if (!health.length || none) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage has no process HEALTHCHECK; a ${kind} app serves nothing, so its healthcheck probes its process.`, { kind }));
176
+ if (exposes.length) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage EXPOSEs ${exposes.join(', ')}; a ${kind} app listens on nothing.`, { exposes }));
177
+ return findings;
178
+ }
179
+
180
+ function entryHealthFindings(file, runtime, side, kind, exposes, health, none) {
181
+ if (side === 'fe' || BE_KINDS_WITH_LISTENER.has(kind)) return listenerHealthFindings(file, runtime, exposes, health, none);
182
+ if (BE_KINDS_ONE_SHOT.has(kind)) return oneShotHealthFindings(file, kind, exposes, none);
183
+ return processHealthFindings(file, kind, exposes, health, none);
184
+ }
185
+
186
+ function nextEntryFindings({ repoRoot, file, parsed, app, project, ts }) {
187
+ const findings = [];
188
+ const build = parsed.stages.find((stage) => stage.name === BUILD_STAGE);
189
+ const filter = `--filter=@${project}/${app}`;
190
+ const builds = build && commandsOf(build).some((command) => command.words.some((word) => word === 'turbo' || word.endsWith('/turbo')) && command.words.includes('build') && command.words.includes(filter));
191
+ if (!builds) findings.push(found(DOCKER_ENTRY, file, `${file} ${BUILD_STAGE} stage does not run \`turbo run build ${filter}\`; a Next image builds its own workspace (and the packages it imports) through turbo.`, { expected: filter }));
192
+ const config = `fe/apps/${app}/next.config.ts`;
193
+ const text = readText(repoRoot, config);
194
+ if (text !== null && ts && nextOutput(ts, text) !== 'standalone') findings.push(found(DOCKER_ENTRY, config, `${config} does not set \`output: "standalone"\`; the runtime stage of ${file} ships the standalone output.`, { expected: 'standalone' }));
195
+ return findings;
196
+ }
197
+
140
198
  /** R189: the entry, the port and the health of the runtime. */
141
199
  function entryFindings({ repoRoot, file, parsed, side, app, kind, project, ts }) {
142
200
  const findings = [];
143
201
  const runtime = parsed.stages.at(-1);
144
- if (!runtime || runtime.name !== RUNTIME_STAGE) return findings;
202
+ if (runtime?.name !== RUNTIME_STAGE) return findings;
145
203
  const entry = side === 'be' ? beEntry(app) : feEntry(app);
146
204
  const starts = [...instructionsOf(runtime, 'ENTRYPOINT'), ...instructionsOf(runtime, 'CMD')].map((item) => execForm(item.text));
147
- if (!starts.some((exec) => exec && exec[0] === 'node' && exec[1] === entry)) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage does not start \`["node", "${entry}"]\` in exec form (CMD or ENTRYPOINT); the app runs its own built entry.`, { expected: entry }));
205
+ if (!starts.some((exec) => exec?.[0] === 'node' && exec[1] === entry)) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage does not start \`["node", "${entry}"]\` in exec form (CMD or ENTRYPOINT); the app runs its own built entry.`, { expected: entry }));
148
206
  const exposes = instructionsOf(runtime, 'EXPOSE').flatMap((item) => words(item.text).map((port) => port.replace(/\/(tcp|udp)$/i, '')));
149
207
  const health = instructionsOf(runtime, 'HEALTHCHECK').map((item) => words(item.text));
150
208
  const none = health.some((parts) => parts[0]?.toUpperCase() === 'NONE');
151
- const listens = side === 'fe' || BE_KINDS_WITH_LISTENER.has(kind);
152
- if (listens) {
153
- const port = envOf(runtime).get('PORT');
154
- if (!port || !exposes.includes(port)) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage must set \`ENV PORT=<port>\` and \`EXPOSE\` the same port (found PORT ${JSON.stringify(port ?? null)}, EXPOSE ${JSON.stringify(exposes)}); a listening app declares the port it serves.`, { port: port ?? null, exposes }));
155
- if (!health.length || none) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage has no HEALTHCHECK; an api or Next app answers a health probe the platform can run.`, {}));
156
- } else if (BE_KINDS_ONE_SHOT.has(kind)) {
157
- if (!none) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage must say \`HEALTHCHECK NONE\`; a ${kind} app is a one-shot command, not a service.`, { kind }));
158
- if (exposes.length) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage EXPOSEs ${exposes.join(', ')}; a ${kind} app listens on nothing.`, { exposes }));
159
- } else {
160
- if (!health.length || none) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage has no process HEALTHCHECK; a ${kind} app serves nothing, so its healthcheck probes its process.`, { kind }));
161
- if (exposes.length) findings.push(found(DOCKER_ENTRY, file, `${file} ${RUNTIME_STAGE} stage EXPOSEs ${exposes.join(', ')}; a ${kind} app listens on nothing.`, { exposes }));
162
- }
163
- if (side === 'fe') {
164
- const build = parsed.stages.find((stage) => stage.name === BUILD_STAGE);
165
- const filter = `--filter=@${project}/${app}`;
166
- const builds = build && commandsOf(build).some((command) => command.words.includes('turbo') && command.words.includes('build') && command.words.includes(filter));
167
- if (!builds) findings.push(found(DOCKER_ENTRY, file, `${file} ${BUILD_STAGE} stage does not run \`turbo run build ${filter}\`; a Next image builds its own workspace (and the packages it imports) through turbo.`, { expected: filter }));
168
- const config = `fe/apps/${app}/next.config.ts`;
169
- const text = readText(repoRoot, config);
170
- if (text !== null && ts && nextOutput(ts, text) !== 'standalone') findings.push(found(DOCKER_ENTRY, config, `${config} does not set \`output: "standalone"\`; the runtime stage of ${file} ships the standalone output.`, { expected: 'standalone' }));
171
- }
209
+ findings.push(...entryHealthFindings(file, runtime, side, kind, exposes, health, none));
210
+ if (side === 'fe') findings.push(...nextEntryFindings({ repoRoot, file, parsed, app, project, ts }));
172
211
  return findings;
173
212
  }
174
213
 
@@ -180,7 +219,7 @@ function resolvedImage(parsed, image) {
180
219
  if (name && rest.length) defaults.set(name, rest.join('='));
181
220
  }
182
221
  let unresolved = false;
183
- const text = image.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (whole, braced, bare) => {
222
+ const text = image.replace(/\$\{([A-Za-z_]\w*)\}|\$([A-Za-z_]\w*)/g, (whole, braced, bare) => {
184
223
  const value = defaults.get(braced ?? bare);
185
224
  if (value === undefined) unresolved = true;
186
225
  return value ?? whole;
@@ -209,23 +248,32 @@ const isSecretPath = (source) => {
209
248
  return env || segments.some((segment) => SECRET_FOLDERS.has(segment)) || SECRET_SUFFIXES.some((suffix) => base.endsWith(suffix)) || base.startsWith('kubeconfig');
210
249
  };
211
250
 
251
+ function copySecretFindings(file, item) {
252
+ const findings = [];
253
+ const parts = words(item.text);
254
+ if (parts.some((part) => part.startsWith('--from='))) return findings;
255
+ const sources = parts.filter((part) => !part.startsWith('--')).slice(0, -1);
256
+ for (const source of sources) {
257
+ if (/^https?:\/\//i.test(source)) findings.push(found(DOCKER_SECRETS, file, `${file}:${item.line} ADD of a URL (${source}); a download is a checksum-verified stage, never an ADD.`, { line: item.line, source }));
258
+ else if (isSecretPath(source)) findings.push(found(DOCKER_SECRETS, file, `${file}:${item.line} ${item.keyword} of ${source}, which holds secret material; credentials are mounted at run time, never copied into an image.`, { line: item.line, source }));
259
+ }
260
+ return findings;
261
+ }
262
+
263
+ function variableSecretFindings(file, item) {
264
+ const findings = [];
265
+ const parts = words(item.text);
266
+ const names = item.keyword === 'ENV' && parts.length >= 2 && !parts[0].includes('=') ? [parts[0]] : parts.map((part) => part.split('=')[0]);
267
+ for (const name of names) if (isCredentialName(name)) findings.push(found(DOCKER_SECRETS, file, `${file}:${item.line} ${item.keyword} ${name} names a credential; an image carries none (an ARG or ENV stays in the image history). Mount it at run time; only NEXT_PUBLIC_* values, which are published by design, are build arguments.`, { line: item.line, name }));
268
+ return findings;
269
+ }
270
+
212
271
  /** R191: no secret enters an image. */
213
272
  function secretFindings(file, parsed) {
214
273
  const findings = [];
215
274
  for (const item of parsed.instructions) {
216
- if (item.keyword === 'COPY' || item.keyword === 'ADD') {
217
- const parts = words(item.text);
218
- if (parts.some((part) => part.startsWith('--from='))) continue;
219
- const sources = parts.filter((part) => !part.startsWith('--')).slice(0, -1);
220
- for (const source of sources) {
221
- if (/^https?:\/\//i.test(source)) findings.push(found(DOCKER_SECRETS, file, `${file}:${item.line} ADD of a URL (${source}); a download is a checksum-verified stage, never an ADD.`, { line: item.line, source }));
222
- else if (isSecretPath(source)) findings.push(found(DOCKER_SECRETS, file, `${file}:${item.line} ${item.keyword} of ${source}, which holds secret material; credentials are mounted at run time, never copied into an image.`, { line: item.line, source }));
223
- }
224
- } else if (item.keyword === 'ARG' || item.keyword === 'ENV') {
225
- const parts = words(item.text);
226
- const names = item.keyword === 'ENV' && parts.length >= 2 && !parts[0].includes('=') ? [parts[0]] : parts.map((part) => part.split('=')[0]);
227
- for (const name of names) if (isCredentialName(name)) findings.push(found(DOCKER_SECRETS, file, `${file}:${item.line} ${item.keyword} ${name} names a credential; an image carries none (an ARG or ENV stays in the image history). Mount it at run time; only NEXT_PUBLIC_* values, which are published by design, are build arguments.`, { line: item.line, name }));
228
- }
275
+ if (item.keyword === 'COPY' || item.keyword === 'ADD') findings.push(...copySecretFindings(file, item));
276
+ else if (item.keyword === 'ARG' || item.keyword === 'ENV') findings.push(...variableSecretFindings(file, item));
229
277
  }
230
278
  return findings;
231
279
  }
@@ -1,111 +1,118 @@
1
- // edition.mjs - L01, the edition rule. An hfs.json edition is `full` or `lite` (absent means full); under lite the standard is a
2
- // filter plus a few additions over the same manifest, never a second engine. The slot layer already answers the paths lite
3
- // removed (slots lite excludes, or whose litePresence is forbidden, come back HFS_FORBIDDEN_PRESENT with their goesTo); this
4
- // rule reports what no path can state:
5
- // - inside a package.json: a test script (`test`, `test:*`, `typecheck:tests`, a jest/vitest/playwright/mocha/cypress or
6
- // `node --test` command) or a jest/vitest/playwright dependency - the same vocabulary FE_NO_TESTS scans on the fe side;
7
- // - a test-tool configuration file no more specific slot already forbids;
8
- // - a declaration of what lite does not have: a worker app, an event pattern (event-bus, queue, fenced-job, projection,
9
- // saga) or its trigger kinds (saga, reactors, jobs, realtime). The forbidden names are DERIVED - a pattern, trigger or
10
- // app kind is gone under lite exactly when every slot that carries it is litePresence: forbidden - so adding or
11
- // restoring a kind is a manifest edit, not an edit here.
12
- // The edition is never tested here: a full app's view forbids none of these names and keeps its tests, so the rule answers [].
13
- import { found, readJson } from './read.mjs';
14
- import { DEPENDENCY_SECTIONS, TEST_DEPENDENCY, TEST_RUNNER_COMMAND, TEST_SCRIPT_NAME, TEST_TOOL_FILE } from './fe-no-tests.mjs';
15
-
16
- export const HFS_EDITION_FORBIDDEN_PRESENT = 'HFS_EDITION_FORBIDDEN_PRESENT';
17
-
18
- const PACKAGE_JSON = /(?:^|\/)package\.json$/;
19
- const TYPECHECK_TESTS = /^(?:pre|post)?typecheck:tests$/;
20
-
21
- /**
22
- * The names the edition cannot declare: for each of `pattern`, `trigger` and `appKind`, the value is gone when every slot of
23
- * the resolver's view that carries it is forbidden there. The view is the edition's own (full forbids none of them), so the
24
- * rule needs no edition test: a full app has nothing gone.
25
- */
26
- function editionGone(resolver) {
27
- const tally = new Map();
28
- for (const slot of resolver.slots()) {
29
- const forbidden = slot.presence === 'forbidden';
30
- for (const field of ['pattern', 'trigger', 'appKind']) {
31
- if (slot[field] === undefined) continue;
32
- const key = `${field}:${slot[field]}`;
33
- const entry = tally.get(key) ?? { total: 0, forbidden: 0 };
34
- entry.total += 1;
35
- entry.forbidden += forbidden ? 1 : 0;
36
- tally.set(key, entry);
37
- }
38
- }
39
- const gone = { pattern: new Set(), trigger: new Set(), appKind: new Set() };
40
- for (const [key, entry] of tally) {
41
- if (entry.total !== entry.forbidden) continue;
42
- const [field, name] = key.split(':');
43
- gone[field].add(name);
44
- }
45
- return gone;
46
- }
47
-
48
- /** The findings one package.json's scripts and dependencies produce under lite. */
49
- function packageFindings(repoRoot, rel) {
50
- const pkg = readJson(repoRoot, rel);
51
- if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) return [];
52
- const findings = [];
53
- const scripts = typeof pkg.scripts === 'object' && pkg.scripts !== null && !Array.isArray(pkg.scripts) ? pkg.scripts : {};
54
- for (const [name, command] of Object.entries(scripts)) {
55
- if (TEST_SCRIPT_NAME.test(name) || TYPECHECK_TESTS.test(name) || TEST_RUNNER_COMMAND.test(String(command))) {
56
- findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} has script ${JSON.stringify(name)}: the lite edition has no tests - a lite app does not typecheck or run a test world`, { name }));
57
- }
58
- }
59
- for (const section of DEPENDENCY_SECTIONS) {
60
- const deps = pkg[section];
61
- if (typeof deps !== 'object' || deps === null || Array.isArray(deps)) continue;
62
- for (const name of Object.keys(deps)) {
63
- if (TEST_DEPENDENCY.test(name)) {
64
- findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: the lite edition has no tests - the dependency belongs to the full edition`, { section, name }));
65
- }
66
- }
67
- }
68
- return findings;
69
- }
70
-
71
- /** The findings one be-side declaration produces under lite: a worker app, an event pattern or a gone trigger kind. */
72
- function declarationFindings(repo, gone) {
73
- const findings = [];
74
- for (const app of repo.apps ?? []) {
75
- if (gone.appKind.has(app.kind)) {
76
- findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, 'hfs.json', `hfs.json declares the ${app.kind} app ${app.name}: a ${app.kind} belongs to the full edition, a lite app has none`, { app: app.name, kind: app.kind }));
77
- }
78
- }
79
- for (const [field, goneNames] of [['pattern', gone.pattern], ['trigger', gone.trigger]]) {
80
- const declared = field === 'pattern' ? (repo.patterns ?? []) : (repo.kinds ?? []);
81
- for (const name of declared) {
82
- if (goneNames.has(name)) {
83
- findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, 'hfs.json', `hfs.json ${field === 'pattern' ? 'declares' : 'names'} the ${name} ${field}: ${name} belongs to the full edition, a lite app has none`, { name }));
84
- }
85
- }
86
- }
87
- return findings;
88
- }
89
-
90
- /**
91
- * L01 for one scope (the app root or one side), over `files` (scope-relative). Both questions are answered by the
92
- * resolver's view of the edition: the names gone from it, and whether any slot of it still has tests. The declaration
93
- * findings are judged from the be side (its apps, patterns and kinds): the root scope looks at sides.be, a standalone
94
- * be-side check at its own view. `withDeclaration: false` (a side scope of a whole-app check) skips them: the root scope
95
- * already reported them on hfs.json, and the side scope would only repeat them on a path that is not this side's.
96
- */
97
- export function editionFindings({ repoRoot, files, repo, resolver, withDeclaration = true }) {
98
- const gone = editionGone(resolver);
99
- const noTestWorld = resolver.slots().every((slot) => slot.tests === 'none');
100
- const beView = !withDeclaration ? null : (repo.profile === 'app' ? repo.sides?.be : (repo.profile === 'be' ? repo : null));
101
- const findings = beView ? declarationFindings(beView, gone) : [];
102
- for (const file of noTestWorld ? files : []) {
103
- if (file.includes('node_modules/')) continue;
104
- if (PACKAGE_JSON.test(file)) {
105
- findings.push(...packageFindings(repoRoot, file));
106
- } else if (TEST_TOOL_FILE.test(file) && resolver.classifyPath(file).status !== 'forbidden') {
107
- findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, file, `${file} is test tooling: the lite edition has no tests - the file belongs to the full edition`));
108
- }
109
- }
110
- return findings;
111
- }
1
+ // edition.mjs - L01, the edition rule. An hfs.json edition is `full` or `lite` (absent means full); under lite the standard is a
2
+ // filter plus a few additions over the same manifest, never a second engine. The slot layer already answers the paths lite
3
+ // removed (slots lite excludes, or whose litePresence is forbidden, come back HFS_FORBIDDEN_PRESENT with their goesTo); this
4
+ // rule reports what no path can state:
5
+ // - inside a package.json: a test script (`test`, `test:*`, `typecheck:tests`, a jest/vitest/playwright/mocha/cypress or
6
+ // `node --test` command) or a jest/vitest/playwright dependency - the same vocabulary FE_NO_TESTS scans on the fe side;
7
+ // - a test-tool configuration file no more specific slot already forbids;
8
+ // - a declaration of what lite does not have: a worker app, an event pattern (event-bus, queue, fenced-job, projection,
9
+ // saga) or its trigger kinds (saga, reactors, jobs, realtime). The forbidden names are DERIVED - a pattern, trigger or
10
+ // app kind is gone under lite exactly when every slot that carries it is litePresence: forbidden - so adding or
11
+ // restoring a kind is a manifest edit, not an edit here.
12
+ // The edition is never tested here: a full app's view forbids none of these names and keeps its tests, so the rule answers [].
13
+ import { found, readJson } from './read.mjs';
14
+ import { DEPENDENCY_SECTIONS, TEST_DEPENDENCY, TEST_RUNNER_COMMAND, TEST_SCRIPT_NAME, TEST_TOOL_FILE } from './fe-no-tests.mjs';
15
+
16
+ export const HFS_EDITION_FORBIDDEN_PRESENT = 'HFS_EDITION_FORBIDDEN_PRESENT';
17
+
18
+ const PACKAGE_JSON = /(?:^|\/)package\.json$/;
19
+ const TYPECHECK_TESTS = /^(?:pre|post)?typecheck:tests$/;
20
+
21
+ /**
22
+ * The names the edition cannot declare: for each of `pattern`, `trigger` and `appKind`, the value is gone when every slot of
23
+ * the resolver's view that carries it is forbidden there. The view is the edition's own (full forbids none of them), so the
24
+ * rule needs no edition test: a full app has nothing gone.
25
+ */
26
+ function editionGone(resolver) {
27
+ const tally = new Map();
28
+ for (const slot of resolver.slots()) {
29
+ const forbidden = slot.presence === 'forbidden';
30
+ for (const field of ['pattern', 'trigger', 'appKind']) {
31
+ if (slot[field] === undefined) continue;
32
+ const key = `${field}:${slot[field]}`;
33
+ const entry = tally.get(key) ?? { total: 0, forbidden: 0 };
34
+ entry.total += 1;
35
+ entry.forbidden += forbidden ? 1 : 0;
36
+ tally.set(key, entry);
37
+ }
38
+ }
39
+ const gone = { pattern: new Set(), trigger: new Set(), appKind: new Set() };
40
+ for (const [key, entry] of tally) {
41
+ if (entry.total !== entry.forbidden) continue;
42
+ const [field, name] = key.split(':');
43
+ gone[field].add(name);
44
+ }
45
+ return gone;
46
+ }
47
+
48
+ const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
49
+
50
+ function testScriptFindings(rel, pkg) {
51
+ const scripts = isPlainObject(pkg.scripts) ? pkg.scripts : {};
52
+ return Object.entries(scripts)
53
+ .filter(([name, command]) => TEST_SCRIPT_NAME.test(name) || TYPECHECK_TESTS.test(name) || TEST_RUNNER_COMMAND.test(String(command)))
54
+ .map(([name]) => found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} has script ${JSON.stringify(name)}: the lite edition has no tests - a lite app does not typecheck or run a test world`, { name }));
55
+ }
56
+
57
+ function testDependencyFindings(rel, pkg) {
58
+ const findings = [];
59
+ for (const section of DEPENDENCY_SECTIONS) {
60
+ const deps = pkg[section];
61
+ if (!isPlainObject(deps)) continue;
62
+ for (const name of Object.keys(deps)) {
63
+ if (TEST_DEPENDENCY.test(name)) {
64
+ findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: the lite edition has no tests - the dependency belongs to the full edition`, { section, name }));
65
+ }
66
+ }
67
+ }
68
+ return findings;
69
+ }
70
+
71
+ /** The findings one package.json's scripts and dependencies produce under lite. */
72
+ function packageFindings(repoRoot, rel) {
73
+ const pkg = readJson(repoRoot, rel);
74
+ if (!isPlainObject(pkg)) return [];
75
+ return [...testScriptFindings(rel, pkg), ...testDependencyFindings(rel, pkg)];
76
+ }
77
+
78
+ const goneAppFindings = (repo, gone) => (repo.apps ?? [])
79
+ .filter((app) => gone.appKind.has(app.kind))
80
+ .map((app) => found(HFS_EDITION_FORBIDDEN_PRESENT, 'hfs.json', `hfs.json declares the ${app.kind} app ${app.name}: a ${app.kind} belongs to the full edition, a lite app has none`, { app: app.name, kind: app.kind }));
81
+
82
+ const goneNameFindings = (field, verb, declared, goneNames) => declared
83
+ .filter((name) => goneNames.has(name))
84
+ .map((name) => found(HFS_EDITION_FORBIDDEN_PRESENT, 'hfs.json', `hfs.json ${verb} the ${name} ${field}: ${name} belongs to the full edition, a lite app has none`, { name }));
85
+
86
+ /** The findings one be-side declaration produces under lite: a worker app, an event pattern or a gone trigger kind. */
87
+ function declarationFindings(repo, gone) {
88
+ return [
89
+ ...goneAppFindings(repo, gone),
90
+ ...goneNameFindings('pattern', 'declares', repo.patterns ?? [], gone.pattern),
91
+ ...goneNameFindings('trigger', 'names', repo.kinds ?? [], gone.trigger),
92
+ ];
93
+ }
94
+
95
+ /**
96
+ * L01 for one scope (the app root or one side), over `files` (scope-relative). Both questions are answered by the
97
+ * resolver's view of the edition: the names gone from it, and whether any slot of it still has tests. The declaration
98
+ * findings are judged from the be side (its apps, patterns and kinds): the root scope looks at sides.be, a standalone
99
+ * be-side check at its own view. `withDeclaration: false` (a side scope of a whole-app check) skips them: the root scope
100
+ * already reported them on hfs.json, and the side scope would only repeat them on a path that is not this side's.
101
+ */
102
+ export function editionFindings({ repoRoot, files, repo, resolver, withDeclaration = true }) {
103
+ const gone = editionGone(resolver);
104
+ const noTestWorld = resolver.slots().every((slot) => slot.tests === 'none');
105
+ let beView = null;
106
+ if (withDeclaration && repo.profile === 'app') beView = repo.sides?.be;
107
+ else if (withDeclaration && repo.profile === 'be') beView = repo;
108
+ const findings = beView ? declarationFindings(beView, gone) : [];
109
+ for (const file of noTestWorld ? files : []) {
110
+ if (file.includes('node_modules/')) continue;
111
+ if (PACKAGE_JSON.test(file)) {
112
+ findings.push(...packageFindings(repoRoot, file));
113
+ } else if (TEST_TOOL_FILE.test(file) && resolver.classifyPath(file).status !== 'forbidden') {
114
+ findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, file, `${file} is test tooling: the lite edition has no tests - the file belongs to the full edition`));
115
+ }
116
+ }
117
+ return findings;
118
+ }