mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
* feat(ship): review loop converges (frozen finding scope, 3-pass cap, NO FINDINGS first-class) and receipts carry across conflict-only merges
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(ship): keep the Step 9 first-entry and Fix-First phrasing the skip/requeue contract asserts; fill the review record's pass, review_scope and range_base placeholders in the caller template test; narrow a test type
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (pass 1) and the review-loop budget wording
Two blocking findings from /ship's own pass 1, both reproduced in the QA smoke before the fix:
gstack-ship-receipt carry hashed the product diff with git patch-id --stable, which strips
whitespace, so a space moved in a reviewed shell line carried as "no product change"; and it
classed package.json and every lockfile as release metadata, so a dependency swap carried too.
The patch-id is now --verbatim, package.json and VERSION are metadata only while the change is a
version stamp (lib/pregate/stamp-only.ts), lockfiles are product unless the policy names them,
the test globs name code extensions, a dirty working tree refuses before anything is compared,
and a receipt carried twice keeps the reviewed head as carry.from.
gstack-review-scope: an unresolvable base is POLICY_SOURCE_UNAVAILABLE instead of a silent
working-tree read; --range-base is validated; git runs with core.quotePath=false; range_base is
null on pass 1; a blocking finding with no path is fix; category "testing" is a test finding
below the floor; defer searches once, files nothing on a failed search and exits 0 unfiled;
summary refuses zero matching passes and the receipt write is chained to it with &&; a finding
the diff causes elsewhere names the changed line in `cause` and classify follows it.
Review-loop budget wording (ideas from tstack's sol-loop and pstack's interrogate, rewritten;
NOTICE.md): the blocker / should / nit glossary and two stop rules in Step 9.4, the checklist
and docs/review-policy.md; a lead-judgment stage in the finding merge both /review and /ship
render. The core read diffs $RANGE_BASE from pass 2 on. Caps and pins re-measured with reasons.
Step 7 coverage audit: four tests added in existing files, the --review-loop case moved to its
own test (the shared test tripped bun's 5 s default).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pin the renumbered review-army stages and point the wording test at product code
CI at c3e27fc failed three free-tier tests, all this branch's own: the
workflow-required-steps fixture and shared-libs-rendering still pinned the
pre-Lead-judgment stage numbers, and test-of-test-ratchet counted
review-loop-budget-wording.test.ts as test-only code because it read only
generated markdown. The fixture and rendering test now pin stages 1-8, and the
wording test renders both armies through generateReviewArmy and reads the
authored ship template as well as the generated sections.
Pass-2 maintainability notes folded in: docs/troubleshooting.md names the two
new RECEIPT_CARRY_REFUSED causes (dirty tree, unreadable status) and the two new
REVIEW_SCOPE_INPUT causes (non-hex --range-base, summary with no matching
pass); gstack-review-scope's help states that classify reads the base branch's
policy and refuses an unresolvable base unless --policy is passed.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: date a review-log row from its binding start when it has no timestamp
The Step 9.4 template asks the writer to fill `timestamp`; the logger stamps
`review_binding.started_at` but never `timestamp`. `gstack-review-scope
summary` dropped such a row silently, so this PR's own pass 2 was missing from
REVIEW_LOOP (passes=1 for a two-pass loop). A row now dates from `timestamp`,
else from its binding start; a row with neither is still not counted.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pass-4 review fixes (carry stamp rule, deleted-file locus, scanned defer, diff prefixes, row time)
Five findings at the blocking floor from the Step 11 outside voice (gpt-6.1-sol)
and the native adversarial pass, fixed as a fourth pass approved by GStack
Minder under the three-pass policy:
- carry: a path the policy names under stamp_files, stamp_paths or
release_outputs is metadata only while its change is a stamp (versionStampOnly
for a manifest kind, stampOnlyText otherwise), the rule the tree receipt
applies; a logic change in a stamped source file is product. Every
metadata-listed path is read at both heads and a non-stamp change drops its
globs from the product exclusion.
- review-scope: parseHunks keeps a deleted file as a whole changed location, so
a cause anchored in a removed export is the diff's to fix, not deferred.
- defer: issues are filed through lib/gstack-post's publication scan; a HIGH
finding in the title or body refuses and a MEDIUM one needs a human, both
recorded as unfiled with the reason; --repo names the destination.
- classify: both diffs pin --src-prefix=a/ --dst-prefix=b/ --no-textconv, so
diff.mnemonicPrefix cannot send every finding outside.
- summary: a row dates from review_binding.started_at before the writer-typed
timestamp, so a date-only value cannot drop a real pass.
Docs: ship-policy.md#receipt-carry, review-policy.md, the Step 9.4 template
and the CHANGELOG gate-integrity note record the rule and the pass.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(review-army): assert the Red Team branch the run took in the N+1 case
Under the blocking floor the Performance specialist rates a 12-line N+1
INFORMATIONAL, so the conditional Red Team does not activate; the case
encoded the old severity (N+1 is CRITICAL, a Red Team always follows). It
now asserts the branch the run took: a Red Team that ran must follow the
Performance specialist; no Red Team is correct only when the report's own
count says 0 critical and its Red Team line names that reason. The N+1
detection assertion is unchanged. The free contract test gains the
informational-not-activated and informational-claimed controls and the
budget test's synthetic report carries the count line.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* v1.91.256.0 stamp
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>
570 lines
38 KiB
TypeScript
570 lines
38 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;
|
||
}
|
||
|
||
/** 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,
|
||
};
|
||
|
||
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.',
|
||
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';
|
||
export const REVIEW_SCOPE_KEY_VERSION = '1.91.249.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,
|
||
};
|
||
|
||
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 ('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;
|
||
}
|
||
|
||
/** 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;
|
||
}
|