@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +7 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/schema-diff.d.ts +118 -2
- package/dist/cli/schema-diff.d.ts.map +1 -1
- package/dist/cli/schema-diff.js +692 -41
- package/dist/cli/schema-diff.js.map +1 -1
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +7 -4
- package/dist/cli/verify.js.map +1 -1
- package/package.json +3 -3
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/schema_diff_gate.py +629 -41
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/tests/test_schema_diff_gate.py +292 -7
- package/tools/agents/tests/test_schema_diff_inline.py +373 -0
package/dist/cli/schema-diff.js
CHANGED
|
@@ -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(
|
|
120
|
+
function extractSQLiteColumns(raw) {
|
|
54
121
|
const columns = new Map();
|
|
55
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
128
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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 =
|
|
255
|
-
console.log(`\nSchema Diff Results (${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
316
|
-
const
|
|
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(
|
|
973
|
+
await recordSchemaDiffPass(run.effectiveBase, run.examined);
|
|
323
974
|
}
|
|
324
975
|
});
|
|
325
976
|
}
|