mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* feat(pregate): lanes follows Python imports and mock.patch targets The import graph only parsed JS/TS imports, so in a Python repo every touched module failed `lanes` with PREGATE_NO_LANE even though SOURCE_EXT, TEST_FILE_RE and the pytest runner/summary already understood Python. Python sources now contribute `import a.b`, `from a.b import c` (c may be a submodule), parenthesised multi-line lists and relative `from ..pkg import x` edges, resolved to `a/b.py` or `a/b/__init__.py` from the repository root. Package `__init__.py` files on the path get edges too, since Python executes them on import. Modules outside the repo resolve to nothing. In test files, dotted string literals such as `'pkg.mod.Class.method'` (mock.patch targets) resolve to the longest dotted prefix that is a repo module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(regen): report missing dependencies as REGEN_DEPS_MISSING, not REGEN_STALE When package.json declares dependencies and node_modules is missing, the scratch worktree runs the generator without them. A generator that degrades and still exits 0 then looked stale, and the fix line sent people to rerun the generator. gstack-regen check and the pregate regen check now report "dependencies missing; run <install>" with its own result code and troubleshooting anchor; the install command follows the lockfile. The fixture reproduces the shape GSTA-31 saw in gbrain. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): keep Python import scanning linear and conservative Review of #3201 on the integrated tree found two quadratic paths on untrusted Python source and three ways a test could be left unselected: - The from-import module spec let two repetitions share a dot, so a failed match on a long run of dots backtracked quadratically. The spec is now dots followed by an optional word-led name. - Dotted literals and imports resolved every prefix with repeated split/join. Names are capped at 32 segments, deeper than any module path. - A literal that also named a repo file skipped its mock.patch reading; both readings now add edges. - A package and a same-named module both map (Python loads the package). - Imports after `;` or a compound statement's `:` are recognised. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): linear Python string blanking, non-empty from-spec, repo-bounded depth Second review round on the Python import scanner: - An empty from-spec let the whitespace on both sides of it compete, so `from` followed by a long run of spaces backtracked quadratically. The spec now starts with a dot or a word character. - Strings and comments are blanked by a single linear scanner that honours backslash escapes, so an escaped triple quote no longer ends a docstring early and swallows the imports after it. - The fixed 32-segment cap dropped real deep modules. Dotted names are now bounded by the repo's deepest path, which no module can exceed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): unreleased entry for Python import lanes and REGEN_DEPS_MISSING Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.126.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * release slot 1.91.126.0: back to unstamped (CHANGELOG entry unreleased) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(changelog): resolve the main-merge conflict (Unreleased entry above 1.91.127.0) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.131.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Scott Gigante <scott@gethubflow.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
287 lines
18 KiB
TypeScript
287 lines
18 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' | 'deps-missing';
|
||
/** 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();
|
||
}
|
||
|
||
/**
|
||
* The install command for a repository whose package.json declares
|
||
* dependencies but has no node_modules (the scratch worktree links none
|
||
* then), picked from its lockfile; null when nothing is missing.
|
||
*/
|
||
export function missingDependencies(repoRoot: string): string | null {
|
||
if (fs.existsSync(path.join(repoRoot, 'node_modules'))) return null;
|
||
let pkg: { dependencies?: Record<string, unknown>; devDependencies?: Record<string, unknown> };
|
||
try { pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) ?? {}; } catch { return null; }
|
||
if (!Object.keys(pkg.dependencies ?? {}).length && !Object.keys(pkg.devDependencies ?? {}).length) return null;
|
||
const lockfiles: Array<[string, string]> = [['bun.lock', 'bun install'], ['bun.lockb', 'bun install'], ['pnpm-lock.yaml', 'pnpm install'], ['yarn.lock', 'yarn install'], ['package-lock.json', 'npm install']];
|
||
return lockfiles.find(([file]) => fs.existsSync(path.join(repoRoot, file)))?.[1] ?? 'bun install';
|
||
}
|
||
|
||
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). A `check` whose outputs differ while the
|
||
* repository's dependencies are not installed is `deps-missing`, not `stale`:
|
||
* the generator may have run without them. 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 install = o.mode === 'check' ? missingDependencies(o.repoRoot) : 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') {
|
||
const differs = changed.length > 0 || outside.length > 0;
|
||
if (differs && install) results.push({ id: e.id, command: e.command, status: 'deps-missing', changed, outside, exit: 0, ms, fix: `run ${install}, then rerun the check` });
|
||
else results.push({ id: e.id, command: e.command, status: differs ? '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)`;
|
||
if (r.status === 'deps-missing') return `${head} — dependencies missing (no node_modules); ${r.fix} — differs without them: ${[...r.changed, ...r.outside].join(' ')} (REGEN_DEPS_MISSING)`;
|
||
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';
|
||
}
|