@starci/hfs 4.0.10 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/emit/operations.mjs +1 -1
  3. package/emit/type-schema.mjs +2 -1
  4. package/lint/run.mjs +65 -24
  5. package/package.json +1 -1
  6. package/report/order.mjs +2 -0
  7. package/runtime/config.example.yaml +194 -0
  8. package/runtime/engine/by-code-unit.mjs +12 -0
  9. package/runtime/engine/config.mjs +222 -239
  10. package/runtime/engine/invalid-config.mjs +5 -5
  11. package/runtime/engine/model-config.mjs +90 -0
  12. package/runtime/engine/orca-config.mjs +3 -1
  13. package/runtime/engine/release-config.mjs +28 -0
  14. package/runtime/engine/removed-vocabulary.mjs +28 -0
  15. package/runtime/engine/resources-config.mjs +52 -0
  16. package/runtime/engine/runtime-root.mjs +17 -0
  17. package/runtime/engine/secrets.mjs +35 -31
  18. package/runtime/engine/sonar-config.mjs +21 -0
  19. package/runtime/engine/temp-root.mjs +32 -0
  20. package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
  21. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  22. package/runtime/knowledge/hfs/rules.yaml +161 -65
  23. package/runtime/knowledge/hfs/slots.yaml +11 -14
  24. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  25. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  26. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  27. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  28. package/runtime/modules/kernel/failure-codes.yaml +1 -41
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +268 -0
  30. package/runtime/modules/models/registry.yaml +25 -359
  31. package/runtime/modules/models/runtimes.yaml +126 -265
  32. package/runtime/modules/models/tiers.yaml +66 -0
  33. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  34. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  35. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  36. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  37. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  38. package/runtime/scripts/api/git/lib.mjs +34 -1
  39. package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
  40. package/runtime/scripts/api/process/run-program.mjs +2 -1
  41. package/runtime/scripts/api/sops/lib.mjs +198 -120
  42. package/runtime/scripts/hfs/allows.mjs +3 -3
  43. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  44. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  45. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  46. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  47. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  48. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  49. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  50. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  51. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  52. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  53. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  54. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  55. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  56. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  57. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  58. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  59. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  60. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  61. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  62. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  63. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  64. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  65. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  66. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  67. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  68. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
  69. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  70. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  71. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  72. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  73. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  74. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  75. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  76. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  77. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  78. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  79. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  80. package/runtime/scripts/hfs/architecture/index.mjs +164 -104
  81. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  82. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  83. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  84. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  85. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  86. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  87. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  88. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  89. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  90. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  91. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  92. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  93. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  94. package/runtime/scripts/hfs/architecture/required-files.mjs +121 -70
  95. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  96. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  97. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  98. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  99. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  100. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  101. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  102. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  103. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  104. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  105. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  106. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  107. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  108. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  109. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  110. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  111. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  112. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  113. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  114. package/runtime/scripts/hfs/architecture.mjs +3 -4
  115. package/runtime/scripts/hfs/catalog-once.mjs +15 -0
  116. package/runtime/scripts/hfs/check.mjs +63 -71
  117. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  118. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  119. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  120. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  121. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  122. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  123. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  124. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  125. package/runtime/scripts/hfs/repo-identity.mjs +21 -4
  126. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  127. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  128. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  129. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  130. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  131. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  132. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  133. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  134. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  135. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  136. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  137. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  138. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  139. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  140. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  141. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  142. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  143. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  144. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  145. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  146. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  147. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  148. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  149. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  150. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  151. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  152. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  153. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  154. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  155. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  156. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  157. package/runtime/scripts/hfs/secret.mjs +59 -48
  158. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  159. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  160. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  161. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  162. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  163. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  164. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  165. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  166. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  167. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  168. package/runtime/scripts/hfs/slots.mjs +74 -695
  169. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  170. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  171. package/runtime/scripts/hfs/tree.mjs +19 -14
  172. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  173. package/runtime/scripts/hfs/view.mjs +1 -1
  174. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  175. package/runtime/scripts/lib/env.mjs +10 -0
  176. package/runtime/scripts/lib/event-contract.mjs +17 -13
  177. package/runtime/scripts/lib/git.mjs +2 -2
  178. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  179. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  180. package/runtime/scripts/lib/i18n.mjs +3 -2
  181. package/runtime/scripts/lib/in-order.mjs +71 -0
  182. package/runtime/scripts/lib/language.mjs +3 -3
  183. package/runtime/scripts/lib/list.mjs +8 -1
  184. package/runtime/scripts/lib/path-key.mjs +38 -5
  185. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  186. package/runtime/scripts/lib/regex.mjs +1 -1
  187. package/runtime/scripts/lib/same-text.mjs +1 -1
  188. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  189. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  190. package/runtime/scripts/lib/sops-envelope.mjs +95 -48
  191. package/runtime/scripts/lib/stack-services.mjs +1 -2
  192. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  193. package/runtime/scripts/lib/walk.mjs +6 -1
  194. package/runtime/scripts/lib/yaml-cached.mjs +14 -0
  195. package/scaffold/add-cli-lite.mjs +2 -1
  196. package/scaffold/add-table.mjs +2 -2
  197. package/scaffold/add.mjs +3 -2
  198. package/scaffold/app.mjs +4 -2
  199. package/scaffold/edition-gate.mjs +25 -25
  200. package/scaffold/lite-exports.mjs +2 -1
  201. package/scaffold/service.mjs +5 -4
  202. package/sync/hygiene.mjs +15 -9
  203. package/sync/index.mjs +7 -3
  204. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  205. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  206. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  207. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  208. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  209. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  210. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  211. package/templates/app/starciwork.gitignore +1 -1
  212. package/templates/be/image/api/Dockerfile +1 -1
  213. package/templates/be/image/cli/Dockerfile +1 -1
  214. package/templates/be/image/worker/Dockerfile +1 -1
  215. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  216. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  217. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  218. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  219. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  220. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  221. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  222. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  223. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  224. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  225. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  226. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  227. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  228. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  229. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  230. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  231. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  232. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  233. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  234. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  235. package/templates/fe/image/next/Dockerfile +2 -1
  236. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  237. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  238. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  239. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  240. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  241. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  242. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  243. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  244. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  245. package/upgrade/index.mjs +9 -2
  246. package/runtime/engine/admission.mjs +0 -308
  247. package/runtime/engine/canonical-json.mjs +0 -10
  248. package/runtime/engine/db/blob.mjs +0 -315
  249. package/runtime/engine/db/ledger-paths.mjs +0 -83
  250. package/runtime/engine/db/ledger.mjs +0 -1118
  251. package/runtime/engine/db/machine-connection.mjs +0 -135
  252. package/runtime/engine/db/machine-schema.mjs +0 -84
  253. package/runtime/engine/db/machine.mjs +0 -1367
  254. package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
  255. package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
  256. package/runtime/engine/db/provider-reservations.mjs +0 -101
  257. package/runtime/engine/digest.mjs +0 -16
  258. package/runtime/engine/refuse.mjs +0 -11
  259. package/runtime/modules/ops/_labels.yaml +0 -53
  260. package/runtime/scripts/api/node/lib.mjs +0 -14
  261. package/runtime/scripts/api/node/spawn-node.mjs +0 -6
  262. package/runtime/scripts/api/process/lib.mjs +0 -111
  263. package/runtime/scripts/api/process/owned-process.mjs +0 -78
  264. package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
  265. package/runtime/scripts/connectors/lib.mjs +0 -488
  266. package/runtime/scripts/lib/clip.mjs +0 -19
  267. package/runtime/scripts/lib/display-names.mjs +0 -259
  268. package/runtime/scripts/lib/example-refs.mjs +0 -158
  269. package/runtime/scripts/lib/json-schema.mjs +0 -52
  270. package/runtime/scripts/lib/process-identity.mjs +0 -6
  271. package/runtime/scripts/lib/read-yaml.mjs +0 -18
  272. package/runtime/scripts/lib/redact.mjs +0 -161
  273. package/runtime/scripts/lib/sleep.mjs +0 -7
  274. package/runtime/scripts/lib/source-phrases.mjs +0 -34
  275. package/runtime/scripts/lib/sqlite.mjs +0 -21
@@ -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 { parseYamlCached } from '../lib/yaml-cached.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 = parseYamlCached(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`;
@@ -8,10 +8,10 @@
8
8
  // api app into a temp directory through the injected `emit` (packages/hfs/emit) and compares hashes; an emit that cannot run is a finding.
9
9
  import { createHash } from 'node:crypto';
10
10
  import fs from 'node:fs';
11
- import os from 'node:os';
12
11
  import path from 'node:path';
13
12
  import { found, readText } from './read.mjs';
14
13
  import { safeRemove } from '../../api/fs/safe-remove.mjs';
14
+ import { makeTempDir } from '../../api/fs/make-temp-dir.mjs';
15
15
 
16
16
  export const CONTRACT_SNAPSHOT_DRIFT = 'HFS_CONTRACT_SNAPSHOT_DRIFT';
17
17
  const GRAPHQL_TRANSPORT_SLOT = 'be.transport.graphql';
@@ -19,7 +19,7 @@ const SNAPSHOT_OF = { graphql: 'schema.graphql', json: 'openapi.json' };
19
19
 
20
20
  /** sha256 of a file's text with line endings folded, or null when it cannot be read. */
21
21
  function contractHash(file) {
22
- try { return createHash('sha256').update(fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n')).digest('hex'); } catch { return null; }
22
+ try { return createHash('sha256').update(fs.readFileSync(file, 'utf8').replaceAll('\r\n', '\n')).digest('hex'); } catch { return null; }
23
23
  }
24
24
 
25
25
  /** The findings of R23 for the be side at `repoRoot` (a side of another profile has none). */
@@ -42,6 +42,36 @@ export function contractFindings({ repoRoot, files, repo, resolver }) {
42
42
  /** The first lines of an error, enough to act on. */
43
43
  const errorText = (error) => String(error?.message ?? error).split(/\r?\n/).filter(Boolean).slice(0, 4).join(' | ');
44
44
 
45
+ function emitFailure(app, artifacts, error, apps, findings) {
46
+ for (const { artifact } of artifacts) apps.push({ app: app.name, artifact, status: 'emit-failed' });
47
+ const snapshot = `contracts/${app.name}/${SNAPSHOT_OF.graphql}`;
48
+ findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `contracts/${app.name} cannot be verified: \`starci app emit\` failed for ${app.name}: ${errorText(error)}`, { app: app.name, drift: 'emit-failed' }));
49
+ }
50
+
51
+ function artifactFindings({ repoRoot, tracked, scratch, app, artifact, what, emitted, apps, findings }) {
52
+ const snapshot = `contracts/${app.name}/${artifact}`;
53
+ const committed = tracked.has(snapshot) ? contractHash(path.join(repoRoot, snapshot)) : null;
54
+ if (!emitted.written.includes(snapshot)) {
55
+ if (committed === null) {
56
+ if (artifact === SNAPSHOT_OF.graphql) apps.push({ app: app.name, artifact, status: 'none' });
57
+ return;
58
+ }
59
+ apps.push({ app: app.name, artifact, status: 'left-behind' });
60
+ findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `${snapshot} is committed but ${app.name} ${what}; delete it`, { app: app.name, drift: 'left-behind' }));
61
+ return;
62
+ }
63
+ const fresh = contractHash(path.join(scratch, snapshot));
64
+ if (committed === null) {
65
+ // the uncommitted snapshot itself is reported by contractFindings above; here it only counts as not judged
66
+ apps.push({ app: app.name, artifact, status: 'not-committed' });
67
+ } else if (committed === fresh) {
68
+ apps.push({ app: app.name, artifact, status: 'fresh' });
69
+ } else {
70
+ apps.push({ app: app.name, artifact, status: 'stale' });
71
+ findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `${snapshot} (${committed.slice(0, 12)}) differs from what ${app.name} emits now (${String(fresh).slice(0, 12)}); run \`npm run contract:emit\` and commit the result`, { app: app.name, drift: 'stale' }));
72
+ }
73
+ }
74
+
45
75
  /**
46
76
  * R23, full pass: each committed `contracts/<app>/schema.graphql` and `contracts/<app>/openapi.json` equals what `emit` writes for
47
77
  * the app now. `emit({ repoRoot, declaration, outDir })` is `emitContracts` of packages/hfs/emit and answers `{ written, skipped }`;
@@ -56,7 +86,7 @@ export function contractEmitFindings({ repoRoot, files, repo, emit }) {
56
86
  const apps = [];
57
87
  if (repo.profile !== 'be') return { findings, apps };
58
88
  const tracked = new Set(files);
59
- const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'hfs-contracts-'));
89
+ const scratch = makeTempDir('hfs-contracts-');
60
90
  try {
61
91
  for (const app of repo.apps.filter((a) => a.kind === 'api')) {
62
92
  const artifacts = [{ artifact: SNAPSHOT_OF.graphql, what: 'serves no GraphQL any more' }, { artifact: SNAPSHOT_OF.json, what: 'declares no operations any more' }];
@@ -64,33 +94,11 @@ export function contractEmitFindings({ repoRoot, files, repo, emit }) {
64
94
  try {
65
95
  emitted = emit({ repoRoot, declaration: { apps: [app] }, outDir: scratch });
66
96
  } catch (error) {
67
- for (const { artifact } of artifacts) apps.push({ app: app.name, artifact, status: 'emit-failed' });
68
- const snapshot = `contracts/${app.name}/${SNAPSHOT_OF.graphql}`;
69
- findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `contracts/${app.name} cannot be verified: \`starci app emit\` failed for ${app.name}: ${errorText(error)}`, { app: app.name, drift: 'emit-failed' }));
97
+ emitFailure(app, artifacts, error, apps, findings);
70
98
  continue;
71
99
  }
72
100
  for (const { artifact, what } of artifacts) {
73
- const snapshot = `contracts/${app.name}/${artifact}`;
74
- const committed = tracked.has(snapshot) ? contractHash(path.join(repoRoot, snapshot)) : null;
75
- if (!emitted.written.includes(snapshot)) {
76
- if (committed === null) {
77
- if (artifact === SNAPSHOT_OF.graphql) apps.push({ app: app.name, artifact, status: 'none' });
78
- continue;
79
- }
80
- apps.push({ app: app.name, artifact, status: 'left-behind' });
81
- findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `${snapshot} is committed but ${app.name} ${what}; delete it`, { app: app.name, drift: 'left-behind' }));
82
- continue;
83
- }
84
- const fresh = contractHash(path.join(scratch, snapshot));
85
- if (committed === null) {
86
- // the uncommitted snapshot itself is reported by contractFindings above; here it only counts as not judged
87
- apps.push({ app: app.name, artifact, status: 'not-committed' });
88
- } else if (committed === fresh) {
89
- apps.push({ app: app.name, artifact, status: 'fresh' });
90
- } else {
91
- apps.push({ app: app.name, artifact, status: 'stale' });
92
- findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `${snapshot} (${committed.slice(0, 12)}) differs from what ${app.name} emits now (${String(fresh).slice(0, 12)}); run \`npm run contract:emit\` and commit the result`, { app: app.name, drift: 'stale' }));
93
- }
101
+ artifactFindings({ repoRoot, tracked, scratch, app, artifact, what, emitted, apps, findings });
94
102
  }
95
103
  }
96
104
  } finally {
@@ -3,7 +3,7 @@
3
3
  import { sameText } from '../../lib/same-text.mjs';
4
4
  import { found, readText } from './read.mjs';
5
5
  import { DB_CONFIG_POLICY, DB_TYPES_DRIFT, TYPES_FILE } from './database-constants.mjs';
6
- const ENV_REF = /^env\([A-Za-z_][A-Za-z0-9_]*\)$/;
6
+ const ENV_REF = /^env\([A-Za-z_]\w*\)$/;
7
7
  const PUBLIC_KEYS = new Set(['anon_key', 'publishable_key', 'public_key']);
8
8
 
9
9
 
@@ -28,17 +28,69 @@ const isCredentialKey = (key) => {
28
28
  || (leaf.endsWith('_key') && !PUBLIC_KEYS.has(leaf));
29
29
  };
30
30
 
31
+ const escapeRegexPart = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
32
+
31
33
  const tomlLine = (text, key) => {
32
- const expression = new RegExp(`^\\s*["']?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']?\\s*=`, 'm');
34
+ const expression = new RegExp(String.raw`^\s*["']?${escapeRegexPart(key)}["']?\s*=`, 'm');
33
35
  const match = expression.exec(text);
34
36
  return match ? text.slice(0, match.index).split('\n').length : undefined;
35
37
  };
36
38
 
39
+ function credentialConfigFindings(file, text, toml) {
40
+ const findings = [];
41
+ for (const [at, key, value] of tomlEntries(toml)) {
42
+ if (!isCredentialKey(key)) continue;
43
+ const line = tomlLine(text, key);
44
+ if (typeof value !== 'string' || !ENV_REF.test(value)) {
45
+ const lineNote = line ? `:${line}` : '';
46
+ findings.push(found(DB_CONFIG_POLICY, file, `${file}${lineNote} ${at} holds a literal credential; a secret in config.toml is written env(NAME), never a value`, { ...(line ? { line } : {}), key: at }));
47
+ }
48
+ }
49
+ return findings;
50
+ }
51
+
52
+ // A finding for an auth setting of config.toml that differs from what hfs.json supabase.<field> declares; `shown` is the setting as read.
53
+ const settingFinding = (file, key, field, shown, declaredValue, note = '') => found(DB_CONFIG_POLICY, file, `${file} ${key} is ${shown}; hfs.json supabase.${field} declares ${declaredValue}${note}`, { key, declared: declaredValue });
54
+
55
+ const shownSetting = (value) => JSON.stringify(value ?? 'absent');
56
+
57
+ function signupFindings(file, auth, declared) {
58
+ const findings = [];
59
+ if (declared.enableSignup === undefined) return findings;
60
+ if (auth.enable_signup !== declared.enableSignup) findings.push(settingFinding(file, 'auth.enable_signup', 'enableSignup', shownSetting(auth.enable_signup), declared.enableSignup, ' - the invite posture is a checked fact, not a doc claim'));
61
+ if (auth.email?.enable_signup !== undefined && auth.email.enable_signup !== declared.enableSignup) {
62
+ findings.push(settingFinding(file, 'auth.email.enable_signup', 'enableSignup', JSON.stringify(auth.email.enable_signup), declared.enableSignup));
63
+ }
64
+ return findings;
65
+ }
66
+
67
+ function redirectUrlFindings(file, auth, declared) {
68
+ const findings = [];
69
+ if (declared.redirectUrls === undefined) return findings;
70
+ const configured = Array.isArray(auth.additional_redirect_urls) ? auth.additional_redirect_urls : [];
71
+ for (const url of configured) {
72
+ if (!declared.redirectUrls.includes(url)) findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.additional_redirect_urls holds ${url}, which hfs.json supabase.redirectUrls does not declare; the config states nothing the app did not declare`, { key: 'auth.additional_redirect_urls', url }));
73
+ }
74
+ for (const url of declared.redirectUrls) {
75
+ if (!configured.includes(url)) findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.additional_redirect_urls omits ${url}, which hfs.json supabase.redirectUrls declares; the config and declaration must carry the same redirect set`, { key: 'auth.additional_redirect_urls', url }));
76
+ }
77
+ return findings;
78
+ }
79
+
80
+ function declarationConfigFindings(file, auth, declared) {
81
+ const findings = [];
82
+ if (declared.jwtExpiry !== undefined && auth.jwt_expiry !== declared.jwtExpiry) findings.push(settingFinding(file, 'auth.jwt_expiry', 'jwtExpiry', shownSetting(auth.jwt_expiry), declared.jwtExpiry));
83
+ findings.push(...signupFindings(file, auth, declared));
84
+ if (declared.siteUrl !== undefined && auth.site_url !== declared.siteUrl) findings.push(settingFinding(file, 'auth.site_url', 'siteUrl', shownSetting(auth.site_url), declared.siteUrl));
85
+ findings.push(...redirectUrlFindings(file, auth, declared));
86
+ return findings;
87
+ }
88
+
37
89
  /** The parsed TOML of `text`, or null when it does not parse. */
38
90
  export async function parseToml(text) {
39
91
  try {
40
92
  const module = await import('smol-toml');
41
- return (module.default?.parse ?? module.parse)(text);
93
+ return module.parse(text);
42
94
  } catch {
43
95
  return null;
44
96
  }
@@ -46,14 +98,7 @@ export async function parseToml(text) {
46
98
 
47
99
  /** L08 policy findings in one already-parsed supabase/config.toml. */
48
100
  export function configFindings({ file, text, toml, supabase }) {
49
- const findings = [];
50
- for (const [at, key, value] of tomlEntries(toml)) {
51
- if (!isCredentialKey(key)) continue;
52
- const line = tomlLine(text, key);
53
- if (typeof value !== 'string' || !ENV_REF.test(value)) {
54
- findings.push(found(DB_CONFIG_POLICY, file, `${file}${line ? `:${line}` : ''} ${at} holds a literal credential; a secret in config.toml is written env(NAME), never a value`, { ...(line ? { line } : {}), key: at }));
55
- }
56
- }
101
+ const findings = credentialConfigFindings(file, text, toml);
57
102
  const auth = toml?.auth ?? {};
58
103
  if (auth.jwt_expiry !== undefined && (typeof auth.jwt_expiry !== 'number' || auth.jwt_expiry > 3600)) {
59
104
  findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.jwt_expiry is ${JSON.stringify(auth.jwt_expiry)}; the access token lives at most 3600 seconds`, { key: 'auth.jwt_expiry' }));
@@ -63,27 +108,7 @@ export function configFindings({ file, text, toml, supabase }) {
63
108
  enableSignup: supabase.enableSignup, jwtExpiry: supabase.jwtExpiry, siteUrl: supabase.siteUrl,
64
109
  redirectUrls: Array.isArray(supabase.redirectUrls) ? supabase.redirectUrls : undefined,
65
110
  };
66
- if (declared.jwtExpiry !== undefined && auth.jwt_expiry !== declared.jwtExpiry) {
67
- findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.jwt_expiry is ${JSON.stringify(auth.jwt_expiry ?? 'absent')}; hfs.json supabase.jwtExpiry declares ${declared.jwtExpiry}`, { key: 'auth.jwt_expiry', declared: declared.jwtExpiry }));
68
- }
69
- if (declared.enableSignup !== undefined) {
70
- if (auth.enable_signup !== declared.enableSignup) findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.enable_signup is ${JSON.stringify(auth.enable_signup ?? 'absent')}; hfs.json supabase.enableSignup declares ${declared.enableSignup} - the invite posture is a checked fact, not a doc claim`, { key: 'auth.enable_signup', declared: declared.enableSignup }));
71
- if (auth.email?.enable_signup !== undefined && auth.email.enable_signup !== declared.enableSignup) {
72
- findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.email.enable_signup is ${JSON.stringify(auth.email.enable_signup)}; hfs.json supabase.enableSignup declares ${declared.enableSignup}`, { key: 'auth.email.enable_signup', declared: declared.enableSignup }));
73
- }
74
- }
75
- if (declared.siteUrl !== undefined && auth.site_url !== declared.siteUrl) {
76
- findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.site_url is ${JSON.stringify(auth.site_url ?? 'absent')}; hfs.json supabase.siteUrl declares ${declared.siteUrl}`, { key: 'auth.site_url', declared: declared.siteUrl }));
77
- }
78
- if (declared.redirectUrls !== undefined) {
79
- const configured = Array.isArray(auth.additional_redirect_urls) ? auth.additional_redirect_urls : [];
80
- for (const url of configured) {
81
- if (!declared.redirectUrls.includes(url)) findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.additional_redirect_urls holds ${url}, which hfs.json supabase.redirectUrls does not declare; the config states nothing the app did not declare`, { key: 'auth.additional_redirect_urls', url }));
82
- }
83
- for (const url of declared.redirectUrls) {
84
- if (!configured.includes(url)) findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.additional_redirect_urls omits ${url}, which hfs.json supabase.redirectUrls declares; the config and declaration must carry the same redirect set`, { key: 'auth.additional_redirect_urls', url }));
85
- }
86
- }
111
+ findings.push(...declarationConfigFindings(file, auth, declared));
87
112
  return findings;
88
113
  }
89
114