/** * 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; }