fix(modules): gen:skill-docs refuses an exclusion render without --out-dir; render tests set up their own arm

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-28 17:01:47 +00:00
1 parent a919a41678
commit 1d3e05aaf9
4 files changed
+18 -3

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ bin/gstack-modules render --host claude \
`render` writes the whole host tree into a staging directory beside `--out-dir`. It checks that no rendered file still points at an excluded module, writes `loadout.lock.json` last (loadout name or `adhoc`, the normalized spec and its sha256, host, model, every excluded id, and the sha256 and byte count of every file), then swaps the result into place. A failure leaves an earlier render at `--out-dir` untouched. Claude section links name `--out-dir`, so point the eval harness at that directory. Pass the same `--model`, `--install-root` and `--plan-mode` the install under test uses, so the arms differ only by the modules. Codex renders default to the host's generic `gpt` overlay, while `./setup` uses `gpt-6-astra`.
`bun run gen:skill-docs --loadout <name>` and `--modules <spec>` apply the same filter to an ordinary generation run. A non-`full` render adds `<!-- loadout: <name> -->` to each SKILL.md header.
`bun run gen:skill-docs --loadout <name> --out-dir <dir>` and `--modules <spec> --out-dir <dir>` apply the same filter to a generation run without the staging, dangling-reference check or lock file; a selection with exclusions and no `--out-dir` is refused with `MODULE_SPEC_INVALID`, because the committed tree is always the `full` render. A non-`full` render adds `<!-- loadout: <name> -->` to each SKILL.md header.
## Building an eval arm
+1 -1
View File
@@ -5159,7 +5159,7 @@ These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modul
<a id="module-spec-invalid"></a>
### `MODULE_SPEC_INVALID: …`
**Meaning.** A `--modules` token is not `-<module id>` or `+<module id>`, both `--loadout` and `--modules` were given, or a render flag is missing or malformed. **Fix.** Write the spec as `<loadout>,-<id>,+<id>` (for example `full,-section:office-hours/second-opinion`) and pass the flags `bin/gstack-modules --help` lists.
**Meaning.** A `--modules` token is not `-<module id>` or `+<module id>`, both `--loadout` and `--modules` were given, `gen:skill-docs` got a selection with exclusions but no `--out-dir`, or a render flag is missing or malformed. **Fix.** Write the spec as `<loadout>,-<id>,+<id>` (for example `full,-section:office-hours/second-opinion`) and pass the flags `bin/gstack-modules --help` lists.
<a id="module-unknown"></a>
### `MODULE_UNKNOWN: "<id>" is not a module`
+4 -1
View File
@@ -24,7 +24,7 @@ import type { Host, TemplateContext } from './resolvers/types';
import { HOST_PATHS } from './resolvers/types';
import { RESOLVERS } from './resolvers/index';
import { applySectionGuards, rewriteCarvedSectionRefs, SKILL_BYTE_CEILING, usesLazySections } from './resolvers/sections';
import { excludedSkillDirs, selectModules } from './modules';
import { ModuleError, excludedSkillDirs, selectModules } from './modules';
import type { ModuleFilter } from './resolvers/types';
import { insertRuntimePreludes } from './resolvers/runtime-root';
import { generateSharedPreambleFile, hoistsPreamble, SHARED_PREAMBLE_FILE } from './resolvers/preamble-hoist';
@@ -150,6 +150,9 @@ function parseGenerationArgs(args: string[]): GenerationOptions {
const badDisabled = disabledSkills.find(n => !/^\/?[a-z0-9-]+$/.test(n));
if (badDisabled) throw new Error(`--disabled-skills takes skill names (got ${JSON.stringify(badDisabled)})`);
const modules = selectModules({ loadout: value('--loadout'), modules: value('--modules') });
if (modules && outDir === undefined) {
throw new ModuleError('MODULE_SPEC_INVALID', `--${value('--modules') === undefined ? 'loadout' : 'modules'} with exclusions needs --out-dir; the committed tree is always the full render`, 'bin/gstack-modules render --host <host> --modules <spec> --out-dir <dir>');
}
const installRoot = value('--install-root');
if (installRoot !== undefined && !INSTALL_ROOT_PATTERN.test(installRoot)) {
throw new Error(`--install-root must be an absolute path of letters, digits and . _ - + @ / (got ${JSON.stringify(installRoot)})`);
+12
View File
@@ -156,6 +156,7 @@ describe('render filter units', () => {
describe('gstack-modules render', () => {
const spec = 'full,-section:office-hours/second-opinion,-section:office-hours/visual-design,-preamble:writing-style,-overlay:claude,-skill:design-shotgun';
const readLock = (dir: string) => JSON.parse(fs.readFileSync(path.join(dir, LOCK_FILE), 'utf-8')) as LoadoutLock;
const ensureArm = async (dir: string) => { if (!fs.existsSync(path.join(dir, LOCK_FILE))) await renderLoadout({ host: 'claude', selection: selectModules({ modules: spec }), outDir: dir }); };
test('claude: one arm excludes one module of each kind and locks every file', async () => {
const out = path.join(tmp, 'claude-arm');
@@ -194,6 +195,7 @@ describe('gstack-modules render', () => {
test('a dangling reference is refused and the earlier render survives', async () => {
const out = path.join(tmp, 'claude-arm');
await ensureArm(out);
const before = readLock(out);
const unsafe = renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-section:plan-design-review/review-sections' }), outDir: out });
await expect(unsafe).rejects.toMatchObject({ code: 'MODULE_EXCLUSION_UNSAFE' });
@@ -204,6 +206,8 @@ describe('gstack-modules render', () => {
test('a later render replaces an earlier one; a foreign directory is never replaced', async () => {
const out = path.join(tmp, 'claude-arm');
await ensureArm(out);
expect(readLock(out).loadout).toBe('adhoc');
const lock = await renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-skill:design-shotgun' }), outDir: out });
expect(readLock(out).spec_sha256).toBe(lock.spec_sha256);
expect(fs.existsSync(path.join(out, 'office-hours/sections/second-opinion.md'))).toBe(true);
@@ -266,6 +270,14 @@ describe('bin/gstack-modules', () => {
expect(run('--help')).toMatchObject({ code: 0, stdout: expect.stringContaining('gstack-modules render') });
});
test('gen:skill-docs refuses an exclusion render into the committed tree', () => {
const before = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout;
const result = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'scripts/gen-skill-docs.ts'), '--host', 'claude', '--modules', 'full,-skill:design-shotgun'], { cwd: ROOT, encoding: 'utf-8', timeout: 120_000 });
expect(result.status).not.toBe(0);
expect(result.stderr + result.stdout).toContain('MODULE_SPEC_INVALID');
expect(spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout).toBe(before);
});
test('render prints one RENDERED line and writes the lock', () => {
const out = path.join(tmp, 'cli-render');
const result = run('render', '--host', 'claude', '--modules', 'full,-skill:design-shotgun', '--out-dir', out);