mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(ship-policy): DOCS_CAPS_KEY_VERSION is 1.91.270.0; key versions move through stamp_paths at the slot stamp #3487 was written naming 1.91.258.0 for docs.caps and main stamped that number for another PR first; the slot call cannot edit anything beyond the four stamp files, so the constant shipped stale. lib/ship-policy.ts now sits in .gstack/ship-policy.json stamp_paths with the _KEY_VERSION = '{version}' template, so a new key written as a tentative future version is moved by gstack-restamp's stamp_paths stage at the slot (read from the base policy, so live from the next PR on). test/gstack-ship-policy.test.ts gains the check the incident lacked: a released key's version must be a heading whose entry names the key. Dry-run proof: a tentative 1.91.272.0 on a branch line moved to the allocated 1.91.271.0 with --policy-from head. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship-policy): tentative key versions are clear of main's pace; the receipt accepts the move only at the base version until #3512 Pass-1 review notes: the next patch above the fork point is the number main takes first, so the convention names the next minor; and the tree receipt's stamp-only check (lib/tree-receipt.ts stampOnlyHunks, with stampOnlyText and stampFreeTree) rewrites only base VERSION -> new VERSION, so a tentative move passes the train only when the tentative value equals the base version at the slot. Filed as #3512 (outside this diff, deferred under the frozen scope). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ship-policy): read origin/main's CHANGELOG with a 64 MiB maxBuffer; say plainly that a new-key PR cannot clear the train until #3512 Pass-2 Red Team: CHANGELOG.md is past spawnSync's default 1 MiB maxBuffer, so both CHANGELOG-reading tests dropped origin/main's file silently and the incident shape passed at the pin. With the buffer the released-key test sees main's headings on a full clone. The comment and CHANGELOG now state the practical consequence of the receipt limit. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship-policy): gstack's own policy excerpt names lib/ship-policy.ts under stamp_paths Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
608 lines
42 KiB
TypeScript
608 lines
42 KiB
TypeScript
/**
|
||
* ship-policy — the machine-readable repo ship policy (plan C1):
|
||
* `.gstack/ship-policy.json`, read from the committed base-branch copy
|
||
* (`origin/<base>`) 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';
|
||
import { cmpVersion, parseVersion } from './version-source';
|
||
|
||
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';
|
||
/** `frozen`: /ship fixes the diff in pass 1, defers unchanged code to issues and reviews only the range after; `all`: every finding is fixed now. */
|
||
export type ReviewScope = 'frozen' | 'diff-plus-blocking' | 'all';
|
||
|
||
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[];
|
||
/** The /ship review loop's finding scope (docs/ship-policy.md#review-scope). */
|
||
review_scope: ReviewScope;
|
||
/** 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;
|
||
/** The repo's full pre-merge gate; gstack-ship-queue prints it for the caller to run (gstack never executes it). */
|
||
gate_command: string | null;
|
||
/** true: a merge needs a passing full-gate record bound to a tree equal to the merging one modulo stamps. */
|
||
full_e2e: boolean;
|
||
/** The oldest gstack that may read this policy; a newer key set to a non-default value requires it. */
|
||
min_gstack_version: string | null;
|
||
/** Merge trains in gstack-ship-queue (docs/ship-policy.md#run-a-merge-train); null keeps the per-PR queue. */
|
||
train: TrainPolicy | null;
|
||
/** Documentation caps /ship Step 14.5 checks before the verification gate (`gstack-ship-receipt docs-caps`); null declares none. */
|
||
docs: { caps: Array<{ path: string; max_bytes: number }> } | null;
|
||
}
|
||
|
||
/** The `train` block: the gate workflow trains dispatch and how its verdict is read. */
|
||
export interface TrainPolicy {
|
||
/** Workflow file dispatched for the full gate and the bisect (it accepts the inputs in TRAIN_DISPATCH_INPUTS). */
|
||
gate_workflow: string;
|
||
/** Workflow file dispatched on train/<nonce> before the paid gate; its run must be green on the train head. */
|
||
free_workflow: string;
|
||
/** The check run the train's own gate run publishes on the train head when its census is complete and green. */
|
||
full_gate_check: string;
|
||
/** Artifact name of the train record (and the prefix of each bisect prefix's artifact). */
|
||
record_artifact: string;
|
||
/** Most PRs in one train. */
|
||
max_size: number;
|
||
/** A train under max_size dispatches once its oldest READY PR has waited this long. */
|
||
fill_hours: number;
|
||
/** How `ready` computes the screen's selection: gstack's paid selector, or not at all. */
|
||
screen_selection: 'gstack-paid' | 'none';
|
||
/** Bisect admission price of one case trial that has no recorded cost. */
|
||
trial_reservation_usd: number;
|
||
}
|
||
|
||
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: [],
|
||
review_scope: 'frozen',
|
||
pr_count: { default_pr_count: 1, split_requires_owner: true },
|
||
maintainers: [],
|
||
stamp_files: [],
|
||
stamp_patterns: [],
|
||
restamp_command: null,
|
||
migrations: null,
|
||
gate_command: null,
|
||
full_e2e: false,
|
||
min_gstack_version: null,
|
||
train: null,
|
||
docs: null,
|
||
};
|
||
|
||
export type PolicyKey = Exclude<keyof ShipPolicy, '$schema'>;
|
||
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<PolicyKey, string> = {
|
||
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.',
|
||
review_scope: 'frozen | diff-plus-blocking | all. frozen (default): /ship Step 9 fixes findings in the diff on pass 1, files blocking findings in code the PR did not change as issues, and later passes read only the range since the previous pass and fix only blocking findings; diff-plus-blocking: the same, but a blocking finding in code the PR did not change is fixed in this PR instead of filed; all: every finding is fixed now (the pre-1.91.249 loop).',
|
||
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.',
|
||
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.',
|
||
full_e2e: 'true: every merge needs a passing full-gate result (gstack-ship-queue gate-ahead --record, or the lane\'s --gate-exit 0 --gate-log on the gated head) bound to a tree equal to the merging one modulo stamps; ready, stamp and verify refuse without one (QUEUE_GATE_MISSING).',
|
||
train: '{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd } or null: turns on merge trains in gstack-ship-queue (train build|status|verdict|bisect|rebuild|prune, ready --screen|--no-screen, verify --train). null keeps today\'s per-PR queue unchanged.',
|
||
docs: '{ caps: [{ path, max_bytes }] } or null: documentation files with a byte cap. /ship Step 14.5 runs `gstack-ship-receipt docs-caps` before the verification gate and prints one DOCS_CAP: line per cap; a file over its cap (or absent) blocks the ship until the doc is trimmed or the cap raised on the base branch. null declares none.',
|
||
min_gstack_version: 'The oldest gstack that may read this file. Any key newer than 1.91.98.0 set to a non-default value requires it at that key’s introducing version (validate prints each); a gstack older than it refuses the file (POLICY_GSTACK_TOO_OLD).',
|
||
};
|
||
|
||
/**
|
||
* The gstack release that introduced each key. Keys newer than
|
||
* POLICY_COMPAT_FLOOR need `min_gstack_version` before they take a
|
||
* non-default value, so a crew on mixed gstack versions sees one named
|
||
* refusal instead of a policy each version reads differently. Unknown keys
|
||
* always fail closed: there is no extension mechanism.
|
||
*/
|
||
export const POLICY_COMPAT_FLOOR = '1.91.98.0';
|
||
export const SHIP_QUEUE_KEYS_VERSION = '1.91.108.0';
|
||
export const TRAIN_KEYS_VERSION = '1.91.146.0';
|
||
// Each constant is the release that first reads its key: a policy declaring the key must name at least
|
||
// that version as min_gstack_version, so an older gstack refuses the policy by name instead of failing on
|
||
// an unknown key. review_scope shipped in 1.91.256.0 (#3481), docs.caps in 1.91.270.0 (#3487). A new key's
|
||
// constant is written as a tentative future version clear of main's pace (the next minor, never the next patch,
|
||
// which main takes first and the released-key test below then reds at the pin), in this exact
|
||
// `_KEY_VERSION = '<version>'` form: this file is in .gstack/ship-policy.json stamp_paths with that form as a
|
||
// stamp_patterns template, so the slot stamp's gstack-restamp stamp_paths stage moves the tentative value to the
|
||
// allocated version. The stage reads lines the branch added: a tentative value newer than the fork version moves,
|
||
// an untouched released constant stays, and a branch-added line naming the base's own version is re-read as a
|
||
// stale stamp and moves too, so never rewrite a released constant's line. Until the tree receipt accepts a
|
||
// stamp-form move whose old value is not the base version (#3512), the train's stamp-only check passes such a
|
||
// move only when the tentative value equals the base version at the slot, which a next-minor value never does:
|
||
// a PR that adds a key cannot clear the train until #3512 lands. test/gstack-ship-policy.test.ts holds
|
||
// an unreleased constant newer than every CHANGELOG heading and a released one on a heading whose entry names
|
||
// its key, so a value a stamp did not move fails there.
|
||
export const REVIEW_SCOPE_KEY_VERSION = '1.91.256.0';
|
||
export const DOCS_CAPS_KEY_VERSION = '1.91.270.0';
|
||
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
|
||
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',
|
||
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
|
||
pr_count: '1.91.73.0', maintainers: '1.91.73.0', stamp_files: '1.91.98.0', stamp_patterns: '1.91.98.0', restamp_command: '1.91.98.0',
|
||
migrations: '1.91.98.0', gate_command: SHIP_QUEUE_KEYS_VERSION, full_e2e: SHIP_QUEUE_KEYS_VERSION, min_gstack_version: SHIP_QUEUE_KEYS_VERSION,
|
||
train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION,
|
||
};
|
||
|
||
const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0;
|
||
|
||
/**
|
||
* The release this checkout reads policies as: its VERSION file, or the
|
||
* newest key it knows when that is later (an unstamped tree that carries a
|
||
* new key already reads policies written for it).
|
||
*/
|
||
export function runningGstackVersion(): string {
|
||
let own = '0.0.0.0';
|
||
try { own = fs.readFileSync(path.resolve(import.meta.dir, '..', 'VERSION'), 'utf8').trim(); } catch {}
|
||
const known = Object.values(POLICY_KEY_INTRODUCED).reduce((a, b) => (newer(b, a) ? b : a));
|
||
return parseVersion(own) && !newer(known, own) ? own : known;
|
||
}
|
||
|
||
/** The newest introducing version among the keys a parsed policy sets: the gstack a crew needs to read it. */
|
||
export function minimumReader(raw: Record<string, unknown>): string {
|
||
return Object.keys(raw).filter((k): k is PolicyKey => POLICY_KEYS.includes(k as PolicyKey))
|
||
.map(k => POLICY_KEY_INTRODUCED[k]).reduce((a, b) => (newer(b, a) ? b : a), POLICY_KEY_INTRODUCED.bump);
|
||
}
|
||
|
||
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<string, unknown> | 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}` }); }
|
||
}
|
||
|
||
const TRAIN_FIELDS: Record<keyof TrainPolicy, (v: unknown) => string | null> = {
|
||
gate_workflow: v => (typeof v === 'string' && /^[A-Za-z0-9_.-]{1,64}\.ya?ml$/.test(v) ? null : 'must be a workflow file name such as evals.yml'),
|
||
free_workflow: v => (typeof v === 'string' && /^[A-Za-z0-9_.-]{1,64}\.ya?ml$/.test(v) ? null : 'must be a workflow file name such as free-tests.yml'),
|
||
full_gate_check: v => (typeof v === 'string' && /^[A-Za-z0-9_./-]{1,64}$/.test(v) ? null : 'must be a check-run name ([A-Za-z0-9_./-]{1,64})'),
|
||
record_artifact: v => (typeof v === 'string' && /^[A-Za-z0-9_.-]{1,64}$/.test(v) ? null : 'must be an artifact name ([A-Za-z0-9_.-]{1,64})'),
|
||
max_size: v => (Number.isInteger(v) && (v as number) >= 1 && (v as number) <= 6 ? null : 'must be an integer from 1 to 6'),
|
||
fill_hours: v => (typeof v === 'number' && v > 0 && v <= 24 ? null : 'must be a number of hours above 0 and at most 24'),
|
||
screen_selection: v => (v === 'gstack-paid' || v === 'none' ? null : 'must be gstack-paid | none'),
|
||
trial_reservation_usd: v => (typeof v === 'number' && v > 0 && v <= 100 ? null : 'must be dollars above 0 and at most 100'),
|
||
};
|
||
|
||
function trainErrors(value: unknown, out: PolicyError[]): void {
|
||
const t = value as Record<string, unknown> | null;
|
||
if (!t || typeof t !== 'object' || Array.isArray(t)) { out.push({ code: 'POLICY_INVALID', path: 'train', message: `must be { ${Object.keys(TRAIN_FIELDS).join(', ')} } or null` }); return; }
|
||
for (const k of Object.keys(t)) if (!(k in TRAIN_FIELDS)) out.push({ code: 'POLICY_INVALID', path: `train.${k}`, message: 'unknown key' });
|
||
for (const [k, check] of Object.entries(TRAIN_FIELDS)) {
|
||
if (!(k in t)) { out.push({ code: 'POLICY_INVALID', path: `train.${k}`, message: 'missing (every train field is required)' }); continue; }
|
||
const why = check(t[k]);
|
||
if (why) out.push({ code: 'POLICY_INVALID', path: `train.${k}`, message: `${JSON.stringify(t[k])} ${why}` });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Schema and containment check of a parsed policy object. Unknown keys are
|
||
* errors (a typo must not silently mean "default", and a key from a newer
|
||
* gstack must not be ignored by an older one).
|
||
*/
|
||
export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } = {}): 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<string, unknown>;
|
||
const newest = Object.values(POLICY_KEY_INTRODUCED).reduce((a, b) => (newer(b, a) ? b : a));
|
||
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 (this gstack reads keys introduced up to ${newest}; a key from a newer gstack needs an upgrade, a misspelled one a fix); valid keys: ${POLICY_KEYS.join(', ')}` });
|
||
}
|
||
compatErrors(obj, opts.gstackVersion ?? runningGstackVersion(), errors);
|
||
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']);
|
||
oneOf('review_scope', ['frozen', 'diff-plus-blocking', 'all']);
|
||
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 ('gate_command' in obj && obj.gate_command !== null) {
|
||
const t = obj.gate_command;
|
||
if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'gate_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: 'gate_command', message: `must be one line under ${MAX_COMMAND} characters` });
|
||
}
|
||
if ('full_e2e' in obj && typeof obj.full_e2e !== 'boolean') errors.push({ code: 'POLICY_INVALID', path: 'full_e2e', message: 'must be true or false' });
|
||
if ('train' in obj && obj.train !== null) trainErrors(obj.train, errors);
|
||
if ('docs' in obj && obj.docs !== null) docsErrors(obj.docs, 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<string, unknown> | 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;
|
||
}
|
||
|
||
/**
|
||
* min_gstack_version: a well-formed version, not newer than this gstack
|
||
* (POLICY_GSTACK_TOO_OLD), and at least the introducing version of every
|
||
* post-floor key the file sets to a non-default value.
|
||
*/
|
||
function compatErrors(obj: Record<string, unknown>, running: string, errors: PolicyError[]): void {
|
||
const min = obj.min_gstack_version;
|
||
if (min !== undefined && min !== null && (typeof min !== 'string' || !parseVersion(min))) {
|
||
errors.push({ code: 'POLICY_INVALID', path: 'min_gstack_version', message: 'must be a version (MAJOR.MINOR.PATCH[.MICRO]) or null' });
|
||
return;
|
||
}
|
||
if (typeof min === 'string' && parseVersion(running) && newer(min, running)) {
|
||
errors.push({ code: 'POLICY_GSTACK_TOO_OLD', path: 'min_gstack_version', message: `this policy needs gstack ${min} or later; this gstack is ${running}` });
|
||
return;
|
||
}
|
||
for (const key of POLICY_KEYS) {
|
||
const since = POLICY_KEY_INTRODUCED[key];
|
||
if (!(key in obj) || !newer(since, POLICY_COMPAT_FLOOR)) continue;
|
||
if (JSON.stringify(obj[key]) === JSON.stringify((DEFAULT_POLICY as any)[key])) continue;
|
||
if (typeof min === 'string' && !newer(since, min)) continue;
|
||
errors.push({ code: 'POLICY_INVALID', path: key, message: `introduced in gstack ${since}; set min_gstack_version to ${since} or later so an older gstack refuses this policy by name` });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/** `docs`: `{ caps: [{ path, max_bytes }] }`, every path contained, every cap a positive integer, at most 64 caps. */
|
||
function docsErrors(raw: unknown, errors: PolicyError[]): void {
|
||
const d = raw as Record<string, unknown> | null;
|
||
if (!d || typeof d !== 'object' || Array.isArray(d)) { errors.push({ code: 'POLICY_INVALID', path: 'docs', message: 'must be { caps: [{ path, max_bytes }] } or null' }); return; }
|
||
for (const k of Object.keys(d)) if (k !== 'caps') errors.push({ code: 'POLICY_INVALID', path: `docs.${k}`, message: 'unknown key' });
|
||
if (!Array.isArray(d.caps)) { errors.push({ code: 'POLICY_INVALID', path: 'docs.caps', message: 'must be an array of { path, max_bytes }' }); return; }
|
||
if (d.caps.length > MAX_LIST) errors.push({ code: 'POLICY_CONTAINMENT', path: 'docs.caps', message: `more than ${MAX_LIST} entries` });
|
||
d.caps.forEach((cap, i) => {
|
||
const c = cap as Record<string, unknown> | null;
|
||
if (!c || typeof c !== 'object' || Array.isArray(c)) { errors.push({ code: 'POLICY_INVALID', path: `docs.caps[${i}]`, message: 'must be { path, max_bytes }' }); return; }
|
||
for (const k of Object.keys(c)) if (!['path', 'max_bytes'].includes(k)) errors.push({ code: 'POLICY_INVALID', path: `docs.caps[${i}].${k}`, message: 'unknown key' });
|
||
const why = typeof c.path === 'string' ? containedRelative(c.path) : 'must be a string';
|
||
if (why) errors.push({ code: 'POLICY_CONTAINMENT', path: `docs.caps[${i}].path`, message: `${JSON.stringify(c.path)} ${why}` });
|
||
if (!Number.isInteger(c.max_bytes) || (c.max_bytes as number) < 1) errors.push({ code: 'POLICY_INVALID', path: `docs.caps[${i}].max_bytes`, message: 'must be an integer >= 1' });
|
||
});
|
||
}
|
||
|
||
/** Merge a parsed file over the defaults, recording per-key provenance. */
|
||
export function mergePolicy(raw: Record<string, unknown> | null, sourceLabel: string): { policy: ShipPolicy; values: Record<string, string> } {
|
||
const policy: ShipPolicy = JSON.parse(JSON.stringify(DEFAULT_POLICY));
|
||
const values: Record<string, string> = {};
|
||
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<string, string>;
|
||
}
|
||
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<string, unknown>), source.label);
|
||
return { policy: merged.policy, present: true, source, errors, values: merged.values };
|
||
}
|
||
|
||
/**
|
||
* The reviewed policy: `.gstack/ship-policy.json` as committed on
|
||
* `origin/<base>`. 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>', 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<ShipPolicy> = {}): string {
|
||
const body: Record<string, unknown> = { $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])} introduced: ${POLICY_KEY_INTRODUCED[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 ?? '<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 <merge|squash|rebase>` 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 <pr-number> --expect-base <base-sha>\` when this PR is next in the queue (\`--after <predecessor-pr>\` gates ahead in a throwaway worktree when it is not); follow its next: line; claims are ignored`);
|
||
out.push(`shared merge queue: when a coordinator runs the queue, \`gstack-ship-queue ready --pr <n>\`, \`gate-ahead --after <pr> --pr <n>\` and \`stamp --pr <n> --version <X> --expect-gated <sha> --expect-base <sha>\` replace the calls above; stamp only when your slot is called, send the READY block and the QUEUE_STAMP: line; the coordinator runs \`gstack-ship-queue verify\` (docs/ship-policy.md#run-a-shared-merge-queue)${policy.gate_command ? `; gate command: \`${policy.gate_command}\`` : ''}`);
|
||
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 <pr> --bundle <bundle> --json > .gstack/tmp/tree-receipt.json` marks reusable; stamp-sensitive and not-eligible lanes run live; `--force-gate <reason>` 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');
|
||
out.push(policy.review_scope === 'all'
|
||
? 'review_scope all: Step 9.4 fixes every finding on every pass (gstack-review-scope classify --policy all)'
|
||
: policy.review_scope === 'diff-plus-blocking'
|
||
? 'review_scope diff-plus-blocking: Step 9.4 runs `gstack-review-scope classify --policy diff-plus-blocking` each pass; pass 1 fixes the diff and every blocking finding, in unchanged code too, later passes read only the range since the previous pass and fix only blocking findings; notes go to the PR body'
|
||
: 'review_scope frozen: Step 9.4 runs `gstack-review-scope classify` each pass; pass 1 fixes the diff and defers blocking findings in unchanged code to issues, later passes read only the range since the previous pass and fix only blocking findings; notes go to the PR body');
|
||
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;
|
||
}
|