Files
gstack/test/stamp-templates.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

83 lines
5.6 KiB
TypeScript

/**
* Field wave CD3: which files count as stamps is the repo's rule. Ship policy
* `stamp_files` is an explicit allowlist applied before any stamp rule;
* `stamp_patterns` are literal templates with one `{version}` slot (no free
* regex), shared by the restamp writer and the tree-receipt classifier.
* Field incident: a pure gbrain restamp from 0.60.188.0 to 0.60.189.0 read
* `TREE: changed` for 13 of 16 files because `since: '…'`, `"version": "…"`
* and `<!-- …-stamp: … -->` were outside gstack's three built-in forms.
*/
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 } from '../lib/restamp';
import { validatePolicy } from '../lib/ship-policy';
import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, runBin, type FixtureRepo } from './helpers/restamp-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const TEMPLATES = ['"version": "{version}"', '<!-- x-plugin-tree-stamp: {version} -->', "since: '{version}'"];
const STAMP_POLICY = { ...BASE_POLICY, release_tool: null, stamp_paths: ['docs/**'], stamp_files: ['VERSION', 'package.json', 'plugin/**', 'src/notice.ts'], stamp_patterns: TEMPLATES };
function stampedFiles(v: string, docs = 'Added in v1.2.4.0.\n'): Record<string, string> {
return {
VERSION: `${v}\n`,
'plugin/.claude-plugin/plugin.json': `{\n "name": "x",\n "version": "${v}",\n "description": "d"\n}\n`,
'plugin/README.md': `# plugin\n<!-- x-plugin-tree-stamp: ${v} -->\n`,
'src/notice.ts': `export const ROWS = [\n { since: '${v}', key: 'k' },\n];\n`,
'docs/guide.md': docs,
};
}
function commitFiles(repo: FixtureRepo, files: Record<string, string>, msg: string): string {
for (const [rel, body] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(repo.work, rel)), { recursive: true }); fs.writeFileSync(path.join(repo.work, rel), body); }
const pkg = JSON.parse(fs.readFileSync(path.join(repo.work, 'package.json'), 'utf8'));
pkg.version = files.VERSION!.trim().split('.').slice(0, 3).join('.');
fs.writeFileSync(path.join(repo.work, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
repo.git('add', '-A'); repo.git('commit', '-q', '-m', msg);
return repo.git('rev-parse', 'HEAD');
}
describe('stamp templates (pure)', () => {
test('rewriteStamps applies literal {version} templates beside the three built-in forms, at version boundaries only', () => {
expect(rewriteStamps("{ since: '1.2.3.0', x }", '1.2.3.0', '1.2.4.0', ["since: '{version}'"])).toEqual({ text: "{ since: '1.2.4.0', x }", count: 1 });
expect(rewriteStamps('"version": "1.2.3.0",', '1.2.3.0', '1.2.4.0', ['"version": "{version}"']).count).toBe(1);
expect(rewriteStamps('<!-- x-plugin-tree-stamp: 1.2.3.0 -->', '1.2.3.0', '1.2.4.0', ['<!-- x-plugin-tree-stamp: {version} -->']).text).toBe('<!-- x-plugin-tree-stamp: 1.2.4.0 -->');
expect(rewriteStamps("since: '1.2.3.01'", '1.2.3.0', '1.2.4.0', ["since: '{version}'"]).count).toBe(0);
// A quoted since: row is a built-in form too (gbrain #7640): the template and the built-in count it once.
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', ["since: '{version}'"]).count).toBe(1);
});
test('policy: stamp_patterns are bounded literal templates with exactly one {version}; stamp_files are contained globs', () => {
const codes = (p: unknown) => validatePolicy(p).map(e => `${e.code}:${e.path}`);
expect(codes(STAMP_POLICY)).toEqual([]);
expect(codes({ stamp_patterns: ['no slot'] })).toEqual(['POLICY_INVALID:stamp_patterns[0]']);
expect(codes({ stamp_patterns: ['{version} and {version}'] })).toEqual(['POLICY_INVALID:stamp_patterns[0]']);
expect(codes({ stamp_patterns: ['x'.repeat(130) + '{version}'] })).toEqual(['POLICY_CONTAINMENT:stamp_patterns[0]']);
expect(codes({ stamp_patterns: Array.from({ length: 17 }, (_, i) => `k${i}: {version}`) })).toEqual(['POLICY_CONTAINMENT:stamp_patterns']);
expect(codes({ stamp_files: ['../VERSION'] })).toEqual(['POLICY_CONTAINMENT:stamp_files[0]']);
});
});
describe('tree receipt under the repo stamp rule', () => {
test('a pure restamp across a manifest, a stamp comment and a since row is same-modulo-stamps; a doc vX mention outside stamp_files is a difference', () => {
const repo = makeFixtureRepo({ prefix: 'stamp-templates-', policy: STAMP_POLICY }); repos.push(repo);
makePr(repo, 'pr-a', { changelog: false });
const gated = commitFiles(repo, stampedFiles('1.2.4.0'), 'stamp 1.2.4.0');
commitFiles(repo, stampedFiles('1.2.5.0'), 'restamp 1.2.5.0');
const r = runBin(repo, 'gstack-tree-receipt', ['--base', 'main', '--gated', gated]);
expect(r.out).toMatch(/^ {2}stamp {3}plugin\/\.claude-plugin\/plugin\.json \(stamp_files\)$/m);
expect(r.out).toMatch(/^ {2}stamp {3}plugin\/README\.md \(stamp_files\)$/m);
expect(r.out).toMatch(/^ {2}stamp {3}src\/notice\.ts \(stamp_files\)$/m);
expect(r.out).toMatch(/^ {2}stamp {3}VERSION \(version-file\)$/m);
expect(r.out).toMatch(/^TREE: same-modulo-stamps$/m);
commitFiles(repo, stampedFiles('1.2.5.0', 'Added in v1.2.5.0.\n'), 'doc mention');
const d = runBin(repo, 'gstack-tree-receipt', ['--base', 'main', '--gated', gated]);
expect(d.status).toBe(EXIT.fail);
expect(d.out).toMatch(/^ {2}changed docs\/guide\.md \(stamp_files: not in the repo's stamp_files allowlist\)$/m);
expect(d.out).toMatch(/^TREE: changed \(docs\/guide\.md\)$/m);
});
});