diff --git a/changelog.d/gsta-145-literals-history.md b/changelog.d/gsta-145-literals-history.md index 3775be5f0..372f06332 100644 --- a/changelog.d/gsta-145-literals-history.md +++ b/changelog.d/gsta-145-literals-history.md @@ -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, ""]`, then `[46, ""]`) 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. diff --git a/docs/pregate.md b/docs/pregate.md index 5056e6064..f37455562 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -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, ""]`), 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 diff --git a/lib/pregate/checks/literals.ts b/lib/pregate/checks/literals.ts index 2b77175bc..5c428c6ed 100644 --- a/lib/pregate/checks/literals.ts +++ b/lib/pregate/checks/literals.ts @@ -69,17 +69,18 @@ function unquote(v: string): string { return /^['"`]/.test(v) ? v.slice(1, -1) : * * A version-history row (`[45, ""]`, 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(`(? 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)}|(? 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})`); diff --git a/test/pregate-selection.test.ts b/test/pregate-selection.test.ts index 05511ac70..027f587c4 100644 --- a/test/pregate-selection.test.ts +++ b/test/pregate-selection.test.ts @@ -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', () => {