mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +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>
This commit is contained in:
1 parent
4d48d39e51
commit
e27c79902a
6 files changed
+294
-57
No files matched your search
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**`gstack-restamp --next` moves a PR's quoted `since:` rows when its VERSION already equals the base's (gbrain #7640).** A gbrain PR stamped 0.60.468.0, lost its slot, merged master, and master was 0.60.468.0 by then. At its next slot restamp left the PR's `{ since: '0.60.468.0', … }` notice rows at .468 while the release shipped as .469, and gbrain's `compareReleases` then hid that notice from every user already on .468. Two gaps combined: with VERSION equal on both sides the branch had no stamp of its own, so nothing was rewritten everywhere, and the mention scanner that reads the lines the branch added could not pass the opening quote of a quoted row (`since:\s*(\d…)`), so it found nothing either. `--was 0.60.468.0` was no answer, because it rewrites master's own .468 rows too.
|
||||
|
||||
### Fixed
|
||||
- **Quoted versions are stamp forms** in both the mention scanner (`tentativeMentions`) and the exact rewrite table (`rewriteStamps`) of `lib/restamp.ts`: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` match like the bare forms and keep their quote style; `vX` already accepted a quote before the `v`. The scanner also reads the policy's `stamp_patterns` templates, so a template-only form on a branch-added line is a candidate too. The tree receipt and the gate-reuse normalizers share `rewriteStamps`, so a quoted `since:` rewrite classifies as a stamp without a template.
|
||||
- **The equal-version rule.** When the branch has no stamp of its own (its VERSION equals the base's and no `--was` is given), a stamp-form mention of the base version on a line the branch added is its stale tentative stamp and moves to the allocated version; the same version on a line the base already carries is the base's release and stays. The `stamp_paths` stage detail, printed in the stage table and kept in the journal, names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit's tool output shows which lines moved and why. The explicit `--was X` path is unchanged and still rewrites X on every line; its help line now says so.
|
||||
|
||||
### Tests
|
||||
- `test/restamp-mentions.test.ts`: the pure forms (single quote, double quote, bare unchanged, quote style kept, a mismatched quote pair refused, a template overlapping the built-in form counted once, the fork-version floor on quoted mentions) and, on the fixture repo, the reporter's state rebuilt with the real `bin/gstack-restamp`: the other PR takes the slot through the tool, the PR merges main with VERSION equal on both sides, and `--next` moves the PR's quoted row, keeps the base's two rows byte for byte, and says why in the stage detail; the same with the built-in form alone (no `since:` template); a negative control where the PR adds no base-version mention and every base-side quoted row stays; a branch forked at the base version (fork equals base, nothing stamped) whose own base-version row and doc mention move while the base's stay; and `--was` rewriting the base's row as well. Seven of the file's nine cases are red at 1.91.253.0 (the template-overlap and `--was` cases pass there, as they should).
|
||||
- `test/stamp-templates.test.ts`: the quoted `since:` row now counts once with or without the template (it pinned zero without one).
|
||||
|
||||
### Docs
|
||||
- `docs/ship-policy.md`: the `stamp_paths` stage states the quoted forms and the provenance rule (the branch's own stamp everywhere; on branch-added lines, mentions newer than the fork version and, with VERSION equal to the base's, the base version; `--was` as the rewrite-everywhere tool).
|
||||
|
||||
## [1.91.253.0] - 2026-10-26
|
||||
|
||||
**`/captain` (also `/crew`): the crew operating model that keeps many agents merging into one base branch without turning it red, as a gstack skill with templates and a team guide.** Teams running a controller agent over worker threads had the model only in one crew's private notes. It now ships generic: one controller merges and lanes never merge; a READY block (full head SHA, base, CI counts, pregate, gate evidence, `SHIP_RECEIPT` at the pin, screen choice with justification, receipt path, logged flaky cases) is the only merge trigger, and a green PR page or a cheap test tier never is; merge trains (build, verdict, per-slot stamp on call, verify, merge with the expected head, verify again, prune), the red-train rules (in-run flaky rerun once, bisect on the red cases, exactly one unchanged rebuild, one infra re-dispatch), the tree rule, the backstop and the freeze, and the post-merge babysit; the digest shape (Needs you / New / In progress with ages); the three approval shapes (the per-thread record, the plan ask with before → after and a "do it" reply, the merge ask with captioned before/after images); and the daily flaky-fix pass (ledger format, the gate runs' flake records and the free suites' retries as sources, red-before-green fix PRs, one named infra rerun allowance, never thresholds, trials or kinds). Where crews that run this model disagree (squash or merge, lane or controller stamps, gate-ahead, the severe race, a gate run id in the READY) the skill ships both as options for the crew rules file. Every repo-specific name is a parameter (`REPO`, `BASE`, `REQUIRED_CHECKS`, `FULL_GATE_CHECK`, `MERGE_METHOD`, `STAMP_FILES`, `KNOWN_INFRA_FLAKES`).
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ const HELP = `gstack-restamp — stamp the version, release tool, CHANGELOG and
|
||||
--bump <level> micro|patch|minor|major (default: policy bump, else micro)
|
||||
--queue-mode <mode> stamp-at-merge | claim-at-open (default: policy queue_mode, else claim-at-open)
|
||||
--pr <n> this PR, for --expect-order and the gate-ahead record
|
||||
--was <X> the version the branch's docs mention today (default: the branch's own stamp, else base + bump)
|
||||
--was <X> rewrite X on every line of every stamp file, the base's own lines included (default: the branch's own stamp everywhere, else only the stale mentions on lines the branch added)
|
||||
--expect-base <sha> refuse with QUEUE_STALE when origin/<base> moved
|
||||
--expect-order <pr,...> refuse with QUEUE_STALE when a predecessor is unmerged or a successor merged
|
||||
--policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands)
|
||||
|
||||
+3
-3
@@ -43,7 +43,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
|
||||
| `queue_mode` | `claim-at-open` \| `stamp-at-merge` | `claim-at-open` | See [stamp-at-merge](#stamp-at-merge). `claim-at-open` is today's queue (`gstack-next-version` claims a slot when the PR opens). |
|
||||
| `release_tool` | command or `null` | `null` | The repo's own release command (`bun run release:restamp`). Runs in an isolated staging worktree under the [release-tool contract](#release-tool-contract). |
|
||||
| `release_outputs` | globs | `[]` | Paths the release tool may write beyond the version files, lockfiles, CHANGELOG and `stamp_paths`. Anything else is `RESTAMP_OUTPUT_OUTSIDE_ALLOWED`. |
|
||||
| `stamp_paths` | globs | `[]` | Where exact `(was X)`, `since: X` and `vX` mentions of the branch's tentative version are rewritten on a restamp. |
|
||||
| `stamp_paths` | globs | `[]` | Where exact `(was X)`, `since: X` and `vX` mentions of the branch's tentative version (quoted or bare: `since: 'X'` counts) are rewritten on a restamp. |
|
||||
| `changelog` | path or `null` | `CHANGELOG.md` | Re-headed on a restamp: the PR's `## [Unreleased]` (or old-version) section moves to the top as `## [<version>] - <date>`. |
|
||||
| `mirror` | relative path or `null` | `null` | A paired checkout to restamp identically (tier 2: validated and confined today, not yet run). |
|
||||
| `history` | `merge-only` \| `squash-ok` | `squash-ok` | `merge-only` makes `/ship` refuse a squash or rebase plan; `gstack-ship-policy check-history` reports a rewritten branch (`HISTORY_POLICY_VIOLATION`). |
|
||||
@@ -612,7 +612,7 @@ Stages, each journaled in `.gstack/tmp/restamp-journal.json`:
|
||||
1. **version**: `gstack-version-bump write --version <X>` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version.
|
||||
2. **release_tool**: the policy command, under the contract below.
|
||||
3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under `<X>`, above its predecessor's entry. A duplicate heading the PR introduces (`CHANGELOG_DUPLICATE_HEADING`: the headings are counted against the base's CHANGELOG at `origin/<base>` and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not `<X>` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once.
|
||||
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. `X` is the branch's own tentative stamp (its VERSION when it moved past base), else `--was <X>`, else the version an unstamped branch would have taken next (`base + bump`); mentions of a version the base never released (not a CHANGELOG heading at base, not a tag) are rewritten too, so a queue reorder that moves the base under a branch still finds its `since:` fields. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
|
||||
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base, else `--was <X>`) is rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (so a queue reorder that moves the base under a branch still finds its `since:` fields), and when the branch has no stamp of its own because its VERSION equals the base's (it stamped X, lost its slot, merged the base and the base was X by then; or it never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
|
||||
5. **mirror**: tier 2; declared and validated, reported as not run.
|
||||
6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage).
|
||||
|
||||
@@ -691,7 +691,7 @@ manifest (`version` key only), npm and bun lockfiles (root `version` keys
|
||||
only, where `gstack-version-bump` writes; a dependency entry is `changed`),
|
||||
the CHANGELOG (a heading move or re-heading, compared as an order-independent
|
||||
multiset of entries), `stamp_paths` and `release_outputs` (every
|
||||
removed/added pair must be an exact `(was X)` / `since: X` / `vX` rewrite with
|
||||
removed/added pair must be an exact `(was X)` / `since: X` / `vX` rewrite (a quote around X in the first two forms included) with
|
||||
X the gated version and the replacement the current one, so a dependency pin
|
||||
that happens to equal the old version is `changed`; `stamp_patterns`
|
||||
templates count as stamp forms too). With `stamp_files` set, a file outside it
|
||||
|
||||
+102
-52
@@ -253,25 +253,39 @@ export function branchAddedLines(cwd: string, since: string, files: string[]): M
|
||||
return out;
|
||||
}
|
||||
|
||||
const escapeRegExp = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const VERSION_RE = '(\\d+(?:\\.\\d+){1,3})(?![0-9.])';
|
||||
/** The built-in stamp forms, each with the version as group 1: `(was X)`, `since: X` (a quote may open X: `since: 'X'`, `(was "X")`) and `vX`. */
|
||||
const BUILT_IN_MENTION = new RegExp(`(?:\\(was ['"]?|since:\\s*['"]?|(?<![A-Za-z0-9.])v)${VERSION_RE}`, 'g');
|
||||
/** A policy `stamp_patterns` template (one `{version}` slot) as a scanner, the version as group 1 at a version boundary. */
|
||||
function templateMention(template: string): RegExp {
|
||||
const [pre, post] = template.split(VERSION_SLOT) as [string, string];
|
||||
return new RegExp(`${escapeRegExp(pre)}(?<![0-9.])${VERSION_RE}${escapeRegExp(post)}`, 'g');
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch's tentative stamps: versions it mentions in a stamp pattern, on
|
||||
* lines it added itself, newer than the version at its fork point and not
|
||||
* the allocated one. A released version is never newer than the fork point
|
||||
* of a branch written against it, and the repo's own version width must
|
||||
* match, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
|
||||
* The branch's tentative stamps: versions it mentions in a stamp form (the
|
||||
* built-in ones and the policy's `stamp_patterns` templates), on lines it
|
||||
* added itself, newer than the version at its fork point and not the
|
||||
* allocated one. A released version is never newer than the fork point of a
|
||||
* branch written against it, and the repo's own version width must match, so
|
||||
* a 3-digit dependency pin in a 4-digit repo is never a candidate.
|
||||
*/
|
||||
export function tentativeMentions(added: Map<string, Set<string>>, forkVersion: string, newVersion: string): string[] {
|
||||
export function tentativeMentions(added: Map<string, Set<string>>, forkVersion: string, newVersion: string, templates: readonly string[] = []): string[] {
|
||||
const fork = parseVersion(forkVersion);
|
||||
if (!fork) return [];
|
||||
const width = versionWidth(forkVersion);
|
||||
const scanners = [BUILT_IN_MENTION, ...templates.map(templateMention)];
|
||||
const found = new Set<string>();
|
||||
for (const lines of added.values()) {
|
||||
for (const text of lines) {
|
||||
for (const m of text.matchAll(/(?:\(was |since:\s*|(?<![A-Za-z0-9.])v)(\d+(?:\.\d+){1,3})(?![0-9.])/g)) {
|
||||
const raw = m[1]!;
|
||||
const v = parseVersion(raw);
|
||||
if (!v || versionWidth(raw) !== width || raw === newVersion || cmpVersion(v, fork) <= 0) continue;
|
||||
found.add(raw);
|
||||
for (const re of scanners) {
|
||||
for (const m of text.matchAll(re)) {
|
||||
const raw = m[1]!;
|
||||
const v = parseVersion(raw);
|
||||
if (!v || versionWidth(raw) !== width || raw === newVersion || cmpVersion(v, fork) <= 0) continue;
|
||||
found.add(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,16 +303,18 @@ export function forkVersion(repoRoot: string, baseRef: string, fallback: string)
|
||||
/**
|
||||
* Exact `(was X)`, `since: X`, `vX` rewrites plus the policy's literal
|
||||
* `stamp_patterns` templates (one `{version}` slot, matched at a version
|
||||
* boundary); anything else in the file stays.
|
||||
* boundary); anything else in the file stays. A quote around X in the
|
||||
* `(was X)` and `since: X` forms (`since: 'X'`, `(was "X")`, the shape of a
|
||||
* quoted source row) is matched and kept as written; `vX` already accepts a
|
||||
* quote before the `v`.
|
||||
*/
|
||||
export function rewriteStamps(text: string, oldVersion: string, newVersion: string, templates: readonly string[] = []): { text: string; count: number } {
|
||||
const esc = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const v = esc(oldVersion);
|
||||
const patterns: Array<[RegExp, string]> = [
|
||||
[new RegExp(`\\(was ${v}\\)`, 'g'), `(was ${newVersion})`],
|
||||
[new RegExp(`(since:\\s*)${v}(?![0-9.])`, 'g'), `$1${newVersion}`],
|
||||
[new RegExp(`(?<![A-Za-z0-9.])v${v}(?![0-9.])`, 'g'), `v${newVersion}`],
|
||||
...templates.map(t => { const [pre, post] = t.split(VERSION_SLOT) as [string, string]; return [new RegExp(`${esc(pre)}(?<![0-9.])${v}(?![0-9.])${esc(post)}`, 'g'), `${pre}${newVersion}${post}`.replace(/\$/g, '$$$$')] as [RegExp, string]; }),
|
||||
const v = escapeRegExp(oldVersion);
|
||||
const patterns: Array<[RegExp, (...m: string[]) => string]> = [
|
||||
[new RegExp(`\\(was (['"]?)${v}\\1\\)`, 'g'), (_, q) => `(was ${q}${newVersion}${q})`],
|
||||
[new RegExp(`(since:\\s*['"]?)${v}(?![0-9.])`, 'g'), (_, pre) => `${pre}${newVersion}`],
|
||||
[new RegExp(`(?<![A-Za-z0-9.])v${v}(?![0-9.])`, 'g'), () => `v${newVersion}`],
|
||||
...templates.map(t => { const [pre, post] = t.split(VERSION_SLOT) as [string, string]; return [new RegExp(`${escapeRegExp(pre)}(?<![0-9.])${v}(?![0-9.])${escapeRegExp(post)}`, 'g'), () => `${pre}${newVersion}${post}`] as [RegExp, () => string]; }),
|
||||
];
|
||||
let count = 0;
|
||||
let out = text;
|
||||
@@ -396,45 +412,79 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
|
||||
} else { cl.status = 'skipped'; cl.detail = clRel ? `${clRel} absent` : 'changelog: null'; }
|
||||
injected('changelog');
|
||||
|
||||
const sp = stage('stamp_paths');
|
||||
const stampGlobs = [...plan.policy.stamp_paths, ...plan.policy.stamp_files];
|
||||
const templates = plan.policy.stamp_patterns;
|
||||
if (stampGlobs.length === 0) { sp.status = 'skipped'; sp.detail = 'no stamp_paths in policy'; }
|
||||
else {
|
||||
const files = git(staging, ['ls-files', '-z']).out.split('\0').filter(f => f && matchesAny(stampGlobs, f));
|
||||
for (const rel of files) {
|
||||
const why = assertInsideRoot(staging, rel);
|
||||
if (why) throw new Refusal('POLICY_CONTAINMENT', `${why}; nothing written`);
|
||||
}
|
||||
const added = branchAddedLines(staging, since, files);
|
||||
const explicit = new Set([...(o.was ? [o.was] : []), ...(plan.branchStamp ? [plan.branchStamp] : [])].filter(v => v !== plan.newVersion));
|
||||
const tentative = new Set(tentativeMentions(added, plan.forkVersion, plan.newVersion).filter(v => !explicit.has(v)));
|
||||
let touched = 0;
|
||||
let total = 0;
|
||||
for (const rel of files) {
|
||||
const abs = path.join(staging, rel);
|
||||
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
|
||||
const own = added.get(rel);
|
||||
if (explicit.size === 0 && !own) continue;
|
||||
let count = 0;
|
||||
const out = fs.readFileSync(abs, 'utf8').split('\n').map(line => {
|
||||
let text = line;
|
||||
for (const old of explicit) { const r = rewriteStamps(text, old, plan.newVersion, templates); text = r.text; count += r.count; }
|
||||
if (own?.has(line)) for (const old of tentative) { const r = rewriteStamps(text, old, plan.newVersion, templates); text = r.text; count += r.count; }
|
||||
return text;
|
||||
}).join('\n');
|
||||
if (count) { fs.writeFileSync(abs, out); touched += 1; total += count; }
|
||||
}
|
||||
const olds = [...explicit, ...tentative];
|
||||
sp.status = olds.length ? 'done' : 'skipped';
|
||||
sp.detail = olds.length ? `${total} mention(s) in ${touched} file(s) (${olds.join(', ')} -> ${plan.newVersion})` : `no tentative mention newer than the fork version ${plan.forkVersion}`;
|
||||
}
|
||||
rewriteStampPaths(o, plan, staging, since, stage('stamp_paths'));
|
||||
injected('stamp_paths');
|
||||
|
||||
const mi = stage('mirror');
|
||||
mi.status = 'skipped'; mi.detail = plan.policy.mirror ? `tier 2: declared ${plan.policy.mirror}, not run` : 'no mirror in policy';
|
||||
}
|
||||
|
||||
/**
|
||||
* The stamp_paths stage. An explicit old version (the branch's own stamp, or
|
||||
* `--was`) is rewritten on every line of every stamp file; a tentative one
|
||||
* (a mention newer than the fork version) only on lines the branch added.
|
||||
* With no explicit version the branch's VERSION equals the base's: either it
|
||||
* never stamped, or it stamped X and the base reached X on its own (a PR that
|
||||
* lost its slot and merged the base). Then a stamp-form mention of the base
|
||||
* version on a line the branch added is its own tentative stamp, stale now,
|
||||
* and moves; the same version on a line the base already carries is the
|
||||
* base's release and stays. The detail names both counts per file.
|
||||
*/
|
||||
function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since: string, sp: StageRecord): void {
|
||||
const stampGlobs = [...plan.policy.stamp_paths, ...plan.policy.stamp_files];
|
||||
const templates = plan.policy.stamp_patterns;
|
||||
if (stampGlobs.length === 0) { sp.status = 'skipped'; sp.detail = 'no stamp_paths in policy'; return; }
|
||||
const files = git(staging, ['ls-files', '-z']).out.split('\0').filter(f => f && matchesAny(stampGlobs, f));
|
||||
for (const rel of files) {
|
||||
const why = assertInsideRoot(staging, rel);
|
||||
if (why) throw new Refusal('POLICY_CONTAINMENT', `${why}; nothing written`);
|
||||
}
|
||||
const added = branchAddedLines(staging, since, files);
|
||||
const explicit = new Set([...(o.was ? [o.was] : []), ...(plan.branchStamp ? [plan.branchStamp] : [])].filter(v => v !== plan.newVersion));
|
||||
const tentative = new Set(tentativeMentions(added, plan.forkVersion, plan.newVersion, templates).filter(v => !explicit.has(v)));
|
||||
const equalBase = !o.was && !plan.branchStamp && plan.baseVersion !== plan.newVersion ? plan.baseVersion : null;
|
||||
if (equalBase) tentative.add(equalBase);
|
||||
const moved = new Map<string, number>();
|
||||
const baseSide: string[] = [];
|
||||
let touched = 0;
|
||||
let total = 0;
|
||||
for (const rel of files) {
|
||||
const abs = path.join(staging, rel);
|
||||
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
|
||||
const own = added.get(rel);
|
||||
if (explicit.size === 0 && !own) continue;
|
||||
let count = 0;
|
||||
let ownBase = 0;
|
||||
let keptBase = 0;
|
||||
const rewrite = (text: string, old: string): string => {
|
||||
const r = rewriteStamps(text, old, plan.newVersion, templates);
|
||||
count += r.count;
|
||||
if (r.count) moved.set(old, (moved.get(old) ?? 0) + r.count);
|
||||
return r.text;
|
||||
};
|
||||
const out = fs.readFileSync(abs, 'utf8').split('\n').map(line => {
|
||||
let text = line;
|
||||
for (const old of explicit) text = rewrite(text, old);
|
||||
if (own?.has(line)) for (const old of tentative) text = rewrite(text, old);
|
||||
if (equalBase) {
|
||||
const n = rewriteStamps(line, equalBase, plan.newVersion, templates).count;
|
||||
if (own?.has(line)) ownBase += n; else keptBase += n;
|
||||
}
|
||||
return text;
|
||||
}).join('\n');
|
||||
if (ownBase || keptBase) baseSide.push(`${rel}: ${ownBase} moved, ${keptBase} kept`);
|
||||
if (count) { fs.writeFileSync(abs, out); touched += 1; total += count; }
|
||||
}
|
||||
const olds = [...explicit, ...tentative].filter(v => moved.has(v));
|
||||
sp.status = olds.length ? 'done' : 'skipped';
|
||||
const equalNote = equalBase && baseSide.length
|
||||
? `; VERSION equals base ${equalBase}, so ${equalBase} moved only on lines this branch added and stayed on the base's own lines (${baseSide.join('; ')})`
|
||||
: '';
|
||||
sp.detail = olds.length
|
||||
? `${total} mention(s) in ${touched} file(s) (${olds.join(', ')} -> ${plan.newVersion})${equalNote}`
|
||||
: `no tentative mention newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`;
|
||||
}
|
||||
|
||||
function applyToBranch(o: RestampOptions, staging: string, wtree: string, files: string[], journal: Journal, journalPath: string): JournalWrite[] {
|
||||
const apply: StageRecord = { name: 'apply', status: 'pending', writes: [] };
|
||||
journal.stages.push(apply);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
|
||||
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 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('--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')]));
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,9 @@ describe('stamp templates (pure)', () => {
|
||||
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);
|
||||
expect(rewriteStamps("since: '1.2.3.0'", '1.2.3.0', '1.2.4.0').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', () => {
|
||||
|
||||
Reference in new issue
Block a user