fix(render): section refs point at the FINAL render dir, never the tmp swap dir (#2692)

gen-skill-docs bakes its --out-dir into rendered CONTENT (rewriteSectionBase),
and both swap-in callers (setup, gstack-config gbrain-refresh) render into
claude.tmp.<pid> before the #2569 atomic rename — so every rendered skill
carried ~9 dead section Read paths that pointed at a directory the swap had
just deleted. New --link-root flag names the final serving dir (defaults to
--out-dir for direct-render callers: bin/dev-setup, dev-skill.ts, mkdtemp
tests — full caller audit in the wave notes); the rewrite now uses a
replacement callback so a $-bearing configured path can't expand as $& in a
replacement string. The swap logic itself stays byte-identical. Tests pin the
generator contract (tmp out-dir files reference the final dir, $-bearing
path included) and both callers' wiring.

Fixes #2692

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-31 21:23:53 +00:00
co-authored by Claude Fable 5
parent e2d6cb570f
commit 7ed5e87bba
4 changed files with 65 additions and 4 deletions
+42
View File
@@ -77,6 +77,48 @@ describe('gen-skill-docs --out-dir (B2 render isolation)', () => {
}
});
// #2692: the swap-in callers (setup, gstack-config gbrain-refresh) render
// into claude.tmp.<pid> then RENAME it into place — so section refs must be
// rewritten to the FINAL serving dir (--link-root), never the tmp out-dir,
// or every rendered Read dies the moment the swap completes.
test('--link-root repoints section refs at the FINAL dir, not the tmp out-dir (#2692)', () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-home-'));
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-swap-'));
// Mirror the real caller shape, including a `$`-bearing path segment so a
// replacement-string regression ($& expansion) fails loudly.
const finalDir = path.join(base, 'render$live', 'claude');
const outDir = `${finalDir}.tmp.12345`;
fs.mkdirSync(path.dirname(finalDir), { recursive: true });
try {
fs.writeFileSync(
path.join(tmpHome, 'gbrain-detection.json'),
JSON.stringify({ gbrain_local_status: 'ok', gbrain_version: '9.9.9' }),
);
const res = spawnSync(
'bun',
['run', 'scripts/gen-skill-docs.ts', '--respect-detection', '--host', 'claude',
'--out-dir', outDir, '--link-root', finalDir],
{ cwd: ROOT, encoding: 'utf-8', timeout: 120_000, env: { ...process.env, GSTACK_HOME: tmpHome } },
);
expect(res.status).toBe(0);
const skillContent = fs.readFileSync(path.join(outDir, 'ship', 'SKILL.md'), 'utf-8');
// Files land in the tmp out-dir; their CONTENT references the final dir.
expect(skillContent).toContain(`${finalDir}/ship/sections/`);
expect(skillContent).not.toContain(`${outDir}/ship/sections/`);
expect(skillContent).not.toContain('~/.claude/skills/gstack/ship/sections/');
} finally {
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(base, { recursive: true, force: true });
}
});
test('both swap-in callers pass --link-root with the final render dir (#2692 wiring)', () => {
const setupSrc = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const configSrc = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-config'), 'utf-8');
expect(setupSrc).toContain('--out-dir "$_GSTACK_RENDER_TMP" --link-root "$_GSTACK_RENDER_DIR"');
expect(configSrc).toContain('--out-dir "$RENDER_TMP" --link-root "$RENDER_DIR"');
});
test('retired global extras (proactive-suggestions.json) are not written anywhere', () => {
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-out-'));
try {