#!/usr/bin/env -S bun --no-env-file --config=/dev/null /** * gstack-ship-policy — read, validate and explain `.gstack/ship-policy.json` (plan C1). * * gstack-ship-policy init [--explain] [--force] write the example (every key at its default) * gstack-ship-policy validate [--policy-from head|base] [--base ] [--json] * gstack-ship-policy show-effective [--policy-from base|head] [--base ] [--json] * gstack-ship-policy check-history [--base ] [--branch ] [--plan merge|squash|rebase] [--json] * * `show-effective` reads the committed copy on origin/ by default (the * reviewed policy); `validate` reads the working tree by default (the file * you are editing). Query commands: no GSTACK_RESULT line; `--json` means * JSON only on stdout. Logic: lib/ship-policy.ts; codes: lib/result-codes.ts. */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { resultLine } from '../lib/result-codes'; import { BUMP_NEVER_KEY_VERSION, POLICY_KEYS, POLICY_KEY_INTRODUCED, SHIP_POLICY_PATH, examplePolicyJson, explainPolicy, isUnavailable, loadPolicyFromBase, loadPolicyFromHead, minimumReader, policyUnavailableLine, renderEffective, resolveBaseBranch, type LoadedPolicy, } from '../lib/ship-policy'; const HELP = `gstack-ship-policy — the repo ship policy (docs/ship-policy.md) init [--explain] [--force] write ${SHIP_POLICY_PATH} with every key at its default validate [--policy-from head|base] [--base ] schema and containment check (default: the working tree) show-effective [--policy-from base|head] [--base ] resolved values with the source of each (default: origin/) check-history [--base ] [--branch ] [--plan merge|squash|rebase] under history: merge-only, refuse a squash/rebase plan or a rewritten branch --json: JSON only on stdout. Error lines end in () with a fix: clause. ${exitTableHelp()}`; function flag(args: string[], name: string): string | undefined { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; } function usage(message: string): number { process.stderr.write(`gstack-ship-policy: ${message}\n${HELP}\n`); return EXIT.usage; } function repoRoot(): string { const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 15_000 }); return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : process.cwd(); } function load(root: string, args: string[], defaultFrom: 'base' | 'head'): LoadedPolicy | number { const from = flag(args, '--policy-from') ?? defaultFrom; if (from !== 'base' && from !== 'head') return usage('--policy-from needs base or head'); if (from === 'head') return loadPolicyFromHead(root); const base = resolveBaseBranch(root, flag(args, '--base')); const loaded = loadPolicyFromBase(root, base); if (isUnavailable(loaded)) { process.stderr.write(policyUnavailableLine('gstack-ship-policy', base, loaded.detail) + '\n'); return EXIT.refused; } return loaded; } function printErrors(loaded: LoadedPolicy): void { for (const e of loaded.errors) process.stderr.write(resultLine('gstack-ship-policy', e.code, `${e.message} at ${e.path || SHIP_POLICY_PATH}`) + '\n'); } function main(argv: string[]): number { const [cmd, ...rest] = argv; const json = rest.includes('--json'); if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; } const root = repoRoot(); if (cmd === 'init') { if (rest.includes('--explain')) { process.stdout.write(explainPolicy()); if (rest.length === 1) return EXIT.ok; } const file = path.join(root, SHIP_POLICY_PATH); if (fs.existsSync(file) && !rest.includes('--force')) { process.stderr.write(resultLine('gstack-ship-policy init', 'POLICY_EXISTS', file) + '\n'); return EXIT.refused; } fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, examplePolicyJson()); process.stdout.write(`POLICY_WRITTEN: ${SHIP_POLICY_PATH} (edit it, then gstack-ship-policy validate; commit it on the base branch before /ship reads it)\n`); return EXIT.ok; } if (cmd === 'validate' || cmd === 'show-effective') { const loaded = load(root, rest, cmd === 'validate' ? 'head' : 'base'); if (typeof loaded === 'number') return loaded; const set = POLICY_KEYS.filter(k => loaded.values[k] && loaded.values[k] !== 'default'); const introduced = Object.fromEntries(set.map(k => [k, k === 'bump' && loaded.policy.bump === 'never' ? BUMP_NEVER_KEY_VERSION : POLICY_KEY_INTRODUCED[k]])); const minReader = set.length ? minimumReader(Object.fromEntries(set.map(k => [k, loaded.policy[k]]))) : null; if (json) { process.stdout.write(JSON.stringify({ valid: loaded.errors.length === 0, present: loaded.present, source: loaded.source, policy: loaded.policy, values: loaded.values, introduced, min_reader: minReader, errors: loaded.errors }) + '\n'); return loaded.errors.length ? EXIT.fail : EXIT.ok; } printErrors(loaded); if (cmd === 'validate') { process.stdout.write(loaded.errors.length ? `POLICY_INVALID: ${loaded.errors.length} error(s) in ${loaded.source.label}\n` : `POLICY_VALID: ${loaded.present ? loaded.source.label : `no ${SHIP_POLICY_PATH} (defaults apply)`}\n`); for (const k of set) process.stdout.write(`KEY: ${k} introduced=${introduced[k]}\n`); if (minReader) process.stdout.write(`MIN_READER: gstack ${minReader} (every lane that reads this policy needs at least this version)\n`); return loaded.errors.length ? EXIT.fail : EXIT.ok; } process.stdout.write(renderEffective(loaded, { base: flag(rest, '--base') ?? resolveBaseBranch(root) })); return loaded.errors.length ? EXIT.fail : EXIT.ok; } if (cmd === 'check-history') { const loaded = load(root, rest, 'base'); if (typeof loaded === 'number') return loaded; const branch = flag(rest, '--branch') ?? (spawnSync('git', ['branch', '--show-current'], { cwd: root, encoding: 'utf8', timeout: 15_000 }).stdout ?? '').trim(); const policy = loaded.policy.history; const plan = flag(rest, '--plan') ?? 'merge'; if (!['merge', 'squash', 'rebase'].includes(plan)) return usage(`--plan must be merge, squash or rebase (got ${plan})`); const result: Record = { history: policy, branch: branch || null, plan }; let verdict: string; let code = EXIT.ok; if (policy !== 'merge-only') verdict = `HISTORY: squash-ok (no check; policy history=${policy}; plan=${plan})`; else if (plan !== 'merge') { verdict = `HISTORY: refused (plan=${plan} rewrites history; policy history=merge-only keeps every reviewed commit reachable so gate evidence and preregistration ancestry stay bound to the shas that were gated) — fix: integrate with a merge commit, or set history: squash-ok in .gstack/ship-policy.json on the base branch (HISTORY_POLICY_VIOLATION)`; code = EXIT.refused; } else if (!branch) { verdict = 'HISTORY: merge-only (detached HEAD; no pushed branch to compare)'; } else { const remote = spawnSync('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], { cwd: root, encoding: 'utf8', timeout: 15_000 }); if (remote.status !== 0) verdict = `HISTORY: merge-only (origin/${branch} not pushed yet; nothing to rewrite)`; else { const anc = spawnSync('git', ['merge-base', '--is-ancestor', remote.stdout.trim(), 'HEAD'], { cwd: root, timeout: 15_000 }); result.remote = remote.stdout.trim(); if (anc.status === 0) verdict = `HISTORY: ok (merge-only; origin/${branch} is an ancestor of HEAD)`; else { verdict = `HISTORY: rewritten (origin/${branch} ${remote.stdout.trim().slice(0, 12)} is not an ancestor of HEAD) — fix: ${'merge origin/ instead of rebasing or squashing; never force-push under history: merge-only'} (HISTORY_POLICY_VIOLATION)`; code = EXIT.fail; } } } result.ok = code === EXIT.ok; result.verdict = verdict; process.stdout.write(json ? JSON.stringify(result) + '\n' : verdict + '\n'); return code; } return usage(`unknown command ${cmd}`); } process.exitCode = main(process.argv.slice(2));