mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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).
This commit is contained in:
1 parent
b4f1bd9034
commit
c050f18371
10 files changed
+315
-33
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
\`\`\`
|
||||
|
||||
|
||||
@@ -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"
|
||||
\`\`\``;
|
||||
}
|
||||
@@ -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"
|
||||
\`\`\`
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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<string, string> = { 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');
|
||||
}
|
||||
@@ -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<string, string>) {
|
||||
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})`);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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$/);
|
||||
|
||||
Reference in new issue
Block a user