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:
garrytanandcapy-ai[bot] committed 2026-10-17 17:06:18 +00:00
1 parent 9b6e23fd31
commit c90e39c19a
9 files changed
+117 -8

No files matched your search

+49 -1
View File
@@ -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');
+17
View File
@@ -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' });