mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
* ship-policy: refuse a min_gstack_version floor above the newest stable release; doctor floor row compares runningGstackVersion and offers no no-op fast-forward (GST-1637) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(policy-floor-stable): build the expected doctor row from the project path, not a forward-slash join (Windows) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: keep fixtures off the live stable release (GSTACK_STABLE_REPO in test-setup); pregate-invalidation counts the policy-floor check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes A vendored gstack copy (no .git of its own, inside a project) no longer resolves the newest stable release from the enclosing project's release tags: newestStableRelease and the doctor's _dc_newest_stable resolve only when the root is the top of its own git checkout, else the floor is not compared (STABLE_RELEASE_UNKNOWN) and the doctor keeps the real upgrade fix. Review-Finding: p1/red-team#1 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes The newest stable release is the highest release tag at origin's stable commit (a tag a half-way cut left behind no longer counts), and locally the VERSION at the stable ref wins over local tags. The doctor skips the remote lookup where timeout is missing (stock macOS) instead of running it unbounded. Review-Finding: p1/outside-voice#1 reader=outside-voice Review-Finding: p1/outside-voice#2 reader=outside-voice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: the newest stable release is the tag at origin's stable commit Review-Finding: p3/fix-check#2 reader=fix-check 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>
153 lines
8.9 KiB
TypeScript
Executable File
153 lines
8.9 KiB
TypeScript
Executable File
#!/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 <ref>] [--json]
|
|
* gstack-ship-policy show-effective [--policy-from base|head] [--base <ref>] [--json]
|
|
* gstack-ship-policy check-history [--base <ref>] [--branch <name>] [--plan merge|squash|rebase] [--json]
|
|
*
|
|
* `show-effective` reads the committed copy on origin/<base> 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';
|
|
import { logShipCall } from '../lib/ship-call-log';
|
|
import { floorAgainstStable, newestStableRelease } from '../lib/stable-release';
|
|
|
|
logShipCall('gstack-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 <ref>] schema and containment check (default: the working tree)
|
|
show-effective [--policy-from base|head] [--base <ref>]
|
|
resolved values with the source of each (default: origin/<base>)
|
|
check-history [--base <ref>] [--branch <name>] [--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 (<CODE>) 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 floor: ReturnType<typeof floorAgainstStable> = cmd === 'validate' && loaded.present ? floorAgainstStable(loaded.policy.min_gstack_version, newestStableRelease()) : {};
|
|
if (floor.error) loaded.errors.push(floor.error);
|
|
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, ...(cmd === 'validate' ? { stable: floor.stable ?? null, warnings: floor.warning ? [floor.warning] : [] } : {}), errors: loaded.errors }) + '\n');
|
|
return loaded.errors.length ? EXIT.fail : EXIT.ok;
|
|
}
|
|
printErrors(loaded);
|
|
if (floor.warning) process.stderr.write(`gstack-ship-policy: WARN ${floor.warning}\n`);
|
|
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<string, unknown> = { 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/<base> 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));
|