mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* 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) Reverts37bac07f8. 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 at8ed6da402) 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>
267 lines
17 KiB
TypeScript
267 lines
17 KiB
TypeScript
/**
|
||
* 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';
|
||
}
|