@starci/hfs 4.0.10 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/emit/operations.mjs +1 -1
  3. package/emit/type-schema.mjs +2 -1
  4. package/lint/run.mjs +3 -2
  5. package/package.json +1 -1
  6. package/report/order.mjs +2 -0
  7. package/runtime/config.example.yaml +187 -0
  8. package/runtime/engine/by-code-unit.mjs +12 -0
  9. package/runtime/engine/config.mjs +221 -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/removed-vocabulary.mjs +28 -0
  14. package/runtime/engine/resources-config.mjs +52 -0
  15. package/runtime/engine/runtime-root.mjs +17 -0
  16. package/runtime/engine/secrets.mjs +35 -31
  17. package/runtime/engine/sonar-config.mjs +21 -0
  18. package/runtime/engine/temp-root.mjs +32 -0
  19. package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
  20. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  21. package/runtime/knowledge/hfs/rules.yaml +98 -65
  22. package/runtime/knowledge/hfs/slots.yaml +11 -14
  23. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  24. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  25. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  26. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  27. package/runtime/modules/kernel/failure-codes.yaml +1 -41
  28. package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
  29. package/runtime/modules/models/registry.yaml +25 -359
  30. package/runtime/modules/models/runtimes.yaml +92 -256
  31. package/runtime/modules/models/tiers.yaml +65 -0
  32. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  33. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  34. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  35. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  36. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  37. package/runtime/scripts/api/git/lib.mjs +34 -1
  38. package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
  39. package/runtime/scripts/api/process/run-program.mjs +2 -1
  40. package/runtime/scripts/api/sops/lib.mjs +198 -120
  41. package/runtime/scripts/hfs/allows.mjs +3 -3
  42. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  43. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  44. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  45. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  46. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  47. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  48. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  49. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  50. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  51. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  52. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  53. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  54. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  55. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  56. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  57. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  58. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  59. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  60. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  61. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  62. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  63. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  64. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  65. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  66. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  67. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
  68. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  69. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  70. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  71. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  72. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  73. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  74. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  75. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  76. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  77. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  78. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  79. package/runtime/scripts/hfs/architecture/index.mjs +158 -102
  80. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  81. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  82. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  83. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  84. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  85. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  86. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  87. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  88. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  89. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  90. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  91. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  92. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  93. package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
  94. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  95. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  96. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  97. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  98. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  99. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  100. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  101. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  102. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  103. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  104. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  105. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  106. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  107. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  108. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  109. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  110. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  111. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  112. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  113. package/runtime/scripts/hfs/architecture.mjs +3 -4
  114. package/runtime/scripts/hfs/check.mjs +61 -70
  115. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  116. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  117. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  118. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  119. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  120. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  121. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  122. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  123. package/runtime/scripts/hfs/repo-identity.mjs +5 -2
  124. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  125. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  126. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  127. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  128. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  129. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  130. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  131. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  132. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  133. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  134. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  135. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  136. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  137. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  138. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  139. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  140. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  141. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  142. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  143. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  144. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  145. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  146. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  147. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  148. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  149. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  150. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  151. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  152. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  153. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  154. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  155. package/runtime/scripts/hfs/secret.mjs +59 -48
  156. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  157. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  158. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  159. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  160. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  161. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  162. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  163. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  164. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  165. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  166. package/runtime/scripts/hfs/slots.mjs +72 -693
  167. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  168. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  169. package/runtime/scripts/hfs/tree.mjs +19 -14
  170. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  171. package/runtime/scripts/hfs/view.mjs +1 -1
  172. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  173. package/runtime/scripts/lib/event-contract.mjs +17 -13
  174. package/runtime/scripts/lib/git.mjs +2 -2
  175. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  176. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  177. package/runtime/scripts/lib/i18n.mjs +3 -2
  178. package/runtime/scripts/lib/in-order.mjs +71 -0
  179. package/runtime/scripts/lib/language.mjs +3 -3
  180. package/runtime/scripts/lib/list.mjs +8 -1
  181. package/runtime/scripts/lib/path-key.mjs +38 -5
  182. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  183. package/runtime/scripts/lib/regex.mjs +1 -1
  184. package/runtime/scripts/lib/same-text.mjs +1 -1
  185. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  186. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  187. package/runtime/scripts/lib/sops-envelope.mjs +95 -48
  188. package/runtime/scripts/lib/stack-services.mjs +1 -2
  189. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  190. package/runtime/scripts/lib/walk.mjs +6 -1
  191. package/scaffold/add-cli-lite.mjs +2 -1
  192. package/scaffold/add-table.mjs +2 -2
  193. package/scaffold/add.mjs +3 -2
  194. package/scaffold/app.mjs +4 -2
  195. package/scaffold/edition-gate.mjs +25 -25
  196. package/scaffold/lite-exports.mjs +2 -1
  197. package/scaffold/service.mjs +5 -4
  198. package/sync/hygiene.mjs +15 -9
  199. package/sync/index.mjs +7 -3
  200. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  201. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  202. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  203. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  204. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  205. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  206. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  207. package/templates/app/starciwork.gitignore +1 -1
  208. package/templates/be/image/api/Dockerfile +1 -1
  209. package/templates/be/image/cli/Dockerfile +1 -1
  210. package/templates/be/image/worker/Dockerfile +1 -1
  211. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  212. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  213. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  214. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  215. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  216. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  217. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  218. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  219. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  220. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  221. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  222. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  223. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  224. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  225. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  226. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  227. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  228. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  229. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  230. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  231. package/templates/fe/image/next/Dockerfile +2 -1
  232. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  233. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  234. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  235. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  236. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  237. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  238. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  239. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  240. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  241. package/upgrade/index.mjs +9 -2
  242. package/runtime/engine/admission.mjs +0 -308
  243. package/runtime/engine/canonical-json.mjs +0 -10
  244. package/runtime/engine/db/blob.mjs +0 -315
  245. package/runtime/engine/db/ledger-paths.mjs +0 -83
  246. package/runtime/engine/db/ledger.mjs +0 -1118
  247. package/runtime/engine/db/machine-connection.mjs +0 -135
  248. package/runtime/engine/db/machine-schema.mjs +0 -84
  249. package/runtime/engine/db/machine.mjs +0 -1367
  250. package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
  251. package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
  252. package/runtime/engine/db/provider-reservations.mjs +0 -101
  253. package/runtime/engine/digest.mjs +0 -16
  254. package/runtime/engine/refuse.mjs +0 -11
  255. package/runtime/modules/ops/_labels.yaml +0 -53
  256. package/runtime/scripts/api/node/lib.mjs +0 -14
  257. package/runtime/scripts/api/node/spawn-node.mjs +0 -6
  258. package/runtime/scripts/api/process/lib.mjs +0 -111
  259. package/runtime/scripts/api/process/owned-process.mjs +0 -78
  260. package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
  261. package/runtime/scripts/connectors/lib.mjs +0 -488
  262. package/runtime/scripts/lib/clip.mjs +0 -19
  263. package/runtime/scripts/lib/display-names.mjs +0 -259
  264. package/runtime/scripts/lib/example-refs.mjs +0 -158
  265. package/runtime/scripts/lib/json-schema.mjs +0 -52
  266. package/runtime/scripts/lib/process-identity.mjs +0 -6
  267. package/runtime/scripts/lib/read-yaml.mjs +0 -18
  268. package/runtime/scripts/lib/redact.mjs +0 -161
  269. package/runtime/scripts/lib/sleep.mjs +0 -7
  270. package/runtime/scripts/lib/source-phrases.mjs +0 -34
  271. package/runtime/scripts/lib/sqlite.mjs +0 -21
@@ -14,6 +14,14 @@ const SUPPRESSION_FLAG = /--(?:suppressions-location|suppress-all|suppress-rule|
14
14
  const ESLINT_CONFIG = /(?:^|\/)eslint\.config\.[cm]?[jt]s$/;
15
15
  const CONFIG_SUPPRESSION = /suppressions/i;
16
16
 
17
+ function packageSuppressionFindings(file, pkg) {
18
+ const findings = [];
19
+ for (const [name, command] of Object.entries(pkg?.scripts ?? {})) {
20
+ if (SUPPRESSION_SCRIPT_NAME.test(name) || SUPPRESSION_FLAG.test(String(command))) findings.push(found(LINT_SUPPRESSION_FILE, file, `${file} script ${name} manages lint suppressions (${String(command).slice(0, 80)}); delete the script and fix the findings in the code`));
21
+ }
22
+ return findings;
23
+ }
24
+
17
25
  /** The findings of R104 over the tracked paths `files` of the repository at `repoRoot`. */
18
26
  export function lintSuppressionFindings({ repoRoot, files }) {
19
27
  const findings = [];
@@ -25,9 +33,7 @@ export function lintSuppressionFindings({ repoRoot, files }) {
25
33
  if (text !== null && CONFIG_SUPPRESSION.test(text)) findings.push(found(LINT_SUPPRESSION_FILE, file, `${file} passes a suppressions configuration to eslint; a repository suppresses nothing`));
26
34
  } else if (file === 'package.json' || file.endsWith('/package.json')) {
27
35
  const pkg = readJson(repoRoot, file);
28
- for (const [name, command] of Object.entries(pkg?.scripts ?? {})) {
29
- if (SUPPRESSION_SCRIPT_NAME.test(name) || SUPPRESSION_FLAG.test(String(command))) findings.push(found(LINT_SUPPRESSION_FILE, file, `${file} script ${name} manages lint suppressions (${String(command).slice(0, 80)}); delete the script and fix the findings in the code`));
30
- }
36
+ findings.push(...packageSuppressionFindings(file, pkg));
31
37
  }
32
38
  }
33
39
  return findings;
@@ -15,6 +15,7 @@ import path from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { loadTypescript } from '../../lib/package-at.mjs';
17
17
  import { found, readJson, readText } from './read.mjs';
18
+ import { byCodeUnit } from '../../lib/list.mjs';
18
19
 
19
20
  const MONO_WORKSPACES = 'HFS_MONO_WORKSPACES';
20
21
  const MONO_FE_WORKSPACE = 'HFS_MONO_FE_WORKSPACE';
@@ -51,7 +52,7 @@ function workspacesOf(files) {
51
52
  const parts = file.split('/');
52
53
  if (parts.length === 4 && parts[0] === 'fe' && (parts[1] === 'apps' || parts[1] === 'packages') && parts[3] === MANIFEST) folders.add(parts.slice(0, 3).join('/'));
53
54
  }
54
- return [...folders].sort();
55
+ return [...folders].sort(byCodeUnit);
55
56
  }
56
57
 
57
58
  /** R143: the root package.json declares the workspaces, the package manager and turbo. */
@@ -66,6 +67,24 @@ function rootFindings(repoRoot) {
66
67
  }
67
68
 
68
69
  /** R144: the manifest of each fe workspace. */
70
+ function appWorkspaceScriptFindings(file, project, name, pkg, scripts) {
71
+ const findings = [];
72
+ const expected = feAppPackageName(project, name);
73
+ if (pkg.name !== expected) findings.push(found(MONO_FE_WORKSPACE, file, `${file} is named ${JSON.stringify(pkg.name ?? null)}; the fe app ${name} is the workspace ${expected}.`, { expected }));
74
+ const want = Object.keys(FE_APP_SCRIPTS).sort(byCodeUnit);
75
+ const off = [...new Set([...want, ...Object.keys(scripts)])].sort(byCodeUnit).filter((key) => scripts[key] !== FE_APP_SCRIPTS[key]);
76
+ if (off.length) findings.push(found(MONO_FE_WORKSPACE, file, `${file} has scripts ${JSON.stringify(scripts)}; an fe app workspace has exactly ${JSON.stringify(FE_APP_SCRIPTS)} (turbo runs build, dev, lint and typecheck; differs at ${off.join(', ')}).`, { scripts: off }));
77
+ return findings;
78
+ }
79
+
80
+ function packageWorkspaceScriptFindings(file, scripts) {
81
+ const findings = [];
82
+ const missing = FE_PACKAGE_SCRIPTS.filter((key) => typeof scripts[key] !== 'string' || !scripts[key]);
83
+ if (missing.length) findings.push(found(MONO_FE_WORKSPACE, file, `${file} has no ${missing.join(', ')} script; an fe package workspace builds, type-checks and lints (turbo builds it before the apps that import it).`, { scripts: missing }));
84
+ else if (scripts.lint !== WORKSPACE_LINT) findings.push(found(MONO_FE_WORKSPACE, file, `${file} lints with ${JSON.stringify(scripts.lint)}; a workspace lints with ${JSON.stringify(WORKSPACE_LINT)}, the one lint of the app scoped to it.`, { scripts: ['lint'] }));
85
+ return findings;
86
+ }
87
+
69
88
  function workspaceManifestFindings(repoRoot, project, workspaces) {
70
89
  const findings = [];
71
90
  for (const folder of workspaces) {
@@ -75,21 +94,32 @@ function workspaceManifestFindings(repoRoot, project, workspaces) {
75
94
  const name = folder.split('/')[2];
76
95
  if (pkg.private !== true) findings.push(found(MONO_FE_WORKSPACE, file, `${file} is not \`"private": true\`; a workspace of the app is never published.`));
77
96
  const scripts = pkg.scripts ?? {};
78
- if (folder.startsWith('fe/apps/')) {
79
- const expected = feAppPackageName(project, name);
80
- if (pkg.name !== expected) findings.push(found(MONO_FE_WORKSPACE, file, `${file} is named ${JSON.stringify(pkg.name ?? null)}; the fe app ${name} is the workspace ${expected}.`, { expected }));
81
- const want = Object.keys(FE_APP_SCRIPTS).sort();
82
- const off = [...new Set([...want, ...Object.keys(scripts)])].sort().filter((key) => scripts[key] !== FE_APP_SCRIPTS[key]);
83
- if (off.length) findings.push(found(MONO_FE_WORKSPACE, file, `${file} has scripts ${JSON.stringify(scripts)}; an fe app workspace has exactly ${JSON.stringify(FE_APP_SCRIPTS)} (turbo runs build, dev, lint and typecheck; differs at ${off.join(', ')}).`, { scripts: off }));
84
- } else {
85
- const missing = FE_PACKAGE_SCRIPTS.filter((key) => typeof scripts[key] !== 'string' || !scripts[key]);
86
- if (missing.length) findings.push(found(MONO_FE_WORKSPACE, file, `${file} has no ${missing.join(', ')} script; an fe package workspace builds, type-checks and lints (turbo builds it before the apps that import it).`, { scripts: missing }));
87
- else if (scripts.lint !== WORKSPACE_LINT) findings.push(found(MONO_FE_WORKSPACE, file, `${file} lints with ${JSON.stringify(scripts.lint)}; a workspace lints with ${JSON.stringify(WORKSPACE_LINT)}, the one lint of the app scoped to it.`, { scripts: ['lint'] }));
88
- }
97
+ if (folder.startsWith('fe/apps/')) findings.push(...appWorkspaceScriptFindings(file, project, name, pkg, scripts));
98
+ else findings.push(...packageWorkspaceScriptFindings(file, scripts));
89
99
  }
90
100
  return findings;
91
101
  }
92
102
 
103
+ const nestProjectShape = (project) => JSON.stringify({ type: project.type, root: project.root, sourceRoot: project.sourceRoot });
104
+
105
+ /** One finding per declared project whose type, root or sourceRoot departs from the application layout. */
106
+ function nestProjectFindings(cli, projects, declared) {
107
+ const findings = [];
108
+ for (const name of projects.filter((project) => declared.includes(project))) {
109
+ const project = cli.projects[name] ?? {};
110
+ if (project.type !== 'application' || project.root !== `apps/${name}` || project.sourceRoot !== `apps/${name}/src`) findings.push(found(MONO_NEST_PROJECTS, NEST_CLI, `${NEST_CLI} project ${name} is ${nestProjectShape(project)}; it is {"type":"application","root":"apps/${name}","sourceRoot":"apps/${name}/src"}.`, { project: name }));
111
+ }
112
+ return findings;
113
+ }
114
+
115
+ /** The finding when the default project of nest-cli.json is no declared api app, or null. */
116
+ function nestRootFinding(cli, beApps) {
117
+ const apis = beApps.filter((app) => app.kind === 'api').map((app) => app.name);
118
+ const rootApp = /^apps\/([^/]+)$/.exec(String(cli.root ?? ''))?.[1];
119
+ if (apis.includes(rootApp) && cli.sourceRoot === `apps/${rootApp}/src`) return null;
120
+ return found(MONO_NEST_PROJECTS, NEST_CLI, `${NEST_CLI} has root ${JSON.stringify(cli.root ?? null)} and sourceRoot ${JSON.stringify(cli.sourceRoot ?? null)}; the default project is an api app (${apis.join(', ') || 'none declared'}): root apps/<api>, sourceRoot apps/<api>/src.`, { root: cli.root ?? null });
121
+ }
122
+
93
123
  /** R145: be/nest-cli.json is the Nest monorepo of exactly the declared be apps. */
94
124
  function nestFindings(repoRoot, files, beApps) {
95
125
  if (!files.includes(NEST_CLI)) return [];
@@ -97,16 +127,12 @@ function nestFindings(repoRoot, files, beApps) {
97
127
  if (!cli) return [found(MONO_NEST_PROJECTS, NEST_CLI, `${NEST_CLI} is not readable JSON.`)];
98
128
  const findings = [];
99
129
  if (cli.monorepo !== true) findings.push(found(MONO_NEST_PROJECTS, NEST_CLI, `${NEST_CLI} is not \`"monorepo": true\`; the back end is a Nest monorepo of be/apps/<app>, even with one service.`));
100
- const declared = beApps.map((app) => app.name).sort();
101
- const projects = Object.keys(cli.projects ?? {}).sort();
130
+ const declared = beApps.map((app) => app.name).sort(byCodeUnit);
131
+ const projects = Object.keys(cli.projects ?? {}).sort(byCodeUnit);
102
132
  if (!sameList(projects, declared)) findings.push(found(MONO_NEST_PROJECTS, NEST_CLI, `${NEST_CLI} has projects ${JSON.stringify(projects)}, but hfs.json declares the be apps ${JSON.stringify(declared)}; one project per declared app, no other.`, { projects, declared }));
103
- for (const name of projects.filter((project) => declared.includes(project))) {
104
- const project = cli.projects[name] ?? {};
105
- if (project.type !== 'application' || project.root !== `apps/${name}` || project.sourceRoot !== `apps/${name}/src`) findings.push(found(MONO_NEST_PROJECTS, NEST_CLI, `${NEST_CLI} project ${name} is ${JSON.stringify({ type: project.type, root: project.root, sourceRoot: project.sourceRoot })}; it is {"type":"application","root":"apps/${name}","sourceRoot":"apps/${name}/src"}.`, { project: name }));
106
- }
107
- const apis = beApps.filter((app) => app.kind === 'api').map((app) => app.name);
108
- const rootApp = /^apps\/([^/]+)$/.exec(String(cli.root ?? ''))?.[1];
109
- if (!apis.includes(rootApp) || cli.sourceRoot !== `apps/${rootApp}/src`) findings.push(found(MONO_NEST_PROJECTS, NEST_CLI, `${NEST_CLI} has root ${JSON.stringify(cli.root ?? null)} and sourceRoot ${JSON.stringify(cli.sourceRoot ?? null)}; the default project is an api app (${apis.join(', ') || 'none declared'}): root apps/<api>, sourceRoot apps/<api>/src.`, { root: cli.root ?? null }));
133
+ findings.push(...nestProjectFindings(cli, projects, declared));
134
+ const rootFinding = nestRootFinding(cli, beApps);
135
+ if (rootFinding) findings.push(rootFinding);
110
136
  return findings;
111
137
  }
112
138
 
@@ -129,6 +155,40 @@ const packageOf = (specifier) => {
129
155
  return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
130
156
  };
131
157
 
158
+ /** Records the first importing file of every package `file` imports without the workspace declaring it. */
159
+ function collectUndeclaredImports(ts, file, text, isAlias, pkg, declared, missing) {
160
+ for (const { fileName: specifier } of ts.preProcessFile(text, true, true).importedFiles) {
161
+ if (specifier.startsWith('.') || specifier.startsWith('/') || isBuiltin(specifier) || isAlias(specifier)) continue;
162
+ const name = packageOf(specifier);
163
+ if (declared.has(name) || name === pkg.name) continue;
164
+ if (!missing.has(name)) missing.set(name, file);
165
+ }
166
+ }
167
+
168
+ /** The first importing file of every undeclared package a workspace's sources import, by package name. */
169
+ function undeclaredImports(repoRoot, files, folder, pkg, declared, ts) {
170
+ const aliases = aliasesOf(ts, path.join(repoRoot, folder));
171
+ const isAlias = (specifier) => aliases.some((alias) => (alias.exact ? specifier === alias.prefix : specifier.startsWith(alias.prefix)));
172
+ const missing = new Map();
173
+ for (const file of files) {
174
+ if (!file.startsWith(`${folder}/`) || !SOURCE.test(file) || file.endsWith('.d.ts')) continue;
175
+ const text = readText(repoRoot, file);
176
+ if (text !== null) collectUndeclaredImports(ts, file, text, isAlias, pkg, declared, missing);
177
+ }
178
+ return missing;
179
+ }
180
+
181
+ function workspaceMissingDependencies(repoRoot, files, folder, names, ts) {
182
+ const manifest = `${folder}/${MANIFEST}`;
183
+ const pkg = readJson(repoRoot, manifest);
184
+ if (!pkg) return [];
185
+ const missing = undeclaredImports(repoRoot, files, folder, pkg, declaredIn(pkg), ts);
186
+ return [...missing].sort(byCodeUnit).map(([name, file]) => {
187
+ const declaration = names.get(name) ? `declare the workspace package ${name} as "*"` : `declare ${name} in the dependencies of ${manifest} (one version across the workspace, R14)`;
188
+ return found(MONO_WORKSPACE_DEP, manifest, `${file} imports ${name}, which ${manifest} does not declare; ${declaration}.`, { dependency: name, importedBy: file });
189
+ });
190
+ }
191
+
132
192
  /** R146: what each fe workspace imports, it declares; the root declares no workspace package. */
133
193
  function workspaceDepFindings(repoRoot, files, workspaces) {
134
194
  const findings = [];
@@ -138,30 +198,7 @@ function workspaceDepFindings(repoRoot, files, workspaces) {
138
198
  if (!workspaces.length) return findings;
139
199
  const ts = typescriptFor(repoRoot);
140
200
  if (!ts) return [...findings, found(MONO_WORKSPACE_DEP, MANIFEST, 'the fe workspaces cannot be judged: no TypeScript resolves from the app; install the app\'s dependencies.', { missing: ['typescript'] })];
141
- for (const folder of workspaces) {
142
- const manifest = `${folder}/${MANIFEST}`;
143
- const pkg = readJson(repoRoot, manifest);
144
- if (!pkg) continue;
145
- const declared = declaredIn(pkg);
146
- const aliases = aliasesOf(ts, path.join(repoRoot, folder));
147
- const isAlias = (specifier) => aliases.some((alias) => (alias.exact ? specifier === alias.prefix : specifier.startsWith(alias.prefix)));
148
- const missing = new Map();
149
- for (const file of files) {
150
- if (!file.startsWith(`${folder}/`) || !SOURCE.test(file) || file.endsWith('.d.ts')) continue;
151
- const text = readText(repoRoot, file);
152
- if (text === null) continue;
153
- for (const { fileName: specifier } of ts.preProcessFile(text, true, true).importedFiles) {
154
- if (specifier.startsWith('.') || specifier.startsWith('/') || isBuiltin(specifier) || isAlias(specifier)) continue;
155
- const name = packageOf(specifier);
156
- if (declared.has(name) || name === pkg.name) continue;
157
- if (!missing.has(name)) missing.set(name, file);
158
- }
159
- }
160
- for (const [name, file] of [...missing].sort()) {
161
- const sibling = names.get(name);
162
- findings.push(found(MONO_WORKSPACE_DEP, manifest, `${file} imports ${name}, which ${manifest} does not declare; ${sibling ? `declare the workspace package ${name} as "*"` : `declare ${name} in the dependencies of ${manifest} (one version across the workspace, R14)`}.`, { dependency: name, importedBy: file }));
163
- }
164
- }
201
+ for (const folder of workspaces) findings.push(...workspaceMissingDependencies(repoRoot, files, folder, names, ts));
165
202
  return findings;
166
203
  }
167
204
 
@@ -11,6 +11,21 @@ import { found, readJson } from './read.mjs';
11
11
  const PEER_INTEGRATION_MISSING = 'HFS_PEER_INTEGRATION_MISSING';
12
12
  const MANIFEST = 'package.json';
13
13
  const CATALOG_FILE = new URL('../../../knowledge/hfs/peer-integrations.yaml', import.meta.url);
14
+ const WHITESPACE = /\s/;
15
+ const isDigit = (char) => char >= '0' && char <= '9';
16
+
17
+ /** The index of the first non-whitespace character of `text` at or after `from`. */
18
+ function skipWhitespace(text, from) {
19
+ let index = from;
20
+ while (index < text.length && WHITESPACE.test(text[index])) index += 1;
21
+ return index;
22
+ }
23
+
24
+ /** The index after an optional range operator (`^`, `~`, `>`, `>=`, `=`) at `from`. */
25
+ function skipOperator(text, from) {
26
+ if (text[from] === '>') return text[from + 1] === '=' ? from + 2 : from + 1;
27
+ return text[from] === '^' || text[from] === '~' || text[from] === '=' ? from + 1 : from;
28
+ }
14
29
 
15
30
  /** The pairs of the catalog: [{ id, when: [{ package, major? }], requires, why }]. */
16
31
  function peerIntegrationPairs() {
@@ -21,8 +36,14 @@ function peerIntegrationPairs() {
21
36
 
22
37
  /** The major a dependency spec allows (`11.2.5`, `^11.0.0`, `~11.1`, `>=11 <12`), or null when the spec names none (a tag, a link, `*`). */
23
38
  function majorOf(spec) {
24
- const match = /^\s*(?:[\^~]|>=?|=)?\s*v?(\d+)(?:\.|\s|$)/.exec(String(spec));
25
- return match ? Number(match[1]) : null;
39
+ const text = String(spec);
40
+ let index = skipWhitespace(text, skipOperator(text, skipWhitespace(text, 0)));
41
+ if (text[index] === 'v') index += 1;
42
+ const digits = index;
43
+ while (isDigit(text[index] ?? '')) index += 1;
44
+ if (index === digits) return null;
45
+ const next = text[index];
46
+ return next === undefined || next === '.' || WHITESPACE.test(next) ? Number(text.slice(digits, index)) : null;
26
47
  }
27
48
 
28
49
  /** The findings of R111 over the app root package.json of `repoRoot`; `pairs` defaults to the catalog. */
@@ -31,7 +52,7 @@ export function peerIntegrationFindings({ repoRoot, files, pairs = peerIntegrati
31
52
  const pkg = readJson(repoRoot, MANIFEST);
32
53
  if (!pkg) return [];
33
54
  const runtime = pkg.dependencies ?? {};
34
- const declared = { ...(pkg.devDependencies ?? {}), ...runtime };
55
+ const declared = { ...pkg.devDependencies, ...runtime };
35
56
  const findings = [];
36
57
  for (const pair of pairs) {
37
58
  const applies = pair.when.every((entry) => typeof declared[entry.package] === 'string' && (entry.major === undefined || majorOf(declared[entry.package]) === entry.major));
@@ -11,16 +11,65 @@ import { found, readJson, readText } from './read.mjs';
11
11
  const CI_MISSING_CANON = 'HFS_CI_MISSING_CANON';
12
12
  const CI_FILE = '.github/workflows/ci.yml';
13
13
  const hfsPackage = '@starci/cli';
14
- const RUN_LINE = /^\s*(?:-\s+)?run:\s*(.+?)\s*$/;
15
- const HFS_LINT = /^(?:npx\s+(?:--no-install\s+|-y\s+)?)?(?:@starci\/cli(?:@(\S+))?|starci)\s+app\s+lint(\s.*)?$/;
16
- const NPM_RUN = /^npm run ([\w:.-]+)(?:\s+--\s+(.*))?$/;
14
+ const RUN_PREFIX = /^\s*(?:-\s+)?run:/;
15
+ const NPX_PREFIX = /^npx\s+(?:(?:--no-install|-y)\s+)?/;
16
+ const LINT_COMMAND = /^(?:@starci\/cli(?:@(\S+))?|starci)\s+app\s+lint/;
17
+ const NPM_RUN_PREFIX = 'npm run ';
18
+ const SCRIPT_NAME = /^[\w:.-]+/;
19
+ const ARGS_SEPARATOR = /^\s+--\s+/;
20
+ const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
21
+ const WHITESPACE = /\s/;
17
22
 
18
- const commandsOf = (text) => text.split(/\r?\n/).map((line) => RUN_LINE.exec(line)?.[1]).filter(Boolean).map((command) => command.replace(/^["']|["']$/g, ''));
23
+ /** The command of a workflow `run:` line (`run: <command>` or `- run: <command>`): the text after `run:` without surrounding whitespace, on one line. */
24
+ function runCommandOf(line) {
25
+ const prefix = RUN_PREFIX.exec(line);
26
+ if (!prefix) return undefined;
27
+ const rest = line.slice(prefix[0].length);
28
+ const command = rest.trim();
29
+ if (command) return LINE_TERMINATOR.test(command) ? undefined : command;
30
+ return [...rest].findLast((character) => !LINE_TERMINATOR.test(character));
31
+ }
32
+
33
+ const commandsOf = (text) => text.split(/\r?\n/).map(runCommandOf).filter(Boolean).map((command) => command.replace(/^["']|["']$/g, ''));
34
+
35
+ /** The commands of a `a && b` chain: the whitespace around each `&&` belongs to the separator. */
36
+ function chainedCommands(command) {
37
+ const pieces = command.split('&&');
38
+ const last = pieces.length - 1;
39
+ return pieces.map((piece, index) => {
40
+ const head = index > 0 ? piece.trimStart() : piece;
41
+ return index < last ? head.trimEnd() : head;
42
+ });
43
+ }
44
+
45
+ /** `npm run <script>` or `npm run <script> -- <args>` as { script, args } (args undefined without `--`), else null. */
46
+ function parseNpmRun(command) {
47
+ if (!command.startsWith(NPM_RUN_PREFIX)) return null;
48
+ const rest = command.slice(NPM_RUN_PREFIX.length);
49
+ const script = SCRIPT_NAME.exec(rest)?.[0];
50
+ if (!script) return null;
51
+ const tail = rest.slice(script.length);
52
+ if (!tail) return { script, args: undefined };
53
+ const separator = ARGS_SEPARATOR.exec(tail);
54
+ const args = separator ? tail.slice(separator[0].length) : null;
55
+ return args !== null && !LINE_TERMINATOR.test(args) ? { script, args } : null;
56
+ }
57
+
58
+ /** { version } of a `starci app lint` command (`version` undefined when no @starci/cli version is named), else null. */
59
+ function lintCommandOf(command) {
60
+ const rest = command.replace(NPX_PREFIX, '');
61
+ const match = LINT_COMMAND.exec(rest);
62
+ if (!match) return null;
63
+ const tail = rest.slice(match[0].length);
64
+ if (tail && !(WHITESPACE.test(tail[0]) && !LINE_TERMINATOR.test(tail.slice(1)))) return null;
65
+ return { version: match[1] };
66
+ }
19
67
 
20
68
  /** `npm run <script> -- <args>` spelled out as the root package.json script it runs; any other command is itself. */
21
69
  function expanded(command, scripts) {
22
- const run = NPM_RUN.exec(command);
23
- return run && typeof scripts[run[1]] === 'string' ? `${scripts[run[1]]}${run[2] ? ` ${run[2]}` : ''}` : command;
70
+ const run = parseNpmRun(command);
71
+ if (!run || typeof scripts[run.script] !== 'string') return command;
72
+ return run.args ? `${scripts[run.script]} ${run.args}` : scripts[run.script];
24
73
  }
25
74
 
26
75
  /** The findings of R13 over the repository at `repoRoot`; `pins` is the parsed canon-pins.yaml pin map. */
@@ -30,10 +79,10 @@ export function pipelineFindings({ repoRoot, files, pins }) {
30
79
  const ci = files.includes(CI_FILE) ? readText(repoRoot, CI_FILE) : null;
31
80
  if (ci !== null) {
32
81
  const scripts = readJson(repoRoot, 'package.json')?.scripts ?? {};
33
- const lints = commandsOf(ci).flatMap((command) => expanded(command, scripts).split(/\s*&&\s*/)).map((command) => HFS_LINT.exec(command)).filter(Boolean);
82
+ const lints = commandsOf(ci).flatMap((command) => chainedCommands(expanded(command, scripts))).map(lintCommandOf).filter(Boolean);
34
83
  if (!lints.length) findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} has no step that runs \`starci app lint\` (directly or through \`npm run lint\`); CI runs the one lint entry, which includes the repository check`, { step: 'starci app lint' }));
35
- else if (pinned && !lints.some((match) => match[1] === undefined || match[1] === pinned)) {
36
- findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} runs starci app lint at ${lints[0][1]}, but ${hfsPackage} is pinned at ${pinned}; run the pinned version`, { step: 'starci app lint', pinned }));
84
+ else if (pinned && !lints.some((match) => match.version === undefined || match.version === pinned)) {
85
+ findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} runs starci app lint at ${lints[0].version}, but ${hfsPackage} is pinned at ${pinned}; run the pinned version`, { step: 'starci app lint', pinned }));
37
86
  }
38
87
  }
39
88
  return findings;
@@ -39,6 +39,19 @@ function proofCommands(record) {
39
39
  return Object.entries(demands).flatMap(([kind, demand]) => (typeof demand?.command === 'string' ? [{ kind, command: demand.command }] : []));
40
40
  }
41
41
 
42
+ function recordProofFindings({ repoRoot, file, record, tracked, resolver }) {
43
+ const findings = [];
44
+ for (const { kind, command } of proofCommands(record)) {
45
+ for (const target of commandFiles(command)) {
46
+ if (tracked.has(target) || fs.existsSync(path.join(repoRoot, target))) continue;
47
+ const classified = resolver.classifyPath(target);
48
+ if (classified.status === 'owned' && classified.tracking === 'ignored') continue;
49
+ findings.push(found(PROOF_COMMAND_FILE_MISSING, file, `${file} requiresProof.${kind}.command runs ${target}, which the repository does not hold; a proof command is runnable as written: create the file or rewrite the proof plan`, { kind, missing: target }));
50
+ }
51
+ }
52
+ return findings;
53
+ }
54
+
42
55
  /**
43
56
  * The findings of R105 over the tracked paths `files` of the app at `repoRoot`; `resolver` names the ignored slots and `sides` the side
44
57
  * names of hfs.json (be, fe), the repositories a record of this app names.
@@ -54,14 +67,7 @@ export function proofCommandFindings({ repoRoot, files, resolver, sides }) {
54
67
  let record;
55
68
  try { record = parseYaml(text); } catch { continue; }
56
69
  if (typeof record?.repository === 'string' && !own.has(record.repository)) continue;
57
- for (const { kind, command } of proofCommands(record)) {
58
- for (const target of commandFiles(command)) {
59
- if (tracked.has(target) || fs.existsSync(path.join(repoRoot, target))) continue;
60
- const classified = resolver.classifyPath(target);
61
- if (classified.status === 'owned' && classified.tracking === 'ignored') continue;
62
- findings.push(found(PROOF_COMMAND_FILE_MISSING, file, `${file} requiresProof.${kind}.command runs ${target}, which the repository does not hold; a proof command is runnable as written: create the file or rewrite the proof plan`, { kind, missing: target }));
63
- }
64
- }
70
+ findings.push(...recordProofFindings({ repoRoot, file, record, tracked, resolver }));
65
71
  }
66
72
  return findings;
67
73
  }
@@ -14,6 +14,16 @@ const LOCAL_RULE_FILE = /(?:^|\/)(?:eslint-local-rules|eslint-plugin|eslint-loca
14
14
  const CHECK_SCRIPT = /(?:^|\/)(?:scripts|tools)\/check-[^/]+$/;
15
15
  const RUNS_CHECK = /(?:^|[\s&|;(])(?:\.\/)?(?:scripts|tools)\/check-[^\s&|;)]+/;
16
16
 
17
+ /** The findings of the package.json scripts of `file` that run a repository-local check. */
18
+ function scriptFindings(repoRoot, file) {
19
+ const findings = [];
20
+ const pkg = readJson(repoRoot, file);
21
+ for (const [name, command] of Object.entries(pkg?.scripts ?? {})) {
22
+ if (RUNS_CHECK.test(String(command))) findings.push(found(REPO_LOCAL_CHECK, file, `${file} script ${name} runs a repository-local check (${String(command).slice(0, 80)}); delete the script, the check belongs to the .claude runtime`));
23
+ }
24
+ return findings;
25
+ }
26
+
17
27
  /** The findings of R103 over the tracked paths `files` of the repository at `repoRoot`. */
18
28
  export function repoLocalCheckFindings({ repoRoot, files }) {
19
29
  const findings = [];
@@ -21,12 +31,7 @@ export function repoLocalCheckFindings({ repoRoot, files }) {
21
31
  if (file.includes('node_modules/')) continue;
22
32
  if (LOCAL_RULE_FILE.test(file)) findings.push(found(REPO_LOCAL_CHECK, file, `${file} is a local lint rule or plugin; a repository keeps no rule of its own: the rule is proposed to the canon (@starci/eslint-canon-*) in the .claude runtime`));
23
33
  else if (CHECK_SCRIPT.test(file)) findings.push(found(REPO_LOCAL_CHECK, file, `${file} is a check kept in the repository; every check lives in the .claude runtime (starci app check, the canons), so a repository has none to keep`));
24
- else if (file === 'package.json' || file.endsWith('/package.json')) {
25
- const pkg = readJson(repoRoot, file);
26
- for (const [name, command] of Object.entries(pkg?.scripts ?? {})) {
27
- if (RUNS_CHECK.test(String(command))) findings.push(found(REPO_LOCAL_CHECK, file, `${file} script ${name} runs a repository-local check (${String(command).slice(0, 80)}); delete the script, the check belongs to the .claude runtime`));
28
- }
29
- }
34
+ else if (file === 'package.json' || file.endsWith('/package.json')) findings.push(...scriptFindings(repoRoot, file));
30
35
  }
31
36
  return findings;
32
37
  }
@@ -19,6 +19,7 @@ import path from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { loadTypescript } from '../../lib/package-at.mjs';
21
21
  import { found, readJson, readText } from './read.mjs';
22
+ import { byCodeUnit } from '../../lib/list.mjs';
22
23
 
23
24
  const SAGA_STEP_COMPENSATION = 'BE_SAGA_STEP_COMPENSATION';
24
25
  const SAGA_STATE_VERSIONED = 'BE_SAGA_STATE_VERSIONED';
@@ -70,7 +71,9 @@ function statesVersion(ts, sourceFile) {
70
71
  const memberNames = (members) => new Map(members.filter((member) => ts.isPropertySignature(member) && ts.isIdentifier(member.name)).map((member) => [member.name.text, member.type]));
71
72
  for (const statement of sourceFile.statements) {
72
73
  if (!statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
73
- const members = ts.isInterfaceDeclaration(statement) ? statement.members : ts.isTypeAliasDeclaration(statement) && ts.isTypeLiteralNode(statement.type) ? statement.type.members : null;
74
+ let members = null;
75
+ if (ts.isInterfaceDeclaration(statement)) members = statement.members;
76
+ else if (ts.isTypeAliasDeclaration(statement) && ts.isTypeLiteralNode(statement.type)) members = statement.type.members;
74
77
  if (members === null) continue;
75
78
  const named = memberNames(members);
76
79
  if (named.has('status') && named.get('version')?.kind === ts.SyntaxKind.NumberKeyword) versioned = true;
@@ -90,7 +93,125 @@ function readsEventId(ts, sourceFile) {
90
93
  }
91
94
 
92
95
  /** The sagas of the app: every folder of the saga kind (`be/src/features/saga/<saga>/`). */
93
- const featuresOf = (files) => [...new Set(files.map((file) => SAGA_FILE.exec(file)?.[1]).filter((feature) => feature !== undefined))].sort();
96
+ const featuresOf = (files) => [...new Set(files.map((file) => SAGA_FILE.exec(file)?.[1]).filter((feature) => feature !== undefined))].sort(byCodeUnit);
97
+
98
+ function filesForFeature(files, pattern, feature) {
99
+ return files.filter((file) => pattern.test(file) && pattern.exec(file)[1] === feature);
100
+ }
101
+
102
+ function featureParts(files, feature) {
103
+ const steps = new Map(filesForFeature(files, STEP, feature).map((file) => [STEP.exec(file)[2], file]));
104
+ const compensations = new Map(filesForFeature(files, COMPENSATION, feature).map((file) => [COMPENSATION.exec(file)[2], file]));
105
+ const orchestrators = filesForFeature(files, ORCHESTRATOR, feature);
106
+ const states = new Map(filesForFeature(files, STATE, feature).map((file) => [STATE.exec(file)[2], file]));
107
+ return { steps, compensations, orchestrators, states };
108
+ }
109
+
110
+ function stepCompensationFindings(folder, steps, compensations, orchestrators) {
111
+ const findings = [];
112
+ for (const [stem, file] of steps) if (!compensations.has(stem)) findings.push(found(SAGA_STEP_COMPENSATION, file, `${file} is a saga step with no compensation: add ${folder}compensations/${stem}.compensation.ts, the command that undoes it; every forward step of a saga has one.`, { step: stem }));
113
+ for (const [stem, file] of compensations) if (!steps.has(stem)) findings.push(found(SAGA_STEP_COMPENSATION, file, `${file} is a compensation of no step: ${folder}steps/${stem}.saga-step.ts does not exist; a compensation undoes the step of the same name.`, { step: stem }));
114
+ if (orchestrators.length === 0 && (steps.size > 0 || compensations.size > 0)) findings.push(found(SAGA_STEP_COMPENSATION, folder, `${folder} holds steps or compensations but no orchestrator: add <saga>.saga.service.ts, the list of the steps and their compensations.`));
115
+ return findings;
116
+ }
117
+
118
+ function orchestratorFindings(ts, repoRoot, orchestrator, folder, steps, compensations, states) {
119
+ const text = readText(repoRoot, orchestrator);
120
+ if (text === null) return [];
121
+ const findings = [];
122
+ const sourceFile = parse(ts, text);
123
+ const imported = new Set(specifiersOf(ts, sourceFile).map((specifier) => path.posix.normalize(path.posix.join(path.posix.dirname(orchestrator), specifier))));
124
+ for (const file of [...steps.values(), ...compensations.values()]) {
125
+ if (!imported.has(file.replace(/\.ts$/, ''))) findings.push(found(SAGA_STEP_COMPENSATION, orchestrator, `${orchestrator} does not list ${file}: the orchestrator imports every step and every compensation of its saga, so a step nobody runs cannot hide.`, { step: file }));
126
+ }
127
+ const stem = ORCHESTRATOR.exec(orchestrator)[2];
128
+ const stateFile = states.get(stem);
129
+ if (stateFile === undefined) findings.push(found(SAGA_STATE_VERSIONED, orchestrator, `${orchestrator} has no ${folder}${stem}.saga-state.ts: the persisted state of a run is typed there, with its status and its version fence.`, { saga: stem }));
130
+ if (!specifiersOf(ts, sourceFile).some((specifier) => STORE_MODULE.test(specifier))) findings.push(found(SAGA_STATE_VERSIONED, orchestrator, `${orchestrator} does not use the fenced store of platform/saga: a run is persisted and moved only through it (every transition names the version it read), never by a write of its own.`, { saga: stem }));
131
+ return findings;
132
+ }
133
+
134
+ function stateFindings(ts, repoRoot, states) {
135
+ const findings = [];
136
+ for (const [stem, file] of states) {
137
+ const text = readText(repoRoot, file);
138
+ if (text !== null && !statesVersion(ts, parse(ts, text))) findings.push(found(SAGA_STATE_VERSIONED, file, `${file} exports no state type with a \`status\` and a \`version: number\`; a saga run is persisted with a version, the fence that stops a zombie delivery from moving it twice.`, { saga: stem }));
139
+ }
140
+ return findings;
141
+ }
142
+
143
+ const STEP_EVENT = { label: 'step', action: 'puts on the wire', key: (stem) => `step:${stem}` };
144
+ const COMPENSATION_EVENT = { label: 'compensation', action: 'answers', key: (stem) => `compensation:${stem}` };
145
+
146
+ /** Findings for the steps (or compensations) that name no event or an event no vendored contract declares; fills `eventOfFile`. */
147
+ function declaredEventFindings(ts, repoRoot, kind, entries, events, eventOfFile) {
148
+ const findings = [];
149
+ for (const [stem, file] of entries) {
150
+ const text = readText(repoRoot, file);
151
+ const name = text === null ? null : eventOf(ts, parse(ts, text));
152
+ eventOfFile.set(kind.key(stem), name);
153
+ if (name === null) findings.push(found(SAGA_EVENT_CONTRACT, file, `${file} names no event: a ${kind.label} of a saga declares \`readonly event = "<name>"\`, the event of the contract it ${kind.action}.`, { step: stem }));
154
+ else if (!events.has(name)) findings.push(found(SAGA_EVENT_CONTRACT, file, `${file} names the event "${name}", which no be/contracts/<service>/events.json declares; every event of a saga is in a vendored contract.`, { step: stem, event: name }));
155
+ }
156
+ return findings;
157
+ }
158
+
159
+ /** Findings for compensation events whose contract does not name the step event it undoes, with the compensation paths found. */
160
+ function compensatesFindings(compensations, events, eventOfFile) {
161
+ const findings = [];
162
+ const compensationPaths = [];
163
+ for (const [stem, file] of compensations) {
164
+ const compensationEvent = eventOfFile.get(COMPENSATION_EVENT.key(stem));
165
+ const stepEvent = eventOfFile.get(STEP_EVENT.key(stem));
166
+ if (compensationEvent && stepEvent && events.has(compensationEvent) && events.get(compensationEvent).compensates !== stepEvent) {
167
+ findings.push(found(SAGA_EVENT_CONTRACT, file, `${file}: the contract of "${compensationEvent}" (${events.get(compensationEvent).contract}) does not declare \`compensates: "${stepEvent}"\`; the failure event of a compensation says which step's event it undoes.`, { step: stem, event: compensationEvent }));
168
+ }
169
+ if (compensationEvent) compensationPaths.push({ file, stem, event: compensationEvent });
170
+ }
171
+ return { findings, compensationPaths };
172
+ }
173
+
174
+ function eventFindings(ts, repoRoot, steps, compensations, events) {
175
+ const eventOfFile = new Map();
176
+ const findings = [
177
+ ...declaredEventFindings(ts, repoRoot, STEP_EVENT, steps, events, eventOfFile),
178
+ ...declaredEventFindings(ts, repoRoot, COMPENSATION_EVENT, compensations, events, eventOfFile),
179
+ ];
180
+ const compensated = compensatesFindings(compensations, events, eventOfFile);
181
+ findings.push(...compensated.findings);
182
+ return { findings, compensationPaths: compensated.compensationPaths };
183
+ }
184
+
185
+ function consumerFindings(ts, repoRoot, feature, files) {
186
+ const findings = [];
187
+ for (const file of filesForFeature(files, CONSUMER, feature)) {
188
+ const text = readText(repoRoot, file);
189
+ if (text !== null && !readsEventId(ts, parse(ts, text))) findings.push(found(SAGA_CONSUMER_DEDUPE, file, `${file} is a consumer of the saga ${feature} but never passes the id of the delivery on (\`message.eventId\`); the saga takes every event through the inbox, so the consumer hands the id to its command.`, { feature }));
190
+ }
191
+ return findings;
192
+ }
193
+
194
+ function featureFindings(ts, repoRoot, files, feature, events) {
195
+ const { steps, compensations, orchestrators, states } = featureParts(files, feature);
196
+ const folder = `be/src/features/saga/${feature}/`;
197
+ const findings = stepCompensationFindings(folder, steps, compensations, orchestrators);
198
+ for (const orchestrator of orchestrators) findings.push(...orchestratorFindings(ts, repoRoot, orchestrator, folder, steps, compensations, states));
199
+ findings.push(...stateFindings(ts, repoRoot, states));
200
+ const eventResult = eventFindings(ts, repoRoot, steps, compensations, events);
201
+ findings.push(...eventResult.findings, ...consumerFindings(ts, repoRoot, feature, files));
202
+ return { findings, compensationPaths: eventResult.compensationPaths };
203
+ }
204
+
205
+ function compensationE2eFindings(compensationPaths, specs) {
206
+ const findings = [];
207
+ for (const { file, stem, event } of compensationPaths) {
208
+ const named = specs.filter(({ text }) => text.includes(event));
209
+ const expected = `be/src/tests/e2e/<area>/<name>.e2e-spec.ts`;
210
+ if (named.length === 0) findings.push(found(SAGA_E2E_MISSING, file, `${file}: the compensation path of "${event}" has no e2e spec; add ${expected}, which boots the apps with useTestWorld, drives the failure and reads the compensated state back.`, { step: stem, event }));
211
+ else if (!named.some(({ text }) => INJECTION.test(text))) findings.push(found(SAGA_E2E_MISSING, named[0].file, `${named[0].file} names "${event}" but injects no failure; the compensation path is proven with a fault through the world (an outage of an infra service or an app with \`during\`, \`cut\`, \`latency\`, a fake's \`failNext\`, \`interruptDatabase\`), not only the happy flow.`, { step: stem, event }));
212
+ }
213
+ return findings;
214
+ }
94
215
 
95
216
  /** Findings of R169 to R173 over the tracked paths `files` of the app at `repoRoot`. */
96
217
  export function sagaFindings({ repoRoot, files }) {
@@ -98,73 +219,19 @@ export function sagaFindings({ repoRoot, files }) {
98
219
  if (features.length === 0) return [];
99
220
  const ts = typescriptFor(repoRoot);
100
221
  if (ts === null) return [];
101
- const findings = [];
102
222
  const events = new Map();
103
223
  for (const contract of files.filter((file) => CONTRACT.test(file))) {
104
224
  for (const [name, event] of Object.entries(readJson(repoRoot, contract)?.events ?? {})) events.set(name, { contract, ...event });
105
225
  }
106
226
  const specs = files.filter((file) => E2E_SPEC.test(file)).map((file) => ({ file, text: readText(repoRoot, file) ?? '' })).filter(({ text }) => USE_TEST_WORLD.test(text));
227
+ const findings = [];
107
228
  const compensationPaths = [];
108
229
  for (const feature of features) {
109
- const under = (pattern) => files.filter((file) => pattern.test(file) && pattern.exec(file)[1] === feature);
110
- const steps = new Map(under(STEP).map((file) => [STEP.exec(file)[2], file]));
111
- const compensations = new Map(under(COMPENSATION).map((file) => [COMPENSATION.exec(file)[2], file]));
112
- const orchestrators = under(ORCHESTRATOR);
113
- const states = new Map(under(STATE).map((file) => [STATE.exec(file)[2], file]));
114
- const folder = `be/src/features/saga/${feature}/`;
115
- // R169
116
- for (const [stem, file] of steps) if (!compensations.has(stem)) findings.push(found(SAGA_STEP_COMPENSATION, file, `${file} is a saga step with no compensation: add ${folder}compensations/${stem}.compensation.ts, the command that undoes it; every forward step of a saga has one.`, { step: stem }));
117
- for (const [stem, file] of compensations) if (!steps.has(stem)) findings.push(found(SAGA_STEP_COMPENSATION, file, `${file} is a compensation of no step: ${folder}steps/${stem}.saga-step.ts does not exist; a compensation undoes the step of the same name.`, { step: stem }));
118
- if (orchestrators.length === 0 && (steps.size > 0 || compensations.size > 0)) findings.push(found(SAGA_STEP_COMPENSATION, folder, `${folder} holds steps or compensations but no orchestrator: add <saga>.saga.service.ts, the list of the steps and their compensations.`));
119
- for (const orchestrator of orchestrators) {
120
- const text = readText(repoRoot, orchestrator);
121
- if (text === null) continue;
122
- const sourceFile = parse(ts, text);
123
- const imported = specifiersOf(ts, sourceFile).map((specifier) => path.posix.normalize(path.posix.join(path.posix.dirname(orchestrator), specifier)));
124
- for (const file of [...steps.values(), ...compensations.values()]) {
125
- if (!imported.includes(file.replace(/\.ts$/, ''))) findings.push(found(SAGA_STEP_COMPENSATION, orchestrator, `${orchestrator} does not list ${file}: the orchestrator imports every step and every compensation of its saga, so a step nobody runs cannot hide.`, { step: file }));
126
- }
127
- // R170
128
- const stem = ORCHESTRATOR.exec(orchestrator)[2];
129
- const stateFile = states.get(stem);
130
- if (stateFile === undefined) findings.push(found(SAGA_STATE_VERSIONED, orchestrator, `${orchestrator} has no ${folder}${stem}.saga-state.ts: the persisted state of a run is typed there, with its status and its version fence.`, { saga: stem }));
131
- if (!specifiersOf(ts, sourceFile).some((specifier) => STORE_MODULE.test(specifier))) findings.push(found(SAGA_STATE_VERSIONED, orchestrator, `${orchestrator} does not use the fenced store of platform/saga: a run is persisted and moved only through it (every transition names the version it read), never by a write of its own.`, { saga: stem }));
132
- }
133
- for (const [stem, file] of states) {
134
- const text = readText(repoRoot, file);
135
- if (text !== null && !statesVersion(ts, parse(ts, text))) findings.push(found(SAGA_STATE_VERSIONED, file, `${file} exports no state type with a \`status\` and a \`version: number\`; a saga run is persisted with a version, the fence that stops a zombie delivery from moving it twice.`, { saga: stem }));
136
- }
137
- // R171
138
- const eventOfFile = new Map();
139
- for (const [kind, map] of [['step', steps], ['compensation', compensations]]) {
140
- for (const [stem, file] of map) {
141
- const text = readText(repoRoot, file);
142
- const name = text === null ? null : eventOf(ts, parse(ts, text));
143
- eventOfFile.set(`${kind}:${stem}`, name);
144
- if (name === null) findings.push(found(SAGA_EVENT_CONTRACT, file, `${file} names no event: a ${kind} of a saga declares \`readonly event = "<name>"\`, the event of the contract it ${kind === 'step' ? 'puts on the wire' : 'answers'}.`, { step: stem }));
145
- else if (!events.has(name)) findings.push(found(SAGA_EVENT_CONTRACT, file, `${file} names the event "${name}", which no be/contracts/<service>/events.json declares; every event of a saga is in a vendored contract.`, { step: stem, event: name }));
146
- }
147
- }
148
- for (const [stem, file] of compensations) {
149
- const compensationEvent = eventOfFile.get(`compensation:${stem}`);
150
- const stepEvent = eventOfFile.get(`step:${stem}`);
151
- if (compensationEvent && stepEvent && events.has(compensationEvent) && events.get(compensationEvent).compensates !== stepEvent) {
152
- findings.push(found(SAGA_EVENT_CONTRACT, file, `${file}: the contract of "${compensationEvent}" (${events.get(compensationEvent).contract}) does not declare \`compensates: "${stepEvent}"\`; the failure event of a compensation says which step's event it undoes.`, { step: stem, event: compensationEvent }));
153
- }
154
- if (compensationEvent) compensationPaths.push({ file, stem, event: compensationEvent });
155
- }
156
- // R172
157
- for (const file of under(CONSUMER)) {
158
- const text = readText(repoRoot, file);
159
- if (text !== null && !readsEventId(ts, parse(ts, text))) findings.push(found(SAGA_CONSUMER_DEDUPE, file, `${file} is a consumer of the saga ${feature} but never passes the id of the delivery on (\`message.eventId\`); the saga takes every event through the inbox, so the consumer hands the id to its command.`, { feature }));
160
- }
230
+ const result = featureFindings(ts, repoRoot, files, feature, events);
231
+ findings.push(...result.findings);
232
+ compensationPaths.push(...result.compensationPaths);
161
233
  }
162
234
  // R173
163
- for (const { file, stem, event } of compensationPaths) {
164
- const named = specs.filter(({ text }) => text.includes(event));
165
- const expected = `be/src/tests/e2e/<area>/<name>.e2e-spec.ts`;
166
- if (named.length === 0) findings.push(found(SAGA_E2E_MISSING, file, `${file}: the compensation path of "${event}" has no e2e spec; add ${expected}, which boots the apps with useTestWorld, drives the failure and reads the compensated state back.`, { step: stem, event }));
167
- else if (!named.some(({ text }) => INJECTION.test(text))) findings.push(found(SAGA_E2E_MISSING, named[0].file, `${named[0].file} names "${event}" but injects no failure; the compensation path is proven with a fault through the world (an outage of an infra service or an app with \`during\`, \`cut\`, \`latency\`, a fake's \`failNext\`, \`interruptDatabase\`), not only the happy flow.`, { step: stem, event }));
168
- }
235
+ findings.push(...compensationE2eFindings(compensationPaths, specs));
169
236
  return findings;
170
237
  }