@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
@@ -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
  }