/**
* ship-policy — the machine-readable repo ship policy (plan C1):
* `.gstack/ship-policy.json`, read from the committed base-branch copy
* (`origin/`) so a PR cannot smuggle a command into its own gate.
*
* File presence never authorizes execution: `release_tool` runs only under
* `/ship` or `--allow-repo-commands` (bin/gstack-restamp enforces that; this
* module only names the commands a policy declares). Every repo-controlled
* path (`stamp_paths`, `release_outputs`, `changelog`, `mirror`) is
* containment-checked here: no absolute paths, no `..`, globs confined to the
* repository, regex size caps, and symlink-escape checks when a glob is
* resolved against a tree. Docs: docs/ship-policy.md. Callers: bin/gstack-
* ship-policy, lib/restamp.ts, lib/tree-receipt.ts, lib/ship-receipt.ts.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { RESULT_CODES, type ResultCodeName } from './result-codes';
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';
export type BumpPolicy = 'patch' | 'ask';
export type QueueMode = 'claim-at-open' | 'stamp-at-merge';
export type HistoryPolicy = 'merge-only' | 'squash-ok';
export interface ShipPolicy {
$schema?: string;
/** `patch` skips the MINOR/MAJOR prompts in /ship Step 12; `ask` keeps them. */
bump: BumpPolicy;
/** `stamp-at-merge`: no claim at PR open; stamp when the PR is next. `claim-at-open` is today's queue. */
queue_mode: QueueMode;
/** The repo's own release tool (for example `bun run release:restamp`); run only under /ship or --allow-repo-commands. */
release_tool: string | null;
/** Globs the release tool may write beyond the stamp files; any other output is a refusal. */
release_outputs: string[];
/** Globs where `(was X)`, `since: X` and `vX` mentions are rewritten on a restamp. */
stamp_paths: string[];
/** The CHANGELOG re-headed on a restamp; null for repos without one. */
changelog: string | null;
/** Path of a paired repo to restamp identically (tier 2). */
mirror: string | null;
/** `merge-only` refuses a squash or rebase plan in /ship. */
history: HistoryPolicy;
/** Each must be an ancestor of HEAD before push (`gstack-evidence ancestor`). */
preregistration_shas: string[];
/** Evidence labels that consume VERSION or generated bytes and always rerun on the stamped tree. */
stamp_sensitive_gates: string[];
/** Consumed by the plan reviews (D1). */
pr_count: { default_pr_count: number; split_requires_owner: boolean };
/** Maintainer logins for contributor-PR mode (E3); empty means "use push permission". */
maintainers: string[];
/** The repo's stamp rule: when non-empty, only these files can be stamps (the allowlist applies before every stamp rule). */
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[];
/** 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. */
migrations: { dir: string; id: string } | null;
}
export const DEFAULT_POLICY: ShipPolicy = {
bump: 'ask',
queue_mode: 'claim-at-open',
release_tool: null,
release_outputs: [],
stamp_paths: [],
changelog: 'CHANGELOG.md',
mirror: null,
history: 'squash-ok',
preregistration_shas: [],
stamp_sensitive_gates: [],
pr_count: { default_pr_count: 1, split_requires_owner: true },
maintainers: [],
stamp_files: [],
stamp_patterns: [],
restamp_command: null,
migrations: null,
};
export type PolicyKey = Exclude;
export const POLICY_KEYS = Object.keys(DEFAULT_POLICY) as PolicyKey[];
/** What each key means, printed by `init --explain` (JSON carries no comments). */
export const POLICY_EXPLANATIONS: Record = {
bump: 'patch | ask. `patch` skips the MINOR/MAJOR prompt in /ship Step 12.',
queue_mode: 'claim-at-open | stamp-at-merge. stamp-at-merge stamps when the PR is next (`gstack-restamp --next`), from the base branch version at that moment.',
release_tool: 'The repo’s own release command, run in an isolated staging worktree with an enumerated allowed-output set. Never runs without /ship or --allow-repo-commands.',
release_outputs: 'Globs the release tool may write beyond the version files, lockfiles, CHANGELOG and stamp_paths. Any other output refuses the restamp.',
stamp_paths: 'Globs (repo-relative, no `..`) where exact `(was X)`, `since: X` and `vX` mentions of the old version are rewritten.',
changelog: 'CHANGELOG path re-headed on a restamp (the PR’s `[Unreleased]` or old-version section moves to the top under the new version). null to skip.',
mirror: 'Relative path of a paired repo to restamp identically; writes are confined to it. null to skip.',
history: 'merge-only | squash-ok. merge-only makes /ship refuse a squash or rebase plan.',
preregistration_shas: 'Commits that must be ancestors of HEAD before push (preregistered evals); checked by `gstack-evidence ancestor`.',
stamp_sensitive_gates: 'Evidence labels (gstack-evidence --label) that read VERSION or generated bytes; the tree receipt never marks them reusable.',
pr_count: '{ default_pr_count, split_requires_owner }: the PR-count row in plan reviews.',
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.',
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.',
};
export interface PolicyError { code: ResultCodeName; path: string; message: string }
const MAX_LIST = 64;
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 === '..');
}
/** A repo-relative path or glob: no absolute paths, drive letters, `..` segments, NULs or control bytes. */
export function containedRelative(p: string): string | null {
if (typeof p !== 'string' || p.length === 0) return 'must be a non-empty string';
if (p.length > MAX_PATTERN) return `longer than ${MAX_PATTERN} characters`;
if (/[\0\r\n]/.test(p)) return 'contains a control character';
if (path.isAbsolute(p) || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')) return 'must be repo-relative, not absolute';
if (p.startsWith('~')) return 'must not start with ~';
if (hasDotDot(p)) return 'must not contain a `..` segment';
return null;
}
/** Glob → anchored RegExp. `**` spans directories, `*` and `?` stay inside one segment. Size-capped. */
export function globToRegExp(glob: string): RegExp {
let out = '^';
for (let i = 0; i < glob.length; i++) {
const ch = glob[i]!;
if (ch === '*') {
if (glob[i + 1] === '*') {
i += 1;
if (glob[i + 1] === '/') { i += 1; out += '(?:.*/)?'; } else out += '.*';
} else out += '[^/]*';
} else if (ch === '?') out += '[^/]';
else out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
out += '$';
if (out.length > MAX_REGEX) throw new Error(`pattern expands past ${MAX_REGEX} characters`);
return new RegExp(out);
}
export function matchesAny(globs: readonly string[], rel: string): boolean {
const norm = rel.replace(/\\/g, '/').replace(/^\.\//, '');
return globs.some(g => globToRegExp(g).test(norm));
}
function listErrors(key: string, value: unknown, out: PolicyError[], check: (item: string) => string | null): void {
if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: key, message: 'must be an array of strings' }); return; }
if (value.length > MAX_LIST) out.push({ code: 'POLICY_CONTAINMENT', path: key, message: `more than ${MAX_LIST} entries` });
value.forEach((item, i) => {
if (typeof item !== 'string') { out.push({ code: 'POLICY_INVALID', path: `${key}[${i}]`, message: 'must be a string' }); return; }
const why = check(item);
if (why) out.push({ code: 'POLICY_CONTAINMENT', path: `${key}[${i}]`, message: `${JSON.stringify(item)} ${why}` });
});
}
function globError(g: string): string | null {
const why = containedRelative(g);
if (why) return why;
try { globToRegExp(g); } catch (e: any) { return e.message; }
return null;
}
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; }
for (const k of Object.keys(m)) if (k !== 'dir' && k !== 'id') out.push({ code: 'POLICY_INVALID', path: `migrations.${k}`, message: 'unknown key' });
const dirWhy = typeof m.dir === 'string' ? containedRelative(m.dir) : 'must be a repo-relative directory';
if (dirWhy) out.push({ code: 'POLICY_CONTAINMENT', path: 'migrations.dir', message: dirWhy });
if (typeof m.id !== 'string' || !m.id || m.id.length > MAX_PATTERN) { out.push({ code: 'POLICY_INVALID', path: 'migrations.id', message: `must be a regex of 1-${MAX_PATTERN} characters` }); return; }
try {
const groups = new RegExp(`${m.id}|`).exec('')!.length - 1;
if (groups !== 1) out.push({ code: 'POLICY_INVALID', path: 'migrations.id', message: `needs exactly one capture group for the migration number (has ${groups})` });
} catch (e: any) { out.push({ code: 'POLICY_INVALID', path: 'migrations.id', message: `not a valid regex: ${e.message}` }); }
}
/** Schema and containment check of a parsed policy object. Unknown keys are errors (a typo must not silently mean "default"). */
export function validatePolicy(raw: unknown): PolicyError[] {
const errors: PolicyError[] = [];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ code: 'POLICY_INVALID', path: '', message: 'policy must be a JSON object' }];
const obj = raw as Record;
for (const key of Object.keys(obj)) {
if (key !== '$schema' && !POLICY_KEYS.includes(key as PolicyKey)) errors.push({ code: 'POLICY_INVALID', path: key, message: `unknown key; valid keys: ${POLICY_KEYS.join(', ')}` });
}
const oneOf = (key: string, values: readonly string[]) => {
if (key in obj && !values.includes(obj[key] as string)) errors.push({ code: 'POLICY_INVALID', path: key, message: `must be one of ${values.join(' | ')}, got ${JSON.stringify(obj[key])}` });
};
oneOf('bump', ['patch', 'ask']);
oneOf('queue_mode', ['claim-at-open', 'stamp-at-merge']);
oneOf('history', ['merge-only', 'squash-ok']);
if ('release_tool' in obj && obj.release_tool !== null) {
const t = obj.release_tool;
if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'release_tool', message: 'must be a non-empty string or null' });
else if (t.length > MAX_COMMAND) errors.push({ code: 'POLICY_CONTAINMENT', path: 'release_tool', message: `longer than ${MAX_COMMAND} characters` });
else if (/[\0\r\n]/.test(t)) errors.push({ code: 'POLICY_CONTAINMENT', path: 'release_tool', message: 'contains a newline or NUL' });
}
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 ('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' });
else if (t.length > MAX_COMMAND || /[\0\r\n]/.test(t)) errors.push({ code: 'POLICY_CONTAINMENT', path: 'restamp_command', message: `must be one line under ${MAX_COMMAND} characters` });
}
if ('migrations' in obj && obj.migrations !== null) migrationErrors(obj.migrations, errors);
if ('stamp_paths' in obj) listErrors('stamp_paths', obj.stamp_paths, errors, globError);
for (const key of ['changelog', 'mirror'] as const) {
if (!(key in obj) || obj[key] === null) continue;
if (typeof obj[key] !== 'string') { errors.push({ code: 'POLICY_INVALID', path: key, message: 'must be a string or null' }); continue; }
const p = obj[key] as string;
const why = key === 'mirror' ? mirrorPathError(p) : containedRelative(p);
if (why) errors.push({ code: 'POLICY_CONTAINMENT', path: key, message: `${JSON.stringify(p)} ${why}` });
}
if ('preregistration_shas' in obj) listErrors('preregistration_shas', obj.preregistration_shas, errors, s => (SHA_RE.test(s) ? null : 'is not a 7-64 character hex commit id'));
if ('stamp_sensitive_gates' in obj) listErrors('stamp_sensitive_gates', obj.stamp_sensitive_gates, errors, s => (/^[A-Za-z0-9_.:-]{1,64}$/.test(s) ? null : 'is not an evidence label ([A-Za-z0-9_.:-]{1,64})'));
if ('maintainers' in obj) listErrors('maintainers', obj.maintainers, errors, s => (/^[A-Za-z0-9-]{1,39}$/.test(s) ? null : 'is not a GitHub login'));
if ('pr_count' in obj) {
const pc = obj.pr_count as Record | null;
if (!pc || typeof pc !== 'object' || Array.isArray(pc)) errors.push({ code: 'POLICY_INVALID', path: 'pr_count', message: 'must be { default_pr_count, split_requires_owner }' });
else {
if (!Number.isInteger(pc.default_pr_count) || (pc.default_pr_count as number) < 1) errors.push({ code: 'POLICY_INVALID', path: 'pr_count.default_pr_count', message: 'must be an integer >= 1' });
if (typeof pc.split_requires_owner !== 'boolean') errors.push({ code: 'POLICY_INVALID', path: 'pr_count.split_requires_owner', message: 'must be a boolean' });
for (const k of Object.keys(pc)) if (!['default_pr_count', 'split_requires_owner'].includes(k)) errors.push({ code: 'POLICY_INVALID', path: `pr_count.${k}`, message: 'unknown key' });
}
}
return errors;
}
/**
* A mirror is a sibling checkout, so leading `..` segments are allowed; an
* interior `..`, an absolute path or the repo itself (`.`) are not.
*/
function mirrorPathError(p: string): string | null {
if (!p || p.length > MAX_PATTERN) return 'must be a non-empty relative path';
if (/[\0\r\n]/.test(p)) return 'contains a control character';
if (path.isAbsolute(p) || /^[A-Za-z]:[\\/]/.test(p)) return 'must be relative to the repository root';
const segs = p.split(/[\\/]+/).filter(s => s && s !== '.');
if (segs.length === 0) return 'must not be the repository itself';
let i = 0;
while (i < segs.length && segs[i] === '..') i += 1;
if (segs.slice(i).includes('..')) return 'must not contain an interior `..` segment';
if (i === segs.length) return 'must name a directory, not only parents';
return null;
}
/** Merge a parsed file over the defaults, recording per-key provenance. */
export function mergePolicy(raw: Record | null, sourceLabel: string): { policy: ShipPolicy; values: Record } {
const policy: ShipPolicy = JSON.parse(JSON.stringify(DEFAULT_POLICY));
const values: Record = {};
for (const key of POLICY_KEYS) {
if (raw && key in raw) { (policy as any)[key] = raw[key]; values[key] = sourceLabel; }
else values[key] = 'default';
}
if (raw && typeof raw.$schema === 'string') policy.$schema = raw.$schema;
return { policy, values };
}
export interface PolicySource { kind: 'base' | 'head' | 'none'; ref: string | null; sha: string | null; label: string }
export interface LoadedPolicy {
policy: ShipPolicy;
present: boolean;
source: PolicySource;
errors: PolicyError[];
values: Record;
}
export type PolicyLoad = LoadedPolicy | { unavailable: true; base: string; detail: string };
function git(repoRoot: string, args: string[]): { ok: boolean; out: string; err: string } {
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 });
return { ok: r.status === 0, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
}
/** `--base` when given, else origin/HEAD's branch, else `main`. */
export function resolveBaseBranch(repoRoot: string, explicit?: string): string {
if (explicit && explicit.trim()) return explicit.trim().replace(/^origin\//, '');
const r = git(repoRoot, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
if (r.ok && r.out) return r.out.replace(/^origin\//, '');
return 'main';
}
function finish(rawText: string | null, source: PolicySource): LoadedPolicy {
if (rawText === null) return { policy: mergePolicy(null, source.label).policy, present: false, source, errors: [], values: mergePolicy(null, source.label).values };
let raw: unknown;
try { raw = JSON.parse(rawText); }
catch (e: any) { return { policy: JSON.parse(JSON.stringify(DEFAULT_POLICY)), present: true, source, errors: [{ code: 'POLICY_INVALID', path: SHIP_POLICY_PATH, message: `not valid JSON: ${e.message}` }], values: {} }; }
const errors = validatePolicy(raw);
const merged = mergePolicy(errors.length ? null : (raw as Record), source.label);
return { policy: merged.policy, present: true, source, errors, values: merged.values };
}
/**
* The reviewed policy: `.gstack/ship-policy.json` as committed on
* `origin/`. An unresolvable base is `unavailable` (POLICY_SOURCE_
* UNAVAILABLE); the caller must never fall back to the working tree silently.
*/
export function loadPolicyFromBase(repoRoot: string, base: string): PolicyLoad {
const ref = `origin/${base.replace(/^origin\//, '')}`;
const sha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
if (!sha.ok || !sha.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref} is not a resolvable commit` };
const source: PolicySource = { kind: 'base', ref, sha: sha.out, label: `${ref}@${sha.out.slice(0, 12)}:${SHIP_POLICY_PATH}` };
const exists = git(repoRoot, ['cat-file', '-e', `${sha.out}:${SHIP_POLICY_PATH}`]);
if (!exists.ok) return finish(null, { ...source, kind: 'none', label: `${ref}@${sha.out.slice(0, 12)} (no ${SHIP_POLICY_PATH})` });
const show = git(repoRoot, ['show', `${sha.out}:${SHIP_POLICY_PATH}`]);
return finish(show.ok ? show.out : null, source);
}
/** The working-tree policy: unreviewed, labeled so in every receipt. */
export function loadPolicyFromHead(repoRoot: string): LoadedPolicy {
const file = path.join(repoRoot, SHIP_POLICY_PATH);
const source: PolicySource = { kind: 'head', ref: null, sha: null, label: `head (unreviewed) ${SHIP_POLICY_PATH}` };
if (!fs.existsSync(file)) return finish(null, { ...source, kind: 'none', label: `head (no ${SHIP_POLICY_PATH})` });
return finish(fs.readFileSync(file, 'utf8'), source);
}
/** The one POLICY_SOURCE_UNAVAILABLE line, with the base branch substituted into the fix clause. */
export function policyUnavailableLine(context: string, base: string, detail: string): string {
const row = RESULT_CODES.POLICY_SOURCE_UNAVAILABLE;
return `${context}: ${row.summary} (${detail}); fix: ${row.fix.replace('', base)} (POLICY_SOURCE_UNAVAILABLE)`;
}
export function isUnavailable(load: PolicyLoad): load is { unavailable: true; base: string; detail: string } {
return (load as any).unavailable === true;
}
/** Commands a policy declares; their presence never authorizes execution. */
export function declaredCommands(policy: ShipPolicy): Array<{ key: string; command: string; cwd: string }> {
const out: Array<{ key: string; command: string; cwd: string }> = [];
if (policy.release_tool) out.push({ key: 'release_tool', command: policy.release_tool, cwd: '.' });
if (policy.release_tool && policy.mirror) out.push({ key: 'mirror.release_tool', command: policy.release_tool, cwd: policy.mirror });
if (policy.restamp_command) out.push({ key: 'restamp_command', command: policy.restamp_command, cwd: '.' });
return out;
}
/** The example `init` writes: every key at its default plus `$schema`. */
export function examplePolicyJson(overrides: Partial = {}): string {
const body: Record = { $schema: SHIP_POLICY_SCHEMA };
for (const key of POLICY_KEYS) body[key] = key in overrides ? (overrides as any)[key] : (DEFAULT_POLICY as any)[key];
return JSON.stringify(body, null, 2) + '\n';
}
export function explainPolicy(): string {
return POLICY_KEYS.map(key => `${key}: ${POLICY_EXPLANATIONS[key]} default: ${JSON.stringify((DEFAULT_POLICY as any)[key])}`).join('\n') + '\n';
}
/** `key=value (source)` lines plus the `policy:` provenance line, as show-effective prints them. */
export function renderEffective(load: LoadedPolicy, opts: { base?: string } = {}): string {
const lines = [`policy: ${load.source.kind === 'base' ? 'base' : load.source.kind === 'head' ? 'head (unreviewed)' : 'none (defaults)'} ${load.source.label}`];
for (const key of POLICY_KEYS) lines.push(`${key}=${JSON.stringify((load.policy as any)[key])} (${load.values[key] ?? 'default'})`);
return lines.join('\n') + '\n' + shipConsequences(load.policy, opts.base ?? '').map(l => `ship: ${l}`).join('\n') + '\n';
}
/** What `/ship` does differently under this policy: the workflow prose the skill prints instead of carrying (docs/ship-policy.md#ship). */
export function shipConsequences(policy: ShipPolicy, base: string): string[] {
const out: string[] = [];
if (policy.history === 'merge-only') out.push('history merge-only: never rebase, squash or force-push this branch; `gstack-ship-policy check-history --plan ` refuses a rewrite plan (HISTORY_POLICY_VIOLATION)');
if (policy.queue_mode === 'stamp-at-merge') {
out.push(`queue_mode stamp-at-merge: Step 12 items 1-4 are replaced by \`gstack-restamp --next --base ${base} --pr --expect-base \` when this PR is next in the queue (\`--after \` gates ahead in a throwaway worktree when it is not); follow its next: line; claims are ignored`);
out.push('gate-ahead: with .gstack/tmp/gate-ahead.json present, Step 16 reuses only the lanes `gstack-tree-receipt --from .gstack/tmp/gate-ahead.json --predecessor-pr --bundle --json > .gstack/tmp/tree-receipt.json` marks reusable; stamp-sensitive and not-eligible lanes run live; `--force-gate ` is the coordinator\'s call');
} else out.push('queue_mode claim-at-open: Step 12 runs as written (claims through gstack-next-version)');
out.push(policy.bump === 'patch' ? 'bump patch: Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size' : 'bump ask: Step 12 asks for MINOR and MAJOR as written');
if (policy.preregistration_shas.length) out.push(`preregistration: \`gstack-evidence ancestor --from-policy --base ${base}\` must print only ANCESTOR: ok before push; the ship receipt records the result`);
if (policy.release_tool) out.push(`release_tool: \`${policy.release_tool}\` runs only inside gstack-restamp (staging worktree, outputs confined to release_outputs)`);
return out;
}
/**
* Files under `globs` inside a committed tree, as `git ls-tree` lists them.
* Symlinks (mode 120000) never match: a stamp write through a link would
* escape the tree the glob confines it to.
*/
export function listTreeFiles(repoRoot: string, treeish: string): Array<{ path: string; mode: string }> {
const r = git(repoRoot, ['ls-tree', '-r', '-z', treeish]);
if (!r.ok) return [];
return r.out.split('\0').filter(Boolean).map(line => {
const [meta, p] = line.split('\t');
return { path: p ?? '', mode: meta?.split(' ')[0] ?? '' };
}).filter(e => e.path);
}
export function stampFilesInTree(repoRoot: string, treeish: string, globs: readonly string[]): string[] {
if (globs.length === 0) return [];
return listTreeFiles(repoRoot, treeish).filter(e => e.mode !== '120000' && matchesAny(globs, e.path)).map(e => e.path);
}
/**
* A working-tree path a stamp may write: inside `root` after realpath (a
* symlink out of the repository is refused even when the glob matched).
*/
export function assertInsideRoot(root: string, rel: string): string | null {
const realRoot = fs.realpathSync(root);
const abs = path.resolve(realRoot, rel);
if (abs !== realRoot && !abs.startsWith(realRoot + path.sep)) return `${rel} resolves outside ${root}`;
let probe = abs;
while (!fs.existsSync(probe)) {
const parent = path.dirname(probe);
if (parent === probe) break;
probe = parent;
}
let real: string;
try { real = fs.realpathSync(probe); } catch { return null; }
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return `${rel} resolves through a symlink to outside ${root}`;
return null;
}