mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
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>
This commit is contained in:
1 parent
9b6e23fd31
commit
c90e39c19a
9 files changed
+117
-8
No files matched your search
@@ -267,6 +267,7 @@ gstack_result_anchor() {
|
||||
APPROVAL_SOURCE_UNAVAILABLE) printf '%s' 'approval-source-unavailable' ;;
|
||||
STATE_MATRIX_INCOMPLETE) printf '%s' 'state-matrix-incomplete' ;;
|
||||
WTREE_UNAVAILABLE) printf '%s' 'wtree-unavailable' ;;
|
||||
REGEN_DEPS_MISSING) printf '%s' 'regen-deps-missing' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
+2
-2
@@ -119,8 +119,8 @@ function main(argv: string[]): number {
|
||||
let outcome;
|
||||
try { outcome = runRegen({ repoRoot: root, registry: loaded.registry, mode: cmd, only: list(flag(rest, '--only')), touched: list(flag(rest, '--touched')) }); }
|
||||
catch (e: any) { process.stderr.write(resultLine(`gstack-regen ${cmd}`, 'PREGATE_INCOMPLETE', e.message) + '\n'); process.stdout.write(formatResult('regen', 'incomplete', loaded.source.label) + '\n'); return EXIT.fail; }
|
||||
const bad = outcome.results.filter(r => r.status === 'stale' || r.status === 'failed');
|
||||
const status = outcome.results.some(r => r.status === 'failed') ? 'incomplete' : 'complete';
|
||||
const bad = outcome.results.filter(r => r.status === 'stale' || r.status === 'failed' || r.status === 'deps-missing');
|
||||
const status = outcome.results.some(r => r.status === 'failed' || r.status === 'deps-missing') ? 'incomplete' : 'complete';
|
||||
if (json) { process.stdout.write(JSON.stringify({ mode: cmd, registry: loaded.source.label, wtree: outcome.wtree, results: outcome.results, ok: bad.length === 0 }) + '\n'); return bad.length ? EXIT.fail : EXIT.ok; }
|
||||
process.stdout.write(`registry: ${loaded.source.label}\n`);
|
||||
for (const r of outcome.results) process.stdout.write(renderEntry(r) + '\n');
|
||||
|
||||
+3
-1
@@ -41,7 +41,9 @@ paths, at most 64 per list); commands are one line under 512 bytes.
|
||||
<paths>` for entries whose inputs or outputs intersect) in a scratch
|
||||
`git worktree` seeded from the working tree, with `node_modules` linked rather
|
||||
than copied, and reports stale outputs (`REGEN_STALE`, exit 1) without touching
|
||||
the tree. An entry with no `command` is tracked only (goldens regenerated by
|
||||
the tree. When outputs differ but `package.json` declares dependencies and
|
||||
`node_modules` is missing, the entry is `REGEN_DEPS_MISSING` (install, then
|
||||
rerun) instead of stale. An entry with no `command` is tracked only (goldens regenerated by
|
||||
another entry).
|
||||
- `gstack-regen write` regenerates in place: what `gstack-restamp` and the
|
||||
Step 3 conflict rule use. On a conflict in a registered output, take either
|
||||
|
||||
@@ -2717,6 +2717,18 @@ take either side, then regenerate.
|
||||
worktree has no `node_modules` of its own, so a command that needs them must
|
||||
reference the repository's (gstack's entries do through `bun run`).
|
||||
|
||||
<a id="regen-deps-missing"></a>
|
||||
### `REGEN_DEPS_MISSING`
|
||||
|
||||
**Meaning.** A registered command exited 0 and its outputs differ from the
|
||||
committed ones, but `package.json` declares dependencies and the repository has
|
||||
no `node_modules`, so the scratch worktree ran the generator without them. The
|
||||
difference may come from the missing dependencies rather than stale files, so
|
||||
the entry is reported as unverified, not `REGEN_STALE`.
|
||||
|
||||
**Fix.** Run the printed install command (`bun install`, or the one matching the
|
||||
repository's lockfile), then rerun `gstack-regen check` or the pre-gate.
|
||||
|
||||
<a id="pregate-config-invalid"></a>
|
||||
### `PREGATE_CONFIG_INVALID`
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ function run(ctx: PregateContext): CheckResult {
|
||||
const lines = results.map(renderEntry);
|
||||
const stale = results.filter(r => r.status === 'stale');
|
||||
const failed = results.filter(r => r.status === 'failed');
|
||||
const depsMissing = results.filter(r => r.status === 'deps-missing');
|
||||
ctx.explain.push(...results.map(r => `regen ${r.id}: selected because touched ∩ (${selected.find(e => e.id === r.id)?.inputs.join(',') || 'always'} ∪ outputs) ≠ ∅`));
|
||||
if (depsMissing.length) return { id: 'regen', stage: 'preflight', status: 'incomplete', inputs, lines, code: 'REGEN_DEPS_MISSING', detail: `dependencies missing; ${depsMissing[0]!.fix} (${depsMissing.length} entr${depsMissing.length === 1 ? 'y' : 'ies'} unverified: ${depsMissing.map(d => d.id).join(', ')})`, fix: depsMissing[0]!.fix };
|
||||
if (failed.length) return { id: 'regen', stage: 'preflight', status: 'incomplete', inputs, lines, code: 'REGEN_COMMAND_FAILED', detail: `${failed.length} regen command(s) failed: ${failed.map(f => f.id).join(', ')}`, fix: failed.map(f => f.fix).join('; ') };
|
||||
if (stale.length) return { id: 'regen', stage: 'preflight', status: 'fail', inputs, lines, code: 'REGEN_STALE', detail: `${stale.length} stale: ${stale.flatMap(s => s.changed.concat(s.outside)).join(' ')}`, fix: stale.map(s => s.fix).join('; ') };
|
||||
return { id: 'regen', stage: 'preflight', status: 'pass', inputs, lines, detail: `${results.length} entr${results.length === 1 ? 'y' : 'ies'} fresh${results.some(r => r.status === 'listed') ? ' (hand-regenerated outputs listed)' : ''}` };
|
||||
|
||||
+23
-3
@@ -156,7 +156,7 @@ export function scratchWorktree(repoRoot: string, prefix = 'gstack-regen-'): Scr
|
||||
|
||||
export interface EntryResult {
|
||||
id: string; command: string | null;
|
||||
status: 'fresh' | 'stale' | 'failed' | 'listed' | 'written' | 'skipped';
|
||||
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`). */
|
||||
@@ -189,6 +189,20 @@ function diffSnapshots(a: Map<string, string>, b: Map<string, string>): string[]
|
||||
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) };
|
||||
@@ -207,7 +221,9 @@ export interface RunRegenOptions {
|
||||
* `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:
|
||||
* 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 } {
|
||||
@@ -215,6 +231,7 @@ export function runRegen(o: RunRegenOptions): { results: EntryResult[]; wtree: s
|
||||
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);
|
||||
@@ -230,7 +247,9 @@ export function runRegen(o: RunRegenOptions): { results: EntryResult[]; wtree: s
|
||||
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) });
|
||||
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) });
|
||||
@@ -247,6 +266,7 @@ export function renderEntry(r: EntryResult): string {
|
||||
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(' ')}`);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Result codes for the release path: gstack-ship-queue and the ship-policy
|
||||
* floor, pregate timing and its working-tree fingerprint, approval classes and
|
||||
* floor, pregate timing and its working-tree fingerprint, regen with missing
|
||||
* dependencies, approval classes and
|
||||
* the state-machine matrix. Kept beside lib/result-codes.ts (which spreads
|
||||
* this table into RESULT_CODES) so
|
||||
* the owner module stays within the module-size ratchet; the same rules apply:
|
||||
@@ -83,4 +84,10 @@ export const RELEASE_RESULT_CODES = {
|
||||
summary: 'cannot fingerprint the working tree: bin/gstack-wtree could not start (on Windows, no Git Bash was found) or it ran and failed',
|
||||
fix: 'on Windows point GSTACK_GIT_BASH at Git Bash\'s bash.exe (Git Bash: export GSTACK_GIT_BASH=\'C:/Program Files/Git/bin/bash.exe\'; PowerShell: $env:GSTACK_GIT_BASH = \'C:\\Program Files\\Git\\bin\\bash.exe\'), or install Git for Windows (winget install --id Git.Git -e); elsewhere rerun ./setup so bin/gstack-wtree is present; then rerun gstack-pregate',
|
||||
},
|
||||
// Fix wave W5 (GSTA-31): pregate regen without installed dependencies.
|
||||
REGEN_DEPS_MISSING: {
|
||||
anchor: 'regen-deps-missing',
|
||||
summary: 'a regen command exited 0 with different outputs while package.json declares dependencies and node_modules is missing, so the outputs were not verified',
|
||||
fix: 'install the dependencies (the printed command, such as bun install), then rerun the check',
|
||||
},
|
||||
} as const satisfies Record<string, ResultCode>;
|
||||
@@ -9,13 +9,24 @@ import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { parseRegistry, selectEntries, validateRegistry } from '../lib/regen';
|
||||
import { missingDependencies, parseRegistry, selectEntries, validateRegistry } from '../lib/regen';
|
||||
import { BASE_REGISTRY, cleanup, commit, makeFixtureRepo, readFile, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
const fixture = (opts?: Parameters<typeof makeFixtureRepo>[0]) => { const r = makeFixtureRepo(opts); repos.push(r); return r; };
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
/** gbrain's shape (GSTA-31): the generator degrades without its dependency and still exits 0. */
|
||||
const DEPS_FIXTURE = {
|
||||
registry: { entries: [{ id: 'digest', command: 'node gen.js', outputs: ['generated/**'], inputs: ['src/version.js', 'gen.js'] }] },
|
||||
baseFiles: {
|
||||
'package.json': JSON.stringify({ name: 'fixture', version: '1.0.0', dependencies: { 'fixture-dep': '1.0.0' } }, null, 2) + '\n',
|
||||
'bun.lock': '{}\n',
|
||||
'gen.js': "const fs=require('fs');const {VERSION}=require('./src/version.js');let style='plain';try{style=require('fixture-dep').style;}catch{}fs.writeFileSync('generated/digest.md',`# digest v${VERSION} (${style})\\n`);\n",
|
||||
'generated/digest.md': '# digest v1.0.0 (fancy)\n',
|
||||
},
|
||||
};
|
||||
|
||||
describe('validateRegistry (schema + containment, pure)', () => {
|
||||
test('the fixture registry validates; command null is a listed entry; inputs default to always', () => {
|
||||
expect(validateRegistry(BASE_REGISTRY)).toEqual([]);
|
||||
@@ -147,6 +158,43 @@ describe('gstack-regen (bin)', () => {
|
||||
expect(all.out).toContain('regen digest: stale');
|
||||
});
|
||||
|
||||
test('missing dependencies: a generator that exits 0 without node_modules is REGEN_DEPS_MISSING (install, then rerun), not REGEN_STALE; installed deps make it fresh', () => {
|
||||
const repo = fixture(DEPS_FIXTURE);
|
||||
commit(repo, { 'src/version.js': "module.exports = { VERSION: '1.0.0' };\n// touched\n" });
|
||||
const r = runBin(repo, 'gstack-regen', ['check', '--base', 'main', '--allow-repo-commands']);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(r.out).toMatch(/^regen digest: deps-missing \(\d+ ms\) — dependencies missing \(no node_modules\); run bun install, then rerun the check — differs without them: generated\/digest\.md \(REGEN_DEPS_MISSING\)$/m);
|
||||
expect(r.out).not.toContain('REGEN_STALE');
|
||||
expect(r.out).toContain('GSTACK_RESULT: skill=regen status=incomplete');
|
||||
const json = JSON.parse(runBin(repo, 'gstack-regen', ['check', '--base', 'main', '--allow-repo-commands', '--json']).out);
|
||||
expect(json.results.map((x: any) => [x.id, x.status])).toEqual([['digest', 'deps-missing']]);
|
||||
expect(repo.git('status', '--porcelain')).toBe('');
|
||||
|
||||
write(repo.work, 'node_modules/fixture-dep/index.js', "exports.style = 'fancy';\n");
|
||||
const installed = runBin(repo, 'gstack-regen', ['check', '--base', 'main', '--allow-repo-commands']);
|
||||
expect(installed.status).toBe(EXIT.ok);
|
||||
expect(installed.out).toContain('regen digest: fresh');
|
||||
|
||||
// With dependencies installed, a real input change is still stale.
|
||||
commit(repo, { 'src/version.js': "module.exports = { VERSION: '1.1.0' };\n" });
|
||||
const stale = runBin(repo, 'gstack-regen', ['check', '--base', 'main', '--allow-repo-commands']);
|
||||
expect(stale.status).toBe(EXIT.fail);
|
||||
expect(stale.out).toContain('regen digest: stale');
|
||||
expect(stale.out).toContain('(REGEN_STALE)');
|
||||
expect(stale.out).not.toContain('REGEN_DEPS_MISSING');
|
||||
});
|
||||
|
||||
test('missing dependencies: the install command follows the lockfile', () => {
|
||||
const repo = fixture(DEPS_FIXTURE);
|
||||
expect(missingDependencies(repo.work)).toBe('bun install');
|
||||
fs.rmSync(path.join(repo.work, 'bun.lock'));
|
||||
write(repo.work, 'package-lock.json', '{}\n');
|
||||
expect(missingDependencies(repo.work)).toBe('npm install');
|
||||
fs.mkdirSync(path.join(repo.work, 'node_modules'));
|
||||
expect(missingDependencies(repo.work)).toBeNull();
|
||||
expect(missingDependencies(fixture().work)).toBeNull();
|
||||
});
|
||||
|
||||
test('validate, list and init: an invalid working-tree registry is REGEN_REGISTRY_INVALID; init refuses to overwrite', () => {
|
||||
const repo = fixture();
|
||||
expect(runBin(repo, 'gstack-regen', ['validate']).out).toContain('REGISTRY_VALID: .gstack/generated.json');
|
||||
|
||||
@@ -221,6 +221,23 @@ describe('gstack-pregate (bin)', () => {
|
||||
expect(readOut(repo).stage2_ms).toBeUndefined();
|
||||
});
|
||||
|
||||
test('missing dependencies: regen is incomplete with REGEN_DEPS_MISSING and the install command, not REGEN_STALE', () => {
|
||||
const repo = fixture({
|
||||
registry: { entries: [{ id: 'digest', command: 'node gen.js', outputs: ['generated/**'], inputs: ['src/version.js', 'gen.js'] }] },
|
||||
baseFiles: {
|
||||
'package.json': JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { test: 'node --test test/*.test.js' }, devDependencies: { 'fixture-dep': '1.0.0' } }, null, 2) + '\n',
|
||||
'gen.js': "const fs=require('fs');const {VERSION}=require('./src/version.js');let style='plain';try{style=require('fixture-dep').style;}catch{}fs.writeFileSync('generated/digest.md',`# digest v${VERSION} (${style})\\n`);\n",
|
||||
'generated/digest.md': '# digest v1.0.0 (fancy)\n',
|
||||
},
|
||||
});
|
||||
commit(repo, { 'src/version.js': "module.exports = { VERSION: '1.0.0' };\n// touched\n" });
|
||||
const r = pregate(repo, ['--stage', 'preflight', '--only', 'regen']);
|
||||
expect(r.status).not.toBe(EXIT.ok);
|
||||
expect(r.out).toMatch(/^preflight {2}regen {5}incomplete .*dependencies missing; run bun install, then rerun the check \(1 entry unverified: digest\) \(REGEN_DEPS_MISSING\)/m);
|
||||
expect(r.err).toContain('(REGEN_DEPS_MISSING)');
|
||||
expect(r.out + r.err).not.toContain('REGEN_STALE');
|
||||
});
|
||||
|
||||
test('p0-4-scratch-script: zz-probe.sh at the root fails strays (downgraded to warn only when the base config says so); a conventional root file passes', () => {
|
||||
const strict = fixture({ pregate: { ...BASE_PREGATE, warn: [] } });
|
||||
commit(strict, { 'zz-probe.sh': '#!/bin/sh\necho hi\n', 'CONTRIBUTING.md': '# c\n', 'notes.txt': 'x\n', 'newtop/thing.js': 'module.exports = 1;\n' });
|
||||
|
||||
Reference in new issue
Block a user