Files
gstack/lib/regen.ts
T
Garry Tanandcapy-ai[bot] 8584d6ff49 v1.91.77.0 feat(ship): two-stage gstack-pregate, gstack-regen generated-file registry, intent-based issue links (multi-agent wave C-pregate) (#3121)
* v1.91.77.0 feat(ship): two-stage gstack-pregate, gstack-regen registry, intent-based issue links (multi-agent wave C-pregate)

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

* test(pregate): keep the bin-driving pregate tests off the Windows subset; pin files are free-only for eval selection

The three C-pregate tests that spawn bin/gstack-regen, bin/gstack-pregate,
bin/gstack-issue-links and bin/gstack-ship-receipt do so through
test/helpers/pregate-fixture.ts runBin, so the Windows curation's source
scan of the test file never saw a bin/ spawn and shipped them to
windows-latest, where CreateProcess cannot exec a shebang (14 failures in
windows-free-shard 2 and 5). Each is now a KNOWN_WINDOWS_INCOMPATIBLE entry
with the precise reason. .gstack/generated.json and .gstack/pregate.json
join FREE_ONLY_PR_FILES: no paid case reads gstack's own pin files (the
tools read origin/<base> of the repo they run in, and paid cases run in
fixture repos), so touching them no longer restores every gate case.

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

* test(harness-version): record the FREE_ONLY_PR_FILES pin-file entries as non-behavioral

scripts/test-pr-profile.ts is a pinned shared-harness file; the two
.gstack pin-file entries change selection only, never a case verdict.

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>
2026-10-11 01:57:16 +00:00

271 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 { 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';
const BIN_DIR = path.dirname(Bun.fileURLToPath(import.meta.url)).replace(/\/lib$/, '/bin');
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)));
}
/** The working-tree content fingerprint (bin/gstack-wtree): tracked and untracked non-ignored files. */
export function wtreeFingerprint(repoRoot: string): string | null {
const r = spawnSync(path.join(BIN_DIR, 'gstack-wtree'), [], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 });
const out = (r.stdout ?? '').trim();
return r.status === 0 && /^[0-9a-f]{40,64}$/.test(out) ? out : null;
}
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 wtree = wtreeFingerprint(repoRoot);
if (!wtree) throw new Error('cannot fingerprint the working tree (gstack-wtree failed)');
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 = spawnSync('bash', ['-c', `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(); }
return { results, wtree: scratch?.wtree ?? wtreeFingerprint(o.repoRoot) };
}
/** 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';
}