fix(pregate): literals reads the merge base through renames, treats an unreadable base as not extended, and recognizes quoted digit-leading history keys

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 05:33:18 +00:00
1 parent de838e3cc1
commit 623cdd7f7e
4 files changed
+31 -17

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
**pregate `literals` no longer flags a version-history table that the branch extended.** A test that keeps one row per past version of a constant (`[45, "<digest>"]`, then `[46, "<digest>"]`) used to fail `literals` on every bump, because the old row still holds the old value. Truther turned the check down to a warning over it, which also hid real stale tests.
### Fixed
- `lib/pregate/checks/literals.ts`: a row that starts with the old value is skipped when the same table holds a new-value row the branch added (the file at the merge base lacks it). The scan continues to the next line holding the old value, so `expect(PROOF_CHECKS).toBe(45)` below an extended table still fails. A table the branch did not extend, or one that already held the new row, still fails.
- `lib/pregate/checks/literals.ts`: a row that starts with the old value is skipped when the same table holds a new-value row the branch added (the file at the merge base lacks it). The scan continues to the next line holding the old value, so `expect(PROOF_CHECKS).toBe(45)` below an extended table still fails. A table the branch did not extend, a file that already held a new-value row at the merge base (read through a rename), and a merge base that cannot be read still fail.
- docs/pregate.md describes the history-row rule and the table shapes it recognizes.
### Tests
- `test/pregate-selection.test.ts`: an extended table, a newest-first table and a new file pass; an unextended table, a stale assertion below an extended table, an unextended table beside an extended one, and a table that already held the new row fail at the right line; a string-valued history passes; and an end-to-end `gstack-pregate --only literals` run fails before the table is extended and passes after.
- `test/pregate-selection.test.ts`: an extended table, a newest-first table and a new file pass; an unextended table, a stale assertion below an extended table, an unextended table beside an extended one, and a table that already held the new row fail at the right line; string-valued and quoted semver histories pass; and an end-to-end `gstack-pregate --only literals` run fails before the table is extended, passes after, and still fails for a renamed file whose table already held the new row.
+4 -3
View File
@@ -172,9 +172,10 @@ Two stages with separate clocks, both printed:
`file:line`. A version-history row, a line that starts a bracketed row with
the old value (`[45, "<digest>"]`), is skipped when the same table (a run of
consecutive lines starting with `[`) also holds a row for the new value that
the branch added. A table the branch did not extend, a table that already
held the new row at the merge base, and every other line holding the old
value still fail. Rows in object form (`{ v: 45 }`) or spread over several
the branch added (the file at the merge base, read through a rename, has no
new-value row). A table the branch did not extend, a file that already held a
new-value row at the merge base, a merge base that cannot be read, and every
other line holding the old value still fail. Rows in object form (`{ v: 45 }`) or spread over several
lines are not recognized as history rows.
- `timing`: added or changed test lines that compare an elapsed wall-clock or
+16 -12
View File
@@ -69,17 +69,18 @@ function unquote(v: string): string { return /^['"`]/.test(v) ? v.slice(1, -1) :
*
* A version-history row (`[45, "<digest>"]`, the old value first in a
* bracketed row) is skipped when the same table, a run of consecutive
* `[…` lines, holds a new-value row that `baseText` (the file at the merge
* base, null for a new file) lacks: the branch extended the table. A table it
* did not extend, or one that already held the new row, still counts, and the
* scan goes on past skipped rows to the first real consumer.
* `[…` lines, holds a new-value row and `baseText` (the file at the merge
* base; null for a file the base lacks, undefined when it cannot be read)
* has no new-value row: the branch extended the table. A table it did not
* extend, a file that already held a new-value row, and an unreadable base
* still count, and the scan goes on past skipped rows to the first real consumer.
*/
export function consumersOf(repoRoot: string, testFiles: readonly string[], change: ChangedLiteral, baseText?: (rel: string) => string | null): string[] {
export function consumersOf(repoRoot: string, testFiles: readonly string[], change: ChangedLiteral, baseText?: (rel: string) => string | null | undefined): string[] {
const value = unquote(change.old);
const literal = (v: string) => /^-?\d/.test(v) ? escape(v) : `['"\`]${escape(v)}['"\`]`;
const valueRe = /^-?\d/.test(value) ? new RegExp(`(?<![\\w.])${escape(value)}(?![\\w.])`) : new RegExp(literal(value));
const oldRowRe = new RegExp(`^\\s*\\[\\s*${literal(value)}\\s*,`);
const newRowRe = new RegExp(`^\\s*\\[\\s*${literal(unquote(change.new))}\\s*,`, 'm');
const valueRe = /^-?\d/.test(value) ? new RegExp(`(?<![\\w.])${escape(value)}(?![\\w.])`) : new RegExp(`['"\`]${escape(value)}['"\`]`);
const row = (raw: string) => new RegExp(`^\\s*\\[\\s*${/^['"`]/.test(raw) ? `['"\`]${escape(unquote(raw))}['"\`]` : escape(raw)}\\s*,`, 'm');
const oldRowRe = row(change.old);
const newRowRe = row(change.new);
const base = path.basename(change.file);
const stem = base.replace(/\.[^.]+$/, '');
const moduleRe = new RegExp(`(?:(?:^|[/\\\\])${escape(stem)}|(?<![\\w-])${escape(base)})(?![\\w-])`, 'm');
@@ -97,7 +98,7 @@ export function consumersOf(repoRoot: string, testFiles: readonly string[], chan
let a = i; while (a > 0 && /^\s*\[/.test(lines[a - 1]!)) a--;
let b = i; while (b < lines.length - 1 && /^\s*\[/.test(lines[b + 1]!)) b++;
if (!lines.slice(a, b + 1).some(l => newRowRe.test(l))) return false;
extended ??= baseText !== undefined && !newRowRe.test(baseText(rel) ?? '');
if (extended === undefined) { const b = baseText?.(rel); extended = b === null || (b !== undefined && !newRowRe.test(b)); }
return extended;
};
for (let i = 0; i < lines.length; i++) if (valueRe.test(lines[i]!) && !skip(i)) { hits.push(`${rel}:${i + 1}`); break; }
@@ -121,8 +122,11 @@ function run(ctx: PregateContext): CheckResult {
const lines: string[] = [];
for (const c of changes) {
const hits = consumersOf(ctx.repoRoot, testFiles, c, rel => {
const r = spawnSync('git', ['show', `${ctx.mergeBase}:${rel}`], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024, env: ctx.env });
return r.status === 0 ? r.stdout : null;
const from = ctx.touched.find(t => t.path === rel)?.renamedFrom ?? rel;
const r = spawnSync('git', ['show', `${ctx.mergeBase}:${from}`], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024, env: ctx.env });
if (r.status === 0) return r.stdout;
const ls = spawnSync('git', ['ls-tree', ctx.mergeBase, '--', from], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 30_000, env: ctx.env });
return ls.status === 0 && !ls.stdout.trim() ? null : undefined;
});
ctx.explain.push(`literals: ${c.file} ${c.name} ${c.old} → ${c.new}: ${hits.length} test file(s) still carry ${c.old}`);
for (const h of hits) lines.push(`${h}: ${c.name} still ${c.old} (now ${c.new} in ${c.file})`);
+9
View File
@@ -249,6 +249,10 @@ describe('literals (pure)', () => {
fs.writeFileSync(path.join(root, 'test/versions.test.ts'), ["import { SCHEMA } from '../src/schema.ts';", 'const history = [', " ['v3', 'a'],", ' ["v4", \'b\'],', '];', ''].join(nl));
const strings = (rel: string) => rel === 'test/versions.test.ts' ? ["import { SCHEMA } from '../src/schema.ts';", 'const history = [', " ['v3', 'a'],", '];'].join(nl) : null;
expect(consumersOf(root, ['test/versions.test.ts'], { name: 'SCHEMA', old: "'v3'", new: "'v4'", file: 'src/schema.ts' }, strings)).toEqual([]);
expect(consumersOf(root, ['test/versions.test.ts'], { name: 'SCHEMA', old: "'v3'", new: "'v4'", file: 'src/schema.ts' }, () => undefined)).toEqual(['test/versions.test.ts:3']);
fs.writeFileSync(path.join(root, 'test/semver.test.ts'), ["import { RELEASE } from '../src/release.ts';", 'const history = [', " ['1.2.3', 'a'],", " ['1.2.4', 'b'],", '];', ''].join(nl));
expect(consumersOf(root, ['test/semver.test.ts'], { name: 'RELEASE', old: "'1.2.3'", new: "'1.2.4'", file: 'src/release.ts' }, () => null)).toEqual([]);
expect(consumersOf(root, ['test/semver.test.ts'], { name: 'RELEASE', old: "'1.2.3'", new: "'1.2.4'", file: 'src/release.ts' }, () => " ['1.2.4', 'b'],")).toEqual(['test/semver.test.ts:3']);
fs.rmSync(root, { recursive: true, force: true });
});
test('runner summaries: bun, node --test, vitest, jest, pytest, zero-run', () => {
@@ -409,6 +413,11 @@ describe('gstack-pregate (bin)', () => {
const r = pregate(repo, ['--stage', 'preflight', '--only', 'literals']);
expect(r.out).toMatch(/^preflight {2}literals {2}pass /m);
expect(r.status).toBe(EXIT.ok);
const renamed = fixture({ baseFiles: { 'test/history.test.js': history(2, 3, 4) } });
commit(renamed, { ...bump, 'test/history.test.js': null, 'test/versions.test.js': history(2, 3, 4) });
const held = pregate(renamed, ['--stage', 'preflight', '--only', 'literals']);
expect(held.status).toBe(EXIT.fail);
expect(held.out).toContain('literals: test/versions.test.js:4: MAX_RETRIES still 3 (now 4 in lib/retry.js)');
});
test('secrets: a credential in an added line fails and cannot be downgraded; a committed-then-removed line is clean', () => {