diff --git a/CHANGELOG.md b/CHANGELOG.md index b371c3a62..3ee6eb368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.94.16.0] - 2026-11-03 + +**A declared placeholder restamped to the new version is a stamp, and a carried receipt names the head's version (GST-1026, GST-1030).** gbrain writes a PR's own BEHAVIOR_CHANGES row as `since: 'TBD'` and its `release:restamp` fills in the version. Finish refused that receipt ("behavior, tests or build inputs changed since review"), and the receipt carry and the tree receipt would have refused the same row (gbrain #8444). Separately, a receipt carried after a main merge and a restamp kept the version of the head it was written at (GGST-1). + +### Added +- **`stamp_placeholders` policy key** (default `[]`, so nothing changes for a repo that does not set it): literal placeholders such as `["TBD"]`, at most 16, one line and 32 characters each, never `{version}` and never a version. It needs `min_gstack_version` at the release that introduced it. + +### Fixed +- **`lib/stamp-only.ts`:** `stampOnlyLine` takes the policy's placeholders. On a line the branch added itself (named by its position), a placeholder inside a `stamp_patterns` template may become that template at the new version, alongside any stamp-form version move on the same line. The built-in forms, a bare `{version}` template, base lines (including one whose text duplicates a branch line) and undeclared placeholders are still refused. Finish's `stampedFiles`, the receipt carry and the tree receipt share it. +- **`bin/gstack-ship-receipt` carry:** the carried receipt's `version` is HEAD's, read the way `write` reads it, instead of the version copied from the old receipt. A head with no version source keeps the version `write --version` recorded. +- **`bin/gstack-ship-receipt` carry, pin-bump PRs:** a `package.json` the branch changed as product (a dependency pin) that both main and a restamp moved since review is compared with only its `version` field masked, at all four sides. The receipt carries when nothing else in the file moved, and still refuses when the pin or any other field changed (GBEV-3, gbrain-evals #306). + +### Tests +- `test/tree-receipt-stamp-sensitive.test.ts`, `test/gstack-ship-receipt-docs.test.ts`: the shared predicate and the carry classifier accept gbrain's `since: 'TBD'` → `since: '0.60.553.0'` row and refuse an undeclared placeholder, a base row, a duplicated base copy, a built-in form and a line no template matches. +- `test/ship-rereview-triggers.test.ts`: finish routes the declared row as metadata and stops on the same controls. +- `test/restamp-mentions.test.ts`: the tree receipt and the carry accept the declared row and refuse it undeclared or on a base row; the carried receipt records the head's version after a main merge and a restamp; a version-only restamp of an EVIDENCE.md declared in `stamp_files` carries, and an undeclared one stays product; a pin-bump PR's receipt carries after a main merge and a restamp, and refuses when the pin moved since review. +- `test/gstack-ship-policy.test.ts`: the key's default, floor and validation. + +The EVIDENCE.md refusal (GST-1030's second point) reproduces on main and is already fixed by the GST-980 own-row carry this branch builds on, so this change adds only the test for it. + ## [1.94.15.0] - 2026-11-03 - **A stacked PR keeps its /ship receipt after its parent squash-merges, even when the receipt was written against main (GST-1199).** `gstack-ship-receipt carry` used to refuse with `RECEIPT_CARRY_PRODUCT_CHANGED` once the parent's hunks moved into main, forcing a full re-review of unchanged code. The carry now finds the newest reviewed commit that main already holds (`git merge-tree` into main changes no product path) and compares from there: an unchanged child carries as `stack parent landed`, base edits next to the child's hunks go to `RECEIPT_CARRY_SCOPED_REVIEW`, and any other change still refuses. A parent that never landed never counts, even if the child's merge dropped its change. diff --git a/VERSION b/VERSION index f2224bc7b..e1438391e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.94.15.0 +1.94.16.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index d3eba6498..18e81912e 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.94.15.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.94.16.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 41a4490e5..e6c01c637 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -507,13 +507,13 @@ function verifiedOutputs(repoRoot: string, base: string, pregateFile: string | u return regen.proven.filter(p => !/[*?[\]\\]/.test(p) && matchesAny(owned, p)); } -interface ProductRules { extra: { metadata: string[]; outputs: readonly string[]; fragments?: string }; metadata: string[]; templates: readonly string[] } +interface ProductRules { extra: { metadata: string[]; outputs: readonly string[]; fragments?: string }; metadata: string[]; templates: readonly string[]; placeholders: readonly string[] } function productRules(repoRoot: string, base: string, outputs: readonly string[]): ProductRules { const load = loadPolicyFromBase(repoRoot, base); const policy: ShipPolicy | null = isUnavailable(load) ? null : load.policy; const fragments = policy?.changelog_fragments?.dir.replace(/^(\.\/)+/, '').replace(/\/+$/, ''); const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])], outputs, fragments }; - return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...(fragments ? [`${fragments}/*.md`] : []), ...outputs], templates: policy?.stamp_patterns ?? [] }; + return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...(fragments ? [`${fragments}/*.md`] : []), ...outputs], templates: policy?.stamp_patterns ?? [], placeholders: policy?.stamp_placeholders ?? [] }; } /** @@ -540,7 +540,7 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st const versions = new Map(); const version = (ref: string) => { if (!versions.has(ref)) versions.set(ref, versionAt(ref)); return versions.get(ref)!; }; const stamped = (p: string) => matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p); - const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates, side: a === from && b === to ? branch?.own(p) : undefined } : undefined); + const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates, placeholders: rules.placeholders, side: a === from && b === to ? branch?.own(p) : undefined } : undefined); const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classify(p, from, to) })); const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`); const demoted = !branch ? changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path) @@ -556,7 +556,7 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st const was = before.split('\n'); const now = after.split('\n'); const lines = was.map((_, i) => i).filter(i => was[i] !== now[i]); - return was.length === now.length && lines.length > 0 && lines.every(i => side.at.has(i + 1) && stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, rules.templates, side, i + 1)); + return was.length === now.length && lines.length > 0 && lines.every(i => side.at.has(i + 1) && stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, rules.templates, side, i + 1, rules.placeholders)); }); return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)], demoted, ownRows }; } @@ -718,6 +718,16 @@ function cmdCarry(rest: string[], json: boolean): number { if (changedProduct.length >= CARRY_NAMED_PATHS) break; const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra); if (cls === 'metadata' && spec.ownRows.includes(p) && !movedBase.has(p)) stampOnly.push(p); + if (cls === 'metadata' && spec.demoted.includes(p) && movedBase.has(p) && /(^|\/)package\.json$/.test(p)) { + const masked = Object.values(sides).map(ref => { + const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); + try { const { version: _, ...rest } = JSON.parse(r.status === 0 ? r.stdout : 'null') ?? {}; return JSON.stringify(rest); } catch { return null; } + }); + const [rb, rv, cb, cv] = masked; + if (masked.some(m => m === null) || rv !== cv || rb !== cb) changedProduct.push(p); + else stampOnly.push(p); + continue; + } if (cls !== 'product') continue; const verdict = comparePath(repoRoot, sides, p); if (verdict === 'changed') changedProduct.push(p); @@ -795,7 +805,10 @@ function cmdCarry(rest: string[], json: boolean): number { depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) }, review_exclude: receiptExclude(review.exclude) }; } // The rebound product_patch_id is HEAD's diff against the carry's base, so a retargeted receipt names that base from now on. - const rebound = { ...receipt, ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}), ...depthField }; + // A receipt that records a version names HEAD's, read as write reads it: a restamp after a base merge moves it (GST-1030). + // A head with no version source keeps the recorded one (write --version named it). + const version = receipt.version === undefined ? undefined : (receiptVersion(repoRoot, head, base) ?? receipt.version); + const rebound = { ...receipt, ...(version !== undefined ? { version } : {}), ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}), ...depthField }; const carried = carryReceipt(rebound, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch }); const errors = validateReceipt(carried); if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`); diff --git a/docs/ship-policy.md b/docs/ship-policy.md index f25e55b85..6612a8aa3 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -53,6 +53,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with | `maintainers` | GitHub logins | `[]` | Maintainer set for `/review` contributor mode; empty means repository push permission. | | `stamp_files` | globs | `[]` | The repo's stamp rule: when set, only these files may differ by a stamp (list the version file, manifests, plugin trees and the files holding the PR's own `since:` rows). It applies before every other stamp rule, so a doc `vX` mention outside it is a difference. | | `stamp_patterns` | literal templates | `[]` | At most 16 templates of at most 128 characters, each with exactly one `{version}` slot (`"version": "{version}"`, `since: '{version}'`, ``); matched literally at a version boundary, never as a regex. The restamp writer, its mention scanner and the tree receipt use them beside the built-in forms. | +| `stamp_placeholders` | literal placeholders | `[]` | At most 16 one-line literals of at most 32 characters, never `{version}` and never a version (`["TBD"]`). On a line the branch added itself, a placeholder inside a `stamp_patterns` template (`since: 'TBD'` under `since: '{version}'`) may become that template at the new version, and finish's route, the receipt carry and the tree receipt read the change as a stamp. The built-in forms never take a placeholder, and neither does a base line, even one whose text duplicates a branch line. The writer does not fill placeholders: the repo's `restamp_command` does. A newer key: set `min_gstack_version` to the release `gstack-ship-policy validate` names. Empty accepts no placeholder. | | `restamp_command` | command or `null` | `null` | The repo's own restamp, run first in the staging worktree; see [restamp_command](#restamp-command). | | `migrations` | `{ dir, id }` or `null` | `null` | The migration directory and a regex with exactly one capture group for the number in a file name (`"^v(\\d+)-"`); `gstack-pregate regate` reports `MIGRATION_COLLISION`. | | `gate_command` | command or `null` | `null` | The repo's full pre-merge gate. `gstack-ship-queue gate-ahead` and `ready` print it for the caller to run (in the background or in CI); gstack never executes it. Needs `min_gstack_version`. | diff --git a/lib/restamp.ts b/lib/restamp.ts index be2c6fc23..9eba5f88e 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -313,7 +313,7 @@ export function branchAddedLines(cwd: string, since: string, files: string[], to return out; } -const escapeRegExp = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +export const escapeRegExp = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const VERSION_RE = '(?\\d+(?:\\.\\d+){1,3})(?![0-9.])'; /** * The built-in stamp forms as scanners, the version in the `v` group: `(was X)`, `since: X` and `vX`. diff --git a/lib/ship-bin-common.ts b/lib/ship-bin-common.ts index 51bb7d167..e36ef6ed7 100644 --- a/lib/ship-bin-common.ts +++ b/lib/ship-bin-common.ts @@ -113,7 +113,8 @@ export function releasePaths(policy: Record | null, cwd: string, re * added since the merge base with origin/, and not added by the base since): after a lost slot and a merge of the * base its stale stamp is older than `ref`'s VERSION, so it must be at or newer than the fork point's version instead, * the bound gstack-restamp's stamp_paths stage uses (GST-895; the fork version itself since GST-963). A branch line is - * named by its position, and with a kept VERSION only such a line may move (GST-979, GST-980). + * named by its position, and with a kept VERSION only such a line may move (GST-979, GST-980); on it a declared + * `stamp_placeholders` value may also become the new version inside a `stamp_patterns` template (GST-1026). */ export function stampedFiles(policy: Record | null, cwd: string, ref: string, base: string): string[] { const globs = Array.isArray(policy?.stamp_files) ? policy.stamp_files.filter((s: unknown): s is string => typeof s === 'string') : []; @@ -123,11 +124,13 @@ export function stampedFiles(policy: Record | null, cwd: string, re let after = ''; try { after = extractVersion(fs.readFileSync(path.join(cwd, before.rel), 'utf8'), before.rel); } catch { return []; } if (!after) return []; - const templates = Array.isArray(policy?.stamp_patterns) ? policy.stamp_patterns.filter((s: unknown): s is string => typeof s === 'string') : []; + const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : []); + const templates = strings(policy?.stamp_patterns); + const placeholders = strings(policy?.stamp_placeholders); const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', ref]); if (diff.status !== 0) return []; const side = branchSides(cwd, `origin/${base}`, 'HEAD', before.version, ref); - const stamp = (rel: string, removed: string, added: string, at: number): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel), at); + const stamp = (rel: string, removed: string, added: string, at: number): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel), at, placeholders); return parseUnifiedDiff(diff.stdout).filter(f => f.status === 'M' && !f.binary && !f.modeChanged && matchesAny(globs, f.path) && f.hunks.length > 0 && f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stamp(f.path, line, h.added[i]!, h.removedAt[i]!)))).map(f => f.path); } diff --git a/lib/ship-policy-stamps.ts b/lib/ship-policy-stamps.ts new file mode 100644 index 000000000..6e0b144da --- /dev/null +++ b/lib/ship-policy-stamps.ts @@ -0,0 +1,35 @@ +/** + * Validation of the stamp policy keys (stamp_patterns templates and stamp_placeholders), split out of ship-policy.ts + * to keep that module under the module-size ratchet. + */ +import type { PolicyError } from './ship-policy'; +import { parseVersion } from './version-source'; + +const MAX_TEMPLATES = 16; +const MAX_TEMPLATE = 128; +const MAX_PLACEHOLDERS = 16; +const MAX_PLACEHOLDER = 32; +export const VERSION_SLOT = '{version}'; + +export function templateErrors(value: unknown, out: PolicyError[]): void { + if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_patterns', message: 'must be an array of literal templates' }); return; } + if (value.length > MAX_TEMPLATES) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_patterns', message: `more than ${MAX_TEMPLATES} templates` }); + value.forEach((t, i) => { + const at = `stamp_patterns[${i}]`; + if (typeof t !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; } + if (t.split(VERSION_SLOT).length !== 2) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(t)} must contain exactly one ${VERSION_SLOT}` }); + else if (t.length > MAX_TEMPLATE || /[\0\r\n]/.test(t)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one line of at most ${MAX_TEMPLATE} characters` }); + }); +} + +export function placeholderErrors(value: unknown, out: PolicyError[]): void { + if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_placeholders', message: 'must be an array of literal placeholders' }); return; } + if (value.length > MAX_PLACEHOLDERS) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_placeholders', message: `more than ${MAX_PLACEHOLDERS} placeholders` }); + value.forEach((p, i) => { + const at = `stamp_placeholders[${i}]`; + if (typeof p !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; } + if (!p.trim() || p.length > MAX_PLACEHOLDER || /[\0\r\n]/.test(p)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one non-empty line of at most ${MAX_PLACEHOLDER} characters` }); + else if (p.includes(VERSION_SLOT)) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(p)} must not contain ${VERSION_SLOT}` }); + else if (parseVersion(p)) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(p)} is a version, not a placeholder` }); + }); +} diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 0092b3be4..3fd85545c 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -18,6 +18,8 @@ import * as path from 'node:path'; import { defaultBaseBranch, noDefaultBaseLine } from './default-base'; import { RESULT_CODES, type ResultCodeName } from './result-codes'; import { cmpVersion, parseVersion } from './version-source'; +import { placeholderErrors, templateErrors } from './ship-policy-stamps'; +export { VERSION_SLOT } from './ship-policy-stamps'; export const SHIP_POLICY_PATH = '.gstack/ship-policy.json'; export const SHIP_POLICY_SCHEMA = 'https://github.com/garrytan/gstack/docs/ship-policy.md#schema-v1'; @@ -64,6 +66,8 @@ export interface ShipPolicy { stamp_files: string[]; /** Literal stamp templates with one `{version}` slot, beside the built-in `(was X)`, `since: X`, `vX`; writer and classifier share them. */ stamp_patterns: string[]; + /** Placeholders (`TBD`) a restamp may replace with the new version inside a `stamp_patterns` template, on the branch's own lines only. */ + stamp_placeholders: string[]; /** The repo's own restamp, run first in the staging worktree with the target version; gstack's version writer is skipped. */ restamp_command: string | null; /** Migration numbering: `{ dir, id }` where `id` is a regex with one capture group over the file name. */ @@ -161,6 +165,7 @@ export const DEFAULT_POLICY: ShipPolicy = { maintainers: [], stamp_files: [], stamp_patterns: [], + stamp_placeholders: [], restamp_command: null, migrations: null, gate_command: null, @@ -193,6 +198,7 @@ export const POLICY_EXPLANATIONS: Record = { maintainers: 'GitHub logins treated as maintainers by /review contributor mode; empty uses repository push permission.', stamp_files: 'The repo’s stamp rule: globs of the only files that may differ by a stamp (version file, manifests, plugin trees, the PR’s own since: rows). Empty keeps gstack’s per-file rules; set, it applies before every rule.', stamp_patterns: 'Literal templates with exactly one {version} slot (at most 16, 128 characters each), for example `"version": "{version}"` or `since: \'{version}\'`; the restamp writer and the tree receipt use them beside (was X), since: X and vX.', + stamp_placeholders: 'Literal placeholders (at most 16, one line, 32 characters each, never `{version}` or a version), for example `["TBD"]`: a line the branch added that reads a placeholder in a stamp_patterns template (`since: \'TBD\'`) may become that template at the new version, and finish, the receipt carry and the tree receipt read it as a stamp. Empty (default) accepts no placeholder.', restamp_command: 'The repo’s own restamp, run first in gstack-restamp’s staging worktree (a clean checkout of HEAD) with the target version as its argument and in GSTACK_RESTAMP_VERSION; gstack’s version writer is skipped, output confinement still applies. Needs a clean caller tree and --allow-repo-commands.', migrations: '{ dir, id }: the migration directory and a regex with one capture group for the number in a file name; `gstack-pregate regate` reports MIGRATION_COLLISION against the base branch and every open PR.', gate_command: 'The repo’s full pre-merge gate command. gstack-ship-queue gate-ahead and ready print it for the caller to run (in the background or in CI); gstack never executes it. null when the repo has none.', @@ -236,6 +242,7 @@ export const BUMP_NEVER_KEY_VERSION = '1.91.353.0'; export const BASE_RED_KEY_VERSION = '1.91.374.0'; /** `train.gates` and `train.land` are values of the older train key, so compatErrors checks them by value, as for bump: never. */ export const TRAIN_GATES_KEY_VERSION = '1.92.0.0'; +export const STAMP_PLACEHOLDERS_KEY_VERSION = '1.94.16.0'; export const REVIEW_EXCLUDE_KEY_VERSION = '1.93.33.0'; export const POLICY_KEY_INTRODUCED: Record = { bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0', @@ -245,6 +252,7 @@ export const POLICY_KEY_INTRODUCED: Record = { train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION, changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION, base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION, + stamp_placeholders: STAMP_PLACEHOLDERS_KEY_VERSION, }; const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0; @@ -274,9 +282,6 @@ const MAX_PATTERN = 256; const MAX_REGEX = 512; const MAX_COMMAND = 512; const SHA_RE = /^[0-9a-f]{7,64}$/; -const MAX_TEMPLATES = 16; -const MAX_TEMPLATE = 128; -export const VERSION_SLOT = '{version}'; function hasDotDot(p: string): boolean { return p.split(/[\\/]+/).some(seg => seg === '..'); @@ -343,17 +348,6 @@ function excludeErrors(value: unknown, out: PolicyError[]): void { }); } -function templateErrors(value: unknown, out: PolicyError[]): void { - if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_patterns', message: 'must be an array of literal templates' }); return; } - if (value.length > MAX_TEMPLATES) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_patterns', message: `more than ${MAX_TEMPLATES} templates` }); - value.forEach((t, i) => { - const at = `stamp_patterns[${i}]`; - if (typeof t !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; } - if (t.split(VERSION_SLOT).length !== 2) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(t)} must contain exactly one ${VERSION_SLOT}` }); - else if (t.length > MAX_TEMPLATE || /[\0\r\n]/.test(t)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one line of at most ${MAX_TEMPLATE} characters` }); - }); -} - function migrationErrors(value: unknown, out: PolicyError[]): void { const m = value as Record | null; if (!m || typeof m !== 'object' || Array.isArray(m)) { out.push({ code: 'POLICY_INVALID', path: 'migrations', message: 'must be { dir, id } or null' }); return; } @@ -497,6 +491,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } = if ('release_outputs' in obj) listErrors('release_outputs', obj.release_outputs, errors, globError); if ('stamp_files' in obj) listErrors('stamp_files', obj.stamp_files, errors, globError); if ('stamp_patterns' in obj) templateErrors(obj.stamp_patterns, errors); + if ('stamp_placeholders' in obj) placeholderErrors(obj.stamp_placeholders, errors); if ('restamp_command' in obj && obj.restamp_command !== null) { const t = obj.restamp_command; if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'restamp_command', message: 'must be a non-empty string or null' }); diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index 3f9ff6dcd..27f911951 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -192,9 +192,9 @@ const CARRY_RERUNS = ['CI at head (gstack-ship-queue ready requires it); test an /** * The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else - * `package.json`) and the base policy's `stamp_patterns` templates. + * `package.json`) and the base policy's `stamp_patterns` templates and `stamp_placeholders`. */ -export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[]; side?: BranchSide } +export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[]; side?: BranchSide; placeholders?: readonly string[] } /** * A policy-named stamp path's change is a stamp when the new text reads as the @@ -217,7 +217,7 @@ export function stampOnlyText(change: CarryChange): boolean { const now = after.split('\n'); if (was.length !== now.length) return false; const changed = was.map((line, i) => i).filter(i => was[i] !== now[i]); - return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [], change.side, i + 1)); + return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [], change.side, i + 1, change.placeholders ?? [])); } /** diff --git a/lib/stamp-only.ts b/lib/stamp-only.ts index bbb87dc66..53b2b5231 100644 --- a/lib/stamp-only.ts +++ b/lib/stamp-only.ts @@ -4,7 +4,8 @@ * lines the branch wrote itself. Split from restamp.ts (module-size ratchet). */ import { spawnSync } from 'node:child_process'; -import { branchAddedLines, forkVersion, rewriteStamps, stampMentions } from './restamp'; +import { branchAddedLines, escapeRegExp, forkVersion, rewriteStamps, stampMentions } from './restamp'; +import { VERSION_SLOT } from './ship-policy'; import { cmpVersion, parseVersion, versionWidth } from './version-source'; /** @@ -26,12 +27,20 @@ import { cmpVersion, parseVersion, versionWidth } from './version-source'; * stage's bound for its own lines (GST-895, GST-963): finish's stampedFiles, * the tree receipt and the receipt carry all pass the same side. When the * version does not move (a slot that keeps its claimed VERSION), only such a - * branch line may change: the stage moves nothing else then. + * branch line may change: the stage moves nothing else then. On such a branch + * line a declared placeholder (`stamp_placeholders`, GST-1026: gbrain's + * `since: 'TBD'`) may also become the new version, but only inside a + * `stamp_patterns` template with literal text around its slot, never a built-in + * form: with at least one such rewrite, the line must then be the added line, + * or one whose remaining stamp-form moves pass the fork-version bound. */ -export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide, at?: number): boolean { +export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide, at?: number, placeholders: readonly string[] = []): boolean { if (removed === added) return false; if (oldV !== newV && stampOnlyWithin(removed, added, oldV, newV, templates)) return true; - return !!side && at !== undefined && side.at.has(at) && side.added.has(removed) && stampOnlyWithin(removed, added, side.fork, newV, templates); + if (!side || at === undefined || !side.at.has(at) || !side.added.has(removed)) return false; + if (stampOnlyWithin(removed, added, side.fork, newV, templates)) return true; + const filled = fillPlaceholders(removed, newV, templates, placeholders); + return filled.count > 0 && (filled.text === added || stampOnlyWithin(filled.text, added, side.fork, newV, templates)); } /** @@ -64,6 +73,21 @@ export function branchSides(repoRoot: string, baseRef: string, head: string, fal }; } +/** Each declared placeholder rewritten to `newV` inside each template with literal text around its slot (a bare edge at a word boundary). */ +function fillPlaceholders(removed: string, newV: string, templates: readonly string[], placeholders: readonly string[]): { text: string; count: number } { + let text = removed; + let count = 0; + for (const t of templates) { + const [pre, post] = t.split(VERSION_SLOT) as [string, string]; + if (!pre && !post) continue; + for (const p of placeholders) { + const re = new RegExp(`${escapeRegExp(pre)}${pre ? '' : '(? { count++; return pre + newV + post; }); + } + } + return { text, count }; +} + function stampOnlyWithin(removed: string, added: string, oldV: string, newV: string, templates: readonly string[]): boolean { const floor = parseVersion(oldV); const width = versionWidth(oldV); diff --git a/lib/tree-receipt.ts b/lib/tree-receipt.ts index f5d46b053..0611d6074 100644 --- a/lib/tree-receipt.ts +++ b/lib/tree-receipt.ts @@ -78,15 +78,16 @@ export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: st /** * Every removed/added pair differs only by stamp rewrites to the new version: * the old version, or a tentative one (newer than it) the stamp_paths stage - * moved (#3512), each in a stamp form (`stampOnlyLine`, bounded by the fork version on the branch's own lines). + * moved (#3512), each in a stamp form (`stampOnlyLine`, bounded by the fork version on the branch's own lines, where a + * declared placeholder may also move inside a stamp_patterns template, GST-1026). */ -function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[], side?: BranchSide): { why: string; line: number } | null { +function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[], side?: BranchSide, placeholders: readonly string[] = []): { why: string; line: number } | null { for (const h of hunks) { if (h.removed.length !== h.added.length) return { why: `${h.removed.length} line(s) removed, ${h.added.length} added`, line: h.at }; for (let i = 0; i < h.removed.length; i++) { const r = h.removed[i]!; const a = h.added[i]!; - if (!stampOnlyLine(r, a, oldV, newV, templates, side, h.removedAt[i])) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i }; + if (!stampOnlyLine(r, a, oldV, newV, templates, side, h.removedAt[i], placeholders)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i }; } } return null; @@ -130,7 +131,7 @@ export function classifyFile(repoRoot: string, f: FileDiff, gated: string, curre const rule = matchesAny(policy.stamp_paths, f.path) ? 'stamp_paths' : matchesAny(policy.release_outputs, f.path) ? 'release_outputs' : matchesAny(policy.stamp_files, f.path) ? 'stamp_files' : null; const own = rule ? side?.(f.path) : undefined; if (oldV && newV && (oldV !== newV || own) && rule) { - const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns, own); + const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns, own, policy.stamp_placeholders); return bad ? changed(rule, bad.why, bad.line) : stamp(rule); } return changed('other', `${f.hunks.length} hunk(s) outside every stamp rule`); diff --git a/package.json b/package.json index 0f033e81f..62b1079ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.94.15", + "version": "1.94.16", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/gstack-ship-policy.test.ts b/test/gstack-ship-policy.test.ts index 025b1bb57..e290256a8 100644 --- a/test/gstack-ship-policy.test.ts +++ b/test/gstack-ship-policy.test.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { spawnSync } from 'node:child_process'; import { EXIT } from '../lib/headless-artifacts'; -import { BUMP_NEVER_KEY_VERSION, CHANGELOG_FRAGMENTS_KEY_VERSION, DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, globToRegExp, matchesAny, minimumReader, shipConsequences, validatePolicy } from '../lib/ship-policy'; +import { BUMP_NEVER_KEY_VERSION, CHANGELOG_FRAGMENTS_KEY_VERSION, DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, STAMP_PLACEHOLDERS_KEY_VERSION, globToRegExp, matchesAny, minimumReader, shipConsequences, validatePolicy } from '../lib/ship-policy'; import { fillDue } from '../lib/ship-queue-train'; import { cmpVersion, parseVersion } from '../lib/version-source'; import { BASE_POLICY, cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture'; @@ -63,6 +63,32 @@ describe('validatePolicy (schema + containment, pure)', () => { expect(codes({ preregistration_shas: ['not-a-sha'] })).toEqual(['POLICY_CONTAINMENT:preregistration_shas[0]']); }); + // Value: protects=stamp_placeholders defaults to none, needs its reader floor, and admits only short one-line literals + // that are neither the {version} slot nor a version (GST-1026); fails_when=a non-default value validates without + // min_gstack_version, or a version, a slot, a multi-line or an oversized entry is accepted; seam=validatePolicy + test('stamp_placeholders: empty by default, needs its floor, and admits only short literal placeholders (GST-1026)', () => { + const codes = (p: unknown) => validatePolicy(p).map(e => `${e.code}:${e.path}`); + const at = (stamp_placeholders: unknown) => codes({ stamp_placeholders, min_gstack_version: STAMP_PLACEHOLDERS_KEY_VERSION }); + expect(DEFAULT_POLICY.stamp_placeholders).toEqual([]); + expect(POLICY_KEY_INTRODUCED.stamp_placeholders).toBe(STAMP_PLACEHOLDERS_KEY_VERSION); + expect(codes({ stamp_placeholders: [] })).toEqual([]); + expect(codes({ stamp_placeholders: ['TBD'] })).toEqual(['POLICY_INVALID:stamp_placeholders']); + expect(minimumReader({ stamp_placeholders: ['TBD'] })).toBe(STAMP_PLACEHOLDERS_KEY_VERSION); + expect(at(['TBD', 'UNRELEASED'])).toEqual([]); + expect(at('TBD')).toEqual(['POLICY_INVALID:stamp_placeholders']); + expect(at([7])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']); + expect(at(Array.from({ length: 17 }, (_, i) => `P${i}`))).toEqual(['POLICY_CONTAINMENT:stamp_placeholders']); + expect(at(['x'.repeat(33)])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']); + expect(at(['x'.repeat(32)])).toEqual([]); + expect(at(['TB\nD'])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']); + expect(at([''])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']); + expect(at([' '])).toEqual(['POLICY_CONTAINMENT:stamp_placeholders[0]']); + expect(at(['{version}'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']); + expect(at(['next-{version}'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']); + expect(at(['1.2.3.0'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']); + expect(at(['0.60.553'])).toEqual(['POLICY_INVALID:stamp_placeholders[0]']); + }); + // Value: protects=bump never is refused without its floor (an older gstack names the cause) and next to any policy that stamps a version at every merge, reports its reader floor, and prints its Step 12 consequence; fails_when=never validates bare, or beside stamp-at-merge, train or changelog_fragments; // why_new=GST-380 added the value to an older key, which the per-key floor check skips; seam=validatePolicy, minimumReader, shipConsequences; no_claim=Step 12's behavior (test/ship-release.test.ts) test('bump never needs its floor and rules out every policy that stamps at merge', () => { diff --git a/test/gstack-ship-receipt-docs.test.ts b/test/gstack-ship-receipt-docs.test.ts index db89a68fb..03213266c 100644 --- a/test/gstack-ship-receipt-docs.test.ts +++ b/test/gstack-ship-receipt-docs.test.ts @@ -53,6 +53,25 @@ describe('E18: a repository without a version source or CHANGELOG', () => { expect(JSON.parse(fs.readFileSync(path.join(out, 'ship-run.json'), 'utf8')).version).toBe('none'); }); + // Value: protects=a receipt write --version named in a repository with no version source keeps that version through a + // carry (GST-1030 review); fails_when=the carry refuses because HEAD has no readable version, or drops the version; + // seam=bin/gstack-ship-receipt cmdCarry + test('a carry keeps the version write --version named when HEAD has no version source (GST-1030)', () => { + const repo = fixture(); + for (const f of ['VERSION', 'package.json', 'package-lock.json']) fs.rmSync(path.join(repo.work, f)); + repo.git('add', '-A'); repo.git('commit', '-qm', 'changelog only'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'pr-unversioned'); + const body = path.join(repo.root, 'body.md'); + fs.writeFileSync(body, '## Summary\n'); + expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--version', '2.0.0', '--body', body]).status).toBe(EXIT.ok); + fs.mkdirSync(path.join(repo.work, 'test'), { recursive: true }); + fs.writeFileSync(path.join(repo.work, 'test', 'a.test.ts'), 'test a\n'); + repo.git('add', '-A'); repo.git('commit', '-qm', 'a test-only change'); + const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + expect(carried.out, carried.err).toContain('CARRY: carried'); + expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt?.version).toBe('2.0.0'); + }); + test('a repository with a version source keeps its version, and one with only a CHANGELOG records no version rather than "none"', () => { const repo = fixture(); const w = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'x', '--json']); @@ -248,6 +267,25 @@ describe('carry follow-ups: git floor, named refusal, branch-touched walk, digit expect(stampOnlyText({ before, after: lines(key('1.2.3.0'), key('1.2.4.0')), oldVersion: '1.2.4.0', newVersion: '1.2.4.0', templates })).toBe(false); }); + // Value: protects=the carry classifier reads a declared placeholder moved to the new version on the branch's own row + // as a stamp, through the same predicate as finish and the tree receipt (GST-1026); fails_when=stampOnlyText ignores + // the policy's placeholders, or accepts an undeclared one or a base row; seam=lib/ship-receipt.ts stampOnlyText + test('stampOnlyText reads a declared placeholder moved on the branch\'s own row as a stamp, and nothing else (GST-1026)', () => { + const templates = ["since: '{version}'"]; + const row = (v: string, id: string) => ` { since: '${v}', id: '${id}' },`; + const text = (...rows: string[]) => ['export const ROWS = [', ...rows, '];', ''].join('\n'); + const before = text(row('TBD', 'base'), row('TBD', 'mine')); + const side = { fork: '0.60.552.0', added: new Set([row('TBD', 'mine')]), at: new Set([3]) }; + const change = (after: string, extra: Record = {}) => stampOnlyText({ before, after, oldVersion: '0.60.552.0', newVersion: '0.60.553.0', templates, side, placeholders: ['TBD'], ...extra }); + expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')))).toBe(true); + expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { oldVersion: '0.60.553.0' })).toBe(true); + expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { placeholders: [] })).toBe(false); + expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { placeholders: ['WIP'] })).toBe(false); + expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { templates: [] })).toBe(false); + expect(change(text(row('TBD', 'base'), row('0.60.553.0', 'mine')), { side: undefined })).toBe(false); + expect(change(text(row('0.60.553.0', 'base'), row('TBD', 'mine')))).toBe(false); + }); + test('planCarry names at most CARRY_NAMED_PATHS paths', () => { const many = Array.from({ length: CARRY_NAMED_PATHS + 3 }, (_, i) => `src/f${i}.ts`); const plan = planCarry({ from: 'a'.repeat(40), head: 'b'.repeat(40), changed: many.map(p => ({ path: p, class: 'product' as const })), productPatchId: { from: 'x', head: 'y' }, testPatchId: { from: '', head: '' }, changedProduct: many, mergeCommits: 0, rangeDiff: '' }); diff --git a/test/restamp-mentions.test.ts b/test/restamp-mentions.test.ts index 68a546a9c..a74641aa0 100644 --- a/test/restamp-mentions.test.ts +++ b/test/restamp-mentions.test.ts @@ -14,6 +14,8 @@ 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 { STAMP_PLACEHOLDERS_KEY_VERSION } from '../lib/ship-policy'; +import { extractReceipt } from '../lib/ship-receipt'; import type { GitFixtureTemplate } from './helpers/git-fixture-template'; import { BASE_POLICY, cleanup, cloneFixture, fixtureTemplate, makePr, mergeMain, mergeToMain, readFile, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture'; @@ -425,10 +427,165 @@ describe('a branch-added row left at the fork version after the base moved (GST- const { repo, body } = carryAfterStamp('restamp-carry-mode-', [row('1.2.3.0', 'base_row')], row('1.2.3.0', 'mine'), [row('1.2.3.0', 'base_row'), '', row('1.2.5.0', 'mine')]); fs.chmodSync(path.join(repo.work, NOTICE), 0o755); - repo.git('commit', '-qam', 'stamp 1.2.5.0, and make the file executable'); + repo.git('add', '-A'); + repo.git('update-index', '--chmod=+x', '--', NOTICE); + repo.git('commit', '-qm', 'stamp 1.2.5.0, and make the file executable'); expect(repo.git('ls-tree', 'HEAD', '--', NOTICE)).toStartWith('100755 '); const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); expect(carried.status, carried.out).toBe(EXIT.refused); expect(carried.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); }); }); + +describe('declared placeholders and the receipt carry (GST-1026, GST-1030)', () => { + /** gbrain's policy (GST-1026): the since: template, and TBD declared as a placeholder when `declared`. */ + const placeholderPolicy = (declared: boolean) => ({ ...receiptPolicy(true), ...(declared ? { stamp_placeholders: ['TBD'], min_gstack_version: STAMP_PLACEHOLDERS_KEY_VERSION } : {}) }); + + /** A branch row written as since: 'TBD' at the claim 1.2.4.0, then the stamp to 1.2.5.0 with the row restamped by hand (gbrain's release:restamp). */ + function tbdStamp(prefix: string, pol: Record, ownRow: string): { repo: FixtureRepo; gated: string } { + const repo = makeFixtureRepo({ prefix, policy: pol }); repos.push(repo); + write(repo, NOTICE, notice([row('TBD', 'base_row')])); + repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'pr-tbd'); + expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp 1.2.4.0'); + write(repo, NOTICE, notice([row('TBD', 'base_row'), '', ownRow])); + repo.git('commit', '-qam', 'mine: a TBD row'); + const gated = repo.git('rev-parse', 'HEAD'); + expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok); + return { repo, gated }; + } + + // Value: protects=the tree receipt (stamp, verify, gate-ahead replay) accepts gbrain's restamp of the branch's own + // since: 'TBD' row when the policy declares TBD, and refuses it undeclared, on a base row, or on a base row whose text + // the branch duplicates (GST-1026); fails_when=classifyFile reads the TBD move as a hand edit, or accepts it off the + // branch's own rows; seam=lib/tree-receipt.ts stampOnlyHunks + test('the tree receipt accepts a declared TBD row restamped on the branch, and refuses the same move on a base row (GST-1026)', () => { + const own = tbdStamp('restamp-tbd-receipt-', placeholderPolicy(true), row('TBD', 'mine')); + write(own.repo, NOTICE, readFile(own.repo, NOTICE).replace(row('TBD', 'mine'), row('1.2.5.0', 'mine'))); + own.repo.git('commit', '-qam', 'stamp 1.2.5.0: the TBD row'); + const accepted = runBin(own.repo, 'gstack-tree-receipt', ['--gated', own.gated, '--base', 'main']); + expect(accepted.out, accepted.err).toContain('TREE: same-modulo-stamps'); + write(own.repo, NOTICE, readFile(own.repo, NOTICE).replace(row('TBD', 'base_row'), row('1.2.5.0', 'base_row'))); + own.repo.git('commit', '-qam', 'move the base TBD row by hand'); + const base = runBin(own.repo, 'gstack-tree-receipt', ['--gated', own.gated, '--base', 'main']); + expect(base.out, base.err).toContain('TREE: changed'); + expect(base.out).toContain("id: 'base_row'"); + }); + + test('the tree receipt refuses an undeclared TBD row restamped on the branch (GST-1026)', () => { + const undeclared = tbdStamp('restamp-tbd-undeclared-', placeholderPolicy(false), row('TBD', 'mine')); + write(undeclared.repo, NOTICE, readFile(undeclared.repo, NOTICE).replace(row('TBD', 'mine'), row('1.2.5.0', 'mine'))); + undeclared.repo.git('commit', '-qam', 'stamp 1.2.5.0: the TBD row'); + const refused = runBin(undeclared.repo, 'gstack-tree-receipt', ['--gated', undeclared.gated, '--base', 'main']); + expect(refused.out, refused.err).toContain('TREE: changed'); + }); + + test('the tree receipt refuses a restamp of the base copy of a TBD row the branch duplicates (GST-1026)', () => { + const dup = tbdStamp('restamp-tbd-dup-', placeholderPolicy(true), row('TBD', 'base_row')); + write(dup.repo, NOTICE, notice([row('1.2.5.0', 'base_row'), '', row('TBD', 'base_row')])); + dup.repo.git('commit', '-qam', 'move the base copy of the duplicated TBD row'); + const copy = runBin(dup.repo, 'gstack-tree-receipt', ['--gated', dup.gated, '--base', 'main']); + expect(copy.out, copy.err).toContain('TREE: changed'); + }); + + /** A receipt written at the claim 1.2.4.0 after `own` changed the branch, then the stamp to 1.2.5.0, uncommitted. */ + function receiptThenStamp(prefix: string, pol: Record, own: (repo: FixtureRepo) => void): { repo: FixtureRepo; body: string } { + const repo = makeFixtureRepo({ prefix, policy: pol }); repos.push(repo); + write(repo, NOTICE, notice([row('TBD', 'base_row')])); + fs.rmSync(path.join(repo.work, 'package-lock.json')); + repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows, no lockfile'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'pr-carry'); + expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp 1.2.4.0'); + own(repo); + repo.git('add', '-A'); repo.git('commit', '-qm', 'mine'); + const body = path.join(repo.root, 'body.md'); + fs.writeFileSync(body, '## Summary\n'); + const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--body', body]); + expect(w.status, w.err).toBe(EXIT.ok); + expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok); + return { repo, body }; + } + const carry = (repo: FixtureRepo, body: string) => runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + + // Value: protects=the receipt carry follows gbrain's restamp of the branch's own since: 'TBD' row when the policy + // declares TBD, and refuses it undeclared (GST-1026); fails_when=productSpec's own-row check ignores the policy's + // placeholders and refuses RECEIPT_CARRY_PRODUCT_CHANGED; seam=bin/gstack-ship-receipt productSpec ownRows + test.each([['declared', true], ['undeclared', false]])('the carry and a TBD row restamped on the branch: %s', (_, declared) => { + const { repo, body } = receiptThenStamp(`restamp-tbd-carry-${declared}-`, placeholderPolicy(declared), r => write(r, NOTICE, notice([row('TBD', 'base_row'), '', row('TBD', 'mine')]))); + write(repo, NOTICE, readFile(repo, NOTICE).replace(row('TBD', 'mine'), row('1.2.5.0', 'mine'))); + repo.git('commit', '-qam', 'stamp 1.2.5.0: the TBD row'); + const carried = carry(repo, body); + if (declared) expect(carried.out, carried.err).toContain('CARRY: carried'); + else expect([carried.status, carried.err.includes('RECEIPT_CARRY_PRODUCT_CHANGED')], carried.err).toEqual([EXIT.refused, true]); + }); + + // Value: protects=a carried receipt names the head's version, the one write would record there, after a main merge + // and a restamp (GST-1030 point 1, GGST-1); fails_when=carryReceipt copies version from the old receipt; + // seam=bin/gstack-ship-receipt cmdCarry + test('the carried receipt records the head\'s version after a main merge and a restamp (GST-1030)', () => { + const { repo, body } = receiptThenStamp('restamp-carry-version-', policy(true), r => write(r, 'src.ts', 'export const x = 0;\n')); + repo.git('reset', '-q', '--hard'); + expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n'); + makePr(repo, 'pr-other'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp other'); + mergeToMain(repo, 'pr-other'); + repo.git('checkout', '-q', 'pr-carry'); + mergeMain(repo); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'restamp after the lost slot'); + expect(readFile(repo, 'VERSION')).toBe('1.2.5.0\n'); + expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt?.version).toBe('1.2.4.0'); + const carried = carry(repo, body); + expect(carried.out, carried.err).toContain('CARRY: carried'); + expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt?.version).toBe('1.2.5.0'); + }); + +/** package.json with its dependency pin set to `pin`, every other field kept. */ + const pinned = (repo: FixtureRepo, pin: string) => { + const pkg = JSON.parse(readFile(repo, 'package.json')); + pkg.dependencies = { ...pkg.dependencies, 'left-pad': pin }; + write(repo, 'package.json', JSON.stringify(pkg, null, 2) + '\n'); + }; + + // Value: protects=a pin-bump PR's receipt carries across a main merge and a restamp when package.json's only change since + // review is its version field, and refuses when the pin itself moved (GST-1030, GBEV-3, gbrain-evals #306); + // fails_when=the carry counts the PR's package.json as product and refuses RECEIPT_CARRY_PRODUCT_CHANGED on a version-only + // move, or lets a changed dependency pin ride; seam=bin/gstack-ship-receipt cmdCarry walk + test.each([['the pin unchanged since review', false], ['the pin moved since review', true]])('the carry and a pin-bump PR after a main merge: %s (GST-1030)', (_, moved) => { + const { repo, body } = receiptThenStamp(`restamp-carry-pin-${moved}-`, policy(true), r => pinned(r, '1.3.0')); + repo.git('reset', '-q', '--hard'); + makePr(repo, 'pr-other'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-qam', 'stamp other'); + mergeToMain(repo, 'pr-other'); + repo.git('checkout', '-q', 'pr-carry'); + mergeMain(repo); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + if (moved) pinned(repo, '1.3.1'); + repo.git('commit', '-qam', 'restamp after the lost slot'); + expect(JSON.parse(readFile(repo, 'package.json')).version).toBe('1.2.5'); + const carried = carry(repo, body); + if (moved) expect([carried.status, carried.err.includes('RECEIPT_CARRY_PRODUCT_CHANGED')], carried.err).toEqual([EXIT.refused, true]); + else expect(carried.out, carried.err).toContain('CARRY: carried'); + }); + + // Value: protects=a version-only restamp of EVIDENCE.md is release metadata to the carry when the policy declares it in + // stamp_files, and product when it does not (GST-1030 point 2, GGST-1); fails_when=the carry counts the declared + // file's stamp as a product change (RECEIPT_CARRY_PRODUCT_CHANGED), or lets an undeclared file's version edit ride; + // seam=bin/gstack-ship-receipt productSpec, lib/ship-receipt.ts stampOnlyText + test.each([['declared in stamp_files', true], ['undeclared', false]])('the carry and a version-only restamp of EVIDENCE.md: %s (GST-1030)', (_, declared) => { + const base = policy(true); + const pol = declared ? { ...base, stamp_files: [...base.stamp_files, 'EVIDENCE.md'] } : base; + const { repo, body } = receiptThenStamp(`restamp-carry-evidence-${declared}-`, pol, r => write(r, 'EVIDENCE.md', '# Evidence\n\nVerified on v1.2.4.0 with the free suite\n')); + if (!declared) write(repo, 'EVIDENCE.md', '# Evidence\n\nVerified on v1.2.5.0 with the free suite\n'); + expect(readFile(repo, 'EVIDENCE.md')).toBe('# Evidence\n\nVerified on v1.2.5.0 with the free suite\n'); + repo.git('commit', '-qam', 'stamp 1.2.5.0'); + const carried = carry(repo, body); + if (declared) expect(carried.out, carried.err).toContain('CARRY: carried'); + else expect([carried.status, carried.err.includes('RECEIPT_CARRY_PRODUCT_CHANGED')], carried.err).toEqual([EXIT.refused, true]); + }); +}); + diff --git a/test/ship-rereview-triggers.test.ts b/test/ship-rereview-triggers.test.ts index f83c6755e..3854c7808 100644 --- a/test/ship-rereview-triggers.test.ts +++ b/test/ship-rereview-triggers.test.ts @@ -9,6 +9,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { comparePath } from '../lib/ship-patch'; +import { STAMP_PLACEHOLDERS_KEY_VERSION } from '../lib/ship-policy'; import { extractReceipt, placeReceiptFirst } from '../lib/ship-receipt'; import { cleanup, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; import { lineOf, PREFLIGHT, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture'; @@ -266,6 +267,37 @@ describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () => const base = kept([['0.9.0.0', 'old'], ['1.0.1.0', 'base'], ['1.0.0.0', 'mine']]); expect([base.status, lineOf(base.stdout, 'STOP')]).toEqual([3, behaviorStop]); }); + + // Value: protects=gbrain's restamp of the PR's own `since: 'TBD'` row to the new version routes as metadata when the + // policy declares TBD in stamp_placeholders (GST-1026, gbrain #8444); an undeclared placeholder, a base row moved the + // same way, a duplicated base copy, or a line no stamp_pattern matches still stops; fails_when=stampedFiles reads the + // move as behavior (gbrain #8444) or accepts it off the branch's own rows; seam=lib/ship-bin-common.ts stampedFiles + test('a declared placeholder on the branch row is metadata; undeclared, a base row or no template stops (GST-1026)', () => { + const declared = (placeholders: string[] | undefined, templates = ["since: '{version}'"]) => JSON.stringify({ stamp_files: ['VERSION', 'src/notice.ts'], stamp_patterns: templates, + ...(placeholders ? { stamp_placeholders: placeholders, min_gstack_version: STAMP_PLACEHOLDERS_KEY_VERSION } : {}) }); + const restamped = (pol: string, baseRows: [string, string][], ownRows: [string, string][], rows: [string, string][]) => { + const f = fx({ '.gstack/ship-policy.json': pol, VERSION: '1.0.0.0\n', 'src/notice.ts': notice(...baseRows) }, + { VERSION: '1.0.1.0\n', 'src/notice.ts': notice(...baseRows, ...ownRows) }); + const reviewed = wtree(f); + const post = snapshot(f); + f.write({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(...rows) }); f.commit('restamp: TBD rows to 1.0.2.0'); + return finish(f, reviewed, post, ['--reviewed-base', mergeBase(f)]); + }; + const base: [string, string][] = [['0.9.0.0', 'old'], ['TBD', 'base']]; + const metadata = / behavior=0 docs=0 metadata=2 tests=0 /; + const own = restamped(declared(['TBD']), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]); + expect(lineOf(own.stdout, 'ROUTE'), own.stdout).toEqual(expect.stringMatching(metadata)); + const undeclared = restamped(declared(undefined), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]); + expect([undeclared.status, lineOf(undeclared.stdout, 'STOP')]).toEqual([3, behaviorStop]); + const other = restamped(declared(['WIP']), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]); + expect([other.status, lineOf(other.stdout, 'STOP')]).toEqual([3, behaviorStop]); + const baseRow = restamped(declared(['TBD']), base, [['TBD', 'mine']], [['0.9.0.0', 'old'], ['1.0.2.0', 'base'], ['1.0.2.0', 'mine']]); + expect([baseRow.status, lineOf(baseRow.stdout, 'STOP')]).toEqual([3, behaviorStop]); + const dup = restamped(declared(['TBD']), base, [['TBD', 'base']], [['0.9.0.0', 'old'], ['1.0.2.0', 'base'], ['TBD', 'base']]); + expect([dup.status, lineOf(dup.stdout, 'STOP')]).toEqual([3, behaviorStop]); + const noTemplate = restamped(declared(['TBD'], ['version: {version}']), base, [['TBD', 'mine']], [...base, ['1.0.2.0', 'mine']]); + expect([noTemplate.status, lineOf(noTemplate.stdout, 'STOP')]).toEqual([3, behaviorStop]); + }); }); describe('adjacent base edits (GST-384, #3631, founderstar#441)', () => { diff --git a/test/tree-receipt-stamp-sensitive.test.ts b/test/tree-receipt-stamp-sensitive.test.ts index e9c56e660..033f4ea5b 100644 --- a/test/tree-receipt-stamp-sensitive.test.ts +++ b/test/tree-receipt-stamp-sensitive.test.ts @@ -121,6 +121,36 @@ describe('tree receipt: stamps, stamp-sensitive gates and negative fixtures', () expect(stampOnlyLine("mine since: '1.2.3.0'", "mine since: '1.2.4.0'", '1.2.4.0', '1.2.4.0', tpl, mine, 5)).toBe(true); }); + // Value: protects=a declared placeholder on the branch's own row moves to the new version as a stamp, only inside a + // stamp_patterns template (gbrain's `since: 'TBD'` row, GST-1026); fails_when=stampOnlyLine reads the move as a hand + // edit, or accepts an undeclared placeholder, a base row, a duplicated base line, a built-in form or a line no + // template matches; seam=lib/stamp-only.ts stampOnlyLine placeholders + test('a declared placeholder on a branch row moves to the new version inside a stamp_patterns template, and nowhere else (GST-1026)', () => { + const tpl = ["since: '{version}'"]; + const row = " { since: 'TBD', id: 'mine' },"; + const moved = " { since: '0.60.553.0', id: 'mine' },"; + const side = (r: string, at = 10) => ({ fork: '0.60.552.0', added: new Set([r]), at: new Set([at]) }); + const line = (r: string, a: string, opts: { templates?: string[]; at?: number; placeholders?: string[]; oldV?: string } = {}) => + stampOnlyLine(r, a, opts.oldV ?? '0.60.552.0', '0.60.553.0', opts.templates ?? tpl, side(r), opts.at ?? 10, opts.placeholders ?? ['TBD']); + expect(line(row, moved)).toBe(true); + expect(line(row, moved, { oldV: '0.60.553.0' })).toBe(true); + expect(line(row, moved, { placeholders: [] })).toBe(false); + expect(line(row, moved, { placeholders: ['WIP'] })).toBe(false); + expect(line(" { since: 'WIP', id: 'mine' },", moved)).toBe(false); + expect(line(row, moved, { at: 2 })).toBe(false); + expect(stampOnlyLine(row, moved, '0.60.552.0', '0.60.553.0', tpl, undefined, undefined, ['TBD'])).toBe(false); + expect(stampOnlyLine(row, moved, '0.60.552.0', '0.60.553.0', tpl, side(" { since: 'TBD', id: 'other' },"), 10, ['TBD'])).toBe(false); + expect(line(row, moved, { templates: [] })).toBe(false); + expect(line(" { release: 'TBD', id: 'mine' },", " { release: '0.60.553.0', id: 'mine' },")).toBe(false); + expect(line(' { since: TBD, id: mine },', ' { since: 0.60.553.0, id: mine },')).toBe(false); + expect(line(row, " { since: '0.60.553.0', id: 'yours' },")).toBe(false); + expect(line(row, " { since: '0.60.554.0', id: 'mine' },")).toBe(false); + const both = " { since: 'TBD', note: 'Added in v0.60.552.0' },"; + expect(line(both, " { since: '0.60.553.0', note: 'Added in v0.60.553.0' },")).toBe(true); + expect(line(both, " { since: '0.60.553.0', note: 'Added in v0.60.551.0' },")).toBe(false); + expect(line(' // TBD: tidy', ' // 0.60.553.0: tidy', { templates: ['{version}'] })).toBe(false); + }); + test('negative fixture: a dependency pin equal to the old version is `changed`, not a stamp, even inside stamp_paths', () => { const repo = fixture(); const g = gated(repo);