@miller-tech/uap 1.210.7 → 1.211.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.
@@ -8,6 +8,7 @@
8
8
  * Replaces the v3.0.0 stub with a working implementation.
9
9
  */
10
10
  import { existsSync, readFileSync } from 'fs';
11
+ import { isAbsolute, join } from 'path';
11
12
  import { SQLiteShortTermMemory } from '../memory/short-term/sqlite.js';
12
13
  import { shortTermDbPath } from '../memory/paths.js';
13
14
  import { loadUapConfig } from '../utils/config-loader.js';
@@ -47,15 +48,87 @@ function extractTypeScriptFields(content) {
47
48
  }
48
49
  return fields;
49
50
  }
51
+ /**
52
+ * Blank out SQL comments, preserving offsets and line structure.
53
+ *
54
+ * Everything below counts parentheses and splits on commas, and a comment is
55
+ * allowed to contain both. `-- natural key (see ADR-14` raised the paren depth
56
+ * and swallowed the rest of the file, so the table it belonged to contributed
57
+ * NO columns; if that happened on one side of the diff only, every column read
58
+ * as removed and an added comment became a BREAKING verdict. Replacing with
59
+ * spaces rather than deleting keeps every offset valid for the callers that
60
+ * index back into the string.
61
+ */
62
+ function stripSqlComments(content) {
63
+ let out = '';
64
+ let i = 0;
65
+ while (i < content.length) {
66
+ const two = content.slice(i, i + 2);
67
+ if (two === '--') {
68
+ while (i < content.length && content[i] !== '\n') {
69
+ out += ' ';
70
+ i++;
71
+ }
72
+ continue;
73
+ }
74
+ if (two === '/*') {
75
+ while (i < content.length && content.slice(i, i + 2) !== '*/') {
76
+ out += content[i] === '\n' ? '\n' : ' ';
77
+ i++;
78
+ }
79
+ out += ' ';
80
+ i += 2;
81
+ continue;
82
+ }
83
+ // A quoted literal may legitimately contain -- or parens; copy it whole.
84
+ if (content[i] === "'" || content[i] === '"') {
85
+ const quote = content[i];
86
+ out += content[i];
87
+ i++;
88
+ while (i < content.length) {
89
+ out += content[i];
90
+ if (content[i] === quote) {
91
+ i++;
92
+ break;
93
+ }
94
+ i++;
95
+ }
96
+ continue;
97
+ }
98
+ out += content[i];
99
+ i++;
100
+ }
101
+ return out;
102
+ }
103
+ /** Contents between `content[open]` ('(') and its matching ')', or null. */
104
+ function tableBody(content, open) {
105
+ let depth = 0;
106
+ for (let i = open; i < content.length; i++) {
107
+ if (content[i] === '(')
108
+ depth++;
109
+ else if (content[i] === ')') {
110
+ depth--;
111
+ if (depth === 0)
112
+ return content.slice(open + 1, i);
113
+ }
114
+ }
115
+ return null;
116
+ }
50
117
  /**
51
118
  * Extract SQLite CREATE TABLE columns from SQL or source code.
52
119
  */
53
- function extractSQLiteColumns(content) {
120
+ function extractSQLiteColumns(raw) {
54
121
  const columns = new Map();
55
- const createTables = content.matchAll(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s*\(([\s\S]*?)\)/gi);
122
+ const content = stripSqlComments(raw);
123
+ const createTables = content.matchAll(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s*\(/gi);
56
124
  for (const table of createTables) {
57
125
  const tableName = table[1];
58
- const body = table[2];
126
+ // Paren-matched, not `\(([\s\S]*?)\)`: the non-greedy form stopped at the
127
+ // FIRST `)`, so one `amt DECIMAL(10,2)` hid every column declared after it
128
+ // — including a later NOT NULL addition, which a blocking gate must see.
129
+ const body = tableBody(content, (table.index ?? 0) + table[0].length - 1);
130
+ if (body === null)
131
+ continue;
59
132
  const colMatches = body.matchAll(/(\w+)\s+(INTEGER|TEXT|REAL|BLOB|DATETIME|BOOLEAN|VARCHAR[^,]*)/gi);
60
133
  for (const col of colMatches) {
61
134
  columns.set(`${tableName}.${col[1]}`, col[2].trim());
@@ -63,6 +136,135 @@ function extractSQLiteColumns(content) {
63
136
  }
64
137
  return columns;
65
138
  }
139
+ /** Top-level comma split: `DECIMAL(10,2)` is one part, not two. */
140
+ function splitTopLevel(body) {
141
+ const parts = [];
142
+ let depth = 0;
143
+ let cur = '';
144
+ for (const ch of body) {
145
+ if (ch === '(')
146
+ depth++;
147
+ else if (ch === ')')
148
+ depth--;
149
+ if (ch === ',' && depth === 0) {
150
+ parts.push(cur);
151
+ cur = '';
152
+ continue;
153
+ }
154
+ cur += ch;
155
+ }
156
+ if (cur.trim())
157
+ parts.push(cur);
158
+ return parts;
159
+ }
160
+ /** Clauses that describe the TABLE, not a column. */
161
+ const TABLE_CONSTRAINT = /^\s*(PRIMARY|FOREIGN|UNIQUE|CHECK|CONSTRAINT|KEY|INDEX|EXCLUDE|LIKE|INHERITS)\b/i;
162
+ /**
163
+ * Columns the schema makes MANDATORY: NOT NULL with nothing to fill them.
164
+ *
165
+ * Needed because the generic added-field rule ("breaking unless the type says
166
+ * optional or ?") is Zod/TypeScript-shaped and reads every SQL type as
167
+ * required — so a plain `ADD COLUMN note TEXT`, the most common and most
168
+ * harmless migration there is, was reported BREAKING. Harmless while the
169
+ * checker only printed advice; not harmless now that a gate blocks on the
170
+ * verdict, where it would refuse ordinary additive migrations and teach people
171
+ * to reach for the waiver.
172
+ *
173
+ * A column is only genuinely breaking to add when existing rows and existing
174
+ * INSERTs cannot satisfy it: NOT NULL, no DEFAULT, and not self-filling
175
+ * (SERIAL/IDENTITY/AUTOINCREMENT/GENERATED).
176
+ *
177
+ * The table body is matched by counting parens rather than with the
178
+ * non-greedy `\(([\s\S]*?)\)` used elsewhere here, which stops at the first
179
+ * `)` and so loses every column after a `DECIMAL(10,2)`. Over-collecting is
180
+ * safe: this set only ever classifies names the diff already produced.
181
+ */
182
+ function sqlRequiredColumns(raw) {
183
+ const required = new Set();
184
+ const content = stripSqlComments(raw);
185
+ const heads = content.matchAll(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w."`\[\]]+)\s*\(/gi);
186
+ for (const head of heads) {
187
+ const table = (head[1] ?? '').replace(/["`[\]]/g, '').split('.').pop() ?? '';
188
+ const body = tableBody(content, (head.index ?? 0) + head[0].length - 1);
189
+ if (body === null)
190
+ continue;
191
+ for (const part of splitTopLevel(body)) {
192
+ if (TABLE_CONSTRAINT.test(part))
193
+ continue;
194
+ const name = part.trim().match(/^["`[]?(\w+)/)?.[1];
195
+ if (!name)
196
+ continue;
197
+ const notNull = /\bNOT\s+NULL\b/i.test(part);
198
+ const filled = /\b(DEFAULT|SERIAL|BIGSERIAL|SMALLSERIAL|AUTOINCREMENT|IDENTITY|GENERATED)\b/i.test(part);
199
+ if (notNull && !filled)
200
+ required.add(`${table}.${name}`);
201
+ }
202
+ }
203
+ return required;
204
+ }
205
+ /**
206
+ * Apply the SQL rule to `added` verdicts produced by the generic differ.
207
+ *
208
+ * Only additions are re-scored: removals and type changes keep the generic
209
+ * judgement, which is right for SQL too.
210
+ */
211
+ function rescoreSqlAdditions(changes, afterContent, context) {
212
+ const required = sqlRequiredColumns(afterContent);
213
+ return changes.map((c) => {
214
+ if (c.type !== 'added')
215
+ return c;
216
+ const column = c.path.startsWith(`${context}.`) ? c.path.slice(context.length + 1) : c.path;
217
+ const breaking = required.has(column);
218
+ if (breaking === c.breaking)
219
+ return c;
220
+ return {
221
+ ...c,
222
+ breaking,
223
+ description: breaking
224
+ ? `${c.description.replace(/ \(required — breaking\)$/, '')} (NOT NULL with no default — breaking)`
225
+ : c.description.replace(/ \(required — breaking\)$/, ''),
226
+ };
227
+ });
228
+ }
229
+ /**
230
+ * Destructive DDL statements, as human-readable descriptions.
231
+ *
232
+ * The CREATE TABLE differ can only speak about a file that HAS a previous
233
+ * version to compare against, which excludes the single most common breaking
234
+ * change there is: a brand-new migration whose whole content is `DROP TABLE
235
+ * users;`. Nothing was removed relative to the old file, because there is no
236
+ * old file -- so the differ said "no changes" and the gate cleared it.
237
+ *
238
+ * These statements are breaking on sight, no baseline required.
239
+ */
240
+ function destructiveDdl(raw) {
241
+ const sql = stripSqlComments(raw);
242
+ const found = [];
243
+ const scan = (re, describe) => {
244
+ for (const m of sql.matchAll(re))
245
+ found.push(describe(m));
246
+ };
247
+ scan(/\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."`[\]]+)/gi, (m) => `DROP TABLE ${m[1]}`);
248
+ scan(/\bDROP\s+(?:MATERIALIZED\s+)?VIEW\s+(?:IF\s+EXISTS\s+)?([\w."`[\]]+)/gi, (m) => `DROP VIEW ${m[1]}`);
249
+ scan(/\bDROP\s+INDEX\s+(?:IF\s+EXISTS\s+)?([\w."`[\]]+)/gi, (m) => `DROP INDEX ${m[1]}`);
250
+ scan(/\bTRUNCATE\s+(?:TABLE\s+)?([\w."`[\]]+)/gi, (m) => `TRUNCATE ${m[1]}`);
251
+ scan(/\bALTER\s+TABLE\s+([\w."`[\]]+)\s+DROP\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?([\w."`[\]]+)/gi, (m) => `ALTER TABLE ${m[1]} DROP COLUMN ${m[2]}`);
252
+ scan(/\bALTER\s+TABLE\s+([\w."`[\]]+)\s+RENAME\s+(?:COLUMN\s+)?([\w."`[\]]+)\s+TO\s+([\w."`[\]]+)/gi, (m) => `ALTER TABLE ${m[1]} RENAME ${m[2]} TO ${m[3]}`);
253
+ scan(/\bALTER\s+TABLE\s+([\w."`[\]]+)\s+RENAME\s+TO\s+([\w."`[\]]+)/gi, (m) => `ALTER TABLE ${m[1]} RENAME TO ${m[2]}`);
254
+ scan(/\bALTER\s+TABLE\s+([\w."`[\]]+)\s+ALTER\s+(?:COLUMN\s+)?([\w."`[\]]+)\s+(?:SET\s+DATA\s+)?TYPE\b/gi, (m) => `ALTER TABLE ${m[1]} ALTER COLUMN ${m[2]} TYPE`);
255
+ scan(/\bALTER\s+TABLE\s+([\w."`[\]]+)\s+ALTER\s+(?:COLUMN\s+)?([\w."`[\]]+)\s+SET\s+NOT\s+NULL/gi, (m) => `ALTER TABLE ${m[1]} SET NOT NULL on ${m[2]}`);
256
+ // ADD COLUMN is breaking only when existing rows cannot satisfy it, the same
257
+ // rule sqlRequiredColumns applies inside CREATE TABLE.
258
+ for (const m of sql.matchAll(/\bALTER\s+TABLE\s+([\w."`[\]]+)\s+ADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?([\w."`[\]]+)([^;]*)/gi)) {
259
+ const tail = m[3] ?? '';
260
+ const notNull = /\bNOT\s+NULL\b/i.test(tail);
261
+ const filled = /\b(DEFAULT|SERIAL|BIGSERIAL|SMALLSERIAL|AUTOINCREMENT|IDENTITY|GENERATED)\b/i.test(tail);
262
+ if (notNull && !filled) {
263
+ found.push(`ALTER TABLE ${m[1]} ADD COLUMN ${m[2]} NOT NULL with no default`);
264
+ }
265
+ }
266
+ return found;
267
+ }
66
268
  /**
67
269
  * Compare two field maps and produce a list of changes.
68
270
  */
@@ -111,24 +313,182 @@ function diffFields(before, after, context) {
111
313
  /**
112
314
  * Diff a single file's schema between two versions (git-based).
113
315
  */
114
- export async function diffFileSchema(filePath, baseBranch = 'HEAD~1') {
316
+ /**
317
+ * git resolves GIT_DIR/GIT_WORK_TREE BEFORE cwd, so passing `cwd` is not enough:
318
+ * inside a git hook (which exports them) git answers about the hook's repo and
319
+ * the cwd argument is silently ignored. This repo has a documented incident
320
+ * from exactly that — see _common.py's _clean_env and worktree.ts.
321
+ */
322
+ function gitEnv() {
323
+ const env = { ...process.env };
324
+ for (const k of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_PREFIX']) {
325
+ delete env[k];
326
+ }
327
+ return env;
328
+ }
329
+ /**
330
+ * Both versions of a path, because a commit may take either.
331
+ *
332
+ * `git commit` stores the INDEX; `git commit -a` stores the WORKTREE. Callers
333
+ * need to know both to decide which one a given commit will take — but only
334
+ * the analysed one is ever RECORDED (see committedVersion), because a marker
335
+ * that names bytes nothing inspected is the bug this whole change exists to
336
+ * remove.
337
+ */
338
+ async function fileVersions(path, cwd) {
339
+ const { execFileSync } = await import('child_process');
340
+ const opts = { cwd, env: gitEnv(), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] };
341
+ const hex = (s) => (/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(s) ? s : '');
342
+ let index = '';
343
+ let worktree = '';
344
+ try {
345
+ index = hex(execFileSync('git', ['rev-parse', `:${path}`], opts).trim());
346
+ }
347
+ catch {
348
+ /* not staged */
349
+ }
350
+ try {
351
+ worktree = hex(execFileSync('git', ['hash-object', '--', path], opts).trim());
352
+ }
353
+ catch {
354
+ /* deleted, unreadable, sparse */
355
+ }
356
+ return { index, worktree };
357
+ }
358
+ /**
359
+ * The version that will be committed, and the bytes to analyse.
360
+ *
361
+ * INDEX-preferred, because `git commit` stores the index. Recording the
362
+ * worktree instead is what let a staged-malicious / benign-worktree pair clear
363
+ * a gate claiming to cover "staged content"; and recording BOTH without
364
+ * analysing both would re-open exactly that, since the marker would vouch for
365
+ * bytes nothing inspected. So the analysed bytes and the recorded sha are the
366
+ * same object, always.
367
+ *
368
+ * When the worktree has since diverged, only the index version is vouched for.
369
+ * A subsequent `git commit -a` would take the worktree, which the enforcer then
370
+ * finds uncovered and blocks — clearable with `git add` and a re-run.
371
+ */
372
+ async function committedVersion(path, cwd, source = 'index') {
373
+ const { execFileSync } = await import('child_process');
374
+ const opts = {
375
+ cwd,
376
+ env: gitEnv(),
377
+ encoding: 'utf-8',
378
+ stdio: ['ignore', 'pipe', 'ignore'],
379
+ };
380
+ const v = await fileVersions(path, cwd);
381
+ // `commit -a` / `--all` stores the worktree; everything else the index. The
382
+ // GATE decides which and tells us, so the bytes examined here are the bytes
383
+ // that command will store — no inference, no drift.
384
+ const preferWorktree = source === 'worktree';
385
+ if (!preferWorktree && v.index) {
386
+ try {
387
+ const content = execFileSync('git', ['cat-file', 'blob', v.index], {
388
+ ...opts,
389
+ maxBuffer: 32 * 1024 * 1024,
390
+ });
391
+ return { sha: v.index, content };
392
+ }
393
+ catch {
394
+ return { sha: v.index, content: null };
395
+ }
396
+ }
397
+ if (v.worktree) {
398
+ const abs = isAbsolute(path) ? path : join(cwd, path);
399
+ try {
400
+ return { sha: v.worktree, content: readFileSync(abs, 'utf-8') };
401
+ }
402
+ catch {
403
+ return { sha: v.worktree, content: null };
404
+ }
405
+ }
406
+ return { sha: '', content: null };
407
+ }
408
+ async function hashFiles(paths, cwd, source = 'index') {
409
+ if (paths.length === 0)
410
+ return [];
411
+ const out = [];
412
+ for (const path of paths) {
413
+ // The source's OWN blob, with no cross-source fallback.
414
+ // committedVersion falls back to the worktree when the index has no entry,
415
+ // which is right for choosing bytes to analyse but wrong as an identity
416
+ // claim: for `git rm --cached x` it reported the worktree sha for a path
417
+ // the index no longer holds, so the gate's sha comparison disagreed and a
418
+ // precisely-detected staged deletion decayed into "could not answer".
419
+ // Empty means "this source holds nothing here" — itself a fact the gate
420
+ // needs, since that is exactly what a deletion looks like.
421
+ const v = await fileVersions(path, cwd);
422
+ out.push({ path, sha: source === 'worktree' ? v.worktree : v.index });
423
+ }
424
+ return out;
425
+ }
426
+ export async function diffFileSchema(filePath, baseBranch = 'HEAD~1', cwd = process.cwd(), source = 'index') {
115
427
  const changes = [];
428
+ const isSql = filePath.endsWith('.sql');
429
+ // False until an analyser proves otherwise. Every early exit and the outer
430
+ // catch therefore report "not analysed", which the gate treats as uncovered
431
+ // rather than as clean.
432
+ let analysed = false;
116
433
  try {
117
434
  // Get the "before" version from git
118
- const { execSync } = await import('child_process');
119
435
  let beforeContent;
436
+ let hasBaseline = true;
120
437
  try {
121
- beforeContent = execSync(`git show ${baseBranch}:${filePath}`, {
438
+ // execFileSync with an argv array: the old template string ran through a
439
+ // shell, and `filePath` comes straight from `git diff --name-only`, which
440
+ // does not quote ASCII metacharacters — a file named `a;$(id).sql`
441
+ // executed. `baseBranch` is user-supplied via -b, same exposure.
442
+ const { execFileSync } = await import('child_process');
443
+ beforeContent = execFileSync('git', ['show', `${baseBranch}:${filePath}`], {
122
444
  encoding: 'utf-8',
123
445
  stdio: ['pipe', 'pipe', 'pipe'],
446
+ cwd,
447
+ env: gitEnv(),
124
448
  });
125
449
  }
126
450
  catch {
127
- // File didn't exist before all fields are new (non-breaking)
128
- return { file: filePath, changes: [], breaking: false };
451
+ // No previous version. Adding fields to something that did not exist
452
+ // cannot break a reader of the OLD schema -- but the file's own
453
+ // statements still can, and a new migration is where they usually live.
454
+ // Returning "no changes" here is what cleared a brand-new
455
+ // migrations/002.sql whose entire content was DROP TABLE users;.
456
+ beforeContent = '';
457
+ hasBaseline = false;
129
458
  }
130
459
  // Get the "after" version from working tree
131
- if (!existsSync(filePath)) {
460
+ // Resolved against the run's cwd: `git diff --name-only` emits
461
+ // repo-root-relative paths, so reading them against process.cwd() made
462
+ // every file look DELETED when run from a subdirectory — reporting
463
+ // breaking:true for each and blocking the very commit it was asked to
464
+ // clear. (join() leaves an absolute path untouched.)
465
+ // Analyse the version that will be COMMITTED (index-preferred), so the
466
+ // bytes inspected here are the same object whose sha the marker records.
467
+ // Reading the worktree while recording the index — or the reverse — is how
468
+ // a marker ends up vouching for bytes nothing looked at.
469
+ const committed = await committedVersion(filePath, cwd, source);
470
+ const absPath = isAbsolute(filePath) ? filePath : join(cwd, filePath);
471
+ // A path removed from the INDEX is a staged deletion even though the
472
+ // worktree copy is still sitting there. committedVersion falls back to the
473
+ // worktree when the index has no entry, so `git rm --cached x` was
474
+ // analysed as "worktree vs HEAD: unchanged" -- clean -- while the commit
475
+ // removed the file. Verified.
476
+ if (hasBaseline && source === 'index' && !(await fileVersions(filePath, cwd)).index) {
477
+ return {
478
+ file: filePath,
479
+ changes: [
480
+ {
481
+ type: 'removed',
482
+ path: filePath,
483
+ description: `File "${filePath}" was removed from the index (staged deletion)`,
484
+ breaking: true,
485
+ },
486
+ ],
487
+ breaking: true,
488
+ analysed: true,
489
+ };
490
+ }
491
+ if (committed.content === null && !existsSync(absPath)) {
132
492
  // File was deleted — all fields removed (breaking)
133
493
  return {
134
494
  file: filePath,
@@ -141,11 +501,28 @@ export async function diffFileSchema(filePath, baseBranch = 'HEAD~1') {
141
501
  },
142
502
  ],
143
503
  breaking: true,
504
+ analysed: true,
144
505
  };
145
506
  }
146
- const afterContent = readFileSync(filePath, 'utf-8');
507
+ const afterContent = committed.content ?? readFileSync(absPath, 'utf-8');
508
+ // Statements that are breaking on their own terms, baseline or not.
509
+ const before = new Set(hasBaseline ? destructiveDdl(beforeContent) : []);
510
+ for (const stmt of destructiveDdl(afterContent)) {
511
+ if (before.has(stmt))
512
+ continue; // already in the base; not new here
513
+ changes.push({
514
+ type: 'removed',
515
+ path: filePath,
516
+ description: `Destructive statement: ${stmt}`,
517
+ breaking: true,
518
+ });
519
+ }
147
520
  // Detect schema type and extract fields
521
+ let understood = isSql; // .sql is covered: CREATE TABLE diff + DDL scan
148
522
  if (filePath.endsWith('.ts') || filePath.endsWith('.js')) {
523
+ // Only meaningful against a baseline -- there is no TS analogue of the
524
+ // DDL scan, so a new file tells us nothing about what it breaks.
525
+ understood = hasBaseline;
149
526
  // Check for Zod schemas
150
527
  if (beforeContent.includes('z.object') || afterContent.includes('z.object')) {
151
528
  const beforeFields = extractZodFields(beforeContent);
@@ -162,14 +539,14 @@ export async function diffFileSchema(filePath, baseBranch = 'HEAD~1') {
162
539
  if (beforeContent.includes('CREATE TABLE') || afterContent.includes('CREATE TABLE')) {
163
540
  const beforeCols = extractSQLiteColumns(beforeContent);
164
541
  const afterCols = extractSQLiteColumns(afterContent);
165
- changes.push(...diffFields(beforeCols, afterCols, 'sqlite'));
542
+ changes.push(...rescoreSqlAdditions(diffFields(beforeCols, afterCols, 'sqlite'), afterContent, 'sqlite'));
166
543
  }
167
544
  }
168
545
  // SQL files
169
546
  if (filePath.endsWith('.sql')) {
170
547
  const beforeCols = extractSQLiteColumns(beforeContent);
171
548
  const afterCols = extractSQLiteColumns(afterContent);
172
- changes.push(...diffFields(beforeCols, afterCols, 'sql'));
549
+ changes.push(...rescoreSqlAdditions(diffFields(beforeCols, afterCols, 'sql'), afterContent, 'sql'));
173
550
  }
174
551
  // JSON schema files
175
552
  if (filePath.endsWith('.json') &&
@@ -177,6 +554,7 @@ export async function diffFileSchema(filePath, baseBranch = 'HEAD~1') {
177
554
  try {
178
555
  const beforeObj = JSON.parse(beforeContent);
179
556
  const afterObj = JSON.parse(afterContent);
557
+ understood = hasBaseline;
180
558
  const flatBefore = flattenObject(beforeObj);
181
559
  const flatAfter = flattenObject(afterObj);
182
560
  const beforeKeys = new Map(Object.keys(flatBefore).map((k) => [k, typeof flatBefore[k]]));
@@ -184,17 +562,21 @@ export async function diffFileSchema(filePath, baseBranch = 'HEAD~1') {
184
562
  changes.push(...diffFields(beforeKeys, afterKeys, 'json'));
185
563
  }
186
564
  catch {
187
- // Not valid JSON
565
+ // Not valid JSON — say so rather than reporting a clean parse.
566
+ understood = false;
188
567
  }
189
568
  }
569
+ analysed = understood;
190
570
  }
191
571
  catch {
192
- // Git or file read error
572
+ // Git or file read error. `analysed` stays false: the gate must not read a
573
+ // failed read as an all-clear.
193
574
  }
194
575
  return {
195
576
  file: filePath,
196
577
  changes,
197
578
  breaking: changes.some((c) => c.breaking),
579
+ analysed,
198
580
  };
199
581
  }
200
582
  /**
@@ -213,22 +595,122 @@ function flattenObject(obj, prefix = '') {
213
595
  }
214
596
  return result;
215
597
  }
598
+ export async function schemaDiffCommand(baseBranch = 'HEAD~1') {
599
+ return (await runSchemaDiff(baseBranch)).results;
600
+ }
601
+ /** True when `base` names a real commit here — not a filename git would
602
+ * refuse as ambiguous, and not a typo. */
603
+ async function baseResolves(base, cwd) {
604
+ try {
605
+ // `await import`, NOT a bare require: this module is built and shipped as
606
+ // ESM, where `require` is not a binding. A bare one type-checks (@types/node
607
+ // declares it globally) and works under vitest (vite-node injects one), so
608
+ // the build and the entire suite stayed green while the SHIPPED artifact
609
+ // threw ReferenceError — swallowed by the catch below, making this function
610
+ // return false unconditionally and silently disabling the check it exists
611
+ // to perform. Verified against dist: the CLI announced "HEAD~1 is not a
612
+ // commit here" for a repo where it plainly was, and recorded a pass.
613
+ const { execFileSync } = await import('child_process');
614
+ execFileSync('git', ['rev-parse', '--verify', '--quiet', `${base}^{commit}`], {
615
+ cwd,
616
+ stdio: 'ignore',
617
+ env: gitEnv(),
618
+ });
619
+ return true;
620
+ }
621
+ catch {
622
+ return false;
623
+ }
624
+ }
216
625
  /**
217
- * Run schema diff on all changed files that contain schemas.
626
+ * What the GATE watches kept identical to WATCHED_RE in
627
+ * src/policies/enforcers/schema_diff_gate.py.
628
+ *
629
+ * The two sets must agree, because the gate now demands that every watched path
630
+ * be covered by the marker, and only paths this CLI EXAMINES get into the
631
+ * marker. When the gate watched a superset, the difference was a permanent
632
+ * deadlock: `infra/helm_charts/pgdog/values.yaml` matched none of the filters
633
+ * below, so it could never be recorded, and the refusal told the operator to
634
+ * re-run a command that produced the identical marker. Verified before the fix.
635
+ *
636
+ * These files are hashed and recorded even though diffFileSchema cannot parse
637
+ * YAML — coverage is about "these bytes were seen", which is exactly the claim
638
+ * the marker needs to support. Any change to the enforcer's WATCHED_RE must be
639
+ * mirrored here; test/cli/gate-watched-mirror.test.ts pins that.
218
640
  */
219
- export async function schemaDiffCommand(baseBranch = 'HEAD~1') {
641
+ export // `s` (dotAll) so `.` spans a newline: a path may legally contain one, and
642
+
643
+ // without the flag `migrations/a\nb.sql` matched nothing — the gate did not
644
+ // consider it watched at all, and a column drop in it was never examined.
645
+ const GATE_WATCHED_RE = /(migrations\/.*\.sql|infra\/postgres-spock\/|infra\/helm_charts\/[^/]*pgdog|infra\/helm_charts\/[^/]*cnpg|infra\/helm_charts\/[^/]*redis|infra\/helm_charts\/[^/]*envoy|infra\/helm_charts\/[^/]*sentinel)/is;
646
+ /** git's empty tree — diffing against it means "every tracked file is new". */
647
+ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
648
+ /**
649
+ * Version of the --json verdict shape.
650
+ *
651
+ * Bump on any change to the fields the gate reads. The gate refuses a contract
652
+ * it does not recognise and falls back rather than guessing, so an old gate
653
+ * paired with a new CLI degrades instead of misreading.
654
+ */
655
+ export const SCHEMA_DIFF_CONTRACT = 1;
656
+ export async function runSchemaDiff(baseBranch = 'HEAD~1', cwd = process.cwd(),
657
+ /**
658
+ * True when the caller did not pass -b, i.e. `HEAD~1` is our default rather
659
+ * than the operator's choice. Only then may an unresolvable base fall back to
660
+ * the empty tree.
661
+ *
662
+ * Without that distinction, removing the rubber stamp DEADLOCKED a repo at
663
+ * its initial commit or a shallow CI clone: `HEAD~1` does not resolve, the
664
+ * run reports ran=false, no marker is written, and the gate refuses every
665
+ * commit with no waiver and no override — verified. Falling back
666
+ * unconditionally would instead resurrect the stamp for `-b typo`.
667
+ */
668
+ baseIsDefault = false, opts = {}) {
669
+ const { only, source = 'index', quiet = false } = opts;
220
670
  const results = [];
671
+ let examined = [];
672
+ let ran = false;
673
+ let effectiveBase = baseBranch;
221
674
  try {
222
- const { execSync } = await import('child_process');
223
675
  // Get list of changed files
224
- const changedFiles = execSync(`git diff --name-only ${baseBranch}`, {
676
+ const { execFileSync } = await import('child_process');
677
+ const listChanged = (base) => execFileSync('git', ['diff', '--name-only', base, '--'], {
225
678
  encoding: 'utf-8',
679
+ cwd,
680
+ env: gitEnv(),
681
+ stdio: ['ignore', 'pipe', 'pipe'],
226
682
  })
227
683
  .trim()
228
684
  .split('\n')
229
685
  .filter(Boolean);
686
+ let changedFiles;
687
+ try {
688
+ changedFiles = listChanged(baseBranch);
689
+ }
690
+ catch (err) {
691
+ // Fall back ONLY when the base is provably not a commit. Inferring
692
+ // "unresolvable" from any `git diff` failure was a full bypass: git
693
+ // refuses an argument that is BOTH a revision and an existing path
694
+ // ("fatal: ambiguous argument"), so `touch 'HEAD~1'` in an ordinary repo
695
+ // forced the fallback and manufactured a pass for a staged DROP TABLE.
696
+ // Verified. `--verify --quiet <base>^{commit}` cannot be confused with a
697
+ // filename, so it answers the question the fallback actually depends on.
698
+ if (!baseIsDefault || (await baseResolves(baseBranch, cwd)))
699
+ throw err;
700
+ // Genuinely baseline-free (initial commit, shallow clone): there is
701
+ // nothing to compare against, so record that honestly rather than
702
+ // deadlocking the gate. The per-file loop is skipped — every `git show`
703
+ // against the empty tree fails by construction, so it can only burn
704
+ // process spawns to conclude nothing.
705
+ console.log(`Note: ${baseBranch} is not a commit here (initial commit or shallow clone) — no baseline to diff against.`);
706
+ effectiveBase = `${EMPTY_TREE} (no baseline)`;
707
+ ran = true;
708
+ examined = [];
709
+ return { ran, examined, effectiveBase, results };
710
+ }
230
711
  // Filter to schema-relevant files
231
- const schemaFiles = changedFiles.filter((f) => f.includes('schema') ||
712
+ const schemaFiles = changedFiles.filter((f) => GATE_WATCHED_RE.test(f) ||
713
+ f.includes('schema') ||
232
714
  f.includes('types') ||
233
715
  f.includes('config') ||
234
716
  f.includes('database') ||
@@ -240,22 +722,46 @@ export async function schemaDiffCommand(baseBranch = 'HEAD~1') {
240
722
  f.includes('memory/short-term/schema') ||
241
723
  f.includes('tasks/database') ||
242
724
  f.includes('tasks/types'))));
243
- for (const file of schemaFiles) {
244
- const result = await diffFileSchema(file, baseBranch);
245
- if (result.changes.length > 0) {
246
- results.push(result);
247
- }
725
+ // `only` REPLACES this enumeration rather than intersecting it. The list
726
+ // above comes from `git diff --name-only <base>`, which compares the
727
+ // WORKTREE so a staged-only change whose worktree copy still matches the
728
+ // base does not appear in it at all. Intersecting would have dropped
729
+ // exactly those paths, handed the gate an empty `examined`, and let the
730
+ // staged change through as "covered": the same class of hole as verifying
731
+ // the index while `-a` commits the worktree. The gate derives its watched
732
+ // set from the source it is about to commit, and it is authoritative.
733
+ const scopedFiles = only ? [...new Set(only)] : schemaFiles;
734
+ // Past this point git has produced a real file list, so the run is genuine
735
+ // even if nothing schema-relevant changed.
736
+ ran = true;
737
+ // Hash what we are about to read, so the marker binds to content. Batched
738
+ // in one process rather than one per file: this runs inside a gate remedy.
739
+ examined = await hashFiles(scopedFiles, cwd, source);
740
+ for (const file of scopedFiles) {
741
+ const result = await diffFileSchema(file, effectiveBase, cwd, source);
742
+ // Every examined path gets a result, not just the ones with changes: the
743
+ // gate needs `analysed` for the quiet ones too, and "absent from the
744
+ // list" is precisely the ambiguity between "clean" and "never looked at"
745
+ // that this change exists to remove. The human report still prints only
746
+ // files with changes.
747
+ results.push(result);
248
748
  }
249
- // Print results
250
- if (results.length === 0) {
749
+ // Print results. In --json mode the verdict IS the output: a stray human
750
+ // line here would break the gate's JSON parse, and a gate that cannot read
751
+ // its checker falls back to allow-with-warning — silently unarmed.
752
+ const changed = results.filter((r) => r.changes.length > 0);
753
+ if (quiet) {
754
+ // caller renders the verdict
755
+ }
756
+ else if (changed.length === 0) {
251
757
  console.log('No schema changes detected.');
252
758
  }
253
759
  else {
254
- const hasBreaking = results.some((r) => r.breaking);
255
- console.log(`\nSchema Diff Results (${results.length} files with changes):`);
760
+ const hasBreaking = changed.some((r) => r.breaking);
761
+ console.log(`\nSchema Diff Results (${changed.length} files with changes):`);
256
762
  console.log(hasBreaking ? ' BREAKING CHANGES DETECTED' : ' No breaking changes');
257
763
  console.log('');
258
- for (const result of results) {
764
+ for (const result of changed) {
259
765
  console.log(` ${result.breaking ? 'BREAKING' : 'OK'} ${result.file}`);
260
766
  for (const change of result.changes) {
261
767
  const icon = change.breaking ? ' !!!' : ' ';
@@ -267,7 +773,7 @@ export async function schemaDiffCommand(baseBranch = 'HEAD~1') {
267
773
  catch (err) {
268
774
  console.error(`Schema diff error: ${err instanceof Error ? err.message : String(err)}`);
269
775
  }
270
- return results;
776
+ return { ran, examined, effectiveBase, results };
271
777
  }
272
778
  /**
273
779
  * Record a successful schema-diff run in SHORT-TERM memory.
@@ -285,41 +791,186 @@ export async function schemaDiffCommand(baseBranch = 'HEAD~1') {
285
791
  * this is a gate-contract record, deliberately below the memory quality bar.
286
792
  * Best-effort — a recording failure must never fail the diff itself.
287
793
  */
288
- async function recordSchemaDiffPass(baseBranch, filesChecked) {
794
+ /** Bounded rendering: one branch touching hundreds of schema files otherwise
795
+ * writes a multi-kilobyte row into the shared short-term store, at high
796
+ * importance, where it crowds out real context and survives pruning. */
797
+ /**
798
+ * How many `path@sha` entries a marker may carry before it degrades to a
799
+ * legacy (time-only) marker.
800
+ *
801
+ * Content scoping needs EVERY examined path in the marker — a truncated list
802
+ * would leave the omitted paths uncovered and block them with no way for the
803
+ * operator to produce a covering marker. Rather than deadlock, an oversized
804
+ * change records `(truncated)` and the gate falls back to time-only. 100
805
+ * entries is roughly 5KB, above any realistic schema change and well under the
806
+ * size where one memory row starts crowding recall.
807
+ */
808
+ const MAX_MARKER_ENTRIES = 100;
809
+ /** Marker rendering. `path@sha7` is what makes a pass content-scoped. */
810
+ function fileList(examined) {
811
+ if (examined.length === 0)
812
+ return '(none changed)';
813
+ if (examined.length > MAX_MARKER_ENTRIES) {
814
+ return `(truncated: ${examined.length} files)`;
815
+ }
816
+ return examined
817
+ .map((f) => {
818
+ // A path containing the delimiters can forge a second, parsable entry:
819
+ // a file named `xschema@<40hex>,migrations/002.sql@<benign sha>` parses
820
+ // into a bogus coverage claim for 002.sql that overwrites the real one.
821
+ // Such a path is recorded WITHOUT identity, so the enforcer treats that
822
+ // one path as uncovered instead of trusting an attacker-built entry.
823
+ const safe = !f.path.includes(',') && !f.path.includes('@');
824
+ return f.sha && safe ? `${f.path}@${f.sha}` : f.path;
825
+ })
826
+ .join(',');
827
+ }
828
+ export async function recordSchemaDiffPass(baseBranch, examined, cwd = process.cwd()) {
289
829
  try {
290
- const cwd = process.cwd();
291
- const config = loadUapConfig(cwd);
292
- const dbPath = shortTermDbPath(cwd, config?.memory?.shortTerm?.path);
830
+ // Resolve to the git toplevel first. shortTermDbPath() anchors to the main
831
+ // checkout by stripping a `.worktrees/` segment, but only if it is GIVEN a
832
+ // path containing one — from a SUBDIRECTORY (say <repo>/migrations) it
833
+ // would happily create a stray agents/data/memory/short_term.db there,
834
+ // print "Recorded schema-diff pass", and leave the gate reading a different
835
+ // database and still blocking.
836
+ let root = cwd;
837
+ try {
838
+ const { execFileSync } = await import('child_process');
839
+ root =
840
+ execFileSync('git', ['rev-parse', '--show-toplevel'], {
841
+ cwd,
842
+ encoding: 'utf-8',
843
+ stdio: ['ignore', 'pipe', 'ignore'],
844
+ // This call picks the DATABASE the marker lands in. Under a poisoned
845
+ // GIT_DIR it answers for another repo, the marker is written there,
846
+ // the CLI prints success, and the gate never sees it — a silent,
847
+ // unbreakable block. A wrong diff is loud; a wrong marker location
848
+ // is not.
849
+ env: gitEnv(),
850
+ }).trim() || cwd;
851
+ }
852
+ catch {
853
+ /* not a git repo — fall back to the caller's directory */
854
+ }
855
+ const config = loadUapConfig(root);
856
+ const dbPath = shortTermDbPath(root, config?.memory?.shortTerm?.path);
293
857
  const db = new SQLiteShortTermMemory({
294
858
  dbPath,
295
859
  projectId: config?.project?.name ?? 'project',
296
860
  maxEntries: config?.memory?.shortTerm?.maxEntries || 50,
297
861
  });
298
- await db.store('action', `schema-diff pass: base ${baseBranch}, ${filesChecked} schema file(s) checked, no breaking changes`,
862
+ await db.store('action',
863
+ // FIXED PREFIX, matched anchored by the enforcer. The gate used to accept
864
+ // any text LIKE '%schema-diff%pass%' anywhere in memory — which its own
865
+ // refusal message ("...require `uap schema-diff` to pass") satisfies, so
866
+ // an agent storing the blocker as a lesson unblocked itself. The file
867
+ // list is recorded so a human can audit what a given pass actually covered.
868
+ `schema-diff pass: base ${baseBranch} | files: ${fileList(examined)}`,
299
869
  // High importance so the rolling-window prune (which orders by
300
870
  // importance) does not evict the marker inside its 1h validity window.
301
871
  8);
302
872
  await db.close();
303
873
  console.log('Recorded schema-diff pass for the schema-diff-gate (1h window).');
304
874
  }
305
- catch {
306
- console.log('Note: could not record the pass marker (schema-diff-gate may still block).');
875
+ catch (err) {
876
+ // Recording IS the deliverable for a gate remedy: a diff nobody can act on
877
+ // is worse than a non-zero exit. Swallowing this printed success while the
878
+ // gate stayed shut, with the only documented remedy being the command that
879
+ // had just "succeeded".
880
+ console.error(`Could not record the pass marker — the schema-diff-gate will keep blocking. ` +
881
+ `${err instanceof Error ? err.message : String(err)}`);
882
+ process.exitCode = 1;
307
883
  }
308
884
  }
309
885
  export function registerSchemaDiffCommand(program) {
310
886
  program
311
887
  .command('schema-diff')
312
888
  .description('Detect breaking schema changes between branches')
313
- .option('-b, --base <branch>', 'Base branch/commit to compare against', 'HEAD~1')
889
+ // No commander default: an omitted -b must be distinguishable from an
890
+ // explicit one, because only the default may fall back to the empty tree.
891
+ .option('-b, --base <branch>', 'Base branch/commit to compare against (default: HEAD~1)')
892
+ // --- the inline-gate contract -------------------------------------------
893
+ // The enforcer calls this directly instead of hunting for a marker written
894
+ // by some earlier run. That deletes an entire class of defect: a stored
895
+ // verdict can be stale, forged, truncated, format-drifted, scoped to a
896
+ // different file set, or written by another worktree — and every one of
897
+ // those actually happened here. A verdict computed now, over the exact
898
+ // paths the gate is about to allow, can be none of them.
899
+ .option('--json', 'Emit a machine-readable verdict for the gate (implies no marker write)')
900
+ .option('--paths-from <file>', 'Read NUL-separated paths from a file. Preferred over --paths: a comma or ' +
901
+ 'newline in a filename cannot corrupt the list, and git C-quotes such ' +
902
+ 'names in --name-only output, which made the checker examine a path ' +
903
+ 'that does not exist and report it clean.')
904
+ .option('--paths <csv>', 'Restrict the check to these paths. The GATE passes its own watched set, ' +
905
+ 'so the checker and the gate can never disagree about what was examined.')
906
+ .option('--source <which>', 'Which bytes to examine: "index" (what `git commit` stores) or "worktree" (what `commit -a` stores)')
314
907
  .action(async (options) => {
315
- const results = await schemaDiffCommand(options.base);
316
- const hasBreaking = results.some((r) => r.breaking);
908
+ const baseIsDefault = options.base === undefined;
909
+ const base = options.base ?? 'HEAD~1';
910
+ // No trimming on this path: a leading or trailing space is part of the
911
+ // filename, and silently trimming it produced a path the checker could
912
+ // not open while the gate still counted it as covered.
913
+ const only = options.pathsFrom
914
+ ? readFileSync(options.pathsFrom, 'utf-8').split('\0').filter(Boolean)
915
+ : options.paths
916
+ ? options.paths.split(',').map((p) => p.trim()).filter(Boolean)
917
+ : undefined;
918
+ const run = await runSchemaDiff(base, process.cwd(), baseIsDefault, {
919
+ only,
920
+ source: options.source === 'worktree' ? 'worktree' : 'index',
921
+ quiet: Boolean(options.json),
922
+ });
923
+ const hasBreaking = run.results.some((r) => r.breaking);
924
+ if (options.json) {
925
+ // The gate reads THIS, not an exit code: exit 1 conflates "breaking
926
+ // change found" with "the run never happened", and those must lead to
927
+ // opposite decisions (block vs fall back).
928
+ //
929
+ // One entry per requested path, each carrying the evidence the gate
930
+ // needs to decide whether to believe it:
931
+ // sha the blob actually read. The gate re-derives it and
932
+ // compares. Without it the old `examined` was a list of
933
+ // the gate's own argument echoed back -- it could only
934
+ // fail on a whitespace artefact, never on "nothing was
935
+ // read", which is exactly the case it existed to catch.
936
+ // analysed whether any analyser understood the file at all. An
937
+ // empty `breaking` from a helm chart is not an all-clear.
938
+ // `contract` lets a future gate refuse a shape it does not know
939
+ // instead of misreading it.
940
+ const byPath = new Map(run.results.map((r) => [r.file, r]));
941
+ console.log(JSON.stringify({
942
+ contract: SCHEMA_DIFF_CONTRACT,
943
+ ran: run.ran,
944
+ base: run.effectiveBase,
945
+ files: run.examined.map((f) => {
946
+ const r = byPath.get(f.path);
947
+ return {
948
+ path: f.path,
949
+ sha: f.sha,
950
+ analysed: r ? r.analysed : false,
951
+ breaking: r
952
+ ? r.changes.filter((c) => c.breaking).map((c) => c.description)
953
+ : [],
954
+ };
955
+ }),
956
+ }));
957
+ process.exitCode = hasBreaking ? 1 : 0;
958
+ return;
959
+ }
317
960
  if (hasBreaking) {
318
961
  console.log('\nBreaking changes require explicit approval before proceeding.');
319
962
  process.exitCode = 1;
320
963
  }
964
+ else if (!run.ran) {
965
+ // Do NOT record: git never produced a file list, so this run verified
966
+ // nothing. Recording here is what turned the documented remedy into a
967
+ // rubber stamp obtainable with one bad --base.
968
+ console.error(`\nSchema diff did not complete against "${base}" — nothing was checked, so no pass ` +
969
+ 'was recorded. If that base does not exist here, re-run without -b.');
970
+ process.exitCode = 1;
971
+ }
321
972
  else {
322
- await recordSchemaDiffPass(options.base, results.length);
973
+ await recordSchemaDiffPass(run.effectiveBase, run.examined);
323
974
  }
324
975
  });
325
976
  }