From c050f183713fa8b5e64f5c643c8f46fc586a5506 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 20:59:44 +0000 Subject: [PATCH] C1: every env-var host fence resolves its own gstack root Codex and the other env-var hosts run each block in a fresh shell, so GSTACK_ROOT/BIN, B and D set by the preamble were empty in later blocks (/bin/gstack-slug, B=/browse). scripts/resolvers/runtime-root.ts now owns root resolution: a valid exported GSTACK_ROOT (bin/ and lib/), else the per-install literal root (no git call), else repo-local, else the global root (CODEX_HOME on Codex); no root prints the setup fix line and exits. One post-render pass inserts it (<=400 bytes) into every fence that uses these variables without assigning them; B/D come from the same helper as BROWSE and DESIGN SETUP. Claude output is byte-identical. Migrated pins of the old preamble lines (gen-skill-docs.test.ts) and a fixture whose exported GSTACK_ROOT lacked lib/ (skill-positional-literals). --- scripts/gen-skill-docs.ts | 5 +- scripts/resolvers/browse.ts | 11 +- scripts/resolvers/composition.ts | 4 +- scripts/resolvers/design.ts | 11 +- scripts/resolvers/make-pdf.ts | 2 +- .../preamble/generate-preamble-bash.ts | 11 +- scripts/resolvers/runtime-root.ts | 110 +++++++++++ test/env-host-fence-conformance.test.ts | 185 ++++++++++++++++++ test/gen-skill-docs.test.ts | 6 +- test/skill-positional-literals.test.ts | 3 +- 10 files changed, 315 insertions(+), 33 deletions(-) create mode 100644 scripts/resolvers/runtime-root.ts create mode 100644 test/env-host-fence-conformance.test.ts diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index ecb8a4d2e..772ddab2d 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -22,6 +22,7 @@ import type { Host, TemplateContext } from './resolvers/types'; import { HOST_PATHS } from './resolvers/types'; import { RESOLVERS } from './resolvers/index'; import { usesLazySections } from './resolvers/sections'; +import { insertRuntimePreludes } from './resolvers/runtime-root'; import { ALL_HOST_NAMES, resolveHostArg, getHostConfig } from '../hosts/index'; import type { HostConfig } from './host-config'; @@ -839,7 +840,7 @@ function processTemplate(tmplPath: string, host: Host, options: RenderOptions): // --out-dir: repoint section-base paths to the out-dir (no-op otherwise). if (host === 'claude') content = rewriteSectionBase(content, options.contentLinkRoot); - content = rewriteInstallRoot(content, currentHostConfig, options.installRoot); + content = rewriteInstallRoot(insertRuntimePreludes(content, ctx), currentHostConfig, options.installRoot); return { outputPath, content, symlinkLoop, metadata }; } @@ -885,7 +886,7 @@ function processSectionTemplate( // repoint those to the out-dir too (no-op when --out-dir is unset). content = rewriteSectionBase(content, options.contentLinkRoot); } - content = rewriteInstallRoot(content, hostConfig, options.installRoot); + content = rewriteInstallRoot(insertRuntimePreludes(content, ctx), hostConfig, options.installRoot); // Plain generated header (no frontmatter to insert after). content = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(sectionTmplPath)) + content; diff --git a/scripts/resolvers/browse.ts b/scripts/resolvers/browse.ts index 7c7dcc0bd..45d13fd09 100644 --- a/scripts/resolvers/browse.ts +++ b/scripts/resolvers/browse.ts @@ -1,4 +1,5 @@ import { type TemplateContext, toShellPath } from './types'; +import { binaryAssignment } from './runtime-root'; import { COMMAND_DESCRIPTIONS } from '../../browse/src/commands'; import { SNAPSHOT_FLAGS } from '../../browse/src/snapshot-flags'; @@ -118,10 +119,7 @@ export function generateBrowseSetup(ctx: TemplateContext): string { return `## SETUP (run this check BEFORE any browse command) \`\`\`bash -_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -B="" -[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" ] && B="$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" -[ -z "$B" ] && B="${toShellPath(ctx.paths.browseDir)}/browse" +${binaryAssignment(ctx, 'browse')} if [ -x "$B" ]; then echo "READY: $B" else @@ -178,10 +176,7 @@ export function generateBrowseFallback(ctx: TemplateContext): string { const setup = `### Find the \`$B\` binary \`\`\`bash -_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -B="" -[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" ] && B="$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" -[ -z "$B" ] && B="${toShellPath(ctx.paths.browseDir)}/browse" +${binaryAssignment(ctx, 'browse')} [ -x "$B" ] && echo "READY: $B" || echo "NEEDS_SETUP" \`\`\` diff --git a/scripts/resolvers/composition.ts b/scripts/resolvers/composition.ts index e8b0ef876..bddb9f822 100644 --- a/scripts/resolvers/composition.ts +++ b/scripts/resolvers/composition.ts @@ -1,7 +1,7 @@ import { toShellPath, type TemplateContext } from './types'; -import { outsideVoiceRuntime } from './outside-voice'; import * as path from 'path'; import { getHostConfig } from '../../hosts'; +import { runtimeRootPrelude } from './runtime-root'; /** Claude's scoped hook enforces the parent publication boundary during /autoplan. */ export function generateAutoplanPublicationHook(ctx: TemplateContext, args?: string[]): string { @@ -111,7 +111,7 @@ export function generateAutoplanReviewFile(ctx: TemplateContext, args?: string[] /** Resolve once to a literal path; later phase commands run in fresh shells. */ export function generateAutoplanSnapshotTool(ctx: TemplateContext): string { return `\`\`\`bash -${outsideVoiceRuntime(ctx)} +${runtimeRootPrelude(ctx)} bun -e 'console.log(require("fs").realpathSync(process.argv[1]))' "${toShellPath(ctx.paths.binDir)}/gstack-autoplan-snapshot.ts" \`\`\``; } diff --git a/scripts/resolvers/design.ts b/scripts/resolvers/design.ts index 075aa3d82..e216e2e2b 100644 --- a/scripts/resolvers/design.ts +++ b/scripts/resolvers/design.ts @@ -1,5 +1,6 @@ import { outsideVoiceFailurePolicy, outsideVoiceFor, outsideVoiceInvocation, outsideVoicePreflight, outsideVoiceProvenance } from './outside-voice'; import { type TemplateContext, toShellPath } from './types'; +import { binaryAssignment } from './runtime-root'; import { AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS, CC_BACKGROUND_DEFAULT_SINCE } from './constants'; import { OVERUSED_FONTS_DISPLAY, BANNED_FONTS, FONTS_BODY_UI_OK, FONTS_MONO_OK, FONTS_VERIFIED_FREE, HANDOFF_COMMANDS, selectCatalog, catalogEntries, renderCatalog, detectorSlopEntries, judgmentTellEntries } from '../../lib/design-catalog'; import { SENTINEL, DETECT_EXIT_ECHO, DETECT_LIMITS } from '../../lib/design-detect-contract'; @@ -1118,10 +1119,7 @@ export function generateDesignSetup(ctx: TemplateContext): string { return `## DESIGN SETUP (run this check BEFORE any design mockup command) \`\`\`bash -_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -D="" -[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" -[ -z "$D" ] && D="${toShellPath(ctx.paths.designDir)}/design" +${binaryAssignment(ctx, 'design')} if [ -x "$D" ]; then echo "DESIGN_READY: $D" else @@ -1160,10 +1158,7 @@ export function generateDesignMockup(ctx: TemplateContext): string { return `## Visual Design Exploration \`\`\`bash -_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -D="" -[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" -[ -z "$D" ] && D="${toShellPath(ctx.paths.designDir)}/design" +${binaryAssignment(ctx, 'design')} [ -x "$D" ] && echo "DESIGN_READY" || echo "DESIGN_NOT_AVAILABLE" \`\`\` diff --git a/scripts/resolvers/make-pdf.ts b/scripts/resolvers/make-pdf.ts index 9520a9838..55729b0cc 100644 --- a/scripts/resolvers/make-pdf.ts +++ b/scripts/resolvers/make-pdf.ts @@ -19,7 +19,7 @@ _ROOT=$(git rev-parse --show-toplevel 2>/dev/null) P="" [ -n "$MAKE_PDF_BIN" ] && [ -x "$MAKE_PDF_BIN" ] && P="$MAKE_PDF_BIN" [ -z "$P" ] && [ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/make-pdf/dist/pdf" ] && P="$_ROOT/${ctx.paths.localSkillRoot}/make-pdf/dist/pdf" -[ -z "$P" ] && P="${toShellPath(ctx.paths.makePdfDir)}/pdf" +[ -z "$P" ] && P="${toShellPath(ctx.paths.skillRoot)}/make-pdf/dist/pdf" if [ -x "$P" ]; then echo "MAKE_PDF_READY: $P" alias _p_="$P" # shellcheck alias helper (not exported) diff --git a/scripts/resolvers/preamble/generate-preamble-bash.ts b/scripts/resolvers/preamble/generate-preamble-bash.ts index 7437ffaa9..f31997570 100644 --- a/scripts/resolvers/preamble/generate-preamble-bash.ts +++ b/scripts/resolvers/preamble/generate-preamble-bash.ts @@ -1,5 +1,6 @@ import type { TemplateContext } from '../types'; import { getHostConfig } from '../../../hosts/index'; +import { runtimeRootPrelude } from '../runtime-root'; /** * Preamble bootstrap (token-reduction Phase 1). @@ -24,15 +25,7 @@ import { getHostConfig } from '../../../hosts/index'; */ export function generatePreambleBash(ctx: TemplateContext): string { const hostConfig = getHostConfig(ctx.host); - const runtimeRoot = hostConfig.usesEnvVars - ? `_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -GSTACK_ROOT="$HOME/${hostConfig.globalRoot}" -[ -n "$_ROOT" ] && [ -d "$_ROOT/${ctx.paths.localSkillRoot}" ] && GSTACK_ROOT="$_ROOT/${ctx.paths.localSkillRoot}" -GSTACK_BIN="$GSTACK_ROOT/bin" -GSTACK_BROWSE="$GSTACK_ROOT/browse/dist" -GSTACK_DESIGN="$GSTACK_ROOT/design/dist" -` - : ''; + const runtimeRoot = hostConfig.usesEnvVars ? `${runtimeRootPrelude(ctx)}\n` : ''; const brainHealthFlag = ctx.host === 'gbrain' || ctx.host === 'hermes' ? ' --brain-health' : ''; const startCommand = hostConfig.usesEnvVars ? `"${ctx.paths.binDir}/gstack-skill-start"` diff --git a/scripts/resolvers/runtime-root.ts b/scripts/resolvers/runtime-root.ts new file mode 100644 index 000000000..e9e6a507e --- /dev/null +++ b/scripts/resolvers/runtime-root.ts @@ -0,0 +1,110 @@ +/** + * Runtime root for env-var hosts (C1, #1159): the one owner of how generated + * bash finds gstack on Codex and every other `usesEnvVars` host. + * + * Those hosts run each fenced block in a fresh shell, so `GSTACK_ROOT`, `B` + * and `D` set by an earlier block are gone. The preamble's root resolution and + * the per-fence prelude come from `runtimeRootPrelude()`; `insertRuntimePreludes()` + * is the single post-render pass (gen-skill-docs, before rewriteInstallRoot) + * that puts it into every fence that uses these variables without assigning + * them. Claude output is returned unchanged. + * + * Precedence: an exported `GSTACK_ROOT` that contains `bin/` and `lib/`; then, + * on a per-install render, the literal install root (no git call); otherwise the + * repo-local install, then the host's global root (`CODEX_HOME` on Codex). No + * root prints one line naming what was tried and the setup command, and exits. + */ +import { getHostConfig } from '../../hosts/index'; +import { toShellPath, type TemplateContext } from './types'; + +/** Per-fence byte budget for the prelude (T-ENG1), asserted by the INV-3 test. */ +export const PRELUDE_BYTE_BUDGET = 400; + +const ROOT_VARS = /\$\{?GSTACK_(?:ROOT|BIN|BROWSE|DESIGN|MAKE_PDF)\b/; +const assigns = (name: string) => new RegExp(`(?:^|[\\s;&|(])${name}=`, 'm'); +const uses = (name: string) => new RegExp(`\\$\\{?${name}\\b`); + +/** + * Root resolution only (sets `GSTACK_ROOT`). Kept compact: it repeats in every + * fence that needs it, under PRELUDE_BYTE_BUDGET together with its derived vars. + */ +function resolveRoot(ctx: TemplateContext): string { + const host = getHostConfig(ctx.host); + const fix = `Fix: ./setup --host ${host.name} from your gstack checkout; ./setup --status shows it.`; + const exported = '[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||'; + if (ctx.installRoot) { + return `${exported}{ GSTACK_ROOT="${ctx.installRoot.replace(/\/+$/, '')}";[ -d "$GSTACK_ROOT/bin" ]||{ echo "gstack: no install found (tried $GSTACK_ROOT). ${fix}">&2;exit 1;};}`; + } + const global = host.name === 'codex' ? '${CODEX_HOME:-~/.codex}/skills/gstack' : `~/${host.globalRoot}`; + return `${exported}{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/${host.localSkillRoot};[ -d "$_r/bin" ]||_r=${global};[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). ${fix}">&2;exit 1;};GSTACK_ROOT=$_r;}`; +} + +const DERIVED: Record = { BIN: 'bin', BROWSE: 'browse/dist', DESIGN: 'design/dist', MAKE_PDF: 'make-pdf/dist' }; + +/** The preamble's root resolution: GSTACK_ROOT plus GSTACK_BIN. */ +export function runtimeRootPrelude(ctx: TemplateContext): string { + if (!getHostConfig(ctx.host).usesEnvVars) return ''; + return `${resolveRoot(ctx)}\nGSTACK_BIN=$GSTACK_ROOT/bin`; +} + +/** + * Lines that assign `B` (browse) or `D` (design). BROWSE SETUP, DESIGN SETUP and + * the prelude all use this, so a later fence runs the binary setup checked. + * Env-var hosts derive from `GSTACK_ROOT`, which the prelude already points at + * the repo-local install when one exists; other hosts keep their + * repo-local-first probe. + */ +export function binaryAssignment(ctx: TemplateContext, tool: 'browse' | 'design'): string { + const v = tool === 'browse' ? 'B' : 'D'; + const dir = tool === 'browse' ? ctx.paths.browseDir : ctx.paths.designDir; + if (getHostConfig(ctx.host).usesEnvVars) return `${v}=$GSTACK_ROOT/${tool}/dist/${tool}`; + return `_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +${v}="" +[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/${tool}/dist/${tool}" ] && ${v}="$_ROOT/${ctx.paths.localSkillRoot}/${tool}/dist/${tool}" +[ -z "$${v}" ] && ${v}="${toShellPath(dir)}/${tool}"`; +} + +/** The prelude one fence needs, or '' when it needs none. */ +export function fencePrelude(ctx: TemplateContext, body: string): string { + if (!getHostConfig(ctx.host).usesEnvVars) return ''; + const needB = uses('B').test(body) && !assigns('B').test(body); + const needD = uses('D').test(body) && !assigns('D').test(body); + if (assigns('GSTACK_ROOT').test(body)) return ''; + const needRoot = needB || needD || ROOT_VARS.test(body); + const derived = Object.entries(DERIVED) + .filter(([name]) => uses(`GSTACK_${name}`).test(body) && !assigns(`GSTACK_${name}`).test(body)) + .map(([name, dir]) => `GSTACK_${name}=$GSTACK_ROOT/${dir}`); + return [ + needRoot ? resolveRoot(ctx) : '', + derived.join(' '), + needB ? binaryAssignment(ctx, 'browse') : '', + needD ? binaryAssignment(ctx, 'design') : '', + ].filter(Boolean).join('\n'); +} + +/** + * Post-render pass: insert the prelude at the top of every top-level ```bash + * fence that needs it. Fences nested inside a longer fence are examples, not + * commands, and are left alone; an indented fence gets indented prelude lines. + */ +export function insertRuntimePreludes(content: string, ctx: TemplateContext): string { + if (!getHostConfig(ctx.host).usesEnvVars) return content; + const lines = content.split('\n'); + const out: string[] = []; + for (let i = 0; i < lines.length; i++) { + const open = lines[i].match(/^(\s*)(`{3,})(.*)$/); + out.push(lines[i]); + if (!open) continue; + let end = i + 1; + while (end < lines.length && !new RegExp(`^\\s*\`{${open[2].length},}\\s*$`).test(lines[end])) end++; + const body = lines.slice(i + 1, end); + if (open[2].length === 3 && open[3].trim() === 'bash') { + const prelude = fencePrelude(ctx, body.join('\n')); + if (prelude) out.push(...prelude.split('\n').map(line => open[1] + line)); + } + out.push(...body); + if (end < lines.length) out.push(lines[end]); + i = end; + } + return out.join('\n'); +} diff --git a/test/env-host-fence-conformance.test.ts b/test/env-host-fence-conformance.test.ts new file mode 100644 index 000000000..93b156f62 --- /dev/null +++ b/test/env-host-fence-conformance.test.ts @@ -0,0 +1,185 @@ +/** + * INV-3 render-level conformance for env-var hosts (C1, #1159). + * + * Codex and every `usesEnvVars` host run each fenced bash block in a fresh + * shell, so a block that uses `$GSTACK_*`, `$B` or `$D` must resolve them + * itself. Renders every host once into a temp dir and checks every fence of + * every SKILL.md and section file; executes the shared prelude under `env -i` + * and `set -u` against temp install layouts. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ALL_HOST_CONFIGS } from '../hosts'; +import { runGeneration } from '../scripts/gen-skill-docs'; +import { binaryAssignment, fencePrelude, insertRuntimePreludes, PRELUDE_BYTE_BUDGET, runtimeRootPrelude } from '../scripts/resolvers/runtime-root'; +import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types'; + +const ENV_HOSTS = ALL_HOST_CONFIGS.filter(h => h.usesEnvVars); +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-env-fences-')); +const renderDir = path.join(tmp, 'render'); + +interface Fence { file: string; line: number; body: string } + +/** Top-level ```bash fences (CommonMark: a longer outer fence hides inner ones). */ +function bashFences(text: string, file = ''): Fence[] { + const lines = text.split('\n'); + const out: Fence[] = []; + for (let i = 0; i < lines.length; i++) { + const open = lines[i].match(/^(\s*)(`{3,})(.*)$/); + if (!open) continue; + let end = i + 1; + const close = new RegExp(`^\\s*\`{${open[2].length},}\\s*$`); + while (end < lines.length && !close.test(lines[end])) end++; + if (open[2].length === 3 && open[3].trim() === 'bash') { + out.push({ file, line: i + 1, body: lines.slice(i + 1, end).map(l => l.startsWith(open[1]) ? l.slice(open[1].length) : l).join('\n') }); + } + i = end; + } + return out; +} + +function renderedDocs(root: string, hostSubdir: string | null): string[] { + const base = hostSubdir ? path.join(root, hostSubdir, 'skills') : root; + const out: string[] = []; + const walk = (dir: string, depth: number) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) { + if (!hostSubdir && depth === 0 && (e.name.startsWith('.') || e.name === 'node_modules')) continue; + walk(p, depth + 1); + } else if (e.name === 'SKILL.md' || (e.name.endsWith('.md') && path.basename(dir) === 'sections')) out.push(p); + } + }; + walk(base, 0); + return out; +} + +const ctx = (host: string, installRoot: string | null = null): TemplateContext => ({ host, skillName: 'review', tmplPath: '', paths: HOST_PATHS[host], installRoot }); + +function sh(script: string, cwd: string, env: Record) { + return spawnSync('bash', ['-uc', script], { cwd, encoding: 'utf8', timeout: 10_000, env: { PATH: process.env.PATH ?? '/usr/bin:/bin', ...env } }); +} +function mkroot(dir: string) { + for (const sub of ['bin', 'lib', 'browse/dist', 'design/dist']) fs.mkdirSync(path.join(dir, sub), { recursive: true }); + return dir; +} + +beforeAll(async () => { + const result = await runGeneration({ host: 'all', outputRoot: renderDir }); + if (result.exitCode !== 0) throw new Error(result.diagnostics.filter(d => d.kind === 'error').map(d => d.message).join('\n')); +}); +afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); + +describe('C1: every env-var host fence resolves its own runtime paths', () => { + test('each fence that uses $GSTACK_*, $B or $D assigns them in the same fence, within the byte budget', () => { + const problems: string[] = []; + let checked = 0; + for (const host of ENV_HOSTS) { + for (const file of renderedDocs(renderDir, host.hostSubdir)) { + for (const fence of bashFences(fs.readFileSync(file, 'utf8'), path.relative(renderDir, file))) { + const usesRoot = /\$\{?GSTACK_(?:ROOT|BIN|BROWSE|DESIGN|MAKE_PDF)\b/.test(fence.body); + const usesB = /\$\{?B\b/.test(fence.body); + const usesD = /\$\{?D\b/.test(fence.body); + if (!usesRoot && !usesB && !usesD) continue; + checked++; + const assigned = (name: string) => new RegExp(`(?:^|[\\s;&|(])${name}=`, 'm').test(fence.body); + if (usesRoot && !assigned('GSTACK_ROOT')) problems.push(`${fence.file}:${fence.line} uses GSTACK_* without resolving GSTACK_ROOT`); + if (usesB && !assigned('B')) problems.push(`${fence.file}:${fence.line} uses $B without deriving it`); + if (usesD && !assigned('D')) problems.push(`${fence.file}:${fence.line} uses $D without deriving it`); + if (fence.body.includes('gstack: no install found')) { + const prelude = fence.body.split('\n').filter(l => /^\[ -d "\$\{GSTACK_ROOT:-\/-\}\/bin" \]|^(?:GSTACK_(?:BIN|BROWSE|DESIGN|MAKE_PDF)=\$GSTACK_ROOT\/\S+ ?)+$|^[BD]=\$GSTACK_ROOT\//.test(l)); + const bytes = Buffer.byteLength(prelude.join('\n') + '\n'); + if (bytes > PRELUDE_BYTE_BUDGET) problems.push(`${fence.file}:${fence.line} prelude is ${bytes} bytes (budget ${PRELUDE_BYTE_BUDGET})`); + if (fence.body.split('gstack: no install found').length !== 2) problems.push(`${fence.file}:${fence.line} carries the prelude more than once`); + } + } + } + } + expect(checked).toBeGreaterThan(1000); + expect(problems.slice(0, 40)).toEqual([]); + }); + + test('Claude output is untouched by the pass', () => { + const claude = renderedDocs(renderDir, null); + expect(claude.length).toBeGreaterThan(50); + for (const file of claude) { + const text = fs.readFileSync(file, 'utf8'); + expect(insertRuntimePreludes(text, ctx('claude'))).toBe(text); + expect(text).not.toContain('gstack: no install found'); + } + expect(runtimeRootPrelude(ctx('claude'))).toBe(''); + }); + + test('a fence that already assigns GSTACK_ROOT gets no prelude; a $B-only fence derives B from the BROWSE SETUP helper', () => { + expect(fencePrelude(ctx('codex'), 'GSTACK_ROOT=/x\n"$GSTACK_BIN/gstack-slug"')).toBe(''); + const b = fencePrelude(ctx('codex'), '$B goto https://example.com'); + expect(b.split('\n').at(-1)).toBe(binaryAssignment(ctx('codex'), 'browse')); + expect(b).toContain('gstack: no install found'); + expect(fencePrelude(ctx('codex'), 'echo plain')).toBe(''); + expect(fencePrelude(ctx('codex'), 'B=x; $B goto y')).toBe(''); + }); +}); + +describe('C1: the prelude resolves the right root in a fresh shell (env -i, set -u)', () => { + const echo = '\necho "ROOT=$GSTACK_ROOT BIN=$GSTACK_BIN"'; + for (const host of ENV_HOSTS) { + test(`${host.name}: exported, repo-local, global and missing roots`, () => { + const w = fs.mkdtempSync(path.join(tmp, `${host.name}-`)); + const home = path.join(w, 'home'); + const prelude = runtimeRootPrelude(ctx(host.name)); + const global = mkroot(path.join(home, host.name === 'codex' ? '.codex/skills/gstack' : host.globalRoot)); + const repo = path.join(w, 'repo'); + fs.mkdirSync(repo); + expect(spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 }).status).toBe(0); + const outside = fs.mkdtempSync(path.join(w, 'outside-')); + + let r = sh(prelude + echo, outside, { HOME: home }); + expect(r.stdout.trim(), r.stderr).toBe(`ROOT=${global} BIN=${global}/bin`); + + const local = mkroot(path.join(repo, host.localSkillRoot)); + r = sh(prelude + echo, repo, { HOME: home }); + expect(r.stdout.trim(), r.stderr).toBe(`ROOT=${local} BIN=${local}/bin`); + + const exported = mkroot(path.join(w, 'exported')); + r = sh(prelude + echo, repo, { HOME: home, GSTACK_ROOT: exported }); + expect(r.stdout.trim(), r.stderr).toBe(`ROOT=${exported} BIN=${exported}/bin`); + + const noLib = path.join(w, 'nolib'); + fs.mkdirSync(path.join(noLib, 'bin'), { recursive: true }); + r = sh(prelude + echo, repo, { HOME: home, GSTACK_ROOT: noLib }); + expect(r.stdout.trim(), r.stderr).toBe(`ROOT=${local} BIN=${local}/bin`); + + if (host.name === 'codex') { + const codexHome = mkroot(path.join(w, 'codex-home', 'skills', 'gstack')); + r = sh(prelude + echo, outside, { HOME: home, CODEX_HOME: path.join(w, 'codex-home') }); + expect(r.stdout.trim(), r.stderr).toBe(`ROOT=${codexHome} BIN=${codexHome}/bin`); + } + + r = sh(prelude + echo, outside, { HOME: path.join(w, 'empty-home') }); + expect(r.status).not.toBe(0); + expect(r.stdout).not.toContain('ROOT='); + expect(r.stderr).toContain('gstack: no install found (tried '); + expect(r.stderr).toContain(`Fix: ./setup --host ${host.name} from your gstack checkout; ./setup --status shows it.`); + }); + } + + test('a per-install render uses the literal root with no git call', () => { + const w = fs.mkdtempSync(path.join(tmp, 'literal-')); + const root = mkroot(path.join(w, 'install')); + const stub = path.join(w, 'stub'); + fs.mkdirSync(stub); + fs.writeFileSync(path.join(stub, 'git'), `#!/bin/sh\necho called >> "${path.join(w, 'git.log')}"\n`, { mode: 0o755 }); + const prelude = runtimeRootPrelude(ctx('codex', root)); + expect(prelude).not.toContain('git '); + const r = sh(prelude + echo, w, { HOME: path.join(w, 'home'), PATH: `${stub}:/usr/bin:/bin` }); + expect(r.stdout.trim(), r.stderr).toBe(`ROOT=${root} BIN=${root}/bin`); + expect(fs.existsSync(path.join(w, 'git.log'))).toBe(false); + fs.rmSync(path.join(root, 'bin'), { recursive: true }); + const missing = sh(prelude + echo, w, { HOME: path.join(w, 'home'), PATH: `${stub}:/usr/bin:/bin` }); + expect(missing.status).not.toBe(0); + expect(missing.stderr).toContain(`gstack: no install found (tried ${root})`); + }); +}); diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 8a626db9e..dc3f6699e 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -2917,7 +2917,9 @@ describe('Codex generation (--host codex)', () => { // Check a skill that has a preamble (review is a good candidate) const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8'); expect(content).toContain('GSTACK_ROOT'); - expect(content).toContain('$_ROOT/.agents/skills/gstack'); + // C1: one shared root resolution — repo-local first, then CODEX_HOME's global root. + expect(content).toContain('_r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack'); + expect(content).toContain('_r=${CODEX_HOME:-~/.codex}/skills/gstack'); // Phase 1/2: config reads moved into gstack-skill-start — the fence itself // is the bin asset the preamble must resolve through $GSTACK_BIN, and the // question-preference runtime call still resolves the same way. @@ -3233,7 +3235,7 @@ describe('Factory generation (--host factory)', () => { test('Factory preamble uses .factory paths', () => { const content = fs.readFileSync(path.join(FACTORY_DIR, 'gstack-review', 'SKILL.md'), 'utf-8'); expect(content).toContain('GSTACK_ROOT'); - expect(content).toContain('$_ROOT/.factory/skills/gstack'); + expect(content).toContain('_r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack'); expect(content).toContain('$GSTACK_BIN/gstack-config'); }); }); diff --git a/test/skill-positional-literals.test.ts b/test/skill-positional-literals.test.ts index 4349b781b..e8280a397 100644 --- a/test/skill-positional-literals.test.ts +++ b/test/skill-positional-literals.test.ts @@ -93,10 +93,11 @@ for (const host of ['claude', 'codex'] as const) for (const args of [[], tenArgu test(`${label}: upgrade snooze advances the same-version level`, () => { mkdirSync(join(root, '.gstack'), { recursive: true }); writeFileSync(join(root, '.gstack/update-snoozed'), '{new} 1 0\n'); - const block = skill(host, 'gstack-upgrade').match(/```bash\n((?:GSTACK_STATE_ROOT=\$\([^\n]*gstack-paths --get GSTACK_STATE_ROOT[^\n]*\n)?_SNOOZE_FILE=[\s\S]*?)```/)![1]; + const block = skill(host, 'gstack-upgrade').match(/```bash\n((?:\[ -d "\$\{GSTACK_ROOT[^\n]*\n(?:GSTACK_BIN=[^\n]*\n)?)?(?:GSTACK_STATE_ROOT=\$\([^\n]*gstack-paths --get GSTACK_STATE_ROOT[^\n]*\n)?_SNOOZE_FILE=[\s\S]*?)```/)![1]; // The block resolves the state root through the installed helper first. const bin = join(root, '.claude/skills/gstack/bin'); mkdirSync(bin, { recursive: true }); + mkdirSync(join(dirname(bin), 'lib'), { recursive: true }); // C1: an exported GSTACK_ROOT needs bin/ and lib/ for (const name of ['gstack-paths', 'gstack-state-root.sh']) writeFileSync(join(bin, name), readFileSync(join(import.meta.dir, '../bin', name)), { mode: 0o755 }); expect(run(apply(block), { GSTACK_STATE_ROOT: join(root, '.gstack'), GSTACK_BIN: bin, GSTACK_ROOT: dirname(bin) }).status).toBe(0); expect(readFileSync(join(root, '.gstack/update-snoozed'), 'utf8')).toMatch(/^\{new\} 2 \d+\n$/);