#!/usr/bin/env -S bun --no-env-file --config=/dev/null /** * gstack-regen — the generated-file registry `.gstack/generated.json` (plan C4). * * gstack-regen check [--allow-repo-commands] [--base ] [--registry-from base|head] [--only ] [--touched ] [--json] * gstack-regen write [--allow-repo-commands] [--base ] [--registry-from base|head] [--only ] [--json] * gstack-regen list | validate | init [--explain] [--force] * * `check` regenerates every selected entry in a scratch `git worktree` seeded * from the working tree (node_modules linked, never copied) and reports stale * outputs without touching the tree; `write` regenerates in place. Commands * are read from the committed copy on origin/; they run only with * --allow-repo-commands (what /ship passes). Standalone, `check`/`write` list * the commands and exit 3 (REPO_COMMANDS_NOT_ALLOWED). Workflow commands end * with the GSTACK_RESULT line. Logic: lib/regen.ts. */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT, exitTableHelp, formatResult } from '../lib/headless-artifacts'; import { GENERATED_PATH, exampleRegistryJson, isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, renderEntry, runRegen, type RegistryLoad, } from '../lib/regen'; import { RESULT_CODES, resultLine } from '../lib/result-codes'; import { policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy'; const HELP = `gstack-regen — generated files regenerate, never hand-merge (docs/pregate.md#generated) check regenerate in a scratch git worktree; report stale entries (exit 1) without touching the tree write regenerate in place (the restamp and the Step 3 conflict rule) list print every entry: id, command, outputs, inputs validate schema and containment check of the working-tree ${GENERATED_PATH} init [--explain] [--force] write an example registry --allow-repo-commands run the registry's commands (what /ship passes); without it, check/write list them and exit 3 --registry-from base|head base (default, reviewed: origin/) or head (unreviewed; needs --allow-repo-commands) --base base branch (default: origin/HEAD, else main) --only restrict to these entries --touched restrict to entries whose inputs or outputs intersect these paths --json JSON only on stdout Prints one \`regen : fresh|stale|failed|listed|written\` line per entry and GSTACK_RESULT: skill=regen status=complete|incomplete|refused run=. ${exitTableHelp()}`; function flag(args: string[], name: string): string | undefined { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; } function list(v: string | undefined): string[] | undefined { return v === undefined ? undefined : v.split(',').map(s => s.trim()).filter(Boolean); } 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, rest: string[], defaultFrom: 'base' | 'head'): RegistryLoad | number { const from = flag(rest, '--registry-from') ?? defaultFrom; if (from !== 'base' && from !== 'head') { process.stderr.write(`gstack-regen: --registry-from needs base or head\n`); return EXIT.usage; } if (from === 'head') { if (defaultFrom === 'base' && !rest.includes('--allow-repo-commands')) { process.stderr.write('gstack-regen: --registry-from head needs --allow-repo-commands (the working-tree registry is unreviewed)\n'); return EXIT.refused; } return loadRegistryFromHead(root); } const base = resolveBaseBranch(root, flag(rest, '--base')); const loaded = loadRegistryFromBase(root, base); if (isRegistryUnavailable(loaded)) { process.stderr.write(policyUnavailableLine('gstack-regen', base, loaded.detail).replace('.gstack/ship-policy.json', GENERATED_PATH) + '\n'); return EXIT.refused; } return loaded; } 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('entries[].id: stable id ([a-z0-9][a-z0-9_.-]*), used by --only and the pre-gate\nentries[].command: the one command that regenerates the outputs; null lists outputs regenerated by hand\nentries[].outputs: repo-relative globs the command writes (non-empty)\nentries[].inputs: repo-relative globs that feed it; empty or absent means always regenerate\nentries[].note: free text shown in list and --explain\n'); if (rest.length === 1) return EXIT.ok; } const file = path.join(root, GENERATED_PATH); if (fs.existsSync(file) && !rest.includes('--force')) { process.stderr.write(resultLine('gstack-regen init', 'POLICY_EXISTS', file) + '\n'); return EXIT.refused; } fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, exampleRegistryJson()); process.stdout.write(`REGISTRY_WRITTEN: ${GENERATED_PATH} (edit it, then gstack-regen validate; commit it on the base branch before /ship reads it)\n`); return EXIT.ok; } if (cmd === 'validate' || cmd === 'list') { const fromBase = cmd === 'list' && flag(rest, '--registry-from') === 'base'; const loaded = fromBase ? loadRegistryFromBase(root, resolveBaseBranch(root, flag(rest, '--base'))) : loadRegistryFromHead(root); if (isRegistryUnavailable(loaded)) { process.stderr.write(policyUnavailableLine('gstack-regen', loaded.base, loaded.detail).replace('.gstack/ship-policy.json', GENERATED_PATH) + '\n'); return EXIT.refused; } for (const e of loaded.errors) process.stderr.write(resultLine(`gstack-regen ${cmd}`, e.code, `${e.message} at ${e.path || GENERATED_PATH}`) + '\n'); if (json) { process.stdout.write(JSON.stringify({ valid: loaded.errors.length === 0, present: loaded.present, source: loaded.source, entries: loaded.registry.entries, errors: loaded.errors }) + '\n'); return loaded.errors.length ? EXIT.fail : EXIT.ok; } if (cmd === 'validate') { process.stdout.write(loaded.errors.length ? `REGISTRY_INVALID: ${loaded.errors.length} error(s)\n` : `REGISTRY_VALID: ${loaded.present ? GENERATED_PATH : `no ${GENERATED_PATH} (nothing registered)`}\n`); return loaded.errors.length ? EXIT.fail : EXIT.ok; } process.stdout.write(`registry: ${loaded.source.label}\n`); for (const e of loaded.registry.entries) process.stdout.write(`${e.id}: command=${e.command === null ? 'null (by hand)' : JSON.stringify(e.command)} outputs=${e.outputs.join(',')} inputs=${e.inputs.join(',') || '(always)'}${e.note ? ` note=${JSON.stringify(e.note)}` : ''}\n`); return loaded.errors.length ? EXIT.fail : EXIT.ok; } if (cmd !== 'check' && cmd !== 'write') { process.stderr.write(`gstack-regen: unknown command ${cmd}\n${HELP}\n`); return EXIT.usage; } const loaded = load(root, rest, 'base'); if (typeof loaded === 'number') return loaded; if (isRegistryUnavailable(loaded)) return EXIT.refused; if (loaded.errors.length) { for (const e of loaded.errors) process.stderr.write(resultLine(`gstack-regen ${cmd}`, e.code, `${e.message} at ${e.path || GENERATED_PATH} (${loaded.source.label})`) + '\n'); process.stdout.write(formatResult('regen', 'refused', loaded.source.label) + '\n'); return EXIT.refused; } const commands = loaded.registry.entries.filter(e => e.command); if (commands.length && !rest.includes('--allow-repo-commands')) { process.stderr.write(`gstack-regen ${cmd}: ${RESULT_CODES.REPO_COMMANDS_NOT_ALLOWED.summary.replace('the policy declares commands', `${GENERATED_PATH} declares ${commands.length} command(s)`)}; fix: pass --allow-repo-commands (what /ship does) (REPO_COMMANDS_NOT_ALLOWED)\n`); for (const e of commands) process.stdout.write(`COMMAND: ${e.id}=${JSON.stringify(e.command)} outputs=${e.outputs.join(',')}\n`); process.stdout.write(formatResult('regen', 'refused', loaded.source.label) + '\n'); return EXIT.refused; } let outcome; try { outcome = runRegen({ repoRoot: root, registry: loaded.registry, mode: cmd, only: list(flag(rest, '--only')), touched: list(flag(rest, '--touched')) }); } catch (e: any) { process.stderr.write(resultLine(`gstack-regen ${cmd}`, 'PREGATE_INCOMPLETE', e.message) + '\n'); process.stdout.write(formatResult('regen', 'incomplete', loaded.source.label) + '\n'); return EXIT.fail; } const bad = outcome.results.filter(r => r.status === 'stale' || r.status === 'failed' || r.status === 'deps-missing'); const status = outcome.results.some(r => r.status === 'failed' || r.status === 'deps-missing') ? 'incomplete' : 'complete'; if (json) { process.stdout.write(JSON.stringify({ mode: cmd, registry: loaded.source.label, wtree: outcome.wtree, results: outcome.results, ok: bad.length === 0 }) + '\n'); return bad.length ? EXIT.fail : EXIT.ok; } process.stdout.write(`registry: ${loaded.source.label}\n`); for (const r of outcome.results) process.stdout.write(renderEntry(r) + '\n'); process.stdout.write(`REGEN: ${cmd} ${bad.length ? `${bad.length} stale or failed` : 'fresh'} (${outcome.results.length} entries)\n`); process.stdout.write(formatResult('regen', status, outcome.wtree ?? loaded.source.label) + '\n'); return bad.length ? EXIT.fail : EXIT.ok; } process.exitCode = main(process.argv.slice(2));