Files
gstack/lib/regen.ts
T
82530ac143 v1.91.117.0 fix: pregate bats lanes, WTREE_UNAVAILABLE, doctor rows, SKILL.md size guard, planMode hosts (fix wave PR 5) (#3236)
* fix(pregate): recognise bats test files so lanes does not fail in bats repos (#3161)

gstack-pregate never treated a .bats file as a test, so in a repository whose
tests are bats files the lanes check failed every run with PREGATE_NO_LANE: a
.gstack/pregate.json dependency pointing at tests/foo.bats selected nothing, a
selection manifest printing .bats paths yielded 0 files, and a bats run that
executed no tests could not trip the zero-run rule.

Recognise .bats in the test-file pattern and in the manifest-output parser, and
read the bats TAP plan and pretty summary (as a last resort, after every
existing runner's summary) so a run that reports zero tests is a failure.

Fixes #3161.

Cherry-picked from #3177 (d355c116b). Conflicts resolved as in the author's own
merges (b09476beb, 444f4e768): lane-selection.ts keeps main's isTestFile-based
manifest parser, and lanes.ts carries the bats summary on main's supervised runner.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(pregate): a nested .bats file is selected and run end to end (#3161)

Reviewer test for #3177: touched itself with no declaration, and through a
declared dependency; a failing nested bats run reports the bats counts.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): a wtree failure is WTREE_UNAVAILABLE, not an unresolvable base (#3197)

The pregate consumers of the working-tree fingerprint (buildContext,
withFingerprint, verifyPregate) move to lib/script-invocation.ts's
{tree}|{error} form, and lib/regen.ts drops its string-returning wrapper.

- A fingerprint failure was reported as "policy source unavailable:
  origin/<base> is not a resolvable commit" with git fetch as the fix. It is
  now WTREE_UNAVAILABLE: a launch failure lists each Windows bash candidate
  with its rejection reason and names GSTACK_GIT_BASH; a gstack-wtree exit
  keeps the repair it printed (set TMPDIR to a writable directory).
- A fingerprint failing after a check is incomplete with that cause, not
  "working tree changed"; verify says the tree cannot be fingerprinted.
- #3190's gstack-config read (test_backend) goes through runScriptSync, so
  Windows reaches it through Git Bash. The tripwire learns the bin-constant
  shape that let it through.
- Under --json every context refusal is one envelope on stdout
  ({ok:false,status:'refused',code,detail,fix,docs}, exit 3); the run object
  carries ok and status too, and GSTACK_RESULT goes to stderr.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(pregate): gstack-pregate joins the Windows pregate job; pregate tests return to the Windows lane (#3197)

- windows-pregate-e2e runs gstack-pregate run (preflight: regen, secrets
  through the gstack-redact bun bin, literals, guards) in both journeys, and
  asserts a missing GSTACK_GIT_BASH refuses with one WTREE_UNAVAILABLE --json
  envelope.
- test/helpers/pregate-fixture.ts and restamp-fixture.ts runBin launch bins
  through lib/script-invocation.ts (bun bins as Bun argv, bash bins through
  Git Bash on Windows), which removes the "shebang via CreateProcess" reason
  for the pregate tests; they move from KNOWN_WINDOWS_INCOMPATIBLE to
  KNOWN_WINDOWS_SAFE.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* doctor: list rescue refs and warn about CLAUDE.md skill lists

gstack-doctor's plain report gains two rows. 'rescue refs' lists each
refs/gstack/rescue/* commit in the install checkout, newest first, with its
date, a needs-manual-resolution label when the rescue held unmerged files, and
the shell-quoted inspect and delete commands bin/gstack-ff-update's banner
prints. 'CLAUDE.md' warns when a gstack section in ~/.claude/CLAUDE.md or the
project's CLAUDE.md enumerates skills (#3169) and prints the short block to
paste instead; docs/troubleshooting.md#claude-md-skill-list explains it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* doctor: name the Windows bash gstack resolves and what chose it

On Windows (OSTYPE msys/cygwin) gstack-doctor adds a 'windows bash' row from
lib/script-invocation.ts resolveWindowsBash, run through the new thin
bin/gstack-windows-bash.ts with the bun on PATH: the resolved bash and the
override or discovery step that chose it, a warn when GSTACK_GIT_BASH and
GSTACK_BASH differ, and a fail carrying the BASH_UNAVAILABLE line (every
candidate with its rejection reason) when none qualifies. Tests inject the
platform (GSTACK_DOCTOR_PLATFORM) and the discovery inputs
(GSTACK_DOCTOR_BASH_PROBE) so the row runs on every OS.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* doctor --check: name the held-install recovery on Windows, not bin/gstack-upgrade

bin/gstack-upgrade compares a pwd -P path (/c/...) with git's toplevel
(C:/...) and refuses on every Git Bash install, so on Windows the revision
row's upgrade fix (REVISION_BELOW_MINIMUM, the Capy-wrapper variant and
REVISION_UNMET) names the held-install recovery forms from
docs/troubleshooting.md#auto-update-held-install-recovery instead: the Git
Bash form and the Windows PowerShell 5.1 form, each labelled by shell with the
install path quoted for that shell. Other platforms keep bin/gstack-upgrade.
A test pins both printed forms to the documented ones.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(skills): omit the plan-mode section on hosts without plan mode (#3168)

generatePlanModeInfo now honours capabilities.planMode. Hosts that declare
planMode:false (codex, agy, hermes, openclaw, and define-host's default:
factory, kiro, slate, cursor, gbrain) no longer render the
'## Plan Mode Safe Operations' and '## Skill Invocation During Plan Mode'
preamble sections. Claude, copilot and opencode are unchanged.

The codex, factory and agy ship goldens are regenerated. The clarity test that
pinned the plan-mode entry-order prose on every host now pins it on planMode
hosts and asserts its absence elsewhere (intentional contract change).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(skills): guard generated SKILL.md size at 100,000 chars on every host (#3199)

A free test renders every host and fails when a generated SKILL.md exceeds
100,000 characters, naming host, skill, actual size, limit and the fix. The 89
current offenders are pinned at their exact size in
test/fixtures/skill-md-size-allowlist.json: growth past a pin fails, and a
shrink fails until the pin is lowered. The shared-preamble hoist is deferred
(owner Garry, revisit 2026-11-15).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(pregate): keep #3177's bats tests beside the nested-directory test

Moves #3177's bats describe block verbatim from test/pregate-selection.test.ts
into test/pregate-bats-nested.test.ts, so pregate-selection.test.ts is
unchanged on this branch and the in-flight field-list-2 pregate lane
(capy/fl2-L6-pregate, which edits that file's imports and adds cases) merges
without a conflict. No assertion changes.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* doctor --check: keep bin/gstack-upgrade as the Windows fix (path spelling fixed on main)

Reverts 37bac07f8. Plan item D named the held-install recovery forms on
Windows only until bin/gstack-upgrade stopped refusing Git Bash installs.
bin/gstack-upgrade:63-67 now compares with gstack_path_same from
bin/gstack-path-spelling.sh, pinned by test/gstack-upgrade-path-spelling.test.ts,
so bin/gstack-upgrade is the right fix on every platform again.

* test(skills): measure the size-guard pins on v1.91.109.0

#3225 (merged into this branch) grew 53 pinned skills: 3 characters each from
the Aside driver resolver, and about 340 more in plan-design-review and
plan-devex-review from the #3219 summary sections. The pins are this PR's
starting measurement, so they are taken on the merged base; no skill this PR
renders grew.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(windows): only the pregate tests that pass on windows-latest join the lane

windows-free-tests run 38984824731 (this branch at 8ed6da402) ran the moved
files. gstack-regen and the new pregate-wtree-refusal passed. pregate-guards,
-remote, -regate, -invalidation, -selection and -bats-nested launched
gstack-pregate fine but fail on Linux-host expectations: their fixture lanes are
Linux workflow jobs, which a Windows host records as requires-remote
obligations. They go back to KNOWN_WINDOWS_INCOMPATIBLE with that reason
instead of "shebang via CreateProcess".

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* windows curation: keep CI run ids out of reason prose (pii.phone false positive)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* skill-md size pins: follow #3231's review-army line (+21 chars on five inlining hosts)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* doctor test: drop the reverted Windows upgrade-fix wording from item D's describe

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* skill-md size pins: follow #3226 (plan-devex-review +3324) and #3234 (autoplan +59)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* WTREE_UNAVAILABLE moves to lib/result-codes-release.ts (#3235 split; result-codes.ts stays at 796 of 800 lines); doctor fixture copies the release table; size pins follow #3235's review +1

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): fix wave PR 5 release notes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.117.0 fix: fix wave PR 5 (pregate bats + WTREE_UNAVAILABLE, doctor rows, SKILL.md size guard, planMode hosts)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(qa): an invalid-input probe breaks only the rule it tests

The paid gate's qa-functional-cli-report failed again on #3236 (run
39001107574): the agent probed the digits-only amount rule with -5, which
the positivity check rejected first, so the planted parseInt defect stayed
hidden and it reported no defect. The contract map's invalid-input row now
asks for each input to break only the rule it tests, so another check
cannot reject it first.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: harjothkhara <48686985+harjothkhara@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-17 04:36:00 +00:00

267 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* regen — the generated-file registry `.gstack/generated.json` (plan C4).
* Every generated file a repo tracks is an entry: the output globs, the one
* command that regenerates them and (optionally) the input globs that feed
* it. Two verbs: `check` regenerates in a scratch `git worktree` seeded from
* the working tree's content fingerprint (never a copy of a tree with
* node_modules) and reports stale entries without touching the tree; `write`
* regenerates in place. The /ship Step 3 conflict rule, gstack-restamp and
* the pre-gate (lib/pregate/regen.ts) consume it.
*
* Trust boundary (plan C1): file presence never authorizes execution. The
* registry is read from the committed copy on origin/<base> by default; the
* caller opts in to running its commands with --allow-repo-commands or by
* running under /ship. Containment: outputs and inputs are repo-relative
* globs (lib/ship-policy.ts rules), commands are single lines under 512
* bytes, at most 64 entries. Docs: docs/pregate.md#generated.
*/
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { type ResultCodeName } from './result-codes';
import { runBashSync, wtreeFingerprint } from './script-invocation';
import { containedRelative, globToRegExp, matchesAny } from './ship-policy';
export const GENERATED_PATH = '.gstack/generated.json';
export const GENERATED_SCHEMA = 'https://github.com/garrytan/gstack/docs/pregate.md#generated-v1';
export interface RegenEntry {
/** Stable id used in `regen` results and `--only <id>`. */
id: string;
/** The one command that regenerates every output; null for outputs regenerated by hand (listed, never run). */
command: string | null;
/** Repo-relative globs of the files the command writes. */
outputs: string[];
/** Repo-relative globs of the files that feed the command; empty means "always regenerate". */
inputs: string[];
/** Free text shown in `list` and `--explain`. */
note?: string;
}
export interface Registry { $schema?: string; entries: RegenEntry[] }
export interface RegistryError { code: ResultCodeName; path: string; message: string }
const MAX_ENTRIES = 64;
const MAX_COMMAND = 512;
const ID_RE = /^[a-z0-9][a-z0-9_.-]{0,63}$/;
export function validateRegistry(raw: unknown): RegistryError[] {
const errors: RegistryError[] = [];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ code: 'REGEN_REGISTRY_INVALID', path: '', message: 'registry must be a JSON object with an `entries` array' }];
const obj = raw as Record<string, unknown>;
for (const key of Object.keys(obj)) if (key !== '$schema' && key !== 'entries') errors.push({ code: 'REGEN_REGISTRY_INVALID', path: key, message: 'unknown key; valid keys: entries' });
if (!Array.isArray(obj.entries)) return [...errors, { code: 'REGEN_REGISTRY_INVALID', path: 'entries', message: 'must be an array' }];
if (obj.entries.length > MAX_ENTRIES) errors.push({ code: 'REGEN_REGISTRY_INVALID', path: 'entries', message: `more than ${MAX_ENTRIES} entries` });
const ids = new Set<string>();
obj.entries.forEach((e: unknown, i: number) => {
const at = `entries[${i}]`;
if (!e || typeof e !== 'object' || Array.isArray(e)) { errors.push({ code: 'REGEN_REGISTRY_INVALID', path: at, message: 'must be an object' }); return; }
const entry = e as Record<string, unknown>;
for (const key of Object.keys(entry)) if (!['id', 'command', 'outputs', 'inputs', 'note'].includes(key)) errors.push({ code: 'REGEN_REGISTRY_INVALID', path: `${at}.${key}`, message: 'unknown key; valid keys: id, command, outputs, inputs, note' });
if (typeof entry.id !== 'string' || !ID_RE.test(entry.id)) errors.push({ code: 'REGEN_REGISTRY_INVALID', path: `${at}.id`, message: 'must match [a-z0-9][a-z0-9_.-]{0,63}' });
else if (ids.has(entry.id)) errors.push({ code: 'REGEN_REGISTRY_INVALID', path: `${at}.id`, message: `duplicate id ${entry.id}` });
else ids.add(entry.id);
if (entry.command !== null) {
if (typeof entry.command !== 'string' || !entry.command.trim()) errors.push({ code: 'REGEN_REGISTRY_INVALID', path: `${at}.command`, message: 'must be a non-empty string or null' });
else if (entry.command.length > MAX_COMMAND || /[\0\r\n]/.test(entry.command)) errors.push({ code: 'REGEN_CONTAINMENT', path: `${at}.command`, message: `must be one line under ${MAX_COMMAND} characters` });
}
for (const key of ['outputs', 'inputs'] as const) {
const value = entry[key];
if (value === undefined && key === 'inputs') continue;
if (!Array.isArray(value) || (key === 'outputs' && value.length === 0)) { errors.push({ code: 'REGEN_REGISTRY_INVALID', path: `${at}.${key}`, message: key === 'outputs' ? 'must be a non-empty array of globs' : 'must be an array of globs' }); continue; }
if (value.length > MAX_ENTRIES) errors.push({ code: 'REGEN_CONTAINMENT', path: `${at}.${key}`, message: `more than ${MAX_ENTRIES} globs` });
value.forEach((g: unknown, j: number) => {
if (typeof g !== 'string') { errors.push({ code: 'REGEN_REGISTRY_INVALID', path: `${at}.${key}[${j}]`, message: 'must be a string' }); return; }
const why = containedRelative(g);
if (why) { errors.push({ code: 'REGEN_CONTAINMENT', path: `${at}.${key}[${j}]`, message: `${JSON.stringify(g)} ${why}` }); return; }
try { globToRegExp(g); } catch (err: any) { errors.push({ code: 'REGEN_CONTAINMENT', path: `${at}.${key}[${j}]`, message: err.message }); }
});
}
if (entry.note !== undefined && typeof entry.note !== 'string') errors.push({ code: 'REGEN_REGISTRY_INVALID', path: `${at}.note`, message: 'must be a string' });
});
return errors;
}
export function parseRegistry(text: string): { registry: Registry; errors: RegistryError[] } {
let raw: unknown;
try { raw = JSON.parse(text); }
catch (e: any) { return { registry: { entries: [] }, errors: [{ code: 'REGEN_REGISTRY_INVALID', path: GENERATED_PATH, message: `not valid JSON: ${e.message}` }] }; }
const errors = validateRegistry(raw);
if (errors.length) return { registry: { entries: [] }, errors };
const obj = raw as { $schema?: string; entries: Array<Partial<RegenEntry>> };
return { registry: { $schema: obj.$schema, entries: obj.entries.map(e => ({ id: e.id!, command: e.command ?? null, outputs: e.outputs!, inputs: e.inputs ?? [], note: e.note })) }, errors: [] };
}
function git(cwd: string, args: string[]): { ok: boolean; out: string; err: string } {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
return { ok: r.status === 0, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
}
export interface RegistrySource { kind: 'base' | 'head' | 'none'; label: string; sha: string | null }
export type RegistryLoad = { registry: Registry; errors: RegistryError[]; present: boolean; source: RegistrySource } | { unavailable: true; base: string; detail: string };
/** The reviewed registry: `.gstack/generated.json` as committed on origin/<base>; unresolvable base is `unavailable` (never a silent fallback). */
export function loadRegistryFromBase(repoRoot: string, base: string): RegistryLoad {
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 show = git(repoRoot, ['show', `${sha.out}:${GENERATED_PATH}`]);
if (!show.ok) return { registry: { entries: [] }, errors: [], present: false, source: { kind: 'none', label: `${ref}@${sha.out.slice(0, 12)} (no ${GENERATED_PATH})`, sha: sha.out } };
return { ...parseRegistry(show.out), present: true, source: { kind: 'base', label: `${ref}@${sha.out.slice(0, 12)}:${GENERATED_PATH}`, sha: sha.out } };
}
/** The working-tree registry: unreviewed, labeled so in every record. */
export function loadRegistryFromHead(repoRoot: string): RegistryLoad {
const file = path.join(repoRoot, GENERATED_PATH);
if (!fs.existsSync(file)) return { registry: { entries: [] }, errors: [], present: false, source: { kind: 'none', label: `head (no ${GENERATED_PATH})`, sha: null } };
return { ...parseRegistry(fs.readFileSync(file, 'utf8')), present: true, source: { kind: 'head', label: `head (unreviewed) ${GENERATED_PATH}`, sha: null } };
}
export function isRegistryUnavailable(load: RegistryLoad): load is { unavailable: true; base: string; detail: string } {
return (load as any).unavailable === true;
}
/** Entries whose inputs or outputs intersect `touched`; every entry when `touched` is undefined or an entry declares no inputs. */
export function selectEntries(registry: Registry, touched?: readonly string[]): RegenEntry[] {
if (!touched) return registry.entries;
return registry.entries.filter(e => e.inputs.length === 0 || touched.some(t => matchesAny(e.inputs, t) || matchesAny(e.outputs, t)));
}
export interface Scratch { dir: string; wtree: string; remove: () => void }
/**
* A detached `git worktree` seeded from the working tree's content
* fingerprint, with the repository's ignored node_modules linked in (a
* symlink, never a copy). The caller removes it; a failure to create it is an
* Error naming the step.
*/
export function scratchWorktree(repoRoot: string, prefix = 'gstack-regen-'): Scratch {
const fp = wtreeFingerprint({ cwd: repoRoot });
if ('error' in fp) throw new Error(`cannot fingerprint the working tree (${fp.error})`);
const wtree = fp.tree;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
fs.rmSync(dir, { recursive: true, force: true });
const add = git(repoRoot, ['worktree', 'add', '--detach', '-q', dir, 'HEAD']);
if (!add.ok) throw new Error(`cannot create the scratch worktree: ${add.err}`);
const remove = () => { git(repoRoot, ['worktree', 'remove', '--force', dir]); fs.rmSync(dir, { recursive: true, force: true }); git(repoRoot, ['worktree', 'prune']); };
const seed = git(dir, ['read-tree', '--reset', '-u', wtree]);
if (!seed.ok) { remove(); throw new Error(`cannot seed the scratch worktree: ${seed.err}`); }
const nm = path.join(repoRoot, 'node_modules');
if (fs.existsSync(nm) && !fs.existsSync(path.join(dir, 'node_modules')) && git(repoRoot, ['check-ignore', '-q', 'node_modules']).ok) {
try { fs.symlinkSync(nm, path.join(dir, 'node_modules'), 'dir'); } catch { /* the command runs without deps */ }
}
return { dir, wtree, remove };
}
export interface EntryResult {
id: string; command: string | null;
status: 'fresh' | 'stale' | 'failed' | 'listed' | 'written' | 'skipped';
/** Output files whose bytes changed (stale) or were written. */
changed: string[];
/** Files the command wrote outside its declared outputs (a refusal in `check`). */
outside: string[];
exit?: number | null; stderr?: string; ms: number;
/** The exact command an author runs to refresh the entry. */
fix: string;
}
/** Working tree vs the seeded index in a scratch worktree: modified tracked files plus new untracked ones. */
function changedPaths(dir: string): string[] {
const modified = git(dir, ['diff', '--name-only', '-z']).out.split('\0');
const deleted = git(dir, ['ls-files', '--deleted', '-z']).out.split('\0');
const untracked = git(dir, ['ls-files', '--others', '--exclude-standard', '-z']).out.split('\0');
return [...new Set([...modified, ...deleted, ...untracked])].filter(p => p && p !== 'node_modules').sort();
}
/** sha256 per tracked and untracked non-ignored file (write mode compares two of these). */
function snapshotFiles(root: string): Map<string, string> {
const out = new Map<string, string>();
for (const rel of git(root, ['ls-files', '-co', '--exclude-standard', '-z']).out.split('\0').filter(Boolean)) {
try { out.set(rel, createHash('sha256').update(fs.readFileSync(path.join(root, rel))).digest('hex')); } catch { out.set(rel, 'missing'); }
}
return out;
}
function diffSnapshots(a: Map<string, string>, b: Map<string, string>): string[] {
const changed = new Set<string>();
for (const [k, v] of b) if (a.get(k) !== v) changed.add(k);
for (const k of a.keys()) if (!b.has(k)) changed.add(k);
return [...changed].filter(p => p !== 'node_modules').sort();
}
function runCommand(cwd: string, command: string, env: NodeJS.ProcessEnv, timeoutMs: number): { exit: number | null; stderr: string } {
const r = runBashSync(`set -o pipefail; ${command}`, { cwd, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, env: { ...env, GSTACK_REGEN_SCRATCH: cwd } });
return { exit: r.status, stderr: ((r.stderr ?? '') + (r.error ? `\n${r.error.message}` : '')).trim().slice(-4000) };
}
export interface RunRegenOptions {
repoRoot: string; registry: Registry; mode: 'check' | 'write';
/** Restrict to these entry ids. */
only?: string[];
/** Restrict to entries whose inputs or outputs intersect these paths. */
touched?: string[];
env?: NodeJS.ProcessEnv; timeoutMs?: number;
}
/**
* `check`: run every selected command in one scratch worktree and compare its
* outputs with the working tree; `write`: run them in the repository. An
* entry with `command: null` is `listed` (its fix line names the outputs an
* author refreshes by hand). Never runs a command the caller did not opt into:
* the bin enforces --allow-repo-commands before calling this.
*/
export function runRegen(o: RunRegenOptions): { results: EntryResult[]; wtree: string | null } {
const entries = selectEntries(o.registry, o.touched).filter(e => !o.only || o.only.includes(e.id));
const timeout = o.timeoutMs ?? 600_000;
const results: EntryResult[] = [];
let scratch: Scratch | null = null;
const fixFor = (e: RegenEntry) => e.command ? `${e.command} # then commit ${e.outputs.join(' ')}` : `regenerate by hand: ${e.outputs.join(' ')}${e.note ? ` (${e.note})` : ''}`;
try {
if (o.mode === 'check' && entries.some(e => e.command)) scratch = scratchWorktree(o.repoRoot);
for (const e of entries) {
const started = Date.now();
if (!e.command) { results.push({ id: e.id, command: null, status: 'listed', changed: [], outside: [], ms: 0, fix: fixFor(e) }); continue; }
const cwd = o.mode === 'check' ? scratch!.dir : o.repoRoot;
const before = o.mode === 'write' ? snapshotFiles(o.repoRoot) : null;
const run = runCommand(cwd, e.command, o.env ?? process.env, timeout);
const after = before ? diffSnapshots(before, snapshotFiles(o.repoRoot)) : changedPaths(cwd);
const changed = after.filter(p => matchesAny(e.outputs, p));
const outside = after.filter(p => !matchesAny(e.outputs, p) && !matchesAny(o.registry.entries.flatMap(x => x.outputs), p));
const ms = Date.now() - started;
if (run.exit !== 0) { results.push({ id: e.id, command: e.command, status: 'failed', changed, outside, exit: run.exit, stderr: run.stderr, ms, fix: fixFor(e) }); continue; }
if (o.mode === 'check') {
results.push({ id: e.id, command: e.command, status: changed.length || outside.length ? 'stale' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e) });
// Reset the scratch so the next entry's diff is its own.
git(cwd, ['checkout', '-q', '--', '.']); git(cwd, ['clean', '-fdq']);
} else results.push({ id: e.id, command: e.command, status: changed.length ? 'written' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e) });
}
} finally { scratch?.remove(); }
if (scratch) return { results, wtree: scratch.wtree };
const fp = wtreeFingerprint({ cwd: o.repoRoot });
return { results, wtree: 'tree' in fp ? fp.tree : null };
}
/** One line per entry, the way `gstack-regen` and the pre-gate print them. */
export function renderEntry(r: EntryResult): string {
const head = `regen ${r.id}: ${r.status}${r.ms ? ` (${r.ms} ms)` : ''}`;
if (r.status === 'fresh' || r.status === 'skipped') return head;
if (r.status === 'listed') return `${head} — ${r.fix}`;
if (r.status === 'failed') return `${head} — exit ${r.exit}: ${(r.stderr ?? '').split('\n').pop() ?? ''}; fix: run \`${r.command}\` and read its output (REGEN_COMMAND_FAILED)`;
const parts = [head];
if (r.changed.length) parts.push(`stale: ${r.changed.join(' ')}`);
if (r.outside.length) parts.push(`outside declared outputs: ${r.outside.join(' ')}`);
parts.push(`fix: ${r.fix}${r.status === 'stale' ? ' (REGEN_STALE)' : ''}`);
return parts.join(' — ');
}
/** The example `init` writes. */
export function exampleRegistryJson(): string {
const example: Registry = {
$schema: GENERATED_SCHEMA,
entries: [
{ id: 'example', command: 'npm run generate', outputs: ['generated/**'], inputs: ['src/schema/**'], note: 'replace with the repo’s own generator; command: null lists outputs regenerated by hand' },
],
};
return JSON.stringify(example, null, 2) + '\n';
}