@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
@@ -11,6 +11,102 @@ const isSpace = char => /\s/u.test(char);
11
11
  const isWordStart = char => /[\p{L}_]/u.test(char);
12
12
  const isWordPart = char => /[\p{L}\p{N}_$]/u.test(char);
13
13
 
14
+ const commentEnd = (text, index) => {
15
+ if (text[index] === '-' && text[index + 1] === '-') {
16
+ while (index < text.length && text[index] !== '\n') index += 1;
17
+ return index;
18
+ }
19
+ if (text[index] !== '/' || text[index + 1] !== '*') return null;
20
+ const end = text.indexOf('*/', index + 2);
21
+ return end === -1 ? text.length : end + 2;
22
+ };
23
+
24
+ const singleQuotedEnd = (text, index, push) => {
25
+ if (text[index] !== "'") return null;
26
+ index += 1;
27
+ while (index < text.length) {
28
+ if (text[index] === "'" && text[index + 1] === "'") { index += 2; continue; }
29
+ if (text[index] === "'") break;
30
+ index += 1;
31
+ }
32
+ push('string', '');
33
+ return index + 1;
34
+ };
35
+
36
+ const quotedIdentifierEnd = (text, index, push) => {
37
+ if (text[index] !== '"') return null;
38
+ let value = '';
39
+ index += 1;
40
+ while (index < text.length) {
41
+ if (text[index] === '"' && text[index + 1] === '"') { value += '"'; index += 2; continue; }
42
+ if (text[index] === '"') break;
43
+ value += text[index];
44
+ index += 1;
45
+ }
46
+ push('ident', value);
47
+ return index + 1;
48
+ };
49
+
50
+ const dollarTokenEnd = (text, index, push) => {
51
+ if (text[index] !== '$') return null;
52
+ const param = /^\$(\d+)/u.exec(text.slice(index, index + 12));
53
+ if (param) { push('param', param[0]); return index + param[0].length; }
54
+ const tag = /^\$([\p{L}_][\p{L}\p{N}_]*)?\$/u.exec(text.slice(index));
55
+ if (!tag) return null;
56
+ const end = text.indexOf(tag[0], index + tag[0].length);
57
+ push('string', '');
58
+ return end === -1 ? text.length : end + tag[0].length;
59
+ };
60
+
61
+ const numberEnd = (text, index, push) => {
62
+ if (!/\d/u.test(text[index])) return null;
63
+ let end = index;
64
+ while (end < text.length && /[\d.]/u.test(text[end])) end += 1;
65
+ push('number', text.slice(index, end));
66
+ return end;
67
+ };
68
+
69
+ const wordEnd = (text, index, push) => {
70
+ if (!isWordStart(text[index])) return null;
71
+ let end = index;
72
+ while (end < text.length && isWordPart(text[end])) end += 1;
73
+ const word = text.slice(index, end);
74
+ if (text[end] !== "'" || !/^[EeBbXxNn]$/u.test(word)) { push('word', word); return end; }
75
+ const escaped = /^[Ee]$/u.test(word);
76
+ end += 1;
77
+ while (end < text.length) {
78
+ if (escaped && text[end] === '\\') { end += 2; continue; }
79
+ if (text[end] === "'" && text[end + 1] === "'") { end += 2; continue; }
80
+ if (text[end] === "'") break;
81
+ end += 1;
82
+ }
83
+ push('string', '');
84
+ return end + 1;
85
+ };
86
+
87
+ const punctuationEnd = (text, index, push) => {
88
+ const two = text.slice(index, index + 2);
89
+ if (['::', '<=', '>=', '<>', '!=', '||', '->'].includes(two)) { push('punct', two); return index + 2; }
90
+ push('punct', text[index]);
91
+ return index + 1;
92
+ };
93
+
94
+ function consumeToken(text, index, push) {
95
+ const comment = commentEnd(text, index);
96
+ if (comment !== null) return comment;
97
+ if (text[index] === HOLE) { push('hole', HOLE); return index + 1; }
98
+ const singleQuoted = singleQuotedEnd(text, index, push);
99
+ if (singleQuoted !== null) return singleQuoted;
100
+ const identifier = quotedIdentifierEnd(text, index, push);
101
+ if (identifier !== null) return identifier;
102
+ const dollarToken = dollarTokenEnd(text, index, push);
103
+ if (dollarToken !== null) return dollarToken;
104
+ const number = numberEnd(text, index, push);
105
+ if (number !== null) return number;
106
+ const word = wordEnd(text, index, push);
107
+ return word ?? punctuationEnd(text, index, push);
108
+ }
109
+
14
110
  /** Tokens: {t: 'word'|'ident'|'string'|'number'|'param'|'hole'|'punct', v, up?} (`up` is the uppercase word). */
15
111
  export function tokenizeSql(text) {
16
112
  const tokens = [];
@@ -19,80 +115,7 @@ export function tokenizeSql(text) {
19
115
  while (i < text.length) {
20
116
  const char = text[i];
21
117
  if (isSpace(char)) { i += 1; continue; }
22
- if (char === '-' && text[i + 1] === '-') { while (i < text.length && text[i] !== '\n') i += 1; continue; }
23
- if (char === '/' && text[i + 1] === '*') {
24
- const end = text.indexOf('*/', i + 2);
25
- i = end === -1 ? text.length : end + 2;
26
- continue;
27
- }
28
- if (char === HOLE) { push('hole', HOLE); i += 1; continue; }
29
- if (char === "'") {
30
- i += 1;
31
- while (i < text.length) {
32
- if (text[i] === "'" && text[i + 1] === "'") { i += 2; continue; }
33
- if (text[i] === "'") break;
34
- i += 1;
35
- }
36
- i += 1;
37
- push('string', '');
38
- continue;
39
- }
40
- if (char === '"') {
41
- let value = '';
42
- i += 1;
43
- while (i < text.length) {
44
- if (text[i] === '"' && text[i + 1] === '"') { value += '"'; i += 2; continue; }
45
- if (text[i] === '"') break;
46
- value += text[i];
47
- i += 1;
48
- }
49
- i += 1;
50
- push('ident', value);
51
- continue;
52
- }
53
- if (char === '$') {
54
- const param = /^\$(\d+)/u.exec(text.slice(i, i + 12));
55
- if (param) { push('param', param[0]); i += param[0].length; continue; }
56
- const tag = /^\$([\p{L}_][\p{L}\p{N}_]*)?\$/u.exec(text.slice(i));
57
- if (tag) {
58
- const end = text.indexOf(tag[0], i + tag[0].length);
59
- i = end === -1 ? text.length : end + tag[0].length;
60
- push('string', '');
61
- continue;
62
- }
63
- }
64
- if (/\d/u.test(char)) {
65
- let j = i;
66
- while (j < text.length && /[\d.]/u.test(text[j])) j += 1;
67
- push('number', text.slice(i, j));
68
- i = j;
69
- continue;
70
- }
71
- if (isWordStart(char)) {
72
- let j = i;
73
- while (j < text.length && isWordPart(text[j])) j += 1;
74
- const word = text.slice(i, j);
75
- if (text[j] === "'" && /^[EeBbXxNn]$/u.test(word)) {
76
- const escaped = /^[Ee]$/u.test(word);
77
- j += 1;
78
- while (j < text.length) {
79
- if (escaped && text[j] === '\\') { j += 2; continue; }
80
- if (text[j] === "'" && text[j + 1] === "'") { j += 2; continue; }
81
- if (text[j] === "'") break;
82
- j += 1;
83
- }
84
- i = j + 1;
85
- push('string', '');
86
- continue;
87
- }
88
- push('word', word);
89
- i = j;
90
- continue;
91
- }
92
- const two = text.slice(i, i + 2);
93
- if (['::', '<=', '>=', '<>', '!=', '||', '->'].includes(two)) { push('punct', two); i += 2; continue; }
94
- push('punct', char);
95
- i += 1;
118
+ i = consumeToken(text, i, push);
96
119
  }
97
120
  return tokens;
98
121
  }
@@ -114,7 +137,7 @@ const isWord = (token, up) => token?.t === 'word' && token.up === up;
114
137
  * The table reference at `start`: {parts, table, alias, next}, {dynamic: true}, or null (a subquery, a function, nothing).
115
138
  * `next` is the index after the reference and its alias.
116
139
  */
117
- function tableRef(tokens, start, { columns = false } = {}) {
140
+ function tableNameAt(tokens, start) {
118
141
  let i = start;
119
142
  while (isWord(tokens[i], 'ONLY') || isWord(tokens[i], 'LATERAL')) i += 1;
120
143
  const first = tokens[i];
@@ -123,11 +146,36 @@ function tableRef(tokens, start, { columns = false } = {}) {
123
146
  const parts = [nameOf(first)];
124
147
  i += 1;
125
148
  while (tokens[i]?.v === '.' && isName(tokens[i + 1])) { parts.push(nameOf(tokens[i + 1])); i += 2; }
149
+ return { parts, next: i };
150
+ }
151
+
152
+ function tableAliasAt(tokens, index) {
153
+ if (isWord(tokens[index], 'AS') && isName(tokens[index + 1])) return { alias: nameOf(tokens[index + 1]), next: index + 2 };
154
+ if (isName(tokens[index]) && !(tokens[index].t === 'word' && CLAUSE_WORDS.has(tokens[index].up))) return { alias: nameOf(tokens[index]), next: index + 1 };
155
+ return { alias: null, next: index };
156
+ }
157
+
158
+ function columnListEnd(tokens, start) {
159
+ let i = start;
160
+ let depth = 0;
161
+ do {
162
+ if (tokens[i].v === '(') depth += 1;
163
+ else if (tokens[i].v === ')') depth -= 1;
164
+ i += 1;
165
+ } while (i < tokens.length && depth > 0);
166
+ return i;
167
+ }
168
+
169
+ function tableRef(tokens, start, { columns = false } = {}) {
170
+ const reference = tableNameAt(tokens, start);
171
+ if (!reference) return null;
172
+ if (reference.dynamic) return reference;
173
+ const { parts } = reference;
174
+ let i = reference.next;
126
175
  if (tokens[i]?.v === '(' && !columns) return null;
127
- let alias = null;
128
- if (isWord(tokens[i], 'AS') && isName(tokens[i + 1])) { alias = nameOf(tokens[i + 1]); i += 2; }
129
- else if (isName(tokens[i]) && !(tokens[i].t === 'word' && CLAUSE_WORDS.has(tokens[i].up))) { alias = nameOf(tokens[i]); i += 1; }
130
- if (alias && tokens[i]?.v === '(') { let depth = 0; do { if (tokens[i].v === '(') depth += 1; else if (tokens[i].v === ')') depth -= 1; i += 1; } while (i < tokens.length && depth > 0); }
176
+ const { alias, next } = tableAliasAt(tokens, i);
177
+ i = next;
178
+ if (alias && tokens[i]?.v === '(') i = columnListEnd(tokens, i);
131
179
  return { parts, table: parts.at(-1), schema: parts.length > 1 ? parts.at(-2) : null, alias, next: i };
132
180
  }
133
181
 
@@ -138,26 +186,39 @@ function statementsOf(tokens) {
138
186
  for (const token of tokens) {
139
187
  if (token.t === 'punct' && token.v === '(') depth += 1;
140
188
  if (token.t === 'punct' && token.v === ')') depth -= 1;
141
- if (token.t === 'punct' && token.v === ';' && depth <= 0) { if (current.length) statements.push(current); current = []; continue; }
189
+ if (token.t === 'punct' && token.v === ';' && depth <= 0) { if (current.length) { statements.push(current); } current = []; continue; }
142
190
  current.push(token);
143
191
  }
144
192
  if (current.length) statements.push(current);
145
193
  return statements;
146
194
  }
147
195
 
196
+ function cteNameBefore(tokens, index) {
197
+ let afterAs = index + 1;
198
+ if (isWord(tokens[afterAs], 'NOT')) afterAs += 1;
199
+ if (isWord(tokens[afterAs], 'MATERIALIZED')) afterAs += 1;
200
+ if (tokens[afterAs]?.v !== '(') return null;
201
+ let nameIndex = index - 1;
202
+ if (tokens[nameIndex]?.v === ')') {
203
+ let depth = 0;
204
+ do {
205
+ if (tokens[nameIndex].v === ')') depth += 1;
206
+ else if (tokens[nameIndex].v === '(') depth -= 1;
207
+ nameIndex -= 1;
208
+ } while (nameIndex >= 0 && depth > 0);
209
+ }
210
+ const before = tokens[nameIndex - 1];
211
+ if (isName(tokens[nameIndex]) && (isWord(before, 'WITH') || isWord(before, 'RECURSIVE') || before?.v === ',')) return nameOf(tokens[nameIndex]);
212
+ return null;
213
+ }
214
+
148
215
  /** The CTE names a statement declares: `name [(cols)] AS [NOT] [MATERIALIZED] (`. */
149
216
  function cteNames(tokens) {
150
217
  const names = new Set();
151
218
  for (let i = 1; i < tokens.length; i += 1) {
152
219
  if (!isWord(tokens[i], 'AS')) continue;
153
- let j = i + 1;
154
- if (isWord(tokens[j], 'NOT')) j += 1;
155
- if (isWord(tokens[j], 'MATERIALIZED')) j += 1;
156
- if (tokens[j]?.v !== '(') continue;
157
- let k = i - 1;
158
- if (tokens[k]?.v === ')') { let depth = 0; do { if (tokens[k].v === ')') depth += 1; else if (tokens[k].v === '(') depth -= 1; k -= 1; } while (k >= 0 && depth > 0); }
159
- const before = tokens[k - 1];
160
- if (isName(tokens[k]) && (isWord(before, 'WITH') || isWord(before, 'RECURSIVE') || before?.v === ',')) names.add(nameOf(tokens[k]));
220
+ const name = cteNameBefore(tokens, i);
221
+ if (name !== null) names.add(name);
161
222
  }
162
223
  return names;
163
224
  }
@@ -219,51 +280,160 @@ function equalityColumns(conjunct, qualifiers) {
219
280
  * a LIMIT/FETCH at the statement depth, no FROM at all (one row), only aggregates without GROUP BY, or an equality on
220
281
  * every column of the primary key or of one unique key of the first table of FROM.
221
282
  */
283
+ function aggregateSelectItems(tokens, level, select, from) {
284
+ const items = [];
285
+ let item = [];
286
+ for (let i = select + 1; i < from; i += 1) {
287
+ if (level[i] === 0 && tokens[i].v === ',' && tokens[i].t === 'punct') { items.push(item); item = []; continue; }
288
+ if (!(items.length === 0 && item.length === 0 && (isWord(tokens[i], 'DISTINCT') || isWord(tokens[i], 'ALL')))) item.push(tokens[i]);
289
+ }
290
+ items.push(item);
291
+ return items;
292
+ }
293
+
294
+ function whereRegion(tokens, level, where) {
295
+ const region = [];
296
+ for (let i = where + 1; i < tokens.length; i += 1) {
297
+ if (level[i] === 0 && tokens[i].t === 'word' && WHERE_END.has(tokens[i].up)) break;
298
+ region.push({ token: tokens[i], level: level[i] });
299
+ }
300
+ return region;
301
+ }
302
+
303
+ /** The tokens of a WHERE region split at its top-level AND; the AND of a BETWEEN stays inside its conjunct. */
304
+ function splitConjuncts(region) {
305
+ const conjuncts = [[]];
306
+ let between = false;
307
+ for (const entry of region) {
308
+ const top = entry.level === 0;
309
+ if (top && isWord(entry.token, 'BETWEEN')) between = true;
310
+ const isAnd = top && isWord(entry.token, 'AND');
311
+ if (isAnd && !between) { conjuncts.push([]); continue; }
312
+ if (isAnd) between = false;
313
+ conjuncts.at(-1).push(entry.token);
314
+ }
315
+ return conjuncts;
316
+ }
317
+
318
+ function uniqueWhereColumns(region, primary) {
319
+ if (region.some(entry => entry.level === 0 && isWord(entry.token, 'OR'))) return null;
320
+ const conjuncts = splitConjuncts(region);
321
+ const qualifiers = new Set([primary.table, primary.alias].filter(Boolean));
322
+ const columns = new Set();
323
+ for (const conjunct of conjuncts) for (const column of equalityColumns(conjunct, qualifiers)) columns.add(column);
324
+ return columns;
325
+ }
326
+
327
+ function findTopWord(tokens, level, up, from = 0) {
328
+ for (let i = from; i < tokens.length; i += 1) { if (level[i] === 0 && isWord(tokens[i], up)) return i; }
329
+ return -1;
330
+ }
331
+
332
+ const hasTopGroupBy = (tokens, level) => tokens.some((token, i) => level[i] === 0 && isWord(token, 'GROUP') && isWord(tokens[i + 1], 'BY'));
333
+
334
+ function isUniqueBounded(tokens, level, from, primary, uniqueSets) {
335
+ const where = findTopWord(tokens, level, 'WHERE', from);
336
+ if (!uniqueSets?.length || where < 0) return false;
337
+ const columns = uniqueWhereColumns(whereRegion(tokens, level, where), primary);
338
+ return Boolean(columns) && covers(uniqueSets, columns);
339
+ }
340
+
222
341
  function selectBound(tokens, level, uniqueSetsOf, cte) {
223
- const at0 = index => level[index] === 0;
224
- const find = (up, from = 0) => { for (let i = from; i < tokens.length; i += 1) if (at0(i) && isWord(tokens[i], up)) return i; return -1; };
225
- if (find('LIMIT') >= 0 || find('FETCH') >= 0) return { bounded: true, basis: 'limit' };
226
- const select = find('SELECT');
227
- const from = find('FROM', select);
342
+ if (findTopWord(tokens, level, 'LIMIT') >= 0 || findTopWord(tokens, level, 'FETCH') >= 0) return { bounded: true, basis: 'limit' };
343
+ const select = findTopWord(tokens, level, 'SELECT');
344
+ const from = findTopWord(tokens, level, 'FROM', select);
228
345
  if (from < 0) return { bounded: true, basis: 'no from' };
229
- const groupBy = tokens.findIndex((token, i) => at0(i) && isWord(token, 'GROUP') && isWord(tokens[i + 1], 'BY'));
230
- if (groupBy < 0) {
231
- const items = [];
232
- let item = [];
233
- for (let i = select + 1; i < from; i += 1) {
234
- if (at0(i) && tokens[i].v === ',' && tokens[i].t === 'punct') { items.push(item); item = []; continue; }
235
- if (!(items.length === 0 && item.length === 0 && (isWord(tokens[i], 'DISTINCT') || isWord(tokens[i], 'ALL')))) item.push(tokens[i]);
236
- }
237
- items.push(item);
238
- if (items.length && items.every(isAggregateItem)) return { bounded: true, basis: 'aggregate' };
239
- }
346
+ if (!hasTopGroupBy(tokens, level) && aggregateSelectItems(tokens, level, select, from).every(isAggregateItem)) return { bounded: true, basis: 'aggregate' };
240
347
  const primary = tableRef(tokens, from + 1);
241
348
  if (!primary || primary.dynamic || cte.has(primary.table)) return { bounded: false, basis: 'unbounded', table: primary?.table ?? null };
242
- const uniqueSets = uniqueSetsOf(primary.table);
243
- const where = find('WHERE', from);
244
- if (uniqueSets?.length && where >= 0) {
245
- const region = [];
246
- for (let i = where + 1; i < tokens.length; i += 1) {
247
- if (at0(i) && tokens[i].t === 'word' && WHERE_END.has(tokens[i].up)) break;
248
- region.push({ token: tokens[i], level: level[i] });
249
- }
250
- if (!region.some(entry => entry.level === 0 && isWord(entry.token, 'OR'))) {
251
- const conjuncts = [[]];
252
- let between = false;
253
- for (const entry of region) {
254
- if (entry.level === 0 && isWord(entry.token, 'BETWEEN')) between = true;
255
- if (entry.level === 0 && isWord(entry.token, 'AND')) {
256
- if (between) { between = false; } else { conjuncts.push([]); continue; }
257
- }
258
- conjuncts.at(-1).push(entry.token);
259
- }
260
- const qualifiers = new Set([primary.table, primary.alias].filter(Boolean));
261
- const columns = new Set();
262
- for (const conjunct of conjuncts) for (const column of equalityColumns(conjunct, qualifiers)) columns.add(column);
263
- if (covers(uniqueSets, columns)) return { bounded: true, basis: 'unique' };
349
+ if (isUniqueBounded(tokens, level, from, primary, uniqueSetsOf(primary.table))) return { bounded: true, basis: 'unique' };
350
+ return { bounded: false, basis: 'unbounded', table: primary.table };
351
+ }
352
+
353
+ function recordSqlReference(list, ref, reads, cte, state) {
354
+ if (!ref) return;
355
+ if (ref.dynamic) { state.dynamic += 1; return; }
356
+ if (ref.schema && SYSTEM_SCHEMAS.has(ref.schema)) return;
357
+ if (ref.table.startsWith('pg_')) return;
358
+ if (list === reads && !ref.schema && cte.has(ref.table)) return;
359
+ list.push({ table: ref.table, schema: ref.schema });
360
+ }
361
+
362
+ function recordTableList(tokens, start, list, record, commaIsPunctuation) {
363
+ let i = start;
364
+ for (;;) {
365
+ const ref = tableRef(tokens, i);
366
+ record(list, ref);
367
+ if (ref && tokens[ref.next]?.v === ',' && (!commaIsPunctuation || tokens[ref.next].t === 'punct')) { i = ref.next + 1; continue; }
368
+ break;
369
+ }
370
+ }
371
+
372
+ // Where the table reference of a write verb starts (an offset from the verb) and whether it may carry a column list; null for any other token.
373
+ function writeTarget(token, next, previous) {
374
+ if (token.up === 'INSERT' && isWord(next, 'INTO')) return { offset: 2, columns: true };
375
+ if (token.up === 'UPDATE' && !(previous?.t === 'word' && NOT_A_WRITE_BEFORE_UPDATE.has(previous.up))) return { offset: 1, columns: false };
376
+ if ((token.up === 'DELETE' && isWord(next, 'FROM')) || (token.up === 'MERGE' && isWord(next, 'INTO'))) return { offset: 2, columns: false };
377
+ return null;
378
+ }
379
+
380
+ function processWriteToken(tokens, level, index, token, record, state) {
381
+ const target = writeTarget(token, tokens[index + 1], state.previous);
382
+ if (target) {
383
+ if (level[index] === 0) state.hasWriteVerb = true;
384
+ record(state.writes, tableRef(tokens, index + target.offset, { columns: target.columns }));
385
+ return true;
386
+ }
387
+ if (token.up !== 'TRUNCATE') return false;
388
+ let start = index + 1;
389
+ if (isWord(tokens[start], 'TABLE')) start += 1;
390
+ recordTableList(tokens, start, state.writes, record, false);
391
+ return true;
392
+ }
393
+
394
+ function processReadToken(tokens, index, token, record, state, mergeOrDelete) {
395
+ if (token.up === 'FROM' && !isWord(state.previous, 'DISTINCT') && !isWord(state.previous, 'DELETE') && state.parens.at(-1) !== true) {
396
+ recordTableList(tokens, index + 1, state.reads, record, true);
397
+ return;
398
+ }
399
+ if (token.up === 'JOIN') { record(state.reads, tableRef(tokens, index + 1)); return; }
400
+ if (token.up === 'USING' && mergeOrDelete && tokens[index + 1]?.v !== '(') record(state.reads, tableRef(tokens, index + 1));
401
+ }
402
+
403
+ function trackParenthesis(tokens, index, state) {
404
+ const token = tokens[index];
405
+ if (token.t !== 'punct') return false;
406
+ if (token.v === '(') { state.parens.push(index > 0 && tokens[index - 1].t === 'word' && FROM_INSIDE.has(tokens[index - 1].up)); return true; }
407
+ if (token.v !== ')') return false;
408
+ state.parens.pop();
409
+ return true;
410
+ }
411
+
412
+ function statementReferences(tokens, level, cte) {
413
+ const state = { writes: [], reads: [], parens: [], dynamic: 0, hasWriteVerb: false };
414
+ const record = (list, ref) => recordSqlReference(list, ref, state.reads, cte, state);
415
+ const mergeOrDelete = tokens.some(token => isWord(token, 'MERGE')) || tokens.some(token => isWord(token, 'DELETE'));
416
+ for (let i = 0; i < tokens.length; i += 1) {
417
+ const token = tokens[i];
418
+ if (trackParenthesis(tokens, i, state) || token.t !== 'word') continue;
419
+ state.previous = tokens[i - 1];
420
+ if (processWriteToken(tokens, level, i, token, record, state)) {
421
+ if (token.up === 'DELETE') i += 1;
422
+ continue;
264
423
  }
424
+ processReadToken(tokens, i, token, record, state, mergeOrDelete);
265
425
  }
266
- return { bounded: false, basis: 'unbounded', table: primary.table };
426
+ return state;
427
+ }
428
+
429
+ function statementAnalysis(tokens, uniqueSetsOf) {
430
+ const cte = cteNames(tokens);
431
+ const level = depths(tokens);
432
+ const references = statementReferences(tokens, level, cte);
433
+ const selects = [];
434
+ const hasSelect = tokens.some((candidate, i) => level[i] === 0 && isWord(candidate, 'SELECT'));
435
+ if (hasSelect && !references.hasWriteVerb) selects.push(selectBound(tokens, level, uniqueSetsOf, cte));
436
+ return { ...references, selects };
267
437
  }
268
438
 
269
439
  /**
@@ -277,51 +447,11 @@ export function analyzeSql(text, { uniqueSetsOf = () => null } = {}) {
277
447
  const selects = [];
278
448
  let dynamic = 0;
279
449
  for (const tokens of statementsOf(tokenizeSql(text))) {
280
- const cte = cteNames(tokens);
281
- const level = depths(tokens);
282
- const parens = [];
283
- const record = (list, ref) => {
284
- if (!ref) return;
285
- if (ref.dynamic) { dynamic += 1; return; }
286
- if (ref.schema && SYSTEM_SCHEMAS.has(ref.schema)) return;
287
- if (ref.table.startsWith('pg_')) return;
288
- if (list === reads && !ref.schema && cte.has(ref.table)) return;
289
- list.push({ table: ref.table, schema: ref.schema });
290
- };
291
- const mergeOrDelete = tokens.some(token => isWord(token, 'MERGE')) || tokens.some(token => isWord(token, 'DELETE'));
292
- let hasWriteVerb = false;
293
- for (let i = 0; i < tokens.length; i += 1) {
294
- const token = tokens[i];
295
- if (token.t === 'punct' && token.v === '(') { parens.push(i > 0 && tokens[i - 1].t === 'word' && FROM_INSIDE.has(tokens[i - 1].up)); continue; }
296
- if (token.t === 'punct' && token.v === ')') { parens.pop(); continue; }
297
- if (token.t !== 'word') continue;
298
- const previous = tokens[i - 1];
299
- if (token.up === 'INSERT' && isWord(tokens[i + 1], 'INTO')) { if (level[i] === 0) hasWriteVerb = true; record(writes, tableRef(tokens, i + 2, { columns: true })); }
300
- else if (token.up === 'UPDATE' && !(previous?.t === 'word' && NOT_A_WRITE_BEFORE_UPDATE.has(previous.up))) { if (level[i] === 0) hasWriteVerb = true; record(writes, tableRef(tokens, i + 1)); }
301
- else if (token.up === 'DELETE' && isWord(tokens[i + 1], 'FROM')) { if (level[i] === 0) hasWriteVerb = true; record(writes, tableRef(tokens, i + 2)); i += 1; }
302
- else if (token.up === 'MERGE' && isWord(tokens[i + 1], 'INTO')) { if (level[i] === 0) hasWriteVerb = true; record(writes, tableRef(tokens, i + 2)); }
303
- else if (token.up === 'TRUNCATE') {
304
- let j = i + 1;
305
- if (isWord(tokens[j], 'TABLE')) j += 1;
306
- for (;;) {
307
- const ref = tableRef(tokens, j);
308
- record(writes, ref);
309
- if (ref && tokens[ref.next]?.v === ',') { j = ref.next + 1; continue; }
310
- break;
311
- }
312
- } else if (token.up === 'FROM' && !isWord(previous, 'DISTINCT') && !isWord(previous, 'DELETE') && parens.at(-1) !== true) {
313
- let j = i + 1;
314
- for (;;) {
315
- const ref = tableRef(tokens, j);
316
- record(reads, ref);
317
- if (ref && tokens[ref.next]?.v === ',' && tokens[ref.next].t === 'punct') { j = ref.next + 1; continue; }
318
- break;
319
- }
320
- } else if (token.up === 'JOIN') record(reads, tableRef(tokens, i + 1));
321
- else if (token.up === 'USING' && mergeOrDelete && tokens[i + 1]?.v !== '(') record(reads, tableRef(tokens, i + 1));
322
- }
323
- const hasSelect = tokens.some((candidate, i) => level[i] === 0 && isWord(candidate, 'SELECT'));
324
- if (hasSelect && !hasWriteVerb) selects.push(selectBound(tokens, level, uniqueSetsOf, cte));
450
+ const statement = statementAnalysis(tokens, uniqueSetsOf);
451
+ for (const write of statement.writes) writes.push(write);
452
+ for (const read of statement.reads) reads.push(read);
453
+ for (const select of statement.selects) selects.push(select);
454
+ dynamic += statement.dynamic;
325
455
  }
326
456
  return { writes, reads, selects, dynamic };
327
457
  }
@@ -16,7 +16,7 @@ export const importedFrom = (kit, checker, node, modules, names = null) => {
16
16
  };
17
17
 
18
18
  const declarationFromSupabase = (declaration) => /(?:^|\/)node_modules\/@supabase\/(?:ssr|supabase-js|auth-js|postgrest-js|storage-js)(?:\/|$)/u
19
- .test(String(declaration?.getSourceFile?.().fileName ?? '').replace(/\\/gu, '/'));
19
+ .test(String(declaration?.getSourceFile?.().fileName ?? '').replaceAll('\\', '/'));
20
20
 
21
21
  const typeFromSupabase = (checker, node) => {
22
22
  let type;
@@ -72,60 +72,70 @@ const fromRequestBody = (kit, checker, node, seen = new Set()) => {
72
72
  && fromRequestBody(kit, checker, declaration.initializer, seen));
73
73
  };
74
74
 
75
+ function verifyJwtCall(kit, checker, file, node, counters, violations) {
76
+ counters.verifyCalls += 1;
77
+ if (file.slot !== BE_SUPABASE_SLOT) {
78
+ reportAt(violations, kit, BE_JWT_VERIFIED, file, node, `jwtVerify is called outside ${BE_SUPABASE_SLOT}; the integration is the one token verifier.`);
79
+ return true;
80
+ }
81
+ const options = objectLiteral(kit, checker, node.arguments[2]);
82
+ const property = name => options ? kit.propertyOf(options, name) : null;
83
+ const values = name => {
84
+ const entry = property(name);
85
+ return entry ? staticStrings(kit, checker, kit.valueOfProperty(entry)) : null;
86
+ };
87
+ const issuer = property('issuer');
88
+ const audience = values('audience');
89
+ const algorithms = values('algorithms');
90
+ const good = jwksArgument(kit, checker, node.arguments[1]) && issuer != null && audience?.includes('authenticated')
91
+ && algorithms !== null && algorithms.length > 0;
92
+ if (!good) {
93
+ reportAt(violations, kit, BE_JWT_VERIFIED, file, node, 'Supabase jwtVerify must use createRemoteJWKSet(...) and pin issuer, audience `authenticated`, and a non-empty algorithms list; jwtVerify then enforces signature and exp.');
94
+ } else counters.verifiedCalls += 1;
95
+ return false;
96
+ }
97
+
98
+ function inspectJwtCall(kit, checker, file, node, counters, violations) {
99
+ const binding = importedCall(kit, checker, node);
100
+ if (binding && JWT_MODULES.has(binding.module) && ['decode', 'decodeJwt', 'jwtDecode'].includes(binding.name)) {
101
+ reportAt(violations, kit, BE_JWT_VERIFIED, file, node, `${binding.module}.${binding.name} decodes a token without proving its signature; use the Supabase JWKS verifier.`, { module: binding.module, method: binding.name });
102
+ }
103
+ if (binding?.module === 'jose' && binding.name === 'jwtVerify') return verifyJwtCall(kit, checker, file, node, counters, violations);
104
+ return false;
105
+ }
106
+
107
+ function inspectRequestClaims(kit, file, node, checker, violations) {
108
+ const { ts } = kit;
109
+ if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && CLAIM_NAMES.has(accessName(ts, node))) {
110
+ const claim = accessName(ts, node);
111
+ if (fromRequestBody(kit, checker, node.expression)) reportAt(violations, kit, BE_JWT_VERIFIED, file, node, `${claim} is read from the request body; authorization claims come only from the verified token or the database.`, { claim });
112
+ }
113
+ if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && node.initializer && fromRequestBody(kit, checker, node.initializer)) {
114
+ for (const element of node.name.elements) {
115
+ const property = element.propertyName ?? element.name;
116
+ if (ts.isIdentifier(property) && CLAIM_NAMES.has(property.text)) reportAt(violations, kit, BE_JWT_VERIFIED, file, element, `${property.text} is destructured from the request body; authorization claims come only from the verified token or the database.`, { claim: property.text });
117
+ }
118
+ }
119
+ }
120
+
121
+ function inspectBackendNode(kit, checker, file, node, counters, violations) {
122
+ const handled = kit.ts.isCallExpression(node) && inspectJwtCall(kit, checker, file, node, counters, violations);
123
+ if (!handled) inspectRequestClaims(kit, file, node, checker, violations);
124
+ }
125
+
75
126
  export function checkBackendJwt(input) {
76
127
  const kit = machineKit(input);
77
- const { ts } = kit;
78
128
  const violations = [];
79
129
  const integrations = [...input.graph.files.values()].filter((file) => file.slot === BE_SUPABASE_SLOT);
80
130
  if (!integrations.length) return { violations, coverage: { status: 'not-applicable', integrationFiles: 0, verifiedCalls: 0 } };
81
- let verifyCalls = 0;
82
- let verifiedCalls = 0;
131
+ const counters = { verifyCalls: 0, verifiedCalls: 0 };
83
132
  for (const file of input.graph.files.values()) {
84
133
  const checker = kit.checkerOf(file.sourceFile);
85
- kit.walk(file.sourceFile, (node) => {
86
- if (ts.isCallExpression(node)) {
87
- const binding = importedCall(kit, checker, node);
88
- if (binding && JWT_MODULES.has(binding.module) && ['decode', 'decodeJwt', 'jwtDecode'].includes(binding.name)) {
89
- reportAt(violations, kit, BE_JWT_VERIFIED, file, node, `${binding.module}.${binding.name} decodes a token without proving its signature; use the Supabase JWKS verifier.`, { module: binding.module, method: binding.name });
90
- }
91
- if (binding?.module === 'jose' && binding.name === 'jwtVerify') {
92
- verifyCalls += 1;
93
- if (file.slot !== BE_SUPABASE_SLOT) {
94
- reportAt(violations, kit, BE_JWT_VERIFIED, file, node, `jwtVerify is called outside ${BE_SUPABASE_SLOT}; the integration is the one token verifier.`);
95
- return true;
96
- }
97
- const options = objectLiteral(kit, checker, node.arguments[2]);
98
- const property = (name) => options ? kit.propertyOf(options, name) : null;
99
- const values = (name) => {
100
- const entry = property(name);
101
- return entry ? staticStrings(kit, checker, kit.valueOfProperty(entry)) : null;
102
- };
103
- const issuer = property('issuer');
104
- const audience = values('audience');
105
- const algorithms = values('algorithms');
106
- const good = jwksArgument(kit, checker, node.arguments[1]) && issuer != null && audience?.includes('authenticated')
107
- && algorithms !== null && algorithms.length > 0;
108
- if (!good) {
109
- reportAt(violations, kit, BE_JWT_VERIFIED, file, node, 'Supabase jwtVerify must use createRemoteJWKSet(...) and pin issuer, audience `authenticated`, and a non-empty algorithms list; jwtVerify then enforces signature and exp.');
110
- } else verifiedCalls += 1;
111
- }
112
- }
113
- if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && CLAIM_NAMES.has(accessName(ts, node))) {
114
- const claim = accessName(ts, node);
115
- if (fromRequestBody(kit, checker, node.expression)) reportAt(violations, kit, BE_JWT_VERIFIED, file, node, `${claim} is read from the request body; authorization claims come only from the verified token or the database.`, { claim });
116
- }
117
- if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && node.initializer && fromRequestBody(kit, checker, node.initializer)) {
118
- for (const element of node.name.elements) {
119
- const property = element.propertyName ?? element.name;
120
- if (ts.isIdentifier(property) && CLAIM_NAMES.has(property.text)) reportAt(violations, kit, BE_JWT_VERIFIED, file, element, `${property.text} is destructured from the request body; authorization claims come only from the verified token or the database.`, { claim: property.text });
121
- }
122
- }
123
- return true;
124
- });
134
+ kit.walk(file.sourceFile, node => inspectBackendNode(kit, checker, file, node, counters, violations));
125
135
  }
126
- if (verifyCalls === 0) {
136
+ if (counters.verifyCalls === 0) {
127
137
  const anchor = integrations.find((file) => path.posix.basename(file.rel).endsWith('.jwks.ts')) ?? integrations[0];
128
138
  reportAt(violations, kit, BE_JWT_VERIFIED, anchor, anchor.sourceFile, `${BE_SUPABASE_SLOT} has no jose.jwtVerify call backed by createRemoteJWKSet.`);
129
139
  }
130
- return { violations, coverage: { status: 'checked', integrationFiles: integrations.length, verifyCalls, verifiedCalls } };
140
+ return { violations, coverage: { status: 'checked', integrationFiles: integrations.length, verifyCalls: counters.verifyCalls, verifiedCalls: counters.verifiedCalls } };
131
141
  }