@starci/hfs 4.0.9 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/LICENSE +21 -0
  3. package/emit/operations.mjs +1 -1
  4. package/emit/type-schema.mjs +2 -1
  5. package/lint/run.mjs +82 -8
  6. package/package.json +3 -2
  7. package/report/order.mjs +2 -0
  8. package/runtime/config.example.yaml +187 -0
  9. package/runtime/engine/by-code-unit.mjs +12 -0
  10. package/runtime/engine/config.mjs +247 -273
  11. package/runtime/engine/invalid-config.mjs +5 -5
  12. package/runtime/engine/model-config.mjs +90 -0
  13. package/runtime/engine/orca-config.mjs +3 -1
  14. package/runtime/engine/removed-vocabulary.mjs +28 -0
  15. package/runtime/engine/resources-config.mjs +52 -0
  16. package/runtime/engine/runtime-root.mjs +17 -0
  17. package/runtime/engine/secrets.mjs +134 -0
  18. package/runtime/engine/sonar-config.mjs +21 -0
  19. package/runtime/engine/temp-root.mjs +32 -0
  20. package/runtime/knowledge/hfs/canon-pins.yaml +18 -18
  21. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  22. package/runtime/knowledge/hfs/rules.yaml +102 -69
  23. package/runtime/knowledge/hfs/slots.yaml +11 -14
  24. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  25. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  26. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  27. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  28. package/runtime/modules/kernel/failure-codes.yaml +1 -1
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
  30. package/runtime/modules/models/registry.yaml +25 -397
  31. package/runtime/modules/models/runtimes.yaml +92 -262
  32. package/runtime/modules/models/tiers.yaml +65 -0
  33. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  34. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  35. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  36. package/runtime/scripts/api/fs/lib.mjs +6 -0
  37. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  38. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  39. package/runtime/scripts/api/git/lib.mjs +36 -1
  40. package/runtime/scripts/api/process/resolve-real-tool.mjs +52 -0
  41. package/runtime/scripts/api/process/run-program.mjs +8 -0
  42. package/runtime/scripts/api/sops/decrypt.mjs +9 -16
  43. package/runtime/scripts/api/sops/lib.mjs +309 -11
  44. package/runtime/scripts/api/sops/seal.mjs +8 -4
  45. package/runtime/scripts/hfs/allows.mjs +3 -3
  46. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  47. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  48. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  49. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  50. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  51. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  52. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  53. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  54. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  55. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  56. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  57. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  58. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  59. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  60. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  61. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  62. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  63. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  64. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  65. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  66. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  67. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  68. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  69. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  70. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  71. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +3 -2
  72. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  73. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  74. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  75. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  76. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  77. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  78. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  79. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  80. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  81. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  82. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  83. package/runtime/scripts/hfs/architecture/index.mjs +158 -102
  84. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  85. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  86. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  87. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  88. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  89. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  90. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  91. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  92. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  93. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  94. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  95. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  96. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  97. package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
  98. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  99. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  100. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  101. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  102. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  103. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  104. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  105. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  106. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  107. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  108. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  109. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  110. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  111. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  112. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  113. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  114. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  115. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  116. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  117. package/runtime/scripts/hfs/architecture.mjs +3 -4
  118. package/runtime/scripts/hfs/check.mjs +61 -70
  119. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  120. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  121. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  122. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  123. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  124. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  125. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  126. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  127. package/runtime/scripts/hfs/repo-identity.mjs +5 -2
  128. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  129. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  130. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  131. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  132. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  133. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  134. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  135. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  136. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  137. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  138. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  139. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  140. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  141. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  142. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  143. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  144. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  145. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  146. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  147. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  148. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  149. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  150. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  151. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  152. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  153. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  154. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  155. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  156. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  157. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  158. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  159. package/runtime/scripts/hfs/secret.mjs +90 -45
  160. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  161. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  162. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  163. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  164. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  165. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  166. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  167. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  168. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  169. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  170. package/runtime/scripts/hfs/slots.mjs +72 -693
  171. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  172. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  173. package/runtime/scripts/hfs/tree.mjs +19 -14
  174. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  175. package/runtime/scripts/hfs/view.mjs +1 -1
  176. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  177. package/runtime/scripts/lib/event-contract.mjs +17 -13
  178. package/runtime/scripts/lib/fs-kind.mjs +14 -4
  179. package/runtime/scripts/lib/git.mjs +2 -2
  180. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  181. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  182. package/runtime/scripts/lib/i18n.mjs +3 -2
  183. package/runtime/scripts/lib/in-order.mjs +71 -0
  184. package/runtime/scripts/lib/language.mjs +3 -3
  185. package/runtime/scripts/lib/list.mjs +8 -1
  186. package/runtime/scripts/lib/mutation-fence.mjs +15 -0
  187. package/runtime/scripts/lib/path-key.mjs +42 -9
  188. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  189. package/runtime/scripts/lib/regex.mjs +1 -1
  190. package/runtime/scripts/lib/same-text.mjs +1 -1
  191. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  192. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  193. package/runtime/scripts/lib/sops-envelope.mjs +103 -2
  194. package/runtime/scripts/lib/stack-services.mjs +1 -2
  195. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  196. package/runtime/scripts/lib/walk.mjs +6 -1
  197. package/scaffold/add-cli-lite.mjs +2 -1
  198. package/scaffold/add-table.mjs +2 -2
  199. package/scaffold/add.mjs +3 -2
  200. package/scaffold/app.mjs +4 -2
  201. package/scaffold/edition-gate.mjs +25 -25
  202. package/scaffold/lite-exports.mjs +2 -1
  203. package/scaffold/service.mjs +5 -4
  204. package/sync/hygiene.mjs +15 -9
  205. package/sync/index.mjs +7 -3
  206. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  207. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  208. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  209. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  210. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  211. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  212. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  213. package/templates/app/starciwork.gitignore +1 -1
  214. package/templates/be/image/api/Dockerfile +1 -1
  215. package/templates/be/image/cli/Dockerfile +1 -1
  216. package/templates/be/image/worker/Dockerfile +1 -1
  217. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  218. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  219. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  220. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  221. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  222. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  223. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  224. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  225. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  226. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  227. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  228. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  229. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  230. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  231. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  232. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  233. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  234. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  235. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  236. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  237. package/templates/fe/image/next/Dockerfile +2 -1
  238. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  239. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  240. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  241. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  242. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  243. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  244. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  245. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  246. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  247. package/upgrade/index.mjs +9 -2
@@ -21,6 +21,37 @@ const PLAIN_ENTRY = /^<[a-z][a-z0-9-]*>.ts$/;
21
21
  const DATA_ACCESS_HOME = 'SQL text is a constant in <name>.sql.ts of the capability persistence/ folder, and data access is the capability *.service.ts (or the application *.handler.ts) calling the shared EntityManager through its Inject<Conn>EntityManager()';
22
22
  const BANNED_SUFFIX_HOME = Object.freeze({ repository: DATA_ACCESS_HOME, store: DATA_ACCESS_HOME });
23
23
 
24
+ function sourceFormContext(file, resolver, suffixes, bannedSuffixes) {
25
+ if (!file.endsWith('.ts') || !SOURCE_ROOT.test(file)) return null;
26
+ const c = resolver.classifyPath(file);
27
+ if (c.status !== 'owned' || c.tracking === 'ignored') return null;
28
+ const base = path.posix.basename(file);
29
+ if (FREE_NAMES.has(base)) return null;
30
+ const slot = resolver.slot(c.slot);
31
+ if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].includes(base)) return null;
32
+ const admitted = allowsFile(resolver, file);
33
+ if (admitted?.allowed && admitted.entry?.includes('/') && path.posix.basename(admitted.entry) === base) return null;
34
+ if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) return null;
35
+ if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') return null;
36
+ return { c, base, suffixes, bannedSuffixes };
37
+ }
38
+
39
+ function sourceFormFinding(file, { c, base, suffixes, bannedSuffixes }, boundSuffixes) {
40
+ const parts = base.slice(0, -'.ts'.length).split('.');
41
+ const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
42
+ if (banned) {
43
+ const homeNote = BANNED_SUFFIX_HOME[banned] ? `. ${BANNED_SUFFIX_HOME[banned]}` : '';
44
+ return { code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: banned, message: `${file}: the suffix .${banned} is banned; use a role from the closed suffix list (${suffixes.join(', ')})${homeNote}` };
45
+ }
46
+ if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
47
+ return { code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: parts.at(-1), message: `${file}: the suffix .${parts.at(-1)}.ts belongs to ${boundSuffixes.get(parts.at(-1)).path} only; move the file there` };
48
+ }
49
+ if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
50
+ return { code: 'BE_SOURCE_FORM', level: 'error', path: file, message: `${file}: the name must be <kebab-name>.<suffix>.ts with a suffix from the closed list (${suffixes.join(', ')}), or index.ts, main.ts or a migration` };
51
+ }
52
+ return null;
53
+ }
54
+
24
55
  /**
25
56
  * BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
26
57
  * be.persistence, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
@@ -38,53 +69,42 @@ function sourceFormFindings({ files, resolver }) {
38
69
  }
39
70
  const findings = [];
40
71
  for (const file of files) {
41
- if (!file.endsWith('.ts') || !SOURCE_ROOT.test(file)) continue;
42
- const c = resolver.classifyPath(file);
43
- if (c.status !== 'owned' || c.tracking === 'ignored') continue;
44
- const base = path.posix.basename(file);
45
- if (FREE_NAMES.has(base)) continue;
46
- // A literal file name the owning slot itself requires or allows (persistence/connection.ts, world/global-setup.ts) is its role.
47
- const slot = resolver.slot(c.slot);
48
- if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].some((entry) => entry === base)) continue;
49
- // So is an allows entry below the slot root whose last segment is that literal name (be.tests.world fakes/<provider>/server.ts).
50
- const admitted = allowsFile(resolver, file);
51
- if (admitted?.allowed && admitted.entry?.includes('/') && path.posix.basename(admitted.entry) === base) continue;
52
- // A slot whose `allows` holds a bare <name>.ts entry (be.tests.world.kit) names its files plainly, as platform/primitives does: kebab-case is the whole form.
53
- if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) continue;
54
- if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') continue;
55
- const parts = base.slice(0, -'.ts'.length).split('.');
56
- const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
57
- if (banned) {
58
- findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: banned, message: `${file}: the suffix .${banned} is banned; use a role from the closed suffix list (${suffixes.join(', ')})${BANNED_SUFFIX_HOME[banned] ? `. ${BANNED_SUFFIX_HOME[banned]}` : ''}` });
59
- } else if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
60
- findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: parts.at(-1), message: `${file}: the suffix .${parts.at(-1)}.ts belongs to ${boundSuffixes.get(parts.at(-1)).path} only; move the file there` });
61
- } else if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
62
- findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, message: `${file}: the name must be <kebab-name>.<suffix>.ts with a suffix from the closed list (${suffixes.join(', ')}), or index.ts, main.ts or a migration` });
63
- }
72
+ const context = sourceFormContext(file, resolver, suffixes, bannedSuffixes);
73
+ if (!context) continue;
74
+ const finding = sourceFormFinding(file, context, boundSuffixes);
75
+ if (finding) findings.push(finding);
64
76
  }
65
77
  return findings;
66
78
  }
67
79
 
80
+ function forbiddenPathFinding(file, c, resolver) {
81
+ const slot = resolver.slot(c.slot);
82
+ if (slotOwnsSecrets(slot)) return null;
83
+ const own = slot.rules?.includes('HFS_TOOL_CONFIG_LOCAL') ? 'HFS_TOOL_CONFIG_LOCAL' : 'HFS_FORBIDDEN_PRESENT';
84
+ const goesToNote = c.goesTo ? `; it belongs at ${c.goesTo}` : '';
85
+ return { code: own, level: 'error', path: file, slot: c.slot, goesTo: c.goesTo, message: `${file} is tracked but ${c.slot} is forbidden in the tree${goesToNote}` };
86
+ }
87
+
88
+ function pathStatusFinding(file, resolver) {
89
+ const c = resolver.classifyPath(file);
90
+ if (c.status === 'no-slot') {
91
+ const nearestNote = c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : '';
92
+ return { code: 'HFS_SLOT_UNDECLARED', level: 'error', path: file, nearest: c.nearest, message: `${file} matches no slot${nearestNote}` };
93
+ }
94
+ if (c.status === 'ambiguous') return { code: 'HFS_SLOT_AMBIGUOUS', level: 'error', path: file, candidates: c.candidates, message: `${file} is owned equally by ${c.candidates.map((x) => x.slot ?? x).join(', ')}` };
95
+ if (c.status === 'not-enabled') return { code: 'HFS_SLOT_NOT_ENABLED', level: 'error', path: file, slot: c.slot, message: `${file} belongs to ${c.slot}, an opt-in slot hfs.json neither lists in optionalSlots nor implies through an app kind` };
96
+ if (c.status === 'forbidden') return forbiddenPathFinding(file, c, resolver);
97
+ if (c.tracking === 'ignored') return { code: 'HFS_TRACKED_MUST_BE_IGNORED', level: 'error', path: file, slot: c.slot, message: `${file} is tracked but ${c.slot} must be gitignored` };
98
+ return null;
99
+ }
100
+
68
101
  /** The findings of the slot manifest over `files` (repository-relative tracked paths) of a repository of `profile`. */
69
102
  export function pathFindings({ files, resolver, profile }) {
70
103
  const findings = [];
71
104
  for (const file of files) {
72
105
  if (profile === 'fe' && isFeTestPath(file)) continue; // a test path of a front end is FE_NO_TESTS's, the one finding of that file
73
- const c = resolver.classifyPath(file);
74
- if (c.status === 'no-slot') {
75
- findings.push({ code: 'HFS_SLOT_UNDECLARED', level: 'error', path: file, nearest: c.nearest, message: `${file} matches no slot${c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : ''}` });
76
- } else if (c.status === 'ambiguous') {
77
- findings.push({ code: 'HFS_SLOT_AMBIGUOUS', level: 'error', path: file, candidates: c.candidates, message: `${file} is owned equally by ${c.candidates.map((x) => x.slot ?? x).join(', ')}` });
78
- } else if (c.status === 'not-enabled') {
79
- findings.push({ code: 'HFS_SLOT_NOT_ENABLED', level: 'error', path: file, slot: c.slot, message: `${file} belongs to ${c.slot}, an opt-in slot hfs.json neither lists in optionalSlots nor implies through an app kind` });
80
- } else if (c.status === 'forbidden') {
81
- const slot = resolver.slot(c.slot);
82
- if (slotOwnsSecrets(slot)) continue; // the secret scan reports the file (R06): one finding per file
83
- const own = slot.rules?.includes('HFS_TOOL_CONFIG_LOCAL') ? 'HFS_TOOL_CONFIG_LOCAL' : 'HFS_FORBIDDEN_PRESENT';
84
- findings.push({ code: own, level: 'error', path: file, slot: c.slot, goesTo: c.goesTo, message: `${file} is tracked but ${c.slot} is forbidden in the tree${c.goesTo ? `; it belongs at ${c.goesTo}` : ''}` });
85
- } else if (c.tracking === 'ignored') {
86
- findings.push({ code: 'HFS_TRACKED_MUST_BE_IGNORED', level: 'error', path: file, slot: c.slot, message: `${file} is tracked but ${c.slot} must be gitignored` });
87
- }
106
+ const finding = pathStatusFinding(file, resolver);
107
+ if (finding) findings.push(finding);
88
108
  }
89
109
 
90
110
  if (profile === 'be') findings.push(...sourceFormFindings({ files, resolver }), ...specPlacementFindings({ files, resolver }));
@@ -0,0 +1,31 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { APP_SCOPE } from './slots.mjs';
4
+
5
+ const DEP_SECTIONS = ['dependencies', 'devDependencies'];
6
+ const pinnedSpec = (spec, pin) => (spec === pin.version ? null : `declared ${spec}, pinned ${pin.version}`);
7
+
8
+ function pinFileFindings(repoRoot, file, profile, pins) {
9
+ const findings = [];
10
+ let pkg;
11
+ try { pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8')); } catch { return findings; }
12
+ for (const [name, pin] of Object.entries(pins)) {
13
+ // The app root's one package.json carries the pins of both sides.
14
+ if (profile !== APP_SCOPE && pin.side !== 'both' && pin.side !== profile) continue;
15
+ for (const section of DEP_SECTIONS) {
16
+ const spec = pkg[section]?.[name];
17
+ if (spec === undefined) continue;
18
+ const drift = pinnedSpec(spec, pin);
19
+ if (drift) findings.push({ code: 'HFS_CANON_PIN_DRIFT', level: 'error', path: file, dependency: name, section, pinned: pin.version, declared: spec, message: `${name} in ${file} ${section}: ${drift}` });
20
+ }
21
+ }
22
+ return findings;
23
+ }
24
+
25
+ export function pinFindings({ repoRoot, files, profile, pins, only }) {
26
+ const findings = [];
27
+ for (const file of files.filter((f) => (f === 'package.json' || f.endsWith('/package.json')) && (!only || only.has(f)))) {
28
+ findings.push(...pinFileFindings(repoRoot, file, profile, pins));
29
+ }
30
+ return findings;
31
+ }
@@ -6,10 +6,13 @@ import path from 'node:path';
6
6
  import { revParseQuery } from '../api/git/rev-parse-query.mjs';
7
7
  import { remote as gitRemote } from '../api/git/remote.mjs';
8
8
  import { gitOutputOf } from '../lib/git.mjs';
9
+ import { trimTrailingSlashes } from './trailing-slashes.mjs';
9
10
 
10
11
  /** One git call (a scripts/api/git call file) in `root`: its trimmed stdout; throws unless git exits 0. */
11
12
  const git = (call, root, args) => gitOutputOf(call(args, { cwd: root })).trim();
12
13
  const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
14
+ const GIT_SUFFIX_SOURCE = String.raw`\.git$`;
15
+ const GIT_SUFFIX = new RegExp(GIT_SUFFIX_SOURCE, 'iu');
13
16
 
14
17
  /** { repositoryRoot, inWorkTree, home } for `root`: home is the main checkout folder when root is a repository top level. */
15
18
  function identityOf(root) {
@@ -34,8 +37,8 @@ export function repositoryName(root) {
34
37
  if (home) return path.basename(home);
35
38
  if (repositoryRoot) {
36
39
  try {
37
- const remote = git(gitRemote, root, ['get-url', 'origin']).replace(/[\/]+$/u, '').replace(/\.git$/iu, '');
38
- const name = remote.split(/[\/:]/u).pop();
40
+ const remote = trimTrailingSlashes(git(gitRemote, root, ['get-url', 'origin'])).replace(GIT_SUFFIX, '');
41
+ const name = remote.split(/[/:]/u).pop();
39
42
  if (name) return name;
40
43
  } catch { /* No origin remote. */ }
41
44
  }
@@ -0,0 +1,194 @@
1
+ // rule-catalog.mjs - the parsed and validated HFS rule catalog.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { skillRoot } from '../../engine/runtime-root.mjs';
5
+ import { parseYaml } from '../../engine/yaml.mjs';
6
+ import { isPlainObject } from '../../engine/plain-object.mjs';
7
+ import { SEMVER } from './manifest-shape.mjs';
8
+ import { enforcerJudgedInEdition, judgedInEdition, ruleEditionProblems } from './edition-slots.mjs';
9
+ import { fail } from './slot-errors.mjs';
10
+
11
+ const HFS_RULES_FILE = 'knowledge/hfs/rules.yaml';
12
+ const RULE_GATES = Object.freeze(['pre-commit', 'pre-push', 'settle', 'land', 'ci', 'sonar', 'runtime']);
13
+ const ENFORCER_FAMILIES = Object.freeze(['eslint-be', 'eslint-fe', 'stylelint', 'machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
14
+ const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
15
+ const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
16
+ const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
17
+ const FILE_ENFORCERS = new Set(['machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
18
+ const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'enforcers']);
19
+ const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions']);
20
+
21
+ function vocabularyProblems(catalog, key, names, bad) {
22
+ if (!isPlainObject(catalog[key])) { bad.push(`${key} must be a map`); return; }
23
+ if (JSON.stringify(Object.keys(catalog[key])) !== JSON.stringify(names)) bad.push(`${key} must list exactly ${names.join(', ')} in that order`);
24
+ for (const [name, text] of Object.entries(catalog[key])) {
25
+ if (typeof text !== 'string' || !text.trim()) bad.push(`${key}.${name} needs a description`);
26
+ }
27
+ }
28
+
29
+ function checkCatalogHeader(catalog, bad) {
30
+ for (const key of Object.keys(catalog)) {
31
+ if (!['schema', 'version', 'gates', 'enforcerKinds', 'rules'].includes(key)) bad.push(`unknown top-level key ${key}`);
32
+ }
33
+ const schemaOk = /^starci\/hfs-rules@\d+$/.test(String(catalog.schema));
34
+ if (!schemaOk) bad.push('schema must be starci/hfs-rules@<major>');
35
+ if (!SEMVER.test(String(catalog.version))) bad.push('version must be MAJOR.MINOR.PATCH');
36
+ else if (schemaOk && catalog.schema.split('@')[1] !== catalog.version.split('.')[0]) bad.push('the major of version must equal the number after @ in schema');
37
+ vocabularyProblems(catalog, 'gates', RULE_GATES, bad);
38
+ vocabularyProblems(catalog, 'enforcerKinds', ENFORCER_FAMILIES, bad);
39
+ }
40
+
41
+ function enumListProblems(rule, label, key, allowed, bad) {
42
+ if (!Array.isArray(rule[key]) || !rule[key].length) { bad.push(`${label}.${key} must be a non-empty list`); return []; }
43
+ for (const value of rule[key]) {
44
+ if (!allowed.includes(value)) bad.push(`${label}.${key} has ${JSON.stringify(value)}, not one of ${allowed.join(', ')}`);
45
+ }
46
+ if (new Set(rule[key]).size !== rule[key].length) bad.push(`${label}.${key} repeats a value`);
47
+ return rule[key];
48
+ }
49
+
50
+ function checkRuleIdentity(rule, index, catalog, at, label, bad) {
51
+ if (!/^R\d{2,3}$/.test(String(rule.id))) bad.push(`${at}.id must be R<two or three digits>`);
52
+ else if (index > 0 && typeof catalog.rules[index - 1]?.id === 'string' && Number(rule.id.slice(1)) <= Number(catalog.rules[index - 1].id.slice(1))) bad.push(`${label} is out of order: ids must increase, and ${catalog.rules[index - 1].id} comes before it`);
53
+ if (!FINDING_CODE.test(String(rule.code))) bad.push(`${label}.code must be an UPPER_SNAKE finding code`);
54
+ }
55
+
56
+ function checkRuleText(rule, label, bad) {
57
+ if (typeof rule.law !== 'string' || !rule.law.trim()) bad.push(`${label}.law is missing`);
58
+ if (typeof rule.law === 'string' && rule.law.includes('\n')) bad.push(`${label}.law must be one line`);
59
+ if (rule.scope !== undefined && rule.scope !== 'runtime') bad.push(`${label}.scope is absent or runtime`);
60
+ }
61
+
62
+ function checkRuleGates(label, gates, bad) {
63
+ if (!gates.length) return;
64
+ if (!gates.includes('land')) bad.push(`${label} must run at the land gate (every rule does)`);
65
+ if (gates.includes('pre-commit') && !gates.includes('pre-push')) bad.push(`${label} runs at pre-commit, so it also runs at pre-push`);
66
+ }
67
+
68
+ function checkRuleFailureCodes(rule, label, codeOwner, bad) {
69
+ if (!Array.isArray(rule.failureCodes) || !rule.failureCodes.length || !rule.failureCodes.every((code) => FINDING_CODE.test(String(code)))) {
70
+ bad.push(`${label}.failureCodes must be a non-empty list of UPPER_SNAKE codes`);
71
+ return;
72
+ }
73
+ if (rule.failureCodes[0] !== rule.code) bad.push(`${label}.failureCodes must start with the rule's own code ${rule.code}`);
74
+ if (new Set(rule.failureCodes).size !== rule.failureCodes.length) bad.push(`${label}.failureCodes repeats a code`);
75
+ for (const code of rule.failureCodes) {
76
+ if (codeOwner.has(code) && codeOwner.get(code) !== label) bad.push(`${label} names ${code}, which ${codeOwner.get(code)} already owns`);
77
+ codeOwner.set(code, label);
78
+ }
79
+ }
80
+
81
+ function checkEnforcerIdentity(enforcer, label, seen, bad) {
82
+ if (!ENFORCER_FAMILIES.includes(enforcer.kind)) bad.push(`${label}.kind must be one of ${ENFORCER_FAMILIES.join(', ')}`);
83
+ if (!ENFORCER_ID.test(String(enforcer.id))) bad.push(`${label}.id must be kebab-case`);
84
+ if (seen.has(`${enforcer.kind}:${enforcer.id}`)) bad.push(`${label} repeats ${enforcer.kind}:${enforcer.id}`);
85
+ seen.add(`${enforcer.kind}:${enforcer.id}`);
86
+ }
87
+
88
+ function checkEnforcerLocation(enforcer, label, bad) {
89
+ if (enforcer.at !== undefined) {
90
+ if (typeof enforcer.at !== 'string' || !enforcer.at.trim() || enforcer.at.startsWith('/') || enforcer.at.includes('..')) bad.push(`${label}.at must be a repository-relative path`);
91
+ if (enforcer.status === 'planned') bad.push(`${label} is planned, so it has no file yet (at)`);
92
+ if (!FILE_ENFORCERS.has(enforcer.kind)) bad.push(`${label}.at belongs to a machine, hfs, work-validate, sonar or runtime enforcer only`);
93
+ } else if (FILE_ENFORCERS.has(enforcer.kind) && enforcer.status !== 'planned') bad.push(`${label} exists, so it names the file (at) that emits its code`);
94
+ }
95
+
96
+ function checkEnforcer(enforcer, index, ruleLabel, seen, bad) {
97
+ const label = `${ruleLabel}.enforcers[${index}]`;
98
+ if (!isPlainObject(enforcer)) { bad.push(`${label} is not a map`); return; }
99
+ for (const key of Object.keys(enforcer)) {
100
+ if (!ENFORCER_KEYS.has(key)) bad.push(`${label} has unknown key ${key}`);
101
+ }
102
+ bad.push(...ruleEditionProblems(enforcer, label));
103
+ checkEnforcerIdentity(enforcer, label, seen, bad);
104
+ if (enforcer.status !== undefined && enforcer.status !== 'planned') bad.push(`${label}.status is either absent or planned`);
105
+ checkEnforcerLocation(enforcer, label, bad);
106
+ }
107
+
108
+ function checkRuleEnforcers(rule, label, bad) {
109
+ if (!Array.isArray(rule.enforcers) || !rule.enforcers.length) { bad.push(`${label}.enforcers must name at least one enforcer`); return; }
110
+ const seen = new Set();
111
+ rule.enforcers.forEach((enforcer, index) => checkEnforcer(enforcer, index, label, seen, bad));
112
+ }
113
+
114
+ function checkRule(rule, index, catalog, codeOwner, bad) {
115
+ const at = `rules[${index}]`;
116
+ if (!isPlainObject(rule)) { bad.push(`${at} is not a map`); return; }
117
+ const label = typeof rule.id === 'string' ? rule.id : at;
118
+ for (const key of Object.keys(rule)) {
119
+ if (!RULE_KEYS.has(key)) bad.push(`${label} has unknown key ${key}`);
120
+ }
121
+ checkRuleIdentity(rule, index, catalog, at, label, bad);
122
+ checkRuleText(rule, label, bad);
123
+ enumListProblems(rule, label, 'kinds', RULE_KINDS, bad);
124
+ bad.push(...ruleEditionProblems(rule, label));
125
+ const gates = enumListProblems(rule, label, 'gates', RULE_GATES, bad);
126
+ checkRuleGates(label, gates, bad);
127
+ checkRuleFailureCodes(rule, label, codeOwner, bad);
128
+ checkRuleEnforcers(rule, label, bad);
129
+ if (Array.isArray(rule.gates)) {
130
+ const hasSonar = rule.enforcers.some((enforcer) => isPlainObject(enforcer) && enforcer.kind === 'sonar');
131
+ if (rule.gates.includes('sonar') !== hasSonar) bad.push(`${label}: the sonar gate and a sonar enforcer go together`);
132
+ }
133
+ }
134
+
135
+ /** Shape and semantic problems of a parsed knowledge/hfs/rules.yaml, in the words of modules/schemas/hfs-rules.schema.yaml. */
136
+ function ruleCatalogProblems(catalog) {
137
+ const bad = [];
138
+ if (!isPlainObject(catalog)) return ['the rule catalog is not a map'];
139
+ checkCatalogHeader(catalog, bad);
140
+ if (!Array.isArray(catalog.rules) || !catalog.rules.length) { bad.push('rules must be a non-empty list'); return bad; }
141
+ const codeOwner = new Map();
142
+ catalog.rules.forEach((rule, index) => checkRule(rule, index, catalog, codeOwner, bad));
143
+ return bad;
144
+ }
145
+
146
+ const deepFreeze = (value) => { if (value && typeof value === 'object') { Object.values(value).forEach(deepFreeze); } return Object.freeze(value); };
147
+
148
+ /**
149
+ * The parsed and validated HFS rule catalog. `text` (or `file`, or `root`) selects the source; the default is the runtime's
150
+ * own knowledge/hfs/rules.yaml. A catalog that breaks its shape or a semantic rule is refused whole (HFS_RULES_INVALID), and
151
+ * so is one whose major differs from the slot manifest passed as `manifest` (HFS_MANIFEST_MAJOR_MISMATCH).
152
+ * Answers: rule(id), byCode(code), forGate(gate), forEnforcer(kind, id), planned() and unbuilt().
153
+ */
154
+ export function loadRuleCatalog({ root = skillRoot, file = path.join(root, HFS_RULES_FILE), text, manifest } = {}) {
155
+ let doc;
156
+ try { doc = parseYaml(text ?? fs.readFileSync(file, 'utf8')); } catch (error) { fail('HFS_RULES_INVALID', `the rule catalog cannot be read (${String(error?.message ?? error).split('\n')[0]})`, { file }); }
157
+ const problems = ruleCatalogProblems(doc);
158
+ if (problems.length) { fail('HFS_RULES_INVALID', `the rule catalog breaks its schema: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? '; and ' + (problems.length - 5) + ' more' : ''}`, { file, problems }); }
159
+ const [major, minor, patch] = doc.version.split('.').map(Number);
160
+ if (manifest && manifest.major !== major) fail('HFS_MANIFEST_MAJOR_MISMATCH', `the rule catalog is major ${major} but the slot manifest is major ${manifest.major}`, { catalog: major, manifest: manifest.major });
161
+ const list = deepFreeze(doc.rules.map((rule) => ({ ...rule, enforcers: rule.enforcers.map((enforcer) => ({ ...enforcer, planned: enforcer.status === 'planned' })) })));
162
+ const byId = new Map(list.map((rule) => [rule.id, rule]));
163
+ const byCode = new Map(list.flatMap((rule) => rule.failureCodes.map((code) => [code, rule])));
164
+ return Object.freeze({
165
+ version: doc.version, major, minor, patch,
166
+ gates: deepFreeze(structuredClone(doc.gates)),
167
+ enforcerKinds: deepFreeze(structuredClone(doc.enforcerKinds)),
168
+ rules: list,
169
+ /** The rule with this id (R01..), or null. */
170
+ rule: (id) => byId.get(id) ?? null,
171
+ /** The rule that owns this failure code (its own or a sub-check code), or null. */
172
+ byCode: (code) => byCode.get(code) ?? null,
173
+ /** Whether a finding code is judged under `edition`: a code of a rule that names `editions` without it is not (a code outside the catalog always is). */
174
+ judgedIn: (code, edition = 'full') => judgedInEdition(byCode.get(code), edition),
175
+ enforcerJudgedIn: (kind, id, edition = 'full') => enforcerJudgedInEdition(list, kind, id, edition),
176
+ /** The rules that run at a gate. */
177
+ forGate: (gate) => list.filter((rule) => rule.gates.includes(gate)),
178
+ /** The catalogued why code of a lint finding's rule id (`starci-be/<id>`, `starci-fe/<id>`), or undefined. */
179
+ lintCode: (ruleId) => {
180
+ const [plugin, id] = String(ruleId ?? '').split('/');
181
+ const kind = (plugin === 'starci-be' && 'eslint-be') || (plugin === 'starci-fe' && 'eslint-fe') || null;
182
+ return kind ? list.find((rule) => rule.enforcers.some((enforcer) => enforcer.kind === kind && enforcer.id === id))?.code : undefined;
183
+ },
184
+ /** The rules one enforcer judges, e.g. forEnforcer('eslint-be', 'error-home'). */
185
+ forEnforcer: (kind, id) => list.filter((rule) => rule.enforcers.some((enforcer) => enforcer.kind === kind && enforcer.id === id)),
186
+ /** Every enforcer still owed, as {rule, kind, id}. */
187
+ planned: () => list.flatMap((rule) => rule.enforcers.filter((enforcer) => enforcer.planned).map((enforcer) => ({ rule: rule.id, kind: enforcer.kind, id: enforcer.id }))),
188
+ /** The rules with no existing enforcer at all. */
189
+ unbuilt: () => list.filter((rule) => rule.enforcers.every((enforcer) => enforcer.planned)),
190
+ });
191
+ }
192
+
193
+ /** The rules of this runtime's catalog, frozen, in id order. */
194
+ export const rules = (options) => loadRuleCatalog(options).rules;
@@ -5,45 +5,51 @@ import { isPlainObject } from '../../engine/plain-object.mjs';
5
5
  import { paramNamesOk } from './param-names.mjs';
6
6
  import { kindParamProblems, scenarioProblem } from './declaration-slots.mjs';
7
7
  import { roleListProblems, SCHEMA_AUTHORITIES, unitRolesProblems } from './manifest-shape.mjs';
8
+ import { byCodeUnit } from '../lib/list.mjs';
8
9
 
9
10
  const PROFILES = ['be', 'fe'];
11
+ const BE_OWN = ['infraOwners', 'eventBus', 'specDoubles', 'paramNames', 'suffixes', 'bannedSuffixes', 'contractShape', 'unitRoles', 'logicRoles', 'thinRoles', 'patternScenarios', 'kindPatterns', 'addKinds'];
12
+ const FORM_NAMES = new Set(['call', 'new', 'curried', 'object', 'primitive', 'array']);
13
+ const blockOk = (v) => isPlainObject(v) && Number.isInteger(v.lines) && v.lines >= 2 && Number.isInteger(v.tokens) && v.tokens >= 1 && Object.keys(v).length === 2;
14
+ const fileLinesOk = (v) => isPlainObject(v) && Number.isInteger(v.soft) && v.soft >= 1 && typeof v.hardGrowth === 'boolean' && Object.keys(v).length === 2;
15
+ const sharedParam = (key, v) => (key === 'fileLines' ? fileLinesOk(v) : blockOk(v));
16
+ const sideOk = (side, own, extra = []) => own.every((k) => side[k] !== undefined) && Object.entries(side).every(([k, v]) => own.includes(k) || extra.includes(k) || (['fileLines', 'duplicateBlock'].includes(k) && sharedParam(k, v)));
17
+ const liteOk = (lite, keys) => lite === undefined || (isPlainObject(lite) && Object.keys(lite).length > 0 && Object.keys(lite).every((k) => k !== 'lite' && keys.includes(k)));
18
+ const memberMap = (v, allowEmpty) => isPlainObject(v) && Object.keys(v).length > 0 && Object.values(v).every((list) => Array.isArray(list) && (allowEmpty || list.length > 0) && list.every((x) => /^[A-Z][A-Za-z0-9]*$/.test(String(x))) && new Set(list).size === list.length);
19
+ const roleList = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => /^[a-z][a-z0-9-]*$/.test(String(x))) && new Set(v).size === v.length;
20
+ const formsOk = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => FORM_NAMES.has(x)) && new Set(v).size === v.length;
21
+ const doubleOk = (v) => isPlainObject(v) && /^[A-Za-z][A-Za-z0-9]*$/.test(String(v.double)) && formsOk(v.forms);
22
+ const regexOk = (v) => { try { return typeof v === 'string' && v.length > 0 && Boolean(new RegExp(v)); } catch { return false; } };
23
+
24
+ function beSpecificProblems(be, manifest) {
25
+ const bad = [];
26
+ bad.push(...scenarioProblem(be.patternScenarios), ...kindParamProblems(be, manifest.triggerKinds));
27
+ if (!isPlainObject(be.contractShape) || Object.keys(be.contractShape).length !== 1 || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(String(be.contractShape.helper))) bad.push('ruleParams.be.contractShape must be {helper: <identifier>}');
28
+ const owners = be.infraOwners;
29
+ const ownerId = /^(platform|integrations)\/[a-z][a-z0-9-]*$/;
30
+ if (!isPlainObject(owners) || !Object.keys(owners).length || !Object.entries(owners).every(([key, list]) => key && Array.isArray(list) && list.every((o) => ownerId.test(String(o))) && new Set(list).size === list.length)) bad.push('ruleParams.be.infraOwners must map a non-empty specifier to a list of unique platform/<capability> or integrations/<provider> owners ([] means nowhere)');
31
+ const eventBus = be.eventBus;
32
+ if (!isPlainObject(eventBus) || Object.keys(eventBus).sort(byCodeUnit).join() !== 'classes,imports' || !memberMap(eventBus.imports, true) || !memberMap(eventBus.classes, false)) bad.push('ruleParams.be.eventBus must be {imports: {<module>: [unique PascalCase members]}, classes: {<module>: [non-empty unique PascalCase classes]}}');
33
+ if (!paramNamesOk(be.paramNames)) bad.push('ruleParams.be.paramNames must be a non-empty list of unique {type | typeSuffix, names, nameSuffix?} entries');
34
+ if (!roleList(be.suffixes)) bad.push('ruleParams.be.suffixes must be a non-empty list of unique kebab-case role suffixes');
35
+ if (!roleList(be.bannedSuffixes)) bad.push('ruleParams.be.bannedSuffixes must be a non-empty list of unique kebab-case suffixes');
36
+ else if (roleList(be.suffixes) && be.suffixes.some((x) => be.bannedSuffixes.includes(x))) bad.push('ruleParams.be.suffixes and bannedSuffixes must be disjoint');
37
+ const sd = be.specDoubles;
38
+ if (!isPlainObject(sd) || Object.keys(sd).sort(byCodeUnit).join() !== 'doubles,fallback,kit' || typeof sd.kit !== 'string' || !sd.kit || !Array.isArray(sd.doubles) || !sd.doubles.length || !sd.doubles.every((e) => doubleOk(e) && regexOk(e.token) && Object.keys(e).length === 3) || !doubleOk(sd.fallback) || Object.keys(sd.fallback).length !== 2) bad.push('ruleParams.be.specDoubles must be {kit, doubles: [{token: regex, double, forms}], fallback: {double, forms}} with forms drawn from call, new, curried, object, primitive, array');
39
+ return bad;
40
+ }
10
41
 
11
42
  /** The shape problems of `m.ruleParams`. */
12
43
  export function ruleParamsProblems(m) {
13
44
  const bad = [];
14
- const blockOk = (v) => isPlainObject(v) && Number.isInteger(v.lines) && v.lines >= 2 && Number.isInteger(v.tokens) && v.tokens >= 1 && Object.keys(v).length === 2;
15
- const fileLinesOk = (v) => isPlainObject(v) && Number.isInteger(v.soft) && v.soft >= 1 && typeof v.hardGrowth === 'boolean' && Object.keys(v).length === 2;
16
45
  const rp = m.ruleParams;
17
- if (!isPlainObject(rp) || Object.keys(rp).some((k) => ![...PROFILES, 'common'].includes(k)) || !isPlainObject(rp.common) || !isPlainObject(rp.be) || (rp.fe !== undefined && !isPlainObject(rp.fe))) bad.push('ruleParams must be a map with common, be and optionally fe');
46
+ if (!isPlainObject(rp) || Object.keys(rp).some((k) => ![...PROFILES, 'common'].includes(k)) || !isPlainObject(rp.common) || !isPlainObject(rp.be) || (rp.fe !== undefined && !isPlainObject(rp.fe))) return ['ruleParams must be a map with common, be and optionally fe'];
18
47
  else {
19
48
  // common holds the parameters both sides share; a side may restate one of them (a valid value) as an override, nothing else.
20
- const sharedParam = (key, v) => (key === 'fileLines' ? fileLinesOk(v) : blockOk(v));
21
- const sideOk = (side, own, extra = []) => own.every((k) => side[k] !== undefined) && Object.entries(side).every(([k, v]) => own.includes(k) || extra.includes(k) || (['fileLines', 'duplicateBlock'].includes(k) && sharedParam(k, v)));
22
49
  // Edition lite: a side's optional `lite` map overrides keys of the same side (never `lite` itself) when the app declares edition lite.
23
- const liteOk = (lite, keys) => lite === undefined || (isPlainObject(lite) && Object.keys(lite).length > 0 && Object.keys(lite).every((k) => k !== 'lite' && keys.includes(k)));
24
- const BE_OWN = ['infraOwners', 'eventBus', 'specDoubles', 'paramNames', 'suffixes', 'bannedSuffixes', 'contractShape', 'unitRoles', 'logicRoles', 'thinRoles', 'patternScenarios', 'kindPatterns', 'addKinds'];
25
50
  if (Object.keys(rp.common).length !== 2 || !fileLinesOk(rp.common.fileLines) || !blockOk(rp.common.duplicateBlock)) bad.push('ruleParams.common needs exactly fileLines {soft, hardGrowth} and duplicateBlock {lines >= 2, tokens >= 1}');
26
51
  if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !liteOk(rp.be.lite, [...BE_OWN, 'fileLines', 'duplicateBlock', 'schemaAuthority'])) bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns and addKinds (fileLines and duplicateBlock live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + ') and lite (overrides of the same keys)'); else bad.push(...unitRolesProblems(rp.be), ...roleListProblems(rp.be));
27
- bad.push(...scenarioProblem(rp.be.patternScenarios), ...kindParamProblems(rp.be, m.triggerKinds));
28
- if (!isPlainObject(rp.be.contractShape) || Object.keys(rp.be.contractShape).length !== 1 || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(String(rp.be.contractShape.helper))) bad.push('ruleParams.be.contractShape must be {helper: <identifier>}');
29
- const owners = rp.be.infraOwners;
30
- const ownerId = /^(platform|integrations)\/[a-z][a-z0-9-]*$/;
31
- if (!isPlainObject(owners) || !Object.keys(owners).length || !Object.entries(owners).every(([key, list]) => key && Array.isArray(list) && list.every((o) => ownerId.test(String(o))) && new Set(list).size === list.length)) bad.push('ruleParams.be.infraOwners must map a non-empty specifier to a list of unique platform/<capability> or integrations/<provider> owners ([] means nowhere)');
32
- const className = /^[A-Z][A-Za-z0-9]*$/;
33
- const memberMap = (v, allowEmpty) => isPlainObject(v) && Object.keys(v).length > 0 && Object.values(v).every((list) => Array.isArray(list) && (allowEmpty || list.length > 0) && list.every((x) => className.test(String(x))) && new Set(list).size === list.length);
34
- const eventBus = rp.be.eventBus;
35
- if (!isPlainObject(eventBus) || Object.keys(eventBus).sort().join() !== 'classes,imports' || !memberMap(eventBus.imports, true) || !memberMap(eventBus.classes, false)) bad.push('ruleParams.be.eventBus must be {imports: {<module>: [unique PascalCase members]}, classes: {<module>: [non-empty unique PascalCase classes]}}');
36
- if (!paramNamesOk(rp.be.paramNames)) bad.push('ruleParams.be.paramNames must be a non-empty list of unique {type | typeSuffix, names, nameSuffix?} entries');
37
- const roleList = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => /^[a-z][a-z0-9-]*$/.test(String(x))) && new Set(v).size === v.length;
38
- if (!roleList(rp.be.suffixes)) bad.push('ruleParams.be.suffixes must be a non-empty list of unique kebab-case role suffixes');
39
- if (!roleList(rp.be.bannedSuffixes)) bad.push('ruleParams.be.bannedSuffixes must be a non-empty list of unique kebab-case suffixes');
40
- else if (roleList(rp.be.suffixes) && rp.be.suffixes.some((x) => rp.be.bannedSuffixes.includes(x))) bad.push('ruleParams.be.suffixes and bannedSuffixes must be disjoint');
41
- const formNames = ['call', 'new', 'curried', 'object', 'primitive', 'array'];
42
- const formsOk = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => formNames.includes(x)) && new Set(v).size === v.length;
43
- const doubleOk = (v) => isPlainObject(v) && /^[A-Za-z][A-Za-z0-9]*$/.test(String(v.double)) && formsOk(v.forms);
44
- const regexOk = (v) => { try { return typeof v === 'string' && v.length > 0 && Boolean(new RegExp(v)); } catch { return false; } };
45
- const sd = rp.be.specDoubles;
46
- if (!isPlainObject(sd) || Object.keys(sd).sort().join() !== 'doubles,fallback,kit' || typeof sd.kit !== 'string' || !sd.kit || !Array.isArray(sd.doubles) || !sd.doubles.length || !sd.doubles.every((e) => doubleOk(e) && regexOk(e.token) && Object.keys(e).length === 3) || !doubleOk(sd.fallback) || Object.keys(sd.fallback).length !== 2) bad.push('ruleParams.be.specDoubles must be {kit, doubles: [{token: regex, double, forms}], fallback: {double, forms}} with forms drawn from call, new, curried, object, primitive, array');
52
+ bad.push(...beSpecificProblems(rp.be, m));
47
53
  if (rp.fe !== undefined && (!sideOk(rp.fe, [], ['lite']) || !liteOk(rp.fe.lite, ['fileLines', 'duplicateBlock']))) bad.push('ruleParams.fe may only restate fileLines and duplicateBlock of ruleParams.common, and carry a lite override of them');
48
54
  }
49
55
  return bad;
@@ -29,7 +29,11 @@ export function cliFindings({ files, repo, resolver }) {
29
29
  // Where the cli app is only optional (litePresence: schema migration is the Supabase CLI's job) a connection no longer forces it; a tracked command still does.
30
30
  const migrates = (be.connections ?? []).length > 0 && beResolver.slot(CLI_APP_SLOT)?.presence !== 'optional';
31
31
  const needs = migrates || commandFiles.length > 0;
32
- const why = migrates ? `it declares the connection${be.connections.length === 1 ? '' : 's'} ${be.connections.map((c) => c.name).join(', ')} (their migrations run as \`cli migrate run\`)` : 'it tracks commands under be/src/features/cli/';
32
+ let why = 'it tracks commands under be/src/features/cli/';
33
+ if (migrates) {
34
+ const plural = be.connections.length === 1 ? '' : 's';
35
+ why = `it declares the connection${plural} ${be.connections.map((c) => c.name).join(', ')} (their migrations run as \`cli migrate run\`)`;
36
+ }
33
37
  if (needs && cliApps.length === 0) findings.push(found(CLI_REQUIRED, 'hfs.json', `the back end declares no cli app, but ${why}; declare { "name": "${CLI_APP}", "kind": "${CLI_KIND}" } in sides.be.apps and build be/apps/${CLI_APP} on nest-commander: every one-off action of the back end is one of its commands.`, { needs: why }));
34
38
  for (const app of cliApps.filter((entry) => entry.name !== CLI_APP)) findings.push(found(CLI_REQUIRED, 'hfs.json', `the cli app is named ${app.name}; the back end has ONE cli app, be/apps/${CLI_APP}, so its image and its command line are the same in every app.`, { app: app.name }));
35
39
  if (cliApps.length > 0 && !tracked.has(`apps/${CLI_APP}/${IMAGE}`) && cliApps.some((app) => app.name === CLI_APP)) findings.push(found(CLI_REQUIRED, `be/apps/${CLI_APP}/${IMAGE}`, `be/apps/${CLI_APP} has no image: track be/apps/${CLI_APP}/${IMAGE}, the one cli image every one-off action runs from (\`cli <group> <command>\`).`, { app: CLI_APP }));
@@ -12,6 +12,7 @@
12
12
  import path from 'node:path';
13
13
  import { isPlainObject } from '../../../engine/plain-object.mjs';
14
14
  import { found, readJson } from './read.mjs';
15
+ import { byCodeUnit } from '../../lib/list.mjs';
15
16
 
16
17
  export const CONTRACT_BREAKING = 'BE_CONTRACT_BREAKING';
17
18
 
@@ -21,43 +22,60 @@ const same = (a, b) => JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b
21
22
  function sortKeys(value) {
22
23
  if (Array.isArray(value)) return value.map(sortKeys);
23
24
  if (!isPlainObject(value)) return value;
24
- return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortKeys(value[key])]));
25
+ return Object.fromEntries(Object.keys(value).sort(byCodeUnit).map((key) => [key, sortKeys(value[key])]));
25
26
  }
26
27
  /** {type, optional} of a payload field spelling (`string`, `number?`, `string[]`). */
27
28
  const fieldOf = (spelling) => (typeof spelling === 'string' && spelling.endsWith('?') ? { type: spelling.slice(0, -1), optional: true } : { type: spelling, optional: false });
28
29
 
30
+ function eventMetadataChanges(name, before, after) {
31
+ const problems = [];
32
+ for (const key of Object.keys(before)) {
33
+ if (key === 'payload') continue;
34
+ if (!same(before[key], after[key])) problems.push({ event: name, message: `event ${name} changed its ${key} from ${JSON.stringify(before[key])} to ${JSON.stringify(after[key] ?? null)}; a published event keeps its ${key}. Add ${name}.v2 for the new shape.` });
35
+ }
36
+ return problems;
37
+ }
38
+
39
+ function existingPayloadChanges(name, was, now) {
40
+ const problems = [];
41
+ for (const [field, spelling] of Object.entries(was)) {
42
+ if (!(field in now)) { problems.push({ event: name, message: `event ${name} lost payload field ${field}; fields are only ever added. Keep it, and add ${name}.v2 for the new shape.` }); continue; }
43
+ const a = fieldOf(spelling);
44
+ const b = fieldOf(now[field]);
45
+ if (a.type !== b.type) problems.push({ event: name, message: `event ${name} changed payload field ${field} from ${a.type} to ${b.type}; a field keeps its type. Add ${name}.v2 for the new shape.` });
46
+ else if (a.optional !== b.optional) problems.push({ event: name, message: `event ${name} changed payload field ${field} from ${a.optional ? 'optional' : 'required'} to ${b.optional ? 'optional' : 'required'}; a field keeps its optionality. Add ${name}.v2 for the new shape.` });
47
+ }
48
+ return problems;
49
+ }
50
+
51
+ function addedRequiredPayloadChanges(name, was, now) {
52
+ const problems = [];
53
+ for (const [field, spelling] of Object.entries(now)) {
54
+ if (!(field in was) && !fieldOf(spelling).optional) problems.push({ event: name, message: `event ${name} gained required payload field ${field}; events already on the wire do not carry it, so a new field is optional (${field}?). Make it optional, or add ${name}.v2.` });
55
+ }
56
+ return problems;
57
+ }
58
+
59
+ function eventChanges(name, before, after) {
60
+ if (!isPlainObject(after)) return [{ event: name, message: `event ${name} was removed; a published event is never deleted while it can still be on the wire. Keep it, and add ${name}.v2 for the new shape.` }];
61
+ const was = isPlainObject(before.payload) ? before.payload : {};
62
+ const now = isPlainObject(after.payload) ? after.payload : {};
63
+ return [...eventMetadataChanges(name, before, after), ...existingPayloadChanges(name, was, now), ...addedRequiredPayloadChanges(name, was, now)];
64
+ }
65
+
29
66
  /** The breaking changes of `current` against `pinned`: [{event, message}]. */
30
67
  export function breakingChanges(pinned, current) {
31
68
  const problems = [];
32
69
  const pinnedEvents = isPlainObject(pinned?.events) ? pinned.events : {};
33
70
  const currentEvents = isPlainObject(current?.events) ? current.events : {};
34
- for (const [name, before] of Object.entries(pinnedEvents)) {
35
- const after = currentEvents[name];
36
- if (!isPlainObject(after)) { problems.push({ event: name, message: `event ${name} was removed; a published event is never deleted while it can still be on the wire. Keep it, and add ${name}.v2 for the new shape.` }); continue; }
37
- for (const key of Object.keys(before)) {
38
- if (key === 'payload') continue;
39
- if (!same(before[key], after[key])) problems.push({ event: name, message: `event ${name} changed its ${key} from ${JSON.stringify(before[key])} to ${JSON.stringify(after[key] ?? null)}; a published event keeps its ${key}. Add ${name}.v2 for the new shape.` });
40
- }
41
- const was = isPlainObject(before.payload) ? before.payload : {};
42
- const now = isPlainObject(after.payload) ? after.payload : {};
43
- for (const [field, spelling] of Object.entries(was)) {
44
- if (!(field in now)) { problems.push({ event: name, message: `event ${name} lost payload field ${field}; fields are only ever added. Keep it, and add ${name}.v2 for the new shape.` }); continue; }
45
- const a = fieldOf(spelling);
46
- const b = fieldOf(now[field]);
47
- if (a.type !== b.type) problems.push({ event: name, message: `event ${name} changed payload field ${field} from ${a.type} to ${b.type}; a field keeps its type. Add ${name}.v2 for the new shape.` });
48
- else if (a.optional !== b.optional) problems.push({ event: name, message: `event ${name} changed payload field ${field} from ${a.optional ? 'optional' : 'required'} to ${b.optional ? 'optional' : 'required'}; a field keeps its optionality. Add ${name}.v2 for the new shape.` });
49
- }
50
- for (const [field, spelling] of Object.entries(now)) {
51
- if (!(field in was) && !fieldOf(spelling).optional) problems.push({ event: name, message: `event ${name} gained required payload field ${field}; events already on the wire do not carry it, so a new field is optional (${field}?). Make it optional, or add ${name}.v2.` });
52
- }
53
- }
71
+ for (const [name, before] of Object.entries(pinnedEvents)) problems.push(...eventChanges(name, before, currentEvents[name]));
54
72
  return problems;
55
73
  }
56
74
 
57
75
  /** R177: the findings of every service whose contract is pinned. `files` are the repository-relative tracked paths. */
58
76
  export function contractCompatFindings({ repoRoot, files }) {
59
77
  const findings = [];
60
- for (const rel of [...files].sort()) {
78
+ for (const rel of [...files].sort(byCodeUnit)) {
61
79
  const match = CONTRACTS.exec(rel);
62
80
  if (!match) continue;
63
81
  const current = `be/contracts/${match[1]}/events.json`;