mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* 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>
83 lines
5.6 KiB
TypeScript
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);
|
|
});
|
|
});
|