From 065a8b14b703bbab7282a5302f64f1bf5926b338 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 27 May 2026 07:31:37 -0700 Subject: [PATCH] feat(bins): honor GSTACK_STATE_ROOT override for test isolation Plan-tune cathedral T1 (per D16 / Codex outside voice). The 3 bins that back /plan-tune (question-log, question-preference, developer-profile) previously ignored GSTACK_STATE_ROOT, so tests that tried to point state at a tempdir via that env var silently wrote to the real ~/.gstack. Make STATE_ROOT take precedence over GSTACK_HOME so the cathedral's E2E + unit tests can isolate cleanly without sledgehammering HOME. Order of precedence: GSTACK_STATE_ROOT > GSTACK_HOME > $HOME/.gstack Matches the existing gstack-paths emission order. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/gstack-developer-profile | 3 +- bin/gstack-question-log | 3 +- bin/gstack-question-preference | 3 +- test/gstack-state-root-override.test.ts | 159 ++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 test/gstack-state-root-override.test.ts diff --git a/bin/gstack-developer-profile b/bin/gstack-developer-profile index 3bd397040..a5721a9c5 100755 --- a/bin/gstack-developer-profile +++ b/bin/gstack-developer-profile @@ -28,7 +28,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16). +GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" PROFILE_FILE="$GSTACK_HOME/developer-profile.json" LEGACY_FILE="$GSTACK_HOME/builder-profile.jsonl" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" diff --git a/bin/gstack-question-log b/bin/gstack-question-log index 4344843ef..c5d664008 100755 --- a/bin/gstack-question-log +++ b/bin/gstack-question-log @@ -28,7 +28,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16). +GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" mkdir -p "$GSTACK_HOME/projects/$SLUG" INPUT="$1" diff --git a/bin/gstack-question-preference b/bin/gstack-question-preference index b8c5665af..eb951ebd3 100755 --- a/bin/gstack-question-preference +++ b/bin/gstack-question-preference @@ -23,7 +23,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16). +GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" SLUG="${SLUG:-unknown}" PREF_FILE="$GSTACK_HOME/projects/$SLUG/question-preferences.json" diff --git a/test/gstack-state-root-override.test.ts b/test/gstack-state-root-override.test.ts new file mode 100644 index 000000000..cc2e672d6 --- /dev/null +++ b/test/gstack-state-root-override.test.ts @@ -0,0 +1,159 @@ +/** + * GSTACK_STATE_ROOT override — verifies the 3 plan-tune bins honor + * GSTACK_STATE_ROOT as a higher-priority override over GSTACK_HOME. + * + * Surfaced by plan-tune cathedral D16 (Codex outside voice): tests can't + * isolate from real ~/.gstack today because the bins ignore STATE_ROOT. + * Without this override, the cathedral's E2E + integration tests would + * silently pollute the user's real profile. + * + * Contract: + * - GSTACK_STATE_ROOT set → bins write under STATE_ROOT (HOME ignored). + * - Only GSTACK_HOME set → bins write under HOME (existing behavior). + * - Neither set → falls back to $HOME/.gstack (existing behavior). + * - Both set → STATE_ROOT wins. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawnSync } from 'child_process'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN_LOG = path.join(ROOT, 'bin', 'gstack-question-log'); +const BIN_PREF = path.join(ROOT, 'bin', 'gstack-question-preference'); +const BIN_DEV = path.join(ROOT, 'bin', 'gstack-developer-profile'); + +let stateRoot: string; +let homeRoot: string; + +beforeEach(() => { + stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-state-')); + homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-home-')); +}); + +afterEach(() => { + fs.rmSync(stateRoot, { recursive: true, force: true }); + fs.rmSync(homeRoot, { recursive: true, force: true }); +}); + +function runBin( + bin: string, + args: string[], + env: Record, +): { stdout: string; stderr: string; status: number } { + const cleaned: Record = {}; + for (const [k, v] of Object.entries({ ...process.env, ...env })) { + if (v !== undefined) cleaned[k] = v; + } + // Strip these from process.env so the override matrix is clean. + if (env.GSTACK_STATE_ROOT === undefined) delete cleaned.GSTACK_STATE_ROOT; + if (env.GSTACK_HOME === undefined) delete cleaned.GSTACK_HOME; + const res = spawnSync(bin, args, { + env: cleaned, + encoding: 'utf-8', + cwd: ROOT, + }); + return { + stdout: res.stdout ?? '', + stderr: res.stderr ?? '', + status: res.status ?? -1, + }; +} + +const SAMPLE_LOG = { + skill: 'plan-tune', + question_id: 'state-root-test', + question_summary: 'Test STATE_ROOT honoring', + category: 'clarification', + door_type: 'two-way', + options_count: 2, + user_choice: 'a', + recommended: 'a', + session_id: 'state-root-test-session', +}; + +describe('gstack-question-log honors GSTACK_STATE_ROOT', () => { + test('STATE_ROOT set, HOME unset → writes under STATE_ROOT', () => { + const r = runBin(BIN_LOG, [JSON.stringify(SAMPLE_LOG)], { + GSTACK_STATE_ROOT: stateRoot, + GSTACK_HOME: undefined, + }); + expect(r.status).toBe(0); + // The slug is derived from cwd; just check at least one log file exists. + const projectDirs = fs.readdirSync(path.join(stateRoot, 'projects')); + expect(projectDirs.length).toBeGreaterThanOrEqual(1); + const logPath = path.join(stateRoot, 'projects', projectDirs[0], 'question-log.jsonl'); + expect(fs.existsSync(logPath)).toBe(true); + }); + + test('STATE_ROOT wins over HOME when both set', () => { + const r = runBin(BIN_LOG, [JSON.stringify(SAMPLE_LOG)], { + GSTACK_STATE_ROOT: stateRoot, + GSTACK_HOME: homeRoot, + }); + expect(r.status).toBe(0); + // STATE_ROOT must have the file. + const stateProjects = fs.readdirSync(path.join(stateRoot, 'projects')); + expect(stateProjects.length).toBeGreaterThanOrEqual(1); + // HOME must NOT have a projects dir (or it must be empty). + const homeProjectsPath = path.join(homeRoot, 'projects'); + if (fs.existsSync(homeProjectsPath)) { + const homeProjects = fs.readdirSync(homeProjectsPath); + expect(homeProjects.length).toBe(0); + } + }); + + test('only HOME set → preserves existing behavior (writes under HOME)', () => { + const r = runBin(BIN_LOG, [JSON.stringify(SAMPLE_LOG)], { + GSTACK_STATE_ROOT: undefined, + GSTACK_HOME: homeRoot, + }); + expect(r.status).toBe(0); + const homeProjects = fs.readdirSync(path.join(homeRoot, 'projects')); + expect(homeProjects.length).toBeGreaterThanOrEqual(1); + // STATE_ROOT must NOT have anything. + const stateProjectsPath = path.join(stateRoot, 'projects'); + if (fs.existsSync(stateProjectsPath)) { + expect(fs.readdirSync(stateProjectsPath).length).toBe(0); + } + }); +}); + +describe('gstack-question-preference honors GSTACK_STATE_ROOT', () => { + test('STATE_ROOT set → preferences file lives under STATE_ROOT', () => { + const write = runBin( + BIN_PREF, + [ + '--write', + JSON.stringify({ + question_id: 'state-root-pref-test', + preference: 'never-ask', + source: 'plan-tune', + }), + ], + { GSTACK_STATE_ROOT: stateRoot, GSTACK_HOME: undefined }, + ); + expect(write.status).toBe(0); + const projectDirs = fs.readdirSync(path.join(stateRoot, 'projects')); + expect(projectDirs.length).toBeGreaterThanOrEqual(1); + const prefPath = path.join(stateRoot, 'projects', projectDirs[0], 'question-preferences.json'); + expect(fs.existsSync(prefPath)).toBe(true); + const prefs = JSON.parse(fs.readFileSync(prefPath, 'utf-8')); + expect(prefs['state-root-pref-test']).toBe('never-ask'); + }); +}); + +describe('gstack-developer-profile honors GSTACK_STATE_ROOT', () => { + test('STATE_ROOT set → profile file lives under STATE_ROOT, not HOME', () => { + // --read creates a stub profile if missing. + const r = runBin(BIN_DEV, ['--read'], { + GSTACK_STATE_ROOT: stateRoot, + GSTACK_HOME: homeRoot, + }); + expect(r.status).toBe(0); + expect(fs.existsSync(path.join(stateRoot, 'developer-profile.json'))).toBe(true); + expect(fs.existsSync(path.join(homeRoot, 'developer-profile.json'))).toBe(false); + }); +});