Files
gstack/test/restamp-mentions.test.ts
Garry Tanandcapy-ai[bot] 51a2b19b75 v1.91.261.0 fix(restamp): quoted since: rows and stale base-version mentions on branch-added lines when VERSION equals the base (gbrain #7640) (#3491)
* fix(restamp): quoted since: rows and stale base-version mentions on branch-added lines when VERSION equals the base (gbrain #7640)

A PR that stamped X, lost its slot and merged a base that had reached X on
its own had no branch stamp, and the mention scanner could not pass the
opening quote of a quoted row, so gstack-restamp --next left the PR's
{ since: 'X' } rows at X while the release shipped as X+1.

- tentativeMentions and rewriteStamps accept since: 'X' / since: "X" /
  (was 'X') / (was "X") and keep the quote style; the scanner also reads
  the policy's stamp_patterns templates.
- With no branch stamp and no --was, a stamp-form mention of the base
  version on a line the branch added moves; the same version on a base
  line stays. The stamp_paths stage detail names both counts per file.
- test/restamp-mentions.test.ts (pure forms + the reporter's state rebuilt
  with the real bin), stamp-templates pin updated, docs/ship-policy.md,
  --was help line, CHANGELOG [Unreleased].

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(restamp): review pass 1 — closing quotes, positional provenance, retry after a partial apply

- since: 'X' and (was 'X') require the quote to close right after X in the
  scanner and the rewrite table, so a quoted prerelease or a mismatched pair
  is never a stamp (the tree receipt shares the table).
- branchAddedLines records positions from the unified=0 hunk headers; the
  stamp_paths stage rewrites by position, so a base line with the same text
  stays and a CRLF checkout still finds the branch's row.
- Journal recovery restores the equal-base provenance (no branch stamp) so a
  stamp interrupted after VERSION was written retries as a stamp, not noop.
- Skipped detail names the explicit version it searched for; --was help line
  names the CHANGELOG re-heading; docs and CHANGELOG state the boundaries,
  the fork-point attribution and the rule's false-positive class.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(restamp): review pass 2 — positions counted through joined-hunk context

parseUnifiedDiff records each added line's new-side position (addedAt),
counting context lines a hunk carries, and branchAddedLines pins
--inter-hunk-context=0, so a diff.interHunkContext setting that joins two
hunks around a base row no longer shifts the branch's second row onto the
base row. Test with interHunkContext=1 (red on the pass-1 tree).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(restamp): correct the red-case count, the --was sentence and the help default

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-27 01:30:23 +00:00

245 lines
17 KiB
TypeScript

/**
* Restamp mention scanning and rewriting (gbrain #7640, gstack 1.91.246.0): a
* PR that stamped X lost its slot, merged the base, and the base was X by
* then, so the branch's VERSION equalled the base's (no branch stamp) and its
* quoted `since: 'X'` rows never moved: the mention scanner could not pass
* the opening quote and, with no branch stamp, nothing else named X. Pins
* the quoted forms (single, double, unquoted unchanged, quote style kept) in
* both the scanner and the rewrite table, the equal-version rule (the base
* version moves only on lines the branch added), and the negative control
* (a base-side quoted row carrying that version stays).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { rewriteStamps, tentativeMentions } from '../lib/restamp';
import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, mergeMain, mergeToMain, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const stageDetail = (out: string, name: string) => new RegExp(`^stage ${name}: (\\w+)(?: \\((.*)\\))?$`, 'm').exec(out);
describe('quoted stamp forms (pure)', () => {
test('rewriteStamps: since: with a single or double quote moves and keeps its quote; the unquoted form is unchanged', () => {
expect(rewriteStamps("{ since: '1.2.3.0', id: 'a' }", '1.2.3.0', '1.2.4.0')).toEqual({ text: "{ since: '1.2.4.0', id: 'a' }", count: 1 });
expect(rewriteStamps('{ since: "1.2.3.0", id: "a" }', '1.2.3.0', '1.2.4.0')).toEqual({ text: '{ since: "1.2.4.0", id: "a" }', count: 1 });
expect(rewriteStamps("since:'1.2.3.0'", '1.2.3.0', '1.2.4.0')).toEqual({ text: "since:'1.2.4.0'", count: 1 });
expect(rewriteStamps('since: 1.2.3.0', '1.2.3.0', '1.2.4.0')).toEqual({ text: 'since: 1.2.4.0', count: 1 });
// A quote is an exact boundary too: a longer version behind it is not X.
expect(rewriteStamps("since: '1.2.3.01'", '1.2.3.0', '1.2.4.0').count).toBe(0);
expect(rewriteStamps("since: '1.2.3.0.1'", '1.2.3.0', '1.2.4.0').count).toBe(0);
// An opened quote must close right after X: a quoted prerelease or a mismatched pair is not the form
// (the tree receipt and gate reuse share this table, so a looser match would call a real change a stamp).
expect(rewriteStamps("since: '1.2.3.0-beta'", '1.2.3.0', '1.2.4.0').count).toBe(0);
expect(rewriteStamps('since: "1.2.3.0-beta"', '1.2.3.0', '1.2.4.0').count).toBe(0);
expect(rewriteStamps("since: '1.2.3.0\"", '1.2.3.0', '1.2.4.0').count).toBe(0);
expect(rewriteStamps('since: 1.2.3.0-beta', '1.2.3.0', '1.2.4.0').count).toBe(1);
});
test('rewriteStamps: (was X) accepts a matching quote pair and keeps it; a mismatched pair is not the form; a quoted vX already matched', () => {
expect(rewriteStamps("(was '1.2.3.0')", '1.2.3.0', '1.2.4.0')).toEqual({ text: "(was '1.2.4.0')", count: 1 });
expect(rewriteStamps('(was "1.2.3.0")', '1.2.3.0', '1.2.4.0')).toEqual({ text: '(was "1.2.4.0")', count: 1 });
expect(rewriteStamps('(was 1.2.3.0)', '1.2.3.0', '1.2.4.0')).toEqual({ text: '(was 1.2.4.0)', count: 1 });
expect(rewriteStamps("(was '1.2.3.0\")", '1.2.3.0', '1.2.4.0').count).toBe(0);
expect(rewriteStamps("'v1.2.3.0'", '1.2.3.0', '1.2.4.0')).toEqual({ text: "'v1.2.4.0'", count: 1 });
// A bare pin equal to X is still not a stamp form, quoted or not.
expect(rewriteStamps("pinned: '1.2.3.0'", '1.2.3.0', '1.2.4.0').count).toBe(0);
});
test('rewriteStamps: a policy template that overlaps the built-in quoted form counts the mention once', () => {
const r = rewriteStamps(" { since: '1.2.3.0', id: 'x', text: 't' },", '1.2.3.0', '1.2.4.0', ["since: '{version}'"]);
expect(r).toEqual({ text: " { since: '1.2.4.0', id: 'x', text: 't' },", count: 1 });
});
test('tentativeMentions: quoted since: rows and template forms on branch-added lines are candidates like the unquoted ones', () => {
const added = new Map([['src/notice.ts', new Set([
" { since: '1.2.4.0', id: 'single', text: 't' },",
' { since: "1.2.5.0", id: "double", text: "t" },',
' since: 1.2.6.0',
'<!-- x-tree-stamp: 1.2.7.0 -->',
" pinned: '1.2.8.0'",
])]]);
expect(tentativeMentions(added, '1.2.3.0', '1.2.9.0').sort()).toEqual(['1.2.4.0', '1.2.5.0', '1.2.6.0']);
expect(tentativeMentions(added, '1.2.3.0', '1.2.9.0', ['<!-- x-tree-stamp: {version} -->']).sort()).toEqual(['1.2.4.0', '1.2.5.0', '1.2.6.0', '1.2.7.0']);
// The fork-version floor and the allocated version still apply to quoted mentions.
expect(tentativeMentions(added, '1.2.5.0', '1.2.6.0').sort()).toEqual([]);
// A quoted prerelease, a mismatched pair and an unclosed (was are not candidates; the bare prerelease still is.
const odd = new Map([['x', new Set(["since: '1.2.4.0-rc'", 'since: "1.2.5.0\'', "(was '1.2.6.0'x", '(was 1.2.7.0-rc)', 'since: 1.2.8.0-rc'])]]);
expect(tentativeMentions(odd, '1.2.3.0', '1.2.9.0').sort()).toEqual(['1.2.8.0']);
});
});
/** A gbrain-shaped fixture: a quoted-row source file in stamp_files, with and without the since: template. */
const NOTICE = 'src/notice.ts';
const row = (v: string, id: string) => ` { since: '${v}', id: '${id}', text: '${id}' },`;
const notice = (rows: string[]) => ['export const ROWS = [', ...rows, '];', ''].join('\n');
function policy(templates: boolean) {
return { ...BASE_POLICY, release_tool: null, stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md', NOTICE], ...(templates ? { stamp_patterns: ["since: '{version}'"] } : {}) };
}
function write(repo: FixtureRepo, rel: string, text: string): void {
fs.mkdirSync(path.dirname(path.join(repo.work, rel)), { recursive: true });
fs.writeFileSync(path.join(repo.work, rel), text);
}
/** The repo's own stamp on the branch, the way gbrain's release:restamp leaves it: VERSION and the rows at X. */
function stampBranch(repo: FixtureRepo, v: string, rows: string[]): void {
write(repo, 'VERSION', v + '\n');
write(repo, NOTICE, notice(rows));
repo.git('commit', '-qam', `chore: bump version (v${v})`);
}
/**
* The reporter's state: the PR stamped 1.2.4.0 (VERSION and its row), another PR took the slot and the base
* is 1.2.4.0 with its own quoted 1.2.4.0 row, the PR merged the base. VERSION equals the base's on both sides.
*/
function lostSlot(repo: FixtureRepo): void {
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-mine');
stampBranch(repo, '1.2.4.0', [row('1.2.3.0', 'base_row'), '', '', '', row('1.2.4.0', 'mine')]);
// The other PR takes the slot through the real stamp: VERSION 1.2.4.0, its CHANGELOG entry headed, its own 1.2.4.0 row.
makePr(repo, 'pr-other');
write(repo, NOTICE, notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row')]));
repo.git('commit', '-qam', 'other row');
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp other');
mergeToMain(repo, 'pr-other');
repo.git('checkout', '-q', 'pr-mine');
mergeMain(repo);
expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n');
expect(repo.git('show', 'origin/main:VERSION')).toBe('1.2.4.0');
}
describe('a PR whose VERSION already equals the base (gbrain #7640)', () => {
test.each([['with the since: template', true], ['built-in quoted form alone', false]])('%s: the PR\'s quoted row moves, the base\'s own quoted rows stay, and the stage says why', (_, templates) => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-', policy: policy(templates) }); repos.push(repo);
lostSlot(repo);
const dry = restamp(repo, ['--next', '--dry-run']);
expect(dry.status).toBe(EXIT.ok);
expect(dry.out).toMatch(/^- \{ since: '1\.2\.4\.0', id: 'mine', text: 'mine' \},$/m);
expect(dry.out).toMatch(/^\+ \{ since: '1\.2\.5\.0', id: 'mine', text: 'mine' \},$/m);
expect(repo.git('status', '--porcelain')).toBe('');
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toContain('RESTAMP: 1.2.4.0 -> 1.2.5.0 (stamped;');
const sp = stageDetail(r.out, 'stamp_paths')!;
expect(sp[1]).toBe('done');
expect(sp[2]).toBe(`1 mention(s) in 1 file(s) (1.2.4.0 -> 1.2.5.0); VERSION equals base 1.2.4.0, so 1.2.4.0 moved only on lines this branch added and stayed on the base's own lines (${NOTICE}: 1 moved, 1 kept)`);
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')]));
expect(readFile(repo, 'VERSION')).toBe('1.2.5.0\n');
// The base's rows are byte-identical to origin/main's: the 1.2.4.0 row is the base's release and never moved.
expect(repo.git('show', `origin/main:${NOTICE}`) + '\n').toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row')]));
const journal = JSON.parse(readFile(repo, '.gstack/tmp/restamp-journal.json'));
expect(journal.inputs).toMatchObject({ old_version: '1.2.4.0', new_version: '1.2.5.0' });
expect(journal.stages.find((s: any) => s.name === 'stamp_paths').detail).toContain('1 moved, 1 kept');
});
test('negative control: with no branch-added mention of the base version nothing moves, every base-side quoted row stays, and the stage says so', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-neg-', policy: policy(true) }); repos.push(repo);
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-mine');
// The PR adds a row of its own in another form entirely, plus an unrelated line in the stamp file.
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), " { since: 'TBD', id: 'mine', text: 'mine' },"]));
repo.git('commit', '-qam', 'my row');
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.ok);
const sp = stageDetail(r.out, 'stamp_paths')!;
expect(sp[1]).toBe('skipped');
expect(sp[2]).toBe('no tentative mention newer than the fork version 1.2.3.0 and none of base 1.2.3.0 on a line this branch added; VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base\'s own lines (src/notice.ts: 0 moved, 1 kept)');
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), " { since: 'TBD', id: 'mine', text: 'mine' },"]));
expect(r.out).not.toMatch(/^[-+] \{ since: '1\.2\.3\.0'/m);
});
test('a branch forked at the base version (fork equals base, nothing stamped) still moves its own base-version row and keeps the base\'s', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-fork-', policy: policy(true) }); repos.push(repo);
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-mine');
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), row('1.2.3.0', 'mine')]));
write(repo, 'docs/pr-mine.md', 'Requires v1.2.3.0 or later.\n');
repo.git('add', '-A'); repo.git('commit', '-qm', 'my row');
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.ok);
expect(stageDetail(r.out, 'stamp_paths')![2]).toBe('2 mention(s) in 2 file(s) (1.2.3.0 -> 1.2.4.0); VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base\'s own lines (docs/pr-mine.md: 1 moved, 0 kept; src/notice.ts: 1 moved, 1 kept)');
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), row('1.2.4.0', 'mine')]));
expect(readFile(repo, 'docs/pr-mine.md')).toBe('Requires v1.2.4.0 or later.\n');
expect(readFile(repo, 'docs/notes.md')).toBe('Previous release v1.2.3.0.\nleft-pad pinned at 1.2.3 (was 1.2.2)\n');
});
test('provenance is by position: a branch-added line whose text equals a base line moves alone, and the base copy stays', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-dup-', policy: policy(true) }); repos.push(repo);
write(repo, NOTICE, notice([row('1.2.3.0', 'dup')]));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-mine');
write(repo, NOTICE, notice([row('1.2.3.0', 'dup'), '', '', '', row('1.2.3.0', 'dup')]));
repo.git('commit', '-qam', 'my row, same text as the base row');
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.ok);
expect(stageDetail(r.out, 'stamp_paths')![2]).toBe(`1 mention(s) in 1 file(s) (1.2.3.0 -> 1.2.4.0); VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base's own lines (${NOTICE}: 1 moved, 1 kept)`);
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'dup'), '', '', '', row('1.2.4.0', 'dup')]));
});
test('diff.interHunkContext joining two hunks around a base row still places each branch row by position', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-ihc-', policy: policy(true) }); repos.push(repo);
write(repo, NOTICE, notice([' { since: "0.0.0.0", id: "first_placeholder" },', row('1.2.3.0', 'base_row'), ' { since: "0.0.0.0", id: "second_placeholder" },']));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
repo.git('config', 'diff.interHunkContext', '1');
makePr(repo, 'pr-mine');
write(repo, NOTICE, notice([row('1.2.3.0', 'first'), row('1.2.3.0', 'base_row'), row('1.2.3.0', 'second')]));
repo.git('commit', '-qam', 'two rows around the base row');
// The joined hunk carries the base row as context between the two added lines.
expect(repo.git('diff', '--unified=0', 'origin/main', '--', NOTICE)).toMatch(/\n\+ \{ since: '1\.2\.3\.0', id: 'first'.*\n \{ since: '1\.2\.3\.0', id: 'base_row'.*\n-.*\n\+ \{ since: '1\.2\.3\.0', id: 'second'/);
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.ok);
expect(stageDetail(r.out, 'stamp_paths')![2]).toContain(`${NOTICE}: 2 moved, 1 kept`);
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'first'), row('1.2.3.0', 'base_row'), row('1.2.4.0', 'second')]));
});
test('a CRLF checkout (core.autocrlf, eol=crlf) whose diff is LF-normalized still finds the branch-added row', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-crlf-', policy: policy(true) }); repos.push(repo);
write(repo, '.gitattributes', `${NOTICE} text eol=crlf\n`);
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
repo.git('config', 'core.autocrlf', 'true');
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-mine');
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), row('1.2.3.0', 'mine')]).replace(/\n/g, '\r\n'));
repo.git('commit', '-qam', 'my row');
expect(repo.git('diff', '--unified=0', 'origin/main', '--', NOTICE)).toContain("+ { since: '1.2.3.0', id: 'mine'");
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.ok);
expect(stageDetail(r.out, 'stamp_paths')![2]).toContain(`${NOTICE}: 1 moved, 1 kept`);
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), row('1.2.4.0', 'mine')]).replace(/\n/g, '\r\n'));
});
test.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('a stamp interrupted after VERSION was written retries as a stamp, not a noop: the journal restores the equal-base provenance', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-retry-', policy: policy(true) }); repos.push(repo);
lostSlot(repo);
const dir = path.join(repo.work, path.dirname(NOTICE));
fs.chmodSync(dir, 0o555);
try {
const first = restamp(repo, ['--next']);
expect(first.status).not.toBe(EXIT.ok);
expect(first.err).toMatch(/EACCES|permission denied/i);
} finally { fs.chmodSync(dir, 0o755); }
expect(readFile(repo, 'VERSION')).toBe('1.2.5.0\n');
expect(readFile(repo, NOTICE)).toContain(row('1.2.4.0', 'mine'));
const again = restamp(repo, ['--next']);
expect(again.status).toBe(EXIT.ok);
expect(again.out).toContain('RESTAMP: 1.2.5.0 -> 1.2.5.0 (stamped;');
expect(stageDetail(again.out, 'stamp_paths')![2]).toContain(`${NOTICE}: 1 moved, 1 kept`);
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')]));
expect(JSON.parse(readFile(repo, '.gstack/tmp/restamp-journal.json')).status).toBe('complete');
});
test('--was still rewrites every line, the base\'s rows included: it is not the fix for this state', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-was-', policy: policy(true) }); repos.push(repo);
lostSlot(repo);
const r = restamp(repo, ['--next', '--was', '1.2.4.0']);
expect(r.status).toBe(EXIT.ok);
expect(stageDetail(r.out, 'stamp_paths')![2]).toBe('2 mention(s) in 1 file(s) (1.2.4.0 -> 1.2.5.0)');
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.5.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')]));
});
});