acuvo-code 0.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 (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
@@ -0,0 +1,1624 @@
1
+ /**
2
+ * ── ⭐ DB-INSPECT — THE AGENT CAN FINALLY SEE THE DATA MODEL ─────────────────
3
+ *
4
+ * App development is mostly data, and until now this CLI had no idea what a
5
+ * `users` row contains. It could read `api/users/route.ts` and infer, which is
6
+ * exactly the failure mode: the model writes `user.emailAddress` because that
7
+ * is what it would have called the column, and nothing on disk contradicts it
8
+ * until runtime.
9
+ *
10
+ * ── ⚠️ WHAT ZERO DEPENDENCIES ACTUALLY ALLOWS, ARGUED RATHER THAN ASSUMED ───
11
+ * `pg`, `mysql2` and `sqlite3` are all out — the package has an empty
12
+ * `dependencies` and that is the product, not an accident. So the four honest
13
+ * options were weighed, and this file ships three of them:
14
+ *
15
+ * 1. **SCHEMA FROM THE WORKSPACE — no database at all.** Migrations, plain
16
+ * `*.sql`, `prisma/schema.prisma`, `supabase/schema.sql`, drizzle table
17
+ * builders. ⭐ THIS IS THE VALUABLE HALF AND IT IS ALSO THE ONE THAT
18
+ * ALWAYS WORKS: no server, no credential, no network, no binary on PATH,
19
+ * and it answers the question the agent actually has ("what are the
20
+ * columns of `orders`?") while it is writing the code. It is also the only
21
+ * half that works on the machine of a developer who has never run the app.
22
+ * Everything else here is a bonus on top of it.
23
+ * 2. **SQLITE, LIVE, VIA `node:sqlite`** — Node's OWN built-in (>= 22.5).
24
+ * Zero dependencies is preserved because it is not a dependency: it ships
25
+ * inside the runtime. ⚠️ BUT `package.json` says `engines: node >= 20`,
26
+ * and on Node 20 the module DOES NOT EXIST. So it is imported lazily,
27
+ * behind a try, and its absence is a NAMED refusal ("your Node is 20.x,
28
+ * node:sqlite arrived in 22.5 — upgrade, or install the sqlite3 CLI") and
29
+ * never a crash. Relying on it unconditionally would have made a Node 20
30
+ * install throw `ERR_MODULE_NOT_FOUND` on a tool call, which is the worst
31
+ * possible way to learn about an engine field.
32
+ * ⚠️ It also prints ONE `ExperimentalWarning` to stderr on first import.
33
+ * That is not suppressed here: suppression means swapping the process-wide
34
+ * `warning` listeners, and `process.emitWarning` defers to the next tick,
35
+ * so the swap either misses the warning (measured — it still printed) or
36
+ * has to stay installed and swallow unrelated warnings from other lanes.
37
+ * A one-line notice on stderr is cheaper than that. Silence it for a whole
38
+ * run with `NODE_OPTIONS=--no-warnings=ExperimentalWarning`.
39
+ * 3. **SQLITE VIA THE `sqlite3` CLI** — spawn, not a driver, and only as the
40
+ * Node 20 fallback. It asks for `.schema` and hands the DDL to the SAME
41
+ * parser option 1 uses, so there is one schema parser in this file and not
42
+ * two that can disagree.
43
+ * ⚠️ HONESTY: `sqlite3` is NOT on this machine's PATH, so this path is
44
+ * proven against an injected `spawnImpl` and has never met a real binary.
45
+ * The argv is deliberately boring for that reason.
46
+ * 4. **POSTGRES VIA `psql`** — shipped, same caveat: `psql` is not on this
47
+ * machine either, so it is spawn-tested only. It is worth shipping because
48
+ * Postgres is where the app data actually lives, and because the offline
49
+ * half already covers the case where it is missing.
50
+ *
51
+ * ⚠️ NOT SHIPPED, NAMED SO NOBODY LOOKS FOR IT: MySQL/MariaDB live. There is no
52
+ * built-in client and the `mysql` CLI's output format is a moving target; the
53
+ * file half already parses MySQL `CREATE TABLE` DDL, which is where a MySQL
54
+ * project's schema lives anyway.
55
+ *
56
+ * ── ⭐ READ-ONLY IS INEXPRESSIBLE, NOT REFUSED (the `git.mjs` shape) ─────────
57
+ * There is NO parameter anywhere in this file that carries SQL. `DROP TABLE`,
58
+ * `DELETE`, `UPDATE` and `ALTER` are not blocked by a regex over a model-authored
59
+ * string — a regex over SQL is a promise nobody can keep, because `DELETE/**\/`
60
+ * and `dElEtE` and a unicode homoglyph all exist. Instead:
61
+ *
62
+ * · every statement this file can run is a CONSTANT written here;
63
+ * · the only model-supplied values that reach a query are a TABLE NAME and
64
+ * COLUMN NAMES, and both are checked against the live catalogue first, so
65
+ * they are strings the database itself just handed us;
66
+ * · and on top of that the connection is opened READ-ONLY at the engine
67
+ * (`node:sqlite` `{ readOnly: true }` — measured: `attempt to write a
68
+ * readonly database`; `sqlite3 -readonly`; and `SET default_transaction_
69
+ * read_only = on` for psql). Belt and braces, because layer three is the
70
+ * one that survives a mistake in layers one and two.
71
+ *
72
+ * ── ⚠️ A CONNECTION STRING IS A CREDENTIAL ──────────────────────────────────
73
+ * WHERE IT MAY COME FROM: `process.env` ONLY, named by the caller (default
74
+ * `DATABASE_URL`). The tool parameter is the NAME OF A VARIABLE, never a URL —
75
+ * so a model cannot paste a production DSN into a transcript, and cannot invent
76
+ * one pointing at a host we have never heard of without a human having put it
77
+ * in the environment first.
78
+ * WHERE IT MAY NEVER GO: any return value, any error message, any log line, and
79
+ * `argv`. `argv` matters and is not paranoia — every user on the box can read
80
+ * another process's command line (`ps`, Task Manager). So the DSN is decomposed
81
+ * into `PGHOST`/`PGUSER`/`PGPASSWORD`/… and passed through the child's
82
+ * ENVIRONMENT, which is libpq's own intended mechanism and is not world-readable.
83
+ * `redactConnectionString` exists for the one place a human needs to see WHICH
84
+ * database was reached, and it keeps the host and drops the password.
85
+ *
86
+ * ── WHAT IS CAPPED, AND SAYING SO ───────────────────────────────────────────
87
+ * Tables, columns per table, sample rows, cell size, files scanned and bytes
88
+ * per file are all bounded — the model pays per token, and a 400-table schema
89
+ * dumped whole is both expensive and unreadable. Every cap that BITES is
90
+ * reported in the result (`capped`, `columnsTruncated`, `totalExact`), because
91
+ * "12 tables" and "the first 12 of many" are different answers and a model told
92
+ * the first one stops looking.
93
+ */
94
+
95
+ import { readFileSync, statSync } from 'node:fs';
96
+
97
+ import { clampOutput, scrubEnvironment, spawnBounded } from './command.mjs';
98
+ import { findFiles } from './search.mjs';
99
+ import { resolveInWorkspace } from './workspace.mjs';
100
+
101
+ /* ── caps ─────────────────────────────────────────────────────────────────── */
102
+
103
+ /** More than this and the answer is "ask about one table". */
104
+ export const MAX_TABLES = 120;
105
+ /** Wide tables exist; 80 columns is already a page of output. */
106
+ export const MAX_COLUMNS = 80;
107
+ /** Rows are for shape-checking, never for reading the data out of a database. */
108
+ export const MAX_SAMPLE_ROWS = 20;
109
+ export const DEFAULT_SAMPLE_ROWS = 5;
110
+ /** A single TEXT cell can hold a megabyte of JSON. */
111
+ export const MAX_CELL_CHARS = 200;
112
+ /** Schema files to read per source kind. */
113
+ export const MAX_SCHEMA_FILES = 60;
114
+ /** A 4MB seed dump is not a schema file; read the head of it and say so. */
115
+ export const MAX_SCHEMA_FILE_BYTES = 512 * 1024;
116
+ /** Both external binaries are fast or wedged. */
117
+ export const DB_TIMEOUT_MS = 20_000;
118
+ /** Seconds, for libpq. Kept below DB_TIMEOUT_MS so psql gives up before we do. */
119
+ export const PG_CONNECT_TIMEOUT_S = 10;
120
+
121
+ /**
122
+ * ⚠️ COLUMNS WHOSE NAME SAYS "SECRET" ARE WITHHELD FROM `sample_db_rows`.
123
+ *
124
+ * The threat is mundane and likely: the agent samples `users` to check the
125
+ * shape, and `password_hash` plus `api_key` land in a transcript that is
126
+ * stored, replayed, and sent to a model provider. Nobody asked for that and
127
+ * nobody would notice.
128
+ *
129
+ * ⭐ AND THE WAY OUT IS NAMED, because a guard that fails correct work is worse
130
+ * than none: naming the column EXPLICITLY in `columns` returns it. Debugging a
131
+ * bad hash is legitimate; doing it by accident is not.
132
+ */
133
+ /**
134
+ * ⚠️ `hash` ON ITS OWN WAS IN THIS LIST AND CAME OUT. It looked prudent and it
135
+ * is the exact shape of "a guard that fails correct work": `file_hash`,
136
+ * `content_hash` and `commit_hash` are ordinary data in half the schemas that
137
+ * exist, and withholding them teaches the agent the column is empty. The case
138
+ * it was there for — `password_hash` — is already covered by `pass(word|wd)?`,
139
+ * so nothing was lost by narrowing it.
140
+ */
141
+ export const SECRET_COLUMN = /(pass(word|wd)?|secret|token|api[_-]?key|private[_-]?key|credential|ssn|cvv|salt|session)/i;
142
+
143
+ /* ── small shared helpers ─────────────────────────────────────────────────── */
144
+
145
+ /**
146
+ * ⚠️ `workspace.mjs` refuses with `{ ok:false, reason }` and `git.mjs` with
147
+ * `{ ok:false, error }`. Two shapes reaching one caller is how a refusal gets
148
+ * rendered as `undefined`. Everything leaving this file uses `error`.
149
+ */
150
+ function refuse(reason) {
151
+ return { ok: false, error: String(reason) };
152
+ }
153
+
154
+ /** Strip `"x"`, `` `x` ``, `[x]`, and split `public.users` into its parts. */
155
+ export function unquoteIdentifier(raw) {
156
+ let s = String(raw ?? '').trim();
157
+ const parts = [];
158
+ let cur = '';
159
+ let i = 0;
160
+ while (i < s.length) {
161
+ const c = s[i];
162
+ if (c === '"' || c === '`') {
163
+ const q = c;
164
+ i += 1;
165
+ while (i < s.length) {
166
+ if (s[i] === q) {
167
+ if (s[i + 1] === q) { cur += q; i += 2; continue; }
168
+ i += 1;
169
+ break;
170
+ }
171
+ cur += s[i];
172
+ i += 1;
173
+ }
174
+ continue;
175
+ }
176
+ if (c === '[') {
177
+ i += 1;
178
+ while (i < s.length && s[i] !== ']') { cur += s[i]; i += 1; }
179
+ i += 1;
180
+ continue;
181
+ }
182
+ if (c === '.') { parts.push(cur); cur = ''; i += 1; continue; }
183
+ cur += c;
184
+ i += 1;
185
+ }
186
+ parts.push(cur);
187
+ const name = parts.pop() ?? '';
188
+ const schema = parts.length > 0 ? parts[parts.length - 1] : null;
189
+ return { schema: schema || null, name: name.trim() };
190
+ }
191
+
192
+ /**
193
+ * ⭐ THE ONLY WAY A NAME REACHES SQL. Doubling `"` is the SQL-standard escape
194
+ * and works in SQLite and Postgres alike; combined with "the name came out of
195
+ * the catalogue we just read", an injection would have to survive the database
196
+ * having reported it as an existing object.
197
+ */
198
+ function quoteIdentifier(name) {
199
+ return `"${String(name).replace(/"/g, '""')}"`;
200
+ }
201
+
202
+ /** The key two files must agree on to be the same table. Unquoted SQL
203
+ * identifiers are case-insensitive; the display name keeps its original case. */
204
+ function tableKey(schema, name) {
205
+ return `${(schema || '').toLowerCase()}|${String(name).toLowerCase()}`;
206
+ }
207
+
208
+ function clampCell(value) {
209
+ if (value === null || value === undefined) return null;
210
+ if (typeof value === 'bigint') return value.toString();
211
+ if (value instanceof Uint8Array) return `<blob ${value.length} bytes>`;
212
+ if (typeof value === 'object') {
213
+ const s = JSON.stringify(value);
214
+ return s.length > MAX_CELL_CHARS ? `${s.slice(0, MAX_CELL_CHARS)}… (${s.length} chars)` : s;
215
+ }
216
+ const s = String(value);
217
+ return s.length > MAX_CELL_CHARS ? `${s.slice(0, MAX_CELL_CHARS)}… (${s.length} chars)` : s;
218
+ }
219
+
220
+ /* ── 1. the SQL parser (used by the file half AND by `sqlite3 .schema`) ───── */
221
+
222
+ /**
223
+ * Split a SQL script into top-level statements.
224
+ *
225
+ * ⚠️ NOT `sql.split(';')`. A default of `';'`, a `COMMENT ON … IS 'a; b'`, and
226
+ * every Postgres function body (`$$ … ; … $$`) contain semicolons, and splitting
227
+ * on them shreds the statement that follows into garbage that then parses as a
228
+ * table with one nonsense column. Quotes, both comment forms and dollar-quoting
229
+ * are tracked instead.
230
+ */
231
+ export function splitStatements(sql) {
232
+ const text = String(sql ?? '');
233
+ const out = [];
234
+ let cur = '';
235
+ let i = 0;
236
+ while (i < text.length) {
237
+ const c = text[i];
238
+ if (c === '-' && text[i + 1] === '-') {
239
+ while (i < text.length && text[i] !== '\n') i += 1;
240
+ cur += ' ';
241
+ continue;
242
+ }
243
+ if (c === '/' && text[i + 1] === '*') {
244
+ i += 2;
245
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
246
+ i += 2;
247
+ cur += ' ';
248
+ continue;
249
+ }
250
+ if (c === "'" || c === '"' || c === '`') {
251
+ const q = c;
252
+ cur += c;
253
+ i += 1;
254
+ while (i < text.length) {
255
+ if (text[i] === q) {
256
+ if (text[i + 1] === q) { cur += q + q; i += 2; continue; }
257
+ cur += q;
258
+ i += 1;
259
+ break;
260
+ }
261
+ cur += text[i];
262
+ i += 1;
263
+ }
264
+ continue;
265
+ }
266
+ if (c === '$') {
267
+ const m = /^\$[A-Za-z_0-9]*\$/.exec(text.slice(i));
268
+ if (m) {
269
+ const tag = m[0];
270
+ const end = text.indexOf(tag, i + tag.length);
271
+ if (end === -1) { cur += text.slice(i); i = text.length; continue; }
272
+ cur += text.slice(i, end + tag.length);
273
+ i = end + tag.length;
274
+ continue;
275
+ }
276
+ }
277
+ if (c === ';') { out.push(cur); cur = ''; i += 1; continue; }
278
+ cur += c;
279
+ i += 1;
280
+ }
281
+ out.push(cur);
282
+ return out.map((s) => s.trim()).filter(Boolean);
283
+ }
284
+
285
+ /**
286
+ * The balanced `( … )` (or `{ … }`) starting at or after `from`, or null.
287
+ * Quote-aware, because a `DEFAULT ')'` inside a column list is legal SQL and a
288
+ * naive depth counter closes the table there and loses every column after it.
289
+ */
290
+ function balanced(text, from, openCh = '(', closeCh = ')') {
291
+ const open = text.indexOf(openCh, from);
292
+ if (open === -1) return null;
293
+ let depth = 0;
294
+ let i = open;
295
+ while (i < text.length) {
296
+ const c = text[i];
297
+ if (c === "'" || c === '"' || c === '`') {
298
+ const q = c;
299
+ i += 1;
300
+ while (i < text.length) {
301
+ if (text[i] === q) {
302
+ if (text[i + 1] === q) { i += 2; continue; }
303
+ break;
304
+ }
305
+ i += 1;
306
+ }
307
+ i += 1;
308
+ continue;
309
+ }
310
+ if (c === openCh) depth += 1;
311
+ else if (c === closeCh) {
312
+ depth -= 1;
313
+ if (depth === 0) return { body: text.slice(open + 1, i), end: i };
314
+ }
315
+ i += 1;
316
+ }
317
+ return null;
318
+ }
319
+
320
+ function parenBody(text, from) {
321
+ return balanced(text, from, '(', ')');
322
+ }
323
+
324
+ /** Split on commas at paren depth 0, ignoring commas inside quotes. */
325
+ export function splitTopLevelCommas(body) {
326
+ const text = String(body ?? '');
327
+ const parts = [];
328
+ let cur = '';
329
+ let depth = 0;
330
+ let i = 0;
331
+ while (i < text.length) {
332
+ const c = text[i];
333
+ if (c === "'" || c === '"' || c === '`') {
334
+ const q = c;
335
+ cur += c;
336
+ i += 1;
337
+ while (i < text.length) {
338
+ if (text[i] === q) {
339
+ if (text[i + 1] === q) { cur += q + q; i += 2; continue; }
340
+ cur += q;
341
+ i += 1;
342
+ break;
343
+ }
344
+ cur += text[i];
345
+ i += 1;
346
+ }
347
+ continue;
348
+ }
349
+ // ⚠️ Braces and brackets count too. Only SQL uses this on a column list,
350
+ // but the drizzle parser uses it on a JS object literal, where a nested
351
+ // `{ onDelete: 'cascade' }` split at its comma turns one column into two.
352
+ if (c === '(' || c === '{' || c === '[') depth += 1;
353
+ else if (c === ')' || c === '}' || c === ']') depth -= 1;
354
+ if (c === ',' && depth === 0) { parts.push(cur); cur = ''; i += 1; continue; }
355
+ cur += c;
356
+ i += 1;
357
+ }
358
+ parts.push(cur);
359
+ return parts.map((p) => p.trim()).filter(Boolean);
360
+ }
361
+
362
+ /** Tokens, where a balanced `( … )` counts as ONE token — so `numeric(10,2)`
363
+ * and `varchar(255)` stay attached to the type instead of ending it. */
364
+ function tokenizeDefinition(def) {
365
+ const text = String(def ?? '');
366
+ const tokens = [];
367
+ let i = 0;
368
+ while (i < text.length) {
369
+ const c = text[i];
370
+ if (/\s/.test(c)) { i += 1; continue; }
371
+ if (c === '(') {
372
+ const grp = parenBody(text, i);
373
+ if (!grp) { tokens.push(text.slice(i)); break; }
374
+ tokens.push(text.slice(i, grp.end + 1));
375
+ i = grp.end + 1;
376
+ continue;
377
+ }
378
+ if (c === "'" || c === '"' || c === '`') {
379
+ const q = c;
380
+ let tok = c;
381
+ i += 1;
382
+ while (i < text.length) {
383
+ tok += text[i];
384
+ if (text[i] === q) {
385
+ if (text[i + 1] === q) { tok += q; i += 2; continue; }
386
+ i += 1;
387
+ break;
388
+ }
389
+ i += 1;
390
+ }
391
+ tokens.push(tok);
392
+ continue;
393
+ }
394
+ let tok = '';
395
+ while (i < text.length && !/[\s(]/.test(text[i])) { tok += text[i]; i += 1; }
396
+ if (text[i] === '(') {
397
+ const grp = parenBody(text, i);
398
+ if (grp) { tok += text.slice(i, grp.end + 1); i = grp.end + 1; }
399
+ }
400
+ tokens.push(tok);
401
+ }
402
+ return tokens.filter(Boolean);
403
+ }
404
+
405
+ /** Where a type stops and the constraints begin. */
406
+ const TYPE_STOP = new Set([
407
+ 'NOT', 'NULL', 'PRIMARY', 'UNIQUE', 'DEFAULT', 'REFERENCES', 'CHECK', 'COLLATE',
408
+ 'GENERATED', 'CONSTRAINT', 'COMMENT', 'AUTO_INCREMENT', 'AUTOINCREMENT',
409
+ 'IDENTITY', 'ON', 'AS', 'STORED', 'KEY',
410
+ ]);
411
+
412
+ const CREATE_TABLE_HEAD = /^create\s+(?:or\s+replace\s+)?(?:(?:global|local|temp|temporary|unlogged|virtual)\s+)*table\s+(?:if\s+not\s+exists\s+)?([^\s(]+)/i;
413
+ const CREATE_INDEX_HEAD = /^create\s+(unique\s+)?index\s+(?:concurrently\s+)?(?:if\s+not\s+exists\s+)?([^\s(]+)\s+on\s+([^\s(]+)/i;
414
+ const ALTER_ADD_COLUMN = /^alter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?([^\s]+)\s+add\s+(?:column\s+)?(?:if\s+not\s+exists\s+)?([\s\S]+)$/i;
415
+ const DESTRUCTIVE_HEAD = /^(drop\s+table|drop\s+column|alter\s+table\s+\S+\s+(drop|rename)|alter\s+table\s+\S+\s+alter)/i;
416
+
417
+ function emptyTable(schema, name, file) {
418
+ return {
419
+ schema: schema || null,
420
+ name,
421
+ file: file || null,
422
+ columns: [],
423
+ primaryKey: [],
424
+ foreignKeys: [],
425
+ indexes: [],
426
+ columnsTruncated: false,
427
+ columnsTotal: 0,
428
+ };
429
+ }
430
+
431
+ function addColumn(table, col) {
432
+ const existing = table.columns.find((c) => c.name.toLowerCase() === col.name.toLowerCase());
433
+ if (existing) { Object.assign(existing, col); return; }
434
+ table.columns.push(col);
435
+ }
436
+
437
+ /** One `name TYPE constraints…` fragment → a column record. */
438
+ export function parseColumnDefinition(fragment) {
439
+ const tokens = tokenizeDefinition(fragment);
440
+ if (tokens.length === 0) return null;
441
+ const { name } = unquoteIdentifier(tokens[0]);
442
+ if (!name) return null;
443
+
444
+ const typeTokens = [];
445
+ let i = 1;
446
+ while (i < tokens.length) {
447
+ const word = tokens[i].replace(/\(.*$/s, '').toUpperCase();
448
+ if (TYPE_STOP.has(word)) break;
449
+ typeTokens.push(tokens[i]);
450
+ i += 1;
451
+ }
452
+ const rest = tokens.slice(i).join(' ');
453
+ const flat = `${rest}`;
454
+
455
+ const col = {
456
+ name,
457
+ type: typeTokens.join(' ') || 'unknown',
458
+ notNull: /\bnot\s+null\b/i.test(flat),
459
+ primaryKey: /\bprimary\s+key\b/i.test(flat),
460
+ unique: /\bunique\b/i.test(flat),
461
+ default: null,
462
+ references: null,
463
+ };
464
+ const def = /\bdefault\s+(\([\s\S]*?\)|'(?:[^']|'')*'|[^\s,]+)/i.exec(flat);
465
+ if (def) col.default = def[1];
466
+ const ref = /\breferences\s+([^\s(]+)\s*(?:\(([^)]*)\))?/i.exec(flat);
467
+ if (ref) {
468
+ const target = unquoteIdentifier(ref[1]);
469
+ col.references = {
470
+ table: target.name,
471
+ schema: target.schema,
472
+ column: ref[2] ? unquoteIdentifier(splitTopLevelCommas(ref[2])[0] ?? '').name : null,
473
+ };
474
+ }
475
+ return col;
476
+ }
477
+
478
+ /**
479
+ * Fold a SQL script into a table map.
480
+ *
481
+ * ⚠️ MIGRATIONS ARE FOLDED, NOT EXECUTED, and the difference is reported.
482
+ * `CREATE TABLE` and `ALTER TABLE … ADD COLUMN` are applied in file order, so a
483
+ * column added by migration 0007 shows up — that is the whole reason to read a
484
+ * migrations directory rather than only `schema.sql`. But `DROP COLUMN`,
485
+ * `RENAME` and `ALTER COLUMN TYPE` are NOT applied: replaying them properly
486
+ * means implementing a dialect, and getting it half right would show the agent
487
+ * a column that no longer exists while claiming to be authoritative. They are
488
+ * collected in `unapplied[]` and set `approximate: true`, so the answer says
489
+ * "here is the shape, and N statements I could not fold" instead of lying.
490
+ */
491
+ export function foldSqlIntoTables(sql, { file = null, tables = new Map(), unapplied = [] } = {}) {
492
+ for (const stmt of splitStatements(sql)) {
493
+ /**
494
+ * ⚠️ THE REGEXES RUN AGAINST `stmt`, NOT AGAINST `head`. `head` collapses
495
+ * whitespace, so an index taken from a match on it does not address the
496
+ * same character in `stmt` — and every body here is located by OFFSET.
497
+ * `head` is used only where nothing is measured from it.
498
+ */
499
+ const head = stmt.replace(/\s+/g, ' ').trim();
500
+
501
+ const create = CREATE_TABLE_HEAD.exec(stmt);
502
+ if (create) {
503
+ const grp = parenBody(stmt, create[0].length);
504
+ const ident = unquoteIdentifier(create[1]);
505
+ if (!ident.name) continue;
506
+ const key = tableKey(ident.schema, ident.name);
507
+ const table = tables.get(key) ?? emptyTable(ident.schema, ident.name, file);
508
+ tables.set(key, table);
509
+ if (!grp) continue;
510
+ for (const part of splitTopLevelCommas(grp.body)) {
511
+ // ⭐ The constraint NAME is kept, not thrown away with the keyword —
512
+ // `uq_users_email` is what a migration has to reference to drop it.
513
+ const named = /^constraint\s+("[^"]*"|`[^`]*`|\[[^\]]*\]|\S+)\s+/i.exec(part);
514
+ const constraintName = named ? unquoteIdentifier(named[1]).name : null;
515
+ const stripped = named ? part.slice(named[0].length) : part;
516
+ if (/^primary\s+key\s*\(/i.test(stripped)) {
517
+ const cols = parenBody(stripped, 0);
518
+ if (cols) {
519
+ for (const c of splitTopLevelCommas(cols.body)) {
520
+ const n = unquoteIdentifier(c.replace(/\s+(asc|desc)$/i, '')).name;
521
+ if (n && !table.primaryKey.includes(n)) table.primaryKey.push(n);
522
+ }
523
+ }
524
+ continue;
525
+ }
526
+ if (/^foreign\s+key\s*\(/i.test(stripped)) {
527
+ const cols = parenBody(stripped, 0);
528
+ const ref = /\breferences\s+([^\s(]+)\s*(?:\(([^)]*)\))?/i.exec(stripped);
529
+ if (cols && ref) {
530
+ const target = unquoteIdentifier(ref[1]);
531
+ table.foreignKeys.push({
532
+ columns: splitTopLevelCommas(cols.body).map((c) => unquoteIdentifier(c).name),
533
+ table: target.name,
534
+ schema: target.schema,
535
+ columnsReferenced: ref[2] ? splitTopLevelCommas(ref[2]).map((c) => unquoteIdentifier(c).name) : [],
536
+ });
537
+ }
538
+ continue;
539
+ }
540
+ if (/^unique\s*\(/i.test(stripped)) {
541
+ const cols = parenBody(stripped, 0);
542
+ if (cols) {
543
+ table.indexes.push({
544
+ name: constraintName,
545
+ unique: true,
546
+ columns: splitTopLevelCommas(cols.body).map((c) => unquoteIdentifier(c).name),
547
+ });
548
+ }
549
+ continue;
550
+ }
551
+ // ⚠️ MySQL writes `KEY idx_a (a)` and `INDEX idx_a (a)` as TABLE
552
+ // constraints — but `key TEXT` is a perfectly ordinary column, and an
553
+ // over-eager match here deletes it from the schema. So these two only
554
+ // count as constraints when a `(` follows the (optional) index name.
555
+ if (/^(?:index|key)\s+(?:\S+\s*)?\(/i.test(stripped)) {
556
+ const cols = parenBody(stripped, 0);
557
+ if (cols) {
558
+ table.indexes.push({
559
+ name: (/^(?:index|key)\s+(\S+)\s*\(/i.exec(stripped)?.[1] ?? null),
560
+ unique: false,
561
+ columns: splitTopLevelCommas(cols.body).map((c) => unquoteIdentifier(c).name),
562
+ });
563
+ }
564
+ continue;
565
+ }
566
+ if (/^(check|exclude|like|primary\s+key|foreign\s+key|unique)\b/i.test(stripped)) continue;
567
+
568
+ const col = parseColumnDefinition(part);
569
+ if (col) {
570
+ addColumn(table, col);
571
+ if (col.primaryKey && !table.primaryKey.includes(col.name)) table.primaryKey.push(col.name);
572
+ if (col.references) {
573
+ table.foreignKeys.push({
574
+ columns: [col.name],
575
+ table: col.references.table,
576
+ schema: col.references.schema,
577
+ columnsReferenced: col.references.column ? [col.references.column] : [],
578
+ });
579
+ }
580
+ }
581
+ }
582
+ continue;
583
+ }
584
+
585
+ const alter = ALTER_ADD_COLUMN.exec(stmt);
586
+ if (alter && !/^alter\s+table\s+\S+\s+add\s+(constraint|primary|foreign|unique|check)\b/i.test(head)) {
587
+ const ident = unquoteIdentifier(alter[1]);
588
+ const key = tableKey(ident.schema, ident.name);
589
+ const table = tables.get(key) ?? emptyTable(ident.schema, ident.name, file);
590
+ tables.set(key, table);
591
+ const col = parseColumnDefinition(alter[2]);
592
+ if (col) addColumn(table, col);
593
+ continue;
594
+ }
595
+
596
+ const index = CREATE_INDEX_HEAD.exec(stmt);
597
+ if (index) {
598
+ const grp = parenBody(stmt, index[0].length);
599
+ const target = unquoteIdentifier(index[3]);
600
+ const key = tableKey(target.schema, target.name);
601
+ const table = tables.get(key) ?? emptyTable(target.schema, target.name, file);
602
+ tables.set(key, table);
603
+ table.indexes.push({
604
+ name: unquoteIdentifier(index[2]).name,
605
+ unique: Boolean(index[1]),
606
+ columns: grp ? splitTopLevelCommas(grp.body).map((c) => unquoteIdentifier(c.replace(/\s+(asc|desc)$/i, '')).name) : [],
607
+ });
608
+ continue;
609
+ }
610
+
611
+ if (DESTRUCTIVE_HEAD.test(head)) {
612
+ unapplied.push({ file, statement: head.slice(0, 120) });
613
+ }
614
+ }
615
+ return { tables, unapplied };
616
+ }
617
+
618
+ /* ── 2. prisma ────────────────────────────────────────────────────────────── */
619
+
620
+ /**
621
+ * `prisma/schema.prisma` → tables.
622
+ *
623
+ * ⚠️ A PRISMA MODEL IS NOT A TABLE, QUITE. `@@map("users")` renames it,
624
+ * `@map("created_at")` renames a column, and a field whose type is another
625
+ * MODEL is a relation that has no column of its own. Emitting those as columns
626
+ * is the failure that matters here: the agent then writes `SELECT posts FROM
627
+ * users`. So model names are collected first, and a field typed as a known
628
+ * model with no `@relation(fields:)` is recorded as a relation, not a column.
629
+ */
630
+ export function parsePrismaSchema(text, { file = null } = {}) {
631
+ const src = String(text ?? '');
632
+ const modelNames = new Set();
633
+ for (const m of src.matchAll(/^\s*model\s+([A-Za-z_]\w*)\s*\{/gm)) modelNames.add(m[1]);
634
+
635
+ const tables = new Map();
636
+ const re = /^\s*model\s+([A-Za-z_]\w*)\s*\{/gm;
637
+ let m;
638
+ while ((m = re.exec(src))) {
639
+ const open = src.indexOf('{', m.index);
640
+ let depth = 0;
641
+ let i = open;
642
+ let close = -1;
643
+ while (i < src.length) {
644
+ if (src[i] === '{') depth += 1;
645
+ else if (src[i] === '}') { depth -= 1; if (depth === 0) { close = i; break; } }
646
+ i += 1;
647
+ }
648
+ if (close === -1) break;
649
+ const body = src.slice(open + 1, close);
650
+ re.lastIndex = close;
651
+
652
+ const modelName = m[1];
653
+ let tableName = modelName;
654
+ const mapped = /@@map\(\s*"([^"]+)"\s*\)/.exec(body);
655
+ if (mapped) tableName = mapped[1];
656
+
657
+ const table = emptyTable(null, tableName, file);
658
+ table.model = modelName;
659
+ for (const rawLine of body.split('\n')) {
660
+ const line = rawLine.replace(/\/\/.*$/, '').trim();
661
+ if (!line || line.startsWith('@@')) continue;
662
+ const parts = line.match(/^([A-Za-z_]\w*)\s+(\S+)\s*(.*)$/);
663
+ if (!parts) continue;
664
+ const [, field, rawType, attrs] = parts;
665
+ const optional = rawType.endsWith('?');
666
+ const list = rawType.endsWith('[]');
667
+ const baseType = rawType.replace(/[?\[\]]+$/g, '');
668
+ /**
669
+ * ⚠️ A FIELD TYPED AS ANOTHER MODEL IS *NEVER* A COLUMN — including the
670
+ * one carrying `@relation(fields: [orgId])`. The first version excluded
671
+ * that case, reasoning that a relation with fields "has" a column, and
672
+ * emitted `org Org?` as a column of type `Org`. It does not: `orgId` is
673
+ * the column, and it is declared separately on its own line. An agent
674
+ * shown a column called `org` writes `SELECT org FROM users` and gets a
675
+ * 42703. The `fields:` list is still read, below, as a FOREIGN KEY.
676
+ */
677
+ const isRelation = modelNames.has(baseType);
678
+ if (isRelation) {
679
+ table.relations = table.relations ?? [];
680
+ table.relations.push({ field, model: baseType, list });
681
+ continue;
682
+ }
683
+ const colName = /@map\(\s*"([^"]+)"\s*\)/.exec(attrs)?.[1] ?? field;
684
+ const col = {
685
+ name: colName,
686
+ type: baseType + (list ? '[]' : ''),
687
+ notNull: !optional,
688
+ primaryKey: /@id\b/.test(attrs),
689
+ unique: /@unique\b/.test(attrs),
690
+ default: /@default\(([^)]*)\)/.exec(attrs)?.[1] ?? null,
691
+ references: null,
692
+ };
693
+ if (col.primaryKey) table.primaryKey.push(colName);
694
+ addColumn(table, col);
695
+ }
696
+ const compound = /@@id\(\s*\[([^\]]*)\]/.exec(body);
697
+ if (compound) {
698
+ for (const c of compound[1].split(',').map((s) => s.trim()).filter(Boolean)) {
699
+ if (!table.primaryKey.includes(c)) table.primaryKey.push(c);
700
+ }
701
+ }
702
+ for (const idx of body.matchAll(/@@(unique|index)\(\s*\[([^\]]*)\]/g)) {
703
+ table.indexes.push({
704
+ name: null,
705
+ unique: idx[1] === 'unique',
706
+ columns: idx[2].split(',').map((s) => s.trim()).filter(Boolean),
707
+ });
708
+ }
709
+ // Relations declared WITH fields carry the foreign key, and that is the
710
+ // one thing a coding agent most needs from a prisma file.
711
+ for (const rel of body.matchAll(/@relation\(([^)]*)\)/g)) {
712
+ const fields = /fields\s*:\s*\[([^\]]*)\]/.exec(rel[1]);
713
+ const references = /references\s*:\s*\[([^\]]*)\]/.exec(rel[1]);
714
+ if (fields) {
715
+ table.foreignKeys.push({
716
+ columns: fields[1].split(',').map((s) => s.trim()).filter(Boolean),
717
+ table: null,
718
+ schema: null,
719
+ columnsReferenced: references ? references[1].split(',').map((s) => s.trim()).filter(Boolean) : [],
720
+ });
721
+ }
722
+ }
723
+ tables.set(tableKey(null, tableName), table);
724
+ }
725
+ return tables;
726
+ }
727
+
728
+ /* ── 3. drizzle ───────────────────────────────────────────────────────────── */
729
+
730
+ const DRIZZLE_TABLE = /(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(pgTable|sqliteTable|mysqlTable)\s*\(\s*(['"`])([^'"`]+)\3\s*,/g;
731
+
732
+ /**
733
+ * Drizzle schema files → tables.
734
+ *
735
+ * ⚠️ THIS IS A TEXT PARSE OF TYPESCRIPT, and it is honest about being one: no
736
+ * `import`s are followed, and a table built by a helper function or spread from
737
+ * a shared object will come back with fewer columns than it really has. The
738
+ * result is marked `approximate` for that reason. The alternative — importing
739
+ * the user's TS at runtime — needs a TypeScript compiler we do not have and
740
+ * would execute workspace code, which the whole package refuses to do casually.
741
+ */
742
+ export function parseDrizzleSchema(text, { file = null } = {}) {
743
+ const src = String(text ?? '');
744
+ const tables = new Map();
745
+ DRIZZLE_TABLE.lastIndex = 0;
746
+ let m;
747
+ while ((m = DRIZZLE_TABLE.exec(src))) {
748
+ /**
749
+ * ⚠️ THE COLUMNS LIVE IN A `{ … }`, NOT A `( … )`. The first version asked
750
+ * `parenBody` for the body and it dutifully returned the argument list of
751
+ * `serial('id')` — the first paren after the brace — so every table came
752
+ * back with one nonsense column. `balanced` takes the delimiters now.
753
+ */
754
+ const grp = balanced(src, m.index + m[0].length, '{', '}');
755
+ if (!grp) continue;
756
+ const table = emptyTable(null, m[4], file);
757
+ table.builder = m[2];
758
+ table.model = m[1];
759
+ for (const entry of splitTopLevelCommas(grp.body)) {
760
+ const head = /^([A-Za-z_$][\w$]*|['"`][^'"`]+['"`])\s*:\s*([A-Za-z_$][\w$]*)\s*\(\s*(?:(['"`])([^'"`]*)\3)?/.exec(entry.trim());
761
+ if (!head) continue;
762
+ const key = unquoteIdentifier(head[1]).name;
763
+ // ⭐ `id: serial('user_id')` names the DB column; `id: serial()` does not,
764
+ // and then the JS key IS the column name.
765
+ const colName = head[4] || key;
766
+ const col = {
767
+ name: colName,
768
+ type: head[2],
769
+ notNull: /\.notNull\s*\(/.test(entry),
770
+ primaryKey: /\.primaryKey\s*\(/.test(entry),
771
+ unique: /\.unique\s*\(/.test(entry),
772
+ default: /\.default(?:Now|Random)?\s*\(([^)]*)\)/.exec(entry)?.[1] ?? null,
773
+ references: null,
774
+ };
775
+ if (col.primaryKey) table.primaryKey.push(colName);
776
+ const ref = /\.references\s*\(\s*\(\s*\)\s*=>\s*([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)/.exec(entry);
777
+ if (ref) {
778
+ table.foreignKeys.push({ columns: [colName], table: ref[1], schema: null, columnsReferenced: [ref[2]] });
779
+ }
780
+ addColumn(table, col);
781
+ }
782
+ tables.set(tableKey(null, table.name), table);
783
+ }
784
+ return tables;
785
+ }
786
+
787
+ /* ── 4. the file half: schema with no database at all ─────────────────────── */
788
+
789
+ /** ⚠️ Drizzle files can be called anything. These globs find the conventional
790
+ * names; anything else is reachable by naming it in `paths`, which the tool
791
+ * description says out loud so "not found" is never mistaken for "not there". */
792
+ const SQL_GLOBS = ['**/*.sql'];
793
+ const PRISMA_GLOBS = ['**/*.prisma'];
794
+ const DRIZZLE_GLOBS = ['**/schema.ts', '**/schema.js', '**/schema.mjs', '**/*.schema.ts', '**/schema/*.ts'];
795
+
796
+ function kindOf(path) {
797
+ if (path.endsWith('.prisma')) return 'prisma';
798
+ if (path.endsWith('.sql')) return 'sql';
799
+ return 'drizzle';
800
+ }
801
+
802
+ function readCapped(absolute) {
803
+ let size = 0;
804
+ try { size = statSync(absolute).size; } catch { return null; }
805
+ const text = readFileSync(absolute, 'utf8');
806
+ if (size > MAX_SCHEMA_FILE_BYTES) {
807
+ return { text: text.slice(0, MAX_SCHEMA_FILE_BYTES), truncated: true, bytes: size };
808
+ }
809
+ return { text, truncated: false, bytes: size };
810
+ }
811
+
812
+ /**
813
+ * Read every schema source in the workspace and fold it into one answer.
814
+ *
815
+ * ⭐ FILE ORDER IS SORTED, AND THAT IS LOAD-BEARING. Migration filenames are
816
+ * timestamp- or serial-prefixed by every tool that generates them
817
+ * (`0007_add_column.sql`, `20260814120000_x.sql`), so a lexical sort IS
818
+ * chronological order, and folding them out of order would apply an ADD COLUMN
819
+ * before its CREATE TABLE and invent a table.
820
+ */
821
+ export function readSchemaFromWorkspace(root, { paths = null, table = null } = {}) {
822
+ /** @type {{path:string,kind:string,tables:number,truncated:boolean}[]} */
823
+ const sources = [];
824
+ const notes = [];
825
+ const unapplied = [];
826
+ const tables = new Map();
827
+ /**
828
+ * ⚠️⭐ ALWAYS TRUE FOR THE FILE HALF, AND THAT IS NOT PESSIMISM.
829
+ *
830
+ * The first version set this only when something went wrong (a truncated
831
+ * file, an unfolded DROP), which made the ordinary answer claim to be exact.
832
+ * It is not: a schema read from source describes what the next migration run
833
+ * WILL make true, not what the database currently holds. A migration that has
834
+ * not been applied, a column added by hand in a console, a table created by
835
+ * an extension — all of them make this answer differ from reality, and none
836
+ * of them leave a trace in the repository. The flag says "reconstructed";
837
+ * `notes` says what specifically was lossy on top of that.
838
+ */
839
+ const approximate = true;
840
+ let filesCapped = false;
841
+
842
+ let candidates = [];
843
+ if (Array.isArray(paths) && paths.length > 0) {
844
+ for (const p of paths.slice(0, MAX_SCHEMA_FILES)) {
845
+ const resolved = resolveInWorkspace(root, p, 'read');
846
+ if (!resolved.ok) return refuse(`${p}: ${resolved.reason}`);
847
+ candidates.push({ rel: resolved.relative, absolute: resolved.absolute });
848
+ }
849
+ } else {
850
+ const seen = new Set();
851
+ for (const glob of [...SQL_GLOBS, ...PRISMA_GLOBS, ...DRIZZLE_GLOBS]) {
852
+ const found = findFiles(root, glob);
853
+ if (!found.ok) continue;
854
+ if (found.truncated) filesCapped = true;
855
+ for (const rel of found.files) {
856
+ if (seen.has(rel)) continue;
857
+ seen.add(rel);
858
+ const resolved = resolveInWorkspace(root, rel, 'read');
859
+ if (!resolved.ok) continue;
860
+ candidates.push({ rel, absolute: resolved.absolute });
861
+ }
862
+ }
863
+ candidates.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
864
+ if (candidates.length > MAX_SCHEMA_FILES) {
865
+ filesCapped = true;
866
+ candidates = candidates.slice(0, MAX_SCHEMA_FILES);
867
+ }
868
+ }
869
+
870
+ for (const { rel, absolute } of candidates) {
871
+ const kind = kindOf(rel);
872
+ const read = readCapped(absolute);
873
+ if (!read) continue;
874
+ if (read.truncated) {
875
+ notes.push(`${rel} is ${read.bytes} bytes — only the first ${MAX_SCHEMA_FILE_BYTES} were parsed`);
876
+ }
877
+ const before = tables.size;
878
+ if (kind === 'sql') {
879
+ foldSqlIntoTables(read.text, { file: rel, tables, unapplied });
880
+ } else if (kind === 'prisma') {
881
+ for (const [key, t] of parsePrismaSchema(read.text, { file: rel })) tables.set(key, t);
882
+ } else {
883
+ const found = parseDrizzleSchema(read.text, { file: rel });
884
+ // ⚠️ Only a file that actually declared a table counts as a drizzle
885
+ // source. `**/schema.ts` matches plenty of zod schemas.
886
+ if (found.size > 0) {
887
+ notes.push(`${rel} was read as a drizzle schema by text-matching — imports are not followed, so a table built by a helper may be missing columns`);
888
+ for (const [key, t] of found) tables.set(key, t);
889
+ } else {
890
+ continue;
891
+ }
892
+ }
893
+ sources.push({ path: rel, kind, tables: tables.size - before, truncated: read.truncated });
894
+ }
895
+
896
+ if (unapplied.length > 0) {
897
+ notes.push(`${unapplied.length} DROP/RENAME/ALTER-COLUMN statement(s) were NOT folded in — the shape below may include columns a later migration removed`);
898
+ }
899
+
900
+ return finishSchema({
901
+ ok: true,
902
+ source: 'files',
903
+ sources,
904
+ tables,
905
+ approximate,
906
+ unapplied,
907
+ notes,
908
+ filesCapped,
909
+ table,
910
+ });
911
+ }
912
+
913
+ /** Shared tail: filter, cap, sort, and report every cap that bit. */
914
+ function finishSchema({ ok, source, sources = [], tables, approximate = false, unapplied = [], notes = [], filesCapped = false, table = null, via = null, database = null }) {
915
+ let list = [...tables.values()];
916
+ const totalTables = list.length;
917
+ if (table) {
918
+ const want = String(table).toLowerCase();
919
+ list = list.filter((t) => t.name.toLowerCase() === want || `${t.schema ?? ''}.${t.name}`.toLowerCase() === want);
920
+ if (list.length === 0) {
921
+ return {
922
+ ok: true,
923
+ source,
924
+ via,
925
+ database,
926
+ sources,
927
+ tables: [],
928
+ tableCount: 0,
929
+ totalTables,
930
+ tablesCapped: false,
931
+ filesCapped,
932
+ approximate,
933
+ unapplied,
934
+ notes: [...notes, `no table named "${table}" — ${totalTables} table(s) were found; ask without a table name to list them`],
935
+ };
936
+ }
937
+ }
938
+ list.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
939
+ const tablesCapped = list.length > MAX_TABLES;
940
+ if (tablesCapped) list = list.slice(0, MAX_TABLES);
941
+
942
+ for (const t of list) {
943
+ t.columnsTotal = t.columns.length;
944
+ if (t.columns.length > MAX_COLUMNS) {
945
+ t.columns = t.columns.slice(0, MAX_COLUMNS);
946
+ t.columnsTruncated = true;
947
+ }
948
+ }
949
+
950
+ return {
951
+ ok,
952
+ source,
953
+ via,
954
+ database,
955
+ sources,
956
+ tables: list,
957
+ tableCount: list.length,
958
+ /** ⚠️ Every table found BEFORE the cap and the filter — so "3 tables"
959
+ * never gets read as "this database has 3 tables". */
960
+ totalTables,
961
+ tablesCapped,
962
+ filesCapped,
963
+ approximate,
964
+ unapplied,
965
+ notes,
966
+ };
967
+ }
968
+
969
+ /* ── 5. sqlite, live ──────────────────────────────────────────────────────── */
970
+
971
+ let nodeSqlitePromise = null;
972
+
973
+ /**
974
+ * ⚠️ LAZY, CACHED, AND ALLOWED TO FAIL. `engines` says node >= 20 and
975
+ * `node:sqlite` landed in 22.5, so a top-level import would turn a Node 20
976
+ * install into a crash on an unrelated tool call. The promise is cached because
977
+ * the ExperimentalWarning is printed once per import and there is no reason to
978
+ * pay for it twice.
979
+ */
980
+ export async function loadNodeSqlite(importImpl = (spec) => import(spec)) {
981
+ if (!nodeSqlitePromise) {
982
+ nodeSqlitePromise = (async () => {
983
+ try {
984
+ const mod = await importImpl('node:sqlite');
985
+ if (!mod?.DatabaseSync) return { ok: false, error: 'node:sqlite loaded but has no DatabaseSync' };
986
+ return { ok: true, DatabaseSync: mod.DatabaseSync };
987
+ } catch (err) {
988
+ return {
989
+ ok: false,
990
+ error: `node:sqlite is not available in this runtime (node ${process.version}); it arrived in Node 22.5. `
991
+ + `Upgrade Node, or install the sqlite3 CLI and this will use that instead: ${err instanceof Error ? err.message : String(err)}`,
992
+ };
993
+ }
994
+ })();
995
+ }
996
+ return nodeSqlitePromise;
997
+ }
998
+
999
+ /** Tests only — the cache is per process and a test that injects an importImpl
1000
+ * must not inherit the previous test's answer. */
1001
+ export function resetSqliteCache() {
1002
+ nodeSqlitePromise = null;
1003
+ }
1004
+
1005
+ function plain(row) {
1006
+ return row ? { ...row } : row;
1007
+ }
1008
+
1009
+ /**
1010
+ * Live SQLite schema through Node's own built-in.
1011
+ *
1012
+ * ⭐ READ-ONLY AT THE ENGINE. `{ readOnly: true }` was measured, not assumed:
1013
+ * a `DELETE` through such a handle fails with `attempt to write a readonly
1014
+ * database` (ERR_SQLITE_ERROR). It also refuses to CREATE a missing file, which
1015
+ * is the second nice property — pointing this at a typo'd path errors instead
1016
+ * of leaving an empty database behind for someone to find later.
1017
+ */
1018
+ export async function inspectSqlite(root, path, { importImpl, openImpl, table = null } = {}) {
1019
+ const resolved = resolveInWorkspace(root, path, 'read');
1020
+ if (!resolved.ok) return refuse(resolved.reason);
1021
+
1022
+ let open = openImpl;
1023
+ if (!open) {
1024
+ const loaded = await loadNodeSqlite(importImpl);
1025
+ if (!loaded.ok) return refuse(loaded.error);
1026
+ open = (file) => new loaded.DatabaseSync(file, { readOnly: true });
1027
+ }
1028
+
1029
+ let db;
1030
+ try {
1031
+ db = open(resolved.absolute);
1032
+ } catch (err) {
1033
+ const message = err instanceof Error ? err.message : String(err);
1034
+ return refuse(`could not open ${resolved.relative} read-only: ${message}`
1035
+ + (/unable to open/i.test(message) ? ' — the file must already exist; a read-only handle never creates one' : ''));
1036
+ }
1037
+
1038
+ try {
1039
+ const objects = db.prepare(
1040
+ "SELECT type, name, tbl_name FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY name",
1041
+ ).all().map(plain);
1042
+
1043
+ const tables = new Map();
1044
+ for (const obj of objects) {
1045
+ const t = emptyTable(null, obj.name, resolved.relative);
1046
+ t.kind = obj.type;
1047
+ // ⭐ The ONLY model-supplied value that could reach here is `table`, and
1048
+ // it is used to FILTER this list — never to build it. `obj.name` came
1049
+ // out of the database a microsecond ago.
1050
+ for (const info of db.prepare('SELECT cid, name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)').all(obj.name).map(plain)) {
1051
+ t.columns.push({
1052
+ name: info.name,
1053
+ type: info.type || 'unknown',
1054
+ notNull: Number(info.notnull) === 1,
1055
+ primaryKey: Number(info.pk) > 0,
1056
+ unique: false,
1057
+ default: info.dflt_value ?? null,
1058
+ references: null,
1059
+ });
1060
+ if (Number(info.pk) > 0) t.primaryKey.push(info.name);
1061
+ }
1062
+ for (const fk of db.prepare('SELECT "table", "from", "to" FROM pragma_foreign_key_list(?)').all(obj.name).map(plain)) {
1063
+ t.foreignKeys.push({
1064
+ columns: [fk.from],
1065
+ table: fk.table,
1066
+ schema: null,
1067
+ columnsReferenced: fk.to ? [fk.to] : [],
1068
+ });
1069
+ }
1070
+ for (const idx of db.prepare('SELECT name, "unique", origin FROM pragma_index_list(?)').all(obj.name).map(plain)) {
1071
+ const cols = db.prepare('SELECT name FROM pragma_index_info(?)').all(idx.name).map((r) => plain(r).name);
1072
+ t.indexes.push({ name: idx.name, unique: Number(idx.unique) === 1, columns: cols, implicit: idx.origin !== 'c' });
1073
+ }
1074
+ tables.set(tableKey(null, obj.name), t);
1075
+ }
1076
+
1077
+ return finishSchema({
1078
+ ok: true,
1079
+ source: 'sqlite',
1080
+ via: 'node:sqlite',
1081
+ database: resolved.relative,
1082
+ sources: [{ path: resolved.relative, kind: 'sqlite', tables: tables.size, truncated: false }],
1083
+ tables,
1084
+ table,
1085
+ notes: [],
1086
+ });
1087
+ } catch (err) {
1088
+ return refuse(`sqlite read failed: ${err instanceof Error ? err.message : String(err)}`);
1089
+ } finally {
1090
+ try { db.close(); } catch { /* already closed, or never opened cleanly */ }
1091
+ }
1092
+ }
1093
+
1094
+ /**
1095
+ * Sample rows — the ONE verb here that returns data rather than shape.
1096
+ *
1097
+ * ⚠️ IT IS DELIBERATELY THE SMALLEST SURFACE IN THE FILE. No WHERE, no ORDER
1098
+ * BY, no JOIN, no expression — because every one of those needs a model-authored
1099
+ * SQL fragment, and the moment one exists "read-only" becomes a claim about a
1100
+ * parser rather than a property of the connection. `LIMIT n` off the top of a
1101
+ * table is enough to answer "what does a row of this actually look like", which
1102
+ * is the question worth paying for.
1103
+ */
1104
+ export async function sampleSqliteRows(root, path, { table, columns = null, limit = DEFAULT_SAMPLE_ROWS, importImpl, openImpl } = {}) {
1105
+ if (!table || typeof table !== 'string') return refuse('a table name is required');
1106
+ const resolved = resolveInWorkspace(root, path, 'read');
1107
+ if (!resolved.ok) return refuse(resolved.reason);
1108
+
1109
+ const n = Number.isFinite(Number(limit)) ? Math.max(1, Math.min(MAX_SAMPLE_ROWS, Math.floor(Number(limit)))) : DEFAULT_SAMPLE_ROWS;
1110
+ const limitCapped = Number(limit) > MAX_SAMPLE_ROWS;
1111
+
1112
+ let open = openImpl;
1113
+ if (!open) {
1114
+ const loaded = await loadNodeSqlite(importImpl);
1115
+ if (!loaded.ok) return refuse(loaded.error);
1116
+ open = (file) => new loaded.DatabaseSync(file, { readOnly: true });
1117
+ }
1118
+
1119
+ let db;
1120
+ try {
1121
+ db = open(resolved.absolute);
1122
+ } catch (err) {
1123
+ return refuse(`could not open ${resolved.relative} read-only: ${err instanceof Error ? err.message : String(err)}`);
1124
+ }
1125
+
1126
+ try {
1127
+ // ⭐ THE NAME IS CHECKED AGAINST THE CATALOGUE, NOT AGAINST A REGEX. What
1128
+ // goes into the query is the row the database returned, so the identifier
1129
+ // is one SQLite itself just spelled for us.
1130
+ const known = db.prepare(
1131
+ "SELECT name FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%'",
1132
+ ).all().map((r) => plain(r).name);
1133
+ const actual = known.find((name) => name.toLowerCase() === table.toLowerCase());
1134
+ if (!actual) {
1135
+ return refuse(`no table or view named "${table}" in ${resolved.relative} — it has: ${known.slice(0, 30).join(', ') || '(none)'}`);
1136
+ }
1137
+
1138
+ const info = db.prepare('SELECT name FROM pragma_table_info(?)').all(actual).map((r) => plain(r).name);
1139
+ const withheld = [];
1140
+ let chosen;
1141
+ if (Array.isArray(columns) && columns.length > 0) {
1142
+ chosen = [];
1143
+ for (const want of columns) {
1144
+ const hit = info.find((c) => c.toLowerCase() === String(want).toLowerCase());
1145
+ if (!hit) return refuse(`"${want}" is not a column of ${actual} — it has: ${info.join(', ')}`);
1146
+ chosen.push(hit);
1147
+ }
1148
+ } else {
1149
+ chosen = info.filter((c) => {
1150
+ if (SECRET_COLUMN.test(c)) { withheld.push(c); return false; }
1151
+ return true;
1152
+ });
1153
+ if (chosen.length === 0) {
1154
+ return refuse(`every column of ${actual} looks like a credential (${withheld.join(', ')}) — name the ones you need in "columns" if you really want them`);
1155
+ }
1156
+ }
1157
+ const columnsCapped = chosen.length > MAX_COLUMNS;
1158
+ if (columnsCapped) chosen = chosen.slice(0, MAX_COLUMNS);
1159
+
1160
+ const sql = `SELECT ${chosen.map(quoteIdentifier).join(', ')} FROM ${quoteIdentifier(actual)} LIMIT ${n}`;
1161
+ const rows = db.prepare(sql).all().map((row) => {
1162
+ const out = {};
1163
+ for (const [k, v] of Object.entries(plain(row))) out[k] = clampCell(v);
1164
+ return out;
1165
+ });
1166
+
1167
+ return {
1168
+ ok: true,
1169
+ database: resolved.relative,
1170
+ table: actual,
1171
+ columns: chosen,
1172
+ rows,
1173
+ rowCount: rows.length,
1174
+ limit: n,
1175
+ limitCapped,
1176
+ columnsCapped,
1177
+ /** ⭐ Named, not silently dropped — a model that cannot see the column
1178
+ * would otherwise conclude the table does not have one. */
1179
+ withheld,
1180
+ note: withheld.length > 0
1181
+ ? `${withheld.length} credential-looking column(s) withheld: ${withheld.join(', ')} — name them in "columns" to include them`
1182
+ : null,
1183
+ };
1184
+ } catch (err) {
1185
+ return refuse(`sqlite read failed: ${err instanceof Error ? err.message : String(err)}`);
1186
+ } finally {
1187
+ try { db.close(); } catch { /* nothing to close */ }
1188
+ }
1189
+ }
1190
+
1191
+ /**
1192
+ * The Node 20 fallback: `sqlite3 -readonly file .schema`.
1193
+ *
1194
+ * ⚠️ SPAWN-TESTED ONLY. `sqlite3` is not on the machine this was written on, so
1195
+ * the argv is deliberately the most boring form that every version since 3.7
1196
+ * accepts, and the DDL it prints is handed to `foldSqlIntoTables` — the same
1197
+ * parser the file half uses — rather than to a second, unproven one.
1198
+ */
1199
+ export async function inspectSqliteViaCli(root, path, { spawnImpl, table = null } = {}) {
1200
+ const resolved = resolveInWorkspace(root, path, 'read');
1201
+ if (!resolved.ok) return refuse(resolved.reason);
1202
+
1203
+ const run = await spawnBounded({
1204
+ file: 'sqlite3',
1205
+ args: ['-readonly', '-batch', '-noheader', resolved.absolute, '.schema'],
1206
+ cwd: root,
1207
+ timeoutMs: DB_TIMEOUT_MS,
1208
+ spawnImpl,
1209
+ env: scrubEnvironment(process.env),
1210
+ });
1211
+ if (!run.ok) {
1212
+ return refuse(`the sqlite3 CLI could not be started — install it, or run on Node 22.5+ where node:sqlite is built in (${run.error})`);
1213
+ }
1214
+ if (run.timedOut) return refuse(`sqlite3 did not finish within ${DB_TIMEOUT_MS}ms`);
1215
+ if (run.exitCode !== 0) return refuse(clampOutput(run.stderr || 'sqlite3 failed').text.trim());
1216
+
1217
+ const { tables, unapplied } = foldSqlIntoTables(run.stdout, { file: resolved.relative });
1218
+ return finishSchema({
1219
+ ok: true,
1220
+ source: 'sqlite',
1221
+ via: 'sqlite3-cli',
1222
+ database: resolved.relative,
1223
+ sources: [{ path: resolved.relative, kind: 'sqlite', tables: tables.size, truncated: false }],
1224
+ tables,
1225
+ unapplied,
1226
+ table,
1227
+ notes: ['read through the sqlite3 CLI in -readonly mode'],
1228
+ });
1229
+ }
1230
+
1231
+ /* ── 6. postgres, live, through psql ──────────────────────────────────────── */
1232
+
1233
+ /** ⚠️ Field/record separators that cannot occur in an identifier or a type, so
1234
+ * a DEFAULT containing a comma or a newline does not shred the row. */
1235
+ export const PG_FS = '\x1f'; // ASCII UNIT SEPARATOR
1236
+ export const PG_RS = '\x1e'; // ASCII RECORD SEPARATOR
1237
+
1238
+ /** Only a NAME may be supplied, and only a plausible one. */
1239
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
1240
+
1241
+ /**
1242
+ * `postgres://u:p@h:5432/db` → `postgres://u:***@h:5432/db`.
1243
+ * ⚠️ The ONLY function in this file allowed to touch a DSN for display, and it
1244
+ * drops the password rather than masking part of it — a partially masked secret
1245
+ * is still a secret with a hint attached.
1246
+ */
1247
+ export function redactConnectionString(raw) {
1248
+ const s = String(raw ?? '');
1249
+ if (!s) return '';
1250
+ try {
1251
+ const u = new URL(s);
1252
+ const user = u.username ? `${u.username}:***@` : '';
1253
+ return `${u.protocol}//${user}${u.hostname}${u.port ? `:${u.port}` : ''}${u.pathname}`;
1254
+ } catch {
1255
+ // Not a URL — a libpq keyword string, or nonsense. Never echo it.
1256
+ return '<connection string, not shown>';
1257
+ }
1258
+ }
1259
+
1260
+ /**
1261
+ * DSN → libpq environment variables.
1262
+ *
1263
+ * ⭐ WHY NOT JUST PASS THE URL AS AN ARGUMENT: argv is world-readable. `ps -ef`
1264
+ * on Linux and Task Manager's command-line column on Windows show every user on
1265
+ * the box the full DSN, password included, for as long as psql runs. The
1266
+ * environment of another user's process is not readable the same way, and these
1267
+ * variables are libpq's documented mechanism, so this is the boring path rather
1268
+ * than a clever one.
1269
+ */
1270
+ export function pgEnvFromUrl(raw) {
1271
+ let u;
1272
+ try {
1273
+ u = new URL(String(raw));
1274
+ } catch {
1275
+ return { ok: false, error: 'not a URL' };
1276
+ }
1277
+ if (!/^postgres(ql)?:$/i.test(u.protocol)) return { ok: false, error: `not a postgres URL (scheme "${u.protocol.replace(':', '')}")` };
1278
+ /** @type {Record<string,string>} */
1279
+ const env = {};
1280
+ if (u.hostname) env.PGHOST = decodeURIComponent(u.hostname);
1281
+ if (u.port) env.PGPORT = u.port;
1282
+ if (u.username) env.PGUSER = decodeURIComponent(u.username);
1283
+ if (u.password) env.PGPASSWORD = decodeURIComponent(u.password);
1284
+ const db = u.pathname.replace(/^\//, '');
1285
+ if (db) env.PGDATABASE = decodeURIComponent(db);
1286
+ const ssl = u.searchParams.get('sslmode');
1287
+ if (ssl) env.PGSSLMODE = ssl;
1288
+ env.PGCONNECT_TIMEOUT = String(PG_CONNECT_TIMEOUT_S);
1289
+ return { ok: true, env };
1290
+ }
1291
+
1292
+ /** ⭐ CONSTANTS. There is no parameter anywhere that can change one character
1293
+ * of these, which is what "read-only is inexpressible" means in practice. */
1294
+ const PG_READ_ONLY = 'SET default_transaction_read_only = on';
1295
+ const PG_COLUMNS_SQL = `
1296
+ SELECT c.table_schema, c.table_name, c.column_name, c.data_type, c.is_nullable, c.column_default, c.ordinal_position
1297
+ FROM information_schema.columns c
1298
+ JOIN information_schema.tables t
1299
+ ON t.table_schema = c.table_schema AND t.table_name = c.table_name
1300
+ WHERE c.table_schema NOT IN ('pg_catalog','information_schema')
1301
+ AND t.table_type IN ('BASE TABLE','VIEW')
1302
+ ORDER BY c.table_schema, c.table_name, c.ordinal_position`;
1303
+ const PG_KEYS_SQL = `
1304
+ SELECT tc.table_schema, tc.table_name, tc.constraint_type, kcu.column_name,
1305
+ COALESCE(ccu.table_name, ''), COALESCE(ccu.column_name, '')
1306
+ FROM information_schema.table_constraints tc
1307
+ JOIN information_schema.key_column_usage kcu
1308
+ ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema
1309
+ LEFT JOIN information_schema.constraint_column_usage ccu
1310
+ ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
1311
+ WHERE tc.table_schema NOT IN ('pg_catalog','information_schema')
1312
+ AND tc.constraint_type IN ('PRIMARY KEY','FOREIGN KEY','UNIQUE')
1313
+ ORDER BY tc.table_schema, tc.table_name`;
1314
+ const PG_INDEXES_SQL = `
1315
+ SELECT schemaname, tablename, indexname, indexdef
1316
+ FROM pg_indexes
1317
+ WHERE schemaname NOT IN ('pg_catalog','information_schema')
1318
+ ORDER BY schemaname, tablename, indexname`;
1319
+
1320
+ function parsePsqlRows(stdout) {
1321
+ return String(stdout ?? '')
1322
+ .split(PG_RS)
1323
+ .map((r) => r.replace(/^\r?\n/, '').trim())
1324
+ .filter(Boolean)
1325
+ .map((r) => r.split(PG_FS));
1326
+ }
1327
+
1328
+ /**
1329
+ * Live Postgres schema through `psql`.
1330
+ *
1331
+ * ⚠️ SPAWN-TESTED ONLY — `psql` is not on the machine this was written on. The
1332
+ * flags are chosen so nothing is interactive and nothing is inherited:
1333
+ * -X ignore ~/.psqlrc, which can `\set` anything including an output format
1334
+ * -w NEVER prompt for a password; a missing one is an error, not a hang
1335
+ * -A -t -F -R unaligned, tuples only, our own separators
1336
+ * -v ON_ERROR_STOP=1 a failed statement is a non-zero exit, not a silent skip
1337
+ */
1338
+ export async function inspectPostgres({ connectionEnv = 'DATABASE_URL', env = process.env, spawnImpl, cwd = process.cwd(), table = null } = {}) {
1339
+ const name = String(connectionEnv || 'DATABASE_URL');
1340
+ if (!ENV_NAME.test(name)) return refuse(`"${name}" is not a usable environment variable name`);
1341
+ const dsn = env?.[name];
1342
+ if (!dsn) {
1343
+ return refuse(`$${name} is not set. A connection string is a credential: it is read from the environment only, never passed as a tool argument. `
1344
+ + `Set it in your shell (or .env) and try again, or read the schema from the workspace instead (source: "files").`);
1345
+ }
1346
+ const pg = pgEnvFromUrl(dsn);
1347
+ // ⚠️ The VALUE is never echoed — only the variable name and what was wrong
1348
+ // with its shape. A malformed DSN in an error message is still a DSN.
1349
+ if (!pg.ok) return refuse(`$${name} is ${pg.error} — expected postgres://user:password@host:5432/database`);
1350
+
1351
+ const childEnv = { ...scrubEnvironment(env), ...pg.env };
1352
+ const run = await spawnBounded({
1353
+ file: 'psql',
1354
+ args: ['-X', '-w', '-A', '-t', '-F', PG_FS, '-R', PG_RS, '-v', 'ON_ERROR_STOP=1',
1355
+ '-c', PG_READ_ONLY, '-c', PG_COLUMNS_SQL, '-c', PG_KEYS_SQL, '-c', PG_INDEXES_SQL],
1356
+ cwd,
1357
+ timeoutMs: DB_TIMEOUT_MS,
1358
+ spawnImpl,
1359
+ env: childEnv,
1360
+ });
1361
+ if (!run.ok) {
1362
+ return refuse('psql could not be started — install the postgres client, or read the schema from the workspace instead (source: "files"). '
1363
+ + `Nothing about $${name} was printed.`);
1364
+ }
1365
+ if (run.timedOut) return refuse(`psql did not finish within ${DB_TIMEOUT_MS}ms against ${redactConnectionString(dsn)}`);
1366
+ if (run.exitCode !== 0) {
1367
+ // ⚠️ psql's own stderr can contain the host and the user; it cannot contain
1368
+ // the password, because the password never entered psql's argv.
1369
+ return refuse(`psql failed (${redactConnectionString(dsn)}): ${clampOutput(run.stderr || '', 600).text.trim() || `exit ${run.exitCode}`}`);
1370
+ }
1371
+
1372
+ // Each -c writes its own result block; the SET produces none, so three blocks
1373
+ // arrive concatenated and are told apart by their column count.
1374
+ const rows = parsePsqlRows(run.stdout);
1375
+ const tables = new Map();
1376
+ const ensure = (schema, name2) => {
1377
+ const key = tableKey(schema, name2);
1378
+ if (!tables.has(key)) tables.set(key, emptyTable(schema, name2, null));
1379
+ return tables.get(key);
1380
+ };
1381
+
1382
+ for (const r of rows) {
1383
+ if (r.length === 7) {
1384
+ const [schema, tbl, column, type, nullable, dflt] = r;
1385
+ const t = ensure(schema, tbl);
1386
+ addColumn(t, {
1387
+ name: column,
1388
+ type,
1389
+ notNull: nullable === 'NO',
1390
+ primaryKey: false,
1391
+ unique: false,
1392
+ default: dflt || null,
1393
+ references: null,
1394
+ });
1395
+ } else if (r.length === 6) {
1396
+ const [schema, tbl, kind, column, refTable, refColumn] = r;
1397
+ const t = ensure(schema, tbl);
1398
+ if (kind === 'PRIMARY KEY') {
1399
+ if (!t.primaryKey.includes(column)) t.primaryKey.push(column);
1400
+ const col = t.columns.find((c) => c.name === column);
1401
+ if (col) col.primaryKey = true;
1402
+ } else if (kind === 'FOREIGN KEY') {
1403
+ t.foreignKeys.push({ columns: [column], table: refTable || null, schema: null, columnsReferenced: refColumn ? [refColumn] : [] });
1404
+ } else if (kind === 'UNIQUE') {
1405
+ const col = t.columns.find((c) => c.name === column);
1406
+ if (col) col.unique = true;
1407
+ }
1408
+ } else if (r.length === 4) {
1409
+ const [schema, tbl, indexName, def] = r;
1410
+ const t = ensure(schema, tbl);
1411
+ const cols = parenBody(def, 0);
1412
+ t.indexes.push({
1413
+ name: indexName,
1414
+ unique: /\bCREATE\s+UNIQUE\b/i.test(def),
1415
+ columns: cols ? splitTopLevelCommas(cols.body).map((c) => unquoteIdentifier(c.replace(/\s+(asc|desc)$/i, '')).name) : [],
1416
+ });
1417
+ }
1418
+ }
1419
+
1420
+ return finishSchema({
1421
+ ok: true,
1422
+ source: 'postgres',
1423
+ via: 'psql',
1424
+ database: redactConnectionString(dsn),
1425
+ sources: [{ path: redactConnectionString(dsn), kind: 'postgres', tables: tables.size, truncated: false }],
1426
+ tables,
1427
+ table,
1428
+ notes: [`session was set ${PG_READ_ONLY}; no statement in this tool can write`],
1429
+ });
1430
+ }
1431
+
1432
+ /* ── 7. the dispatcher the lead wires ─────────────────────────────────────── */
1433
+
1434
+ /**
1435
+ * `source: 'auto'` is the interesting case: it reads the WORKSPACE, because
1436
+ * that is the path that works with no credential, no binary and no server, and
1437
+ * because during code-writing the migrations ARE the truth — they are what the
1438
+ * next deploy will make true. Live inspection is asked for by name.
1439
+ */
1440
+ export async function inspectDatabase(root, args = {}, opts = {}) {
1441
+ const source = String(args.source ?? 'auto');
1442
+ const table = args.table ?? null;
1443
+
1444
+ if (source === 'files' || source === 'auto') {
1445
+ const out = readSchemaFromWorkspace(root, { paths: args.paths ?? null, table });
1446
+ if (!out.ok) return out;
1447
+ if (source === 'auto' && out.tableCount === 0 && out.totalTables === 0) {
1448
+ out.notes = [
1449
+ ...out.notes,
1450
+ 'no schema files found in the workspace (looked for *.sql, *.prisma and conventional drizzle files). '
1451
+ + 'If the schema lives elsewhere, pass "paths"; for a live database pass source "sqlite" with a path, or "postgres".',
1452
+ ];
1453
+ }
1454
+ return out;
1455
+ }
1456
+
1457
+ if (source === 'sqlite') {
1458
+ if (!args.path) return refuse('source "sqlite" needs "path" — the workspace-relative path of the .db/.sqlite file');
1459
+ const native = await inspectSqlite(root, args.path, { ...opts, table });
1460
+ if (native.ok) return native;
1461
+ // ⭐ ONE FALLBACK, AND IT SAYS SO. A Node 20 runtime has no node:sqlite; the
1462
+ // CLI is the only remaining way in, and if it is missing too the refusal
1463
+ // from THIS call names both ways out rather than only the second.
1464
+ if (!/node:sqlite is not available/.test(native.error)) return native;
1465
+ const viaCli = await inspectSqliteViaCli(root, args.path, { ...opts, table });
1466
+ if (viaCli.ok) return viaCli;
1467
+ return refuse(`${native.error}\nand the CLI fallback also failed: ${viaCli.error}`);
1468
+ }
1469
+
1470
+ if (source === 'postgres') {
1471
+ return inspectPostgres({ ...opts, connectionEnv: args.connection_env ?? args.connectionEnv ?? 'DATABASE_URL', cwd: root, table });
1472
+ }
1473
+
1474
+ return refuse(`unknown source "${source}" — use "auto", "files", "sqlite" or "postgres"`);
1475
+ }
1476
+
1477
+ /* ── 8. rendering ─────────────────────────────────────────────────────────── */
1478
+
1479
+ function renderColumn(c) {
1480
+ const bits = [c.name, c.type];
1481
+ if (c.primaryKey) bits.push('PK');
1482
+ if (c.notNull) bits.push('NOT NULL');
1483
+ if (c.unique) bits.push('UNIQUE');
1484
+ if (c.default !== null && c.default !== undefined && c.default !== '') bits.push(`default ${c.default}`);
1485
+ return ` ${bits.join(' ')}`;
1486
+ }
1487
+
1488
+ export function formatSchema(out) {
1489
+ if (!out?.ok) return String(out?.error ?? 'schema inspection failed');
1490
+ const lines = [];
1491
+ const where = out.database ? ` — ${out.database}` : '';
1492
+ lines.push(`schema from ${out.source}${out.via ? ` (${out.via})` : ''}${where}: ${out.tableCount} table(s) shown of ${out.totalTables} found`);
1493
+ if (out.tablesCapped) lines.push(`⚠️ capped at ${MAX_TABLES} tables — ask for one by name to see the rest`);
1494
+ if (out.filesCapped) lines.push('⚠️ the file scan was capped, so there may be schema files this did not read');
1495
+ if (out.approximate) lines.push('⚠️ APPROXIMATE — reconstructed from source files, not read from a running database');
1496
+ for (const s of out.sources ?? []) lines.push(` · ${s.path} (${s.kind})`);
1497
+
1498
+ for (const t of out.tables) {
1499
+ const name = t.schema ? `${t.schema}.${t.name}` : t.name;
1500
+ lines.push(`\n ${name}${t.kind === 'view' ? ' (view)' : ''}${t.file ? ` [${t.file}]` : ''}`);
1501
+ for (const c of t.columns) lines.push(renderColumn(c));
1502
+ if (t.columnsTruncated) lines.push(` … ${t.columnsTotal - t.columns.length} more column(s) not shown`);
1503
+ if (t.primaryKey.length > 0) lines.push(` primary key: ${t.primaryKey.join(', ')}`);
1504
+ for (const fk of t.foreignKeys) {
1505
+ lines.push(` ${fk.columns.join(', ')} → ${fk.table ?? '?'}${fk.columnsReferenced.length ? `(${fk.columnsReferenced.join(', ')})` : ''}`);
1506
+ }
1507
+ for (const idx of t.indexes) {
1508
+ if (idx.implicit) continue;
1509
+ lines.push(` index ${idx.name ?? '(unnamed)'}${idx.unique ? ' UNIQUE' : ''} on (${idx.columns.join(', ')})`);
1510
+ }
1511
+ if (t.relations?.length) lines.push(` relations: ${t.relations.map((r) => `${r.field}→${r.model}${r.list ? '[]' : ''}`).join(', ')}`);
1512
+ }
1513
+ for (const note of out.notes ?? []) lines.push(`\nnote: ${note}`);
1514
+ for (const u of (out.unapplied ?? []).slice(0, 10)) lines.push(` not folded: ${u.file ? `${u.file}: ` : ''}${u.statement}`);
1515
+ return lines.join('\n');
1516
+ }
1517
+
1518
+ export function formatRows(out) {
1519
+ if (!out?.ok) return String(out?.error ?? 'sample failed');
1520
+ const lines = [`${out.rowCount} row(s) from ${out.table} (limit ${out.limit})`];
1521
+ if (out.limitCapped) lines.push(`⚠️ limit capped at ${MAX_SAMPLE_ROWS}`);
1522
+ if (out.columnsCapped) lines.push(`⚠️ columns capped at ${MAX_COLUMNS}`);
1523
+ if (out.note) lines.push(`⚠️ ${out.note}`);
1524
+ lines.push(out.columns.join(' | '));
1525
+ for (const row of out.rows) lines.push(out.columns.map((c) => (row[c] === null ? 'NULL' : row[c])).join(' | '));
1526
+ if (out.rowCount === 0) lines.push('(the table is empty — that is data, not an error)');
1527
+ return lines.join('\n');
1528
+ }
1529
+
1530
+ /* ── 9. tool schemas for the lead to register ─────────────────────────────── */
1531
+
1532
+ export function dbToolSchemas() {
1533
+ return [
1534
+ {
1535
+ type: 'function',
1536
+ function: {
1537
+ name: 'inspect_db',
1538
+ description: [
1539
+ 'Understand the DATABASE behind this project: tables, columns, types, primary keys, foreign keys, indexes.',
1540
+ 'Call it BEFORE writing any code that reads or writes data — guessing a column name is the single most common',
1541
+ 'way generated app code fails at runtime.',
1542
+ 'Default source "auto" reads the schema out of the WORKSPACE — migrations, *.sql, prisma/schema.prisma,',
1543
+ 'supabase/schema.sql, drizzle table builders — so it needs no server, no credential and no network, and it works',
1544
+ 'before the app has ever been run. Migrations are folded in filename order; DROP/RENAME statements are NOT folded',
1545
+ 'and are listed, so treat a "files" answer as the intended shape rather than a census of a live database.',
1546
+ 'source "sqlite" reads a real .db file READ-ONLY (needs Node 22.5+ for node:sqlite, or the sqlite3 CLI).',
1547
+ 'source "postgres" reads a live database through psql, using a connection string taken from the ENVIRONMENT.',
1548
+ 'This tool can only READ. There is no parameter that carries SQL, so DROP/DELETE/UPDATE are not refused — they',
1549
+ 'are not expressible.',
1550
+ ].join(' '),
1551
+ parameters: {
1552
+ type: 'object',
1553
+ properties: {
1554
+ source: {
1555
+ type: 'string',
1556
+ enum: ['auto', 'files', 'sqlite', 'postgres'],
1557
+ description: 'Where to read the schema from. Leave it out for "auto" (the workspace files), which always works.',
1558
+ },
1559
+ path: {
1560
+ type: 'string',
1561
+ description: 'source "sqlite" only: the workspace-relative path of the .db / .sqlite file, e.g. "prisma/dev.db".',
1562
+ },
1563
+ connection_env: {
1564
+ type: 'string',
1565
+ description: 'source "postgres" only: the NAME of the environment variable holding the connection string, default DATABASE_URL. '
1566
+ + 'A connection string is a credential — pass the variable name, never the URL itself; a URL passed here is not read.',
1567
+ },
1568
+ table: {
1569
+ type: 'string',
1570
+ description: 'Show only this table. Use it when a schema is large — the full listing is capped and a named table never is.',
1571
+ },
1572
+ paths: {
1573
+ type: 'array',
1574
+ items: { type: 'string' },
1575
+ description: 'source "files" only: read exactly these files instead of scanning. The way to reach a drizzle schema '
1576
+ + 'whose filename is not one of the conventional ones.',
1577
+ },
1578
+ },
1579
+ },
1580
+ },
1581
+ },
1582
+ {
1583
+ type: 'function',
1584
+ function: {
1585
+ name: 'sample_db_rows',
1586
+ description: [
1587
+ `Read at most ${MAX_SAMPLE_ROWS} rows off the top of one SQLite table, to see what real values look like`,
1588
+ '(are timestamps ISO strings or epoch integers? is `status` an enum or free text?).',
1589
+ 'The connection is opened read-only at the engine, and there is no WHERE, ORDER BY or JOIN — this verb takes a',
1590
+ 'table name and a row count, not SQL.',
1591
+ 'Columns whose NAME looks like a credential (password, token, api_key, secret, hash, …) are withheld and listed;',
1592
+ 'name one explicitly in "columns" if you genuinely need it.',
1593
+ 'Prefer inspect_db: the SHAPE answers most questions and costs far fewer tokens than rows do.',
1594
+ ].join(' '),
1595
+ parameters: {
1596
+ type: 'object',
1597
+ properties: {
1598
+ path: { type: 'string', description: 'Workspace-relative path of the .db / .sqlite file.' },
1599
+ table: { type: 'string', description: 'The table or view to sample. Must already exist; the name is checked against the database.' },
1600
+ columns: {
1601
+ type: 'array',
1602
+ items: { type: 'string' },
1603
+ description: 'Only these columns. Also the way to include a credential-looking column that would otherwise be withheld.',
1604
+ },
1605
+ limit: { type: 'integer', description: `Rows, default ${DEFAULT_SAMPLE_ROWS}, max ${MAX_SAMPLE_ROWS}.` },
1606
+ },
1607
+ required: ['path', 'table'],
1608
+ },
1609
+ },
1610
+ },
1611
+ ];
1612
+ }
1613
+
1614
+ /** For the doctor: what this capability needs, and what it costs. */
1615
+ export function dbChecks() {
1616
+ return {
1617
+ id: 'db.inspect',
1618
+ label: 'database inspection',
1619
+ needsKey: false,
1620
+ note: 'schema from workspace files needs nothing at all. Live SQLite needs Node >= 22.5 (node:sqlite, built in) '
1621
+ + 'or the sqlite3 CLI. Live Postgres needs psql on PATH and a DSN in the environment. Read-only throughout; '
1622
+ + 'no connection string is ever printed or passed in argv.',
1623
+ };
1624
+ }