@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
@@ -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
 
@@ -5,6 +5,7 @@ import { log } from '../../api/git/log.mjs';
5
5
  import { mergeBase } from '../../api/git/merge-base.mjs';
6
6
  import { show } from '../../api/git/show.mjs';
7
7
  import { withoutGitLocalEnv } from '../../lib/git.mjs';
8
+ import { eachInOrder, findInOrder } from '../../lib/in-order.mjs';
8
9
  import { sameText } from '../../lib/same-text.mjs';
9
10
  import { found, readText } from './read.mjs';
10
11
  import { DB_MIGRATION_SHAPE, MIGRATIONS_DIR } from './database-constants.mjs';
@@ -24,7 +25,9 @@ export function defaultGit(args, { cwd }) {
24
25
  const [verb, ...rest] = args;
25
26
  const env = withoutGitLocalEnv(process.env);
26
27
  if (verb === 'merge-base') { const sha = mergeBase(cwd, rest[0], rest[1]); return { ok: sha !== null, stdout: sha ?? '' }; }
27
- const call = verb === 'ls-tree' ? lsTree : verb === 'log' ? log : show;
28
+ let call = show;
29
+ if (verb === 'ls-tree') call = lsTree;
30
+ else if (verb === 'log') call = log;
28
31
  const result = call(rest, { cwd, env, maxBuffer: 64 * 1024 * 1024 });
29
32
  return { ok: !result.error && result.status === 0, stdout: result.stdout };
30
33
  }
@@ -33,11 +36,13 @@ const runGit = (git, repoRoot, args) => Promise.resolve(git(args, { cwd: repoRoo
33
36
  .then((result) => ({ ok: result?.ok === true, stdout: String(result?.stdout ?? '') }));
34
37
 
35
38
  async function baseShaOf(git, repoRoot, base) {
36
- for (const ref of base ? [base] : ['origin/main', 'main']) {
39
+ let found = null;
40
+ await findInOrder(base ? [base] : ['origin/main', 'main'], async (ref) => {
37
41
  const result = await runGit(git, repoRoot, ['merge-base', 'HEAD', ref]);
38
- if (result.ok && result.stdout.trim()) return { ref, sha: result.stdout.trim() };
39
- }
40
- return null;
42
+ if (result.ok && result.stdout.trim()) found = { ref, sha: result.stdout.trim() };
43
+ return found !== null;
44
+ });
45
+ return found;
41
46
  }
42
47
 
43
48
  async function baseMigrationNames(git, repoRoot, sha) {
@@ -48,7 +53,8 @@ async function baseMigrationNames(git, repoRoot, sha) {
48
53
  }
49
54
 
50
55
  async function baseFileText(git, repoRoot, sha, file) {
51
- const result = await runGit(git, repoRoot, ['show', `${sha}:${file}`]);
56
+ // `./` reads the path from the app root, which is a folder of a larger repository when the app is nested in one (an example of the runtime).
57
+ const result = await runGit(git, repoRoot, ['show', `${sha}:./${file}`]);
52
58
  return result.ok ? result.stdout : null;
53
59
  }
54
60
 
@@ -59,25 +65,18 @@ async function migrationTimeLimit(git, repoRoot, file, now) {
59
65
  return { at: now(), tracked: false };
60
66
  }
61
67
 
62
-
63
- /** L02 findings for names, stamps, ordering and immutability of supabase/migrations/*.sql. */
64
- export async function migrationShapeFindings({ repoRoot, migrations, git, base, now }) {
68
+ async function localMigrationFindings(local, repoRoot, git, now) {
65
69
  const findings = [];
66
- const prefix = `${MIGRATIONS_DIR}/`;
67
- const local = migrations.map((file) => {
68
- const name = file.startsWith(prefix) ? file.slice(prefix.length) : file;
69
- return { file, name, match: MIGRATION_NAME.exec(name) };
70
- });
71
70
  const stamps = new Map();
72
- for (const entry of local) {
71
+ await eachInOrder(local, async (entry) => {
73
72
  if (!entry.match) {
74
73
  findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is not named <ts14>_<kebab>.sql; a migration is created by \`npm run db:new\` (supabase migration new), never renamed by hand`, { expected: '<ts14>_<kebab>.sql' }));
75
- continue;
74
+ return;
76
75
  }
77
76
  const stamp = migrationStamp(entry.match[1]);
78
77
  if (stamp === null) {
79
78
  findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} carries ${entry.match[1]}, which is no valid UTC time; the stamp is the real creation time`, { stamp: entry.match[1] }));
80
- continue;
79
+ return;
81
80
  }
82
81
  const limit = await migrationTimeLimit(git, repoRoot, entry.file, now);
83
82
  if (stamp > limit.at) {
@@ -86,26 +85,48 @@ export async function migrationShapeFindings({ repoRoot, migrations, git, base,
86
85
  }
87
86
  if (stamps.has(stamp)) findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} shares stamp ${entry.match[1]} with ${stamps.get(stamp)}; stamps are strictly increasing`, { stamp: entry.match[1], other: stamps.get(stamp) }));
88
87
  stamps.set(stamp, entry.file);
88
+ });
89
+ return findings;
90
+ }
91
+
92
+ async function baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase, baseMax) {
93
+ if (!entry.match) return [];
94
+ const stamp = migrationStamp(entry.match[1]);
95
+ if (onBase.has(entry.name)) {
96
+ const committed = await baseFileText(git, repoRoot, baseSha.sha, entry.file);
97
+ const current = readText(repoRoot, entry.file);
98
+ if (!sameText(current, committed)) return [found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} differs from its content on ${baseSha.ref}; a migration on the base branch is immutable - write a new migration`, { base: baseSha.ref })];
99
+ } else if (baseMax !== null && stamp !== null && stamp <= baseMax) {
100
+ return [found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is stamped ${entry.match[1]}, not after every migration on ${baseSha.ref} (latest ${String(new Date(baseMax).toISOString())}); a new migration sorts after the base ones`, { stamp: entry.match[1], base: baseSha.ref })];
89
101
  }
102
+ return [];
103
+ }
104
+
105
+ function removedMigrationFindings(local, baseNames, baseSha) {
106
+ const findings = [];
107
+ for (const name of baseNames) {
108
+ if (!local.some((entry) => entry.name === name)) findings.push(found(DB_MIGRATION_SHAPE, `${MIGRATIONS_DIR}/${name}`, `${MIGRATIONS_DIR}/${name} exists on ${baseSha.ref} but is gone here; a migration on the base branch is immutable - restore it`, { base: baseSha.ref }));
109
+ }
110
+ return findings;
111
+ }
112
+
113
+ /** L02 findings for names, stamps, ordering and immutability of supabase/migrations/*.sql. */
114
+ export async function migrationShapeFindings({ repoRoot, migrations, git, base, now }) {
115
+ const prefix = `${MIGRATIONS_DIR}/`;
116
+ const local = migrations.map((file) => {
117
+ const name = file.startsWith(prefix) ? file.slice(prefix.length) : file;
118
+ return { file, name, match: MIGRATION_NAME.exec(name) };
119
+ });
120
+ const findings = await localMigrationFindings(local, repoRoot, git, now);
90
121
  const baseSha = await baseShaOf(git, repoRoot, base);
91
122
  if (!baseSha) return findings;
92
123
  const baseNames = await baseMigrationNames(git, repoRoot, baseSha.sha);
93
124
  const onBase = new Set(baseNames);
94
125
  const baseStamps = baseNames.map((name) => MIGRATION_NAME.exec(name)?.[1]).filter(Boolean).map(migrationStamp).filter((stamp) => stamp !== null);
95
126
  const baseMax = baseStamps.length ? Math.max(...baseStamps) : null;
96
- for (const entry of local) {
97
- if (!entry.match) continue;
98
- const stamp = migrationStamp(entry.match[1]);
99
- if (onBase.has(entry.name)) {
100
- const committed = await baseFileText(git, repoRoot, baseSha.sha, entry.file);
101
- const current = readText(repoRoot, entry.file);
102
- if (!sameText(current, committed)) findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} differs from its content on ${baseSha.ref}; a migration on the base branch is immutable - write a new migration`, { base: baseSha.ref }));
103
- } else if (baseMax !== null && stamp !== null && stamp <= baseMax) {
104
- findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is stamped ${entry.match[1]}, not after every migration on ${baseSha.ref} (latest ${String(new Date(baseMax).toISOString())}); a new migration sorts after the base ones`, { stamp: entry.match[1], base: baseSha.ref }));
105
- }
106
- }
107
- for (const name of baseNames) {
108
- if (!local.some((entry) => entry.name === name)) findings.push(found(DB_MIGRATION_SHAPE, `${MIGRATIONS_DIR}/${name}`, `${MIGRATIONS_DIR}/${name} exists on ${baseSha.ref} but is gone here; a migration on the base branch is immutable - restore it`, { base: baseSha.ref }));
109
- }
127
+ await eachInOrder(local, async (entry) => {
128
+ findings.push(...await baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase, baseMax));
129
+ });
130
+ findings.push(...removedMigrationFindings(local, baseNames, baseSha));
110
131
  return findings;
111
132
  }
@@ -1,6 +1,7 @@
1
1
  // PL/pgSQL inspection for DB_DYNAMIC_DDL and DB_DEFINER_SAFE. Function and DO bodies are parsed by libpg-query;
2
2
  // dynamic query expressions are then parsed as PostgreSQL expressions instead of matched as raw migration text.
3
3
  import { found } from './read.mjs';
4
+ import { eachInOrder } from '../../lib/in-order.mjs';
4
5
  import { parsePlpgsqlBody, parseSql } from '../sql/pg-parse.mjs';
5
6
  import { DB_DEFINER_SAFE, DB_DYNAMIC_DDL } from './database-constants.mjs';
6
7
 
@@ -12,36 +13,36 @@ const nameOf = (list, defaultSchema) => {
12
13
 
13
14
  const functionOption = (node, key) => (node.options ?? []).filter((option) => option?.DefElem?.defname === key).map((option) => option.DefElem);
14
15
  const securityDefiner = (node) => functionOption(node, 'security').some((option) => option.arg?.Boolean?.boolval === true || option.arg?.Integer?.ival === 1);
15
- const functionLanguage = (node) => functionOption(node, 'language').map((option) => option.arg?.String?.sval).filter(Boolean).at(-1) ?? 'sql';
16
+ const functionLanguage = (node) => functionOption(node, 'language').map((option) => option.arg?.String?.sval).findLast(Boolean) ?? 'sql';
16
17
  const functionBody = (node) => functionOption(node, 'as').flatMap((option) => (option.arg?.List?.items ?? []).map((item) => item?.String?.sval ?? '')).join('') || null;
17
18
  const functionReturnKind = (node) => {
18
- const name = node.returnType?.names?.map(sval).filter(Boolean).at(-1);
19
+ const name = node.returnType?.names?.map(sval).findLast(Boolean);
19
20
  if (name === 'void') return 'void';
20
21
  if (name === 'trigger' || name === 'event_trigger') return 'trigger';
21
22
  return node.returnType?.setof ? 'setof' : 'scalar';
22
23
  };
23
24
 
25
+ /** The statement kinds that run a dynamic query, with the field holding its query expression. */
26
+ const DYNAMIC_QUERY_FIELDS = new Map([['PLpgSQL_stmt_dynexecute', 'query'], ['PLpgSQL_stmt_dynfors', 'query'], ['PLpgSQL_stmt_open', 'dynquery']]);
27
+
28
+ function collectDynamicQueries(value, out) {
29
+ if (!value || typeof value !== 'object') return;
30
+ if (Array.isArray(value)) {
31
+ for (const item of value) collectDynamicQueries(item, out);
32
+ return;
33
+ }
34
+ for (const [key, child] of Object.entries(value)) {
35
+ const field = DYNAMIC_QUERY_FIELDS.get(key);
36
+ const text = field === undefined ? undefined : child?.[field]?.PLpgSQL_expr?.query;
37
+ if (text !== undefined) out.push({ text, lineno: child.lineno });
38
+ collectDynamicQueries(child, out);
39
+ }
40
+ }
41
+
24
42
  /** Every dynamic query expression in one parsed PL/pgSQL function. */
25
43
  function dynamicQueries(root) {
26
44
  const out = [];
27
- const visit = (value) => {
28
- if (!value || typeof value !== 'object') return;
29
- if (Array.isArray(value)) {
30
- for (const item of value) visit(item);
31
- return;
32
- }
33
- for (const [key, child] of Object.entries(value)) {
34
- if (key === 'PLpgSQL_stmt_dynexecute' || key === 'PLpgSQL_stmt_dynfors') {
35
- const text = child?.query?.PLpgSQL_expr?.query;
36
- if (text !== undefined) out.push({ text, lineno: child.lineno });
37
- } else if (key === 'PLpgSQL_stmt_open') {
38
- const text = child?.dynquery?.PLpgSQL_expr?.query;
39
- if (text !== undefined) out.push({ text, lineno: child.lineno });
40
- }
41
- visit(child);
42
- }
43
- };
44
- visit(root);
45
+ collectDynamicQueries(root, out);
45
46
  return out;
46
47
  }
47
48
 
@@ -80,6 +81,33 @@ function formatSpecifiers(template) {
80
81
  return template.slice(cursor).includes('%') ? null : specs;
81
82
  }
82
83
 
84
+ /** The text one format() conversion renders to; an argument that is no string literal marks the render incomplete. */
85
+ function substituteSpecifier(spec, args, state) {
86
+ if (spec.type === '%') return '%';
87
+ const argumentIndex = spec.position ?? state.sequential;
88
+ if (spec.position === null) state.sequential += 1;
89
+ if (spec.type === 'I') return 'hfs_identifier';
90
+ if (spec.type === 'L') return "'hfs_literal'";
91
+ const value = stringValue(args[argumentIndex + 1]);
92
+ if (value !== undefined) return value;
93
+ state.complete = false;
94
+ return 'hfs_dynamic';
95
+ }
96
+
97
+ function renderFormat(args) {
98
+ const template = stringValue(args[0]);
99
+ if (template === undefined) return { text: 'hfs_dynamic', complete: false };
100
+ const specs = formatSpecifiers(template);
101
+ if (specs === null) return { text: template, complete: false };
102
+ const state = { sequential: 0, specIndex: 0, complete: true };
103
+ const text = template.replace(FORMAT_SPECIFIER, () => {
104
+ const spec = specs[state.specIndex];
105
+ state.specIndex += 1;
106
+ return substituteSpecifier(spec, args, state);
107
+ });
108
+ return { text, complete: state.complete };
109
+ }
110
+
83
111
  /** Render enough of a dynamic expression to let the PostgreSQL parser identify its generated statement kind. */
84
112
  function renderExpression(node) {
85
113
  const literal = stringValue(node);
@@ -90,31 +118,7 @@ function renderExpression(node) {
90
118
  const right = renderExpression(node.A_Expr.rexpr);
91
119
  return { text: `${left.text}${right.text}`, complete: left.complete && right.complete };
92
120
  }
93
- if (formatName(node)) {
94
- const args = node.FuncCall.args ?? [];
95
- const template = stringValue(args[0]);
96
- if (template === undefined) return { text: 'hfs_dynamic', complete: false };
97
- const specs = formatSpecifiers(template);
98
- if (specs === null) return { text: template, complete: false };
99
- let sequential = 0;
100
- let specIndex = 0;
101
- let complete = true;
102
- const text = template.replace(FORMAT_SPECIFIER, () => {
103
- const spec = specs[specIndex];
104
- specIndex += 1;
105
- if (spec.type === '%') return '%';
106
- const argumentIndex = spec.position ?? sequential;
107
- if (spec.position === null) sequential += 1;
108
- const argument = args[argumentIndex + 1];
109
- if (spec.type === 'I') return 'hfs_identifier';
110
- if (spec.type === 'L') return "'hfs_literal'";
111
- const value = stringValue(argument);
112
- if (value !== undefined) return value;
113
- complete = false;
114
- return 'hfs_dynamic';
115
- });
116
- return { text, complete };
117
- }
121
+ if (formatName(node)) return renderFormat(node.FuncCall.args ?? []);
118
122
  return { text: 'hfs_dynamic', complete: false };
119
123
  }
120
124
 
@@ -142,15 +146,16 @@ async function dynamicQueryAnalysis(text) {
142
146
 
143
147
  async function dynamicDdlFindings(file, queries, where, baseLine) {
144
148
  const findings = [];
145
- for (const { text, lineno } of queries) {
149
+ await eachInOrder(queries, async ({ text, lineno }) => {
146
150
  const line = lineno ? baseLine + lineno - 1 : undefined;
151
+ const lineNote = line ? `${file}:${line}` : file;
147
152
  const analysis = await dynamicQueryAnalysis(text);
148
153
  if (analysis.buildsDdl) {
149
- findings.push(found(DB_DYNAMIC_DDL, file, `${file}${line ? `:${line}` : ''} ${where} builds DDL dynamically (\`${String(text).slice(0, 120)}\`); write the create/alter/drop or grant/revoke out as statements so the parser sees every one`, { ...(line ? { line } : {}), query: text }));
154
+ findings.push(found(DB_DYNAMIC_DDL, file, `${lineNote} ${where} builds DDL dynamically (\`${String(text).slice(0, 120)}\`); write the create/alter/drop or grant/revoke out as statements so the parser sees every one`, { ...(line ? { line } : {}), query: text }));
150
155
  } else if (analysis.opaque) {
151
- findings.push(found(DB_DYNAMIC_DDL, file, `${file}${line ? `:${line}` : ''} ${where} runs dynamic SQL the pass cannot read (\`${String(text).slice(0, 120)}\`); dynamic DDL is refused and a query built from an opaque value cannot be judged`, { ...(line ? { line } : {}), query: text }));
156
+ findings.push(found(DB_DYNAMIC_DDL, file, `${lineNote} ${where} runs dynamic SQL the pass cannot read (\`${String(text).slice(0, 120)}\`); dynamic DDL is refused and a query built from an opaque value cannot be judged`, { ...(line ? { line } : {}), query: text }));
152
157
  }
153
- }
158
+ });
154
159
  return findings;
155
160
  }
156
161
 
@@ -163,17 +168,18 @@ function safeDefinerExpression(node) {
163
168
  const template = stringValue(node.FuncCall.args?.[0]);
164
169
  if (template === undefined) return false;
165
170
  const specs = formatSpecifiers(template);
166
- return specs !== null && specs.every((spec) => ['I', 'L', '%'].includes(spec.type));
171
+ return specs?.every((spec) => ['I', 'L', '%'].includes(spec.type)) ?? false;
167
172
  }
168
173
 
169
174
  async function definerDynamicFindings(file, fn, queries, baseLine) {
170
175
  const findings = [];
171
- for (const { text, lineno } of queries) {
176
+ await eachInOrder(queries, async ({ text, lineno }) => {
172
177
  const line = lineno ? baseLine + lineno - 1 : undefined;
178
+ const lineNote = line ? `${file}:${line}` : file;
173
179
  const expression = await expressionOf(text);
174
- if (expression !== null && safeDefinerExpression(expression)) continue;
175
- findings.push(found(DB_DEFINER_SAFE, file, `${file}${line ? `:${line}` : ''} security definer function ${fn.name} builds dynamic SQL without quoting every parameter through format() %I or %L (\`${String(text).slice(0, 120)}\`)`, { ...(line ? { line } : {}), function: fn.name, query: text }));
176
- }
180
+ if (expression !== null && safeDefinerExpression(expression)) return;
181
+ findings.push(found(DB_DEFINER_SAFE, file, `${lineNote} security definer function ${fn.name} builds dynamic SQL without quoting every parameter through format() %I or %L (\`${String(text).slice(0, 120)}\`)`, { ...(line ? { line } : {}), function: fn.name, query: text }));
182
+ });
177
183
  return findings;
178
184
  }
179
185
 
@@ -188,46 +194,55 @@ function revokedRolesFor(fn, grants) {
188
194
  return roles;
189
195
  }
190
196
 
191
- /** All PL/pgSQL-backed L04/L06 findings for one migration's extracted facts. */
192
- export async function plpgsqlFindings(file, facts, exposed) {
193
- const findings = [];
194
- for (const block of facts.doBlocks) {
195
- if (block.body === undefined || block.language !== 'plpgsql') {
196
- findings.push(found(DB_DYNAMIC_DDL, file, `${file}:${block.line} DO block in ${block.language} cannot be inspected; a DO block is plpgsql so a static pass sees its statements`, { line: block.line }));
197
- continue;
198
- }
199
- const ast = await plpgsqlOf(block.body);
200
- if (ast === null) {
201
- findings.push(found(DB_DYNAMIC_DDL, file, `${file}:${block.line} DO block body is not valid plpgsql; the pass must see every statement a migration runs`, { line: block.line }));
202
- continue;
203
- }
204
- findings.push(...await dynamicDdlFindings(file, dynamicQueries(ast), 'DO block', block.line));
197
+ async function doBlockFindings(file, block) {
198
+ if (block.body === undefined || block.language !== 'plpgsql') {
199
+ return [found(DB_DYNAMIC_DDL, file, `${file}:${block.line} DO block in ${block.language} cannot be inspected; a DO block is plpgsql so a static pass sees its statements`, { line: block.line })];
205
200
  }
201
+ const ast = await plpgsqlOf(block.body);
202
+ if (ast === null) return [found(DB_DYNAMIC_DDL, file, `${file}:${block.line} DO block body is not valid plpgsql; the pass must see every statement a migration runs`, { line: block.line })];
203
+ return dynamicDdlFindings(file, dynamicQueries(ast), 'DO block', block.line);
204
+ }
206
205
 
207
- for (const fn of facts.functions) {
208
- const definer = securityDefiner(fn.node);
209
- if (definer) {
210
- const pathSet = functionOption(fn.node, 'set').some((option) => facts.isSearchPathSet(option))
211
- || facts.alterFunctions.some((alter) => alter.name === fn.name && alter.setsPath && alter.index > fn.index);
212
- if (!pathSet) findings.push(found(DB_DEFINER_SAFE, file, `${file}:${fn.line} security definer function ${fn.name} sets no search_path; add \`set search_path = ''\` (or a pinned schema list) on the create, or an alter function in the same migration`, { line: fn.line, function: fn.name }));
213
- if (exposed.has(fn.schema)) {
214
- const revoked = revokedRolesFor(fn, facts.grants);
215
- if (!revoked.has('public') || !revoked.has('anon')) findings.push(found(DB_DEFINER_SAFE, file, `${file}:${fn.line} security definer function ${fn.name} lives in the exposed schema ${fn.schema}; keep it in a non-exposed schema or revoke execute from public and anon in the same migration`, { line: fn.line, function: fn.name }));
216
- }
217
- if (!fn.node.is_procedure && !fn.node.returnType) findings.push(found(DB_DEFINER_SAFE, file, `${file}:${fn.line} security definer function ${fn.name} declares no return type`, { line: fn.line, function: fn.name }));
218
- }
206
+ function definerDeclarationFindings(file, fn, facts, exposed, definer) {
207
+ if (!definer) return [];
208
+ const findings = [];
209
+ const pathSet = functionOption(fn.node, 'set').some((option) => facts.isSearchPathSet(option))
210
+ || facts.alterFunctions.some((alter) => alter.name === fn.name && alter.setsPath && alter.index > fn.index);
211
+ if (!pathSet) findings.push(found(DB_DEFINER_SAFE, file, `${file}:${fn.line} security definer function ${fn.name} sets no search_path; add \`set search_path = ''\` (or a pinned schema list) on the create, or an alter function in the same migration`, { line: fn.line, function: fn.name }));
212
+ if (exposed.has(fn.schema)) {
213
+ const revoked = revokedRolesFor(fn, facts.grants);
214
+ if (!revoked.has('public') || !revoked.has('anon')) findings.push(found(DB_DEFINER_SAFE, file, `${file}:${fn.line} security definer function ${fn.name} lives in the exposed schema ${fn.schema}; keep it in a non-exposed schema or revoke execute from public and anon in the same migration`, { line: fn.line, function: fn.name }));
215
+ }
216
+ if (!fn.node.is_procedure && !fn.node.returnType) findings.push(found(DB_DEFINER_SAFE, file, `${file}:${fn.line} security definer function ${fn.name} declares no return type`, { line: fn.line, function: fn.name }));
217
+ return findings;
218
+ }
219
219
 
220
- if (functionLanguage(fn.node) !== 'plpgsql') continue;
221
- const body = functionBody(fn.node);
222
- if (body === null) continue;
223
- const ast = await plpgsqlOf(body, fn.node.parameters, functionReturnKind(fn.node));
224
- if (ast === null) {
225
- if (definer) findings.push(found(DB_DEFINER_SAFE, file, `${file}:${fn.line} security definer function ${fn.name} has a body the pass cannot read; a definer body must be plain plpgsql`, { line: fn.line, function: fn.name }));
226
- continue;
227
- }
228
- const queries = dynamicQueries(ast);
229
- findings.push(...await dynamicDdlFindings(file, queries, `function ${fn.name}`, fn.line));
230
- if (definer) findings.push(...await definerDynamicFindings(file, fn, queries, fn.line));
220
+ async function functionBodyFindings(file, fn, definer) {
221
+ if (functionLanguage(fn.node) !== 'plpgsql') return [];
222
+ const body = functionBody(fn.node);
223
+ if (body === null) return [];
224
+ const ast = await plpgsqlOf(body, fn.node.parameters, functionReturnKind(fn.node));
225
+ if (ast === null) {
226
+ if (definer) return [found(DB_DEFINER_SAFE, file, `${file}:${fn.line} security definer function ${fn.name} has a body the pass cannot read; a definer body must be plain plpgsql`, { line: fn.line, function: fn.name })];
227
+ return [];
231
228
  }
229
+ const queries = dynamicQueries(ast);
230
+ const findings = await dynamicDdlFindings(file, queries, `function ${fn.name}`, fn.line);
231
+ if (definer) findings.push(...await definerDynamicFindings(file, fn, queries, fn.line));
232
+ return findings;
233
+ }
234
+
235
+ async function functionFindings(file, fn, facts, exposed) {
236
+ const definer = securityDefiner(fn.node);
237
+ const findings = definerDeclarationFindings(file, fn, facts, exposed, definer);
238
+ findings.push(...await functionBodyFindings(file, fn, definer));
239
+ return findings;
240
+ }
241
+
242
+ /** All PL/pgSQL-backed L04/L06 findings for one migration's extracted facts. */
243
+ export async function plpgsqlFindings(file, facts, exposed) {
244
+ const findings = [];
245
+ await eachInOrder(facts.doBlocks, async (block) => { findings.push(...await doBlockFindings(file, block)); });
246
+ await eachInOrder(facts.functions, async (fn) => { findings.push(...await functionFindings(file, fn, facts, exposed)); });
232
247
  return findings;
233
248
  }