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) <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-05-27 07:31:37 -07:00
parent b5aad4aa6d
commit 065a8b14b7
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
4 changed files with 165 additions and 3 deletions

View File

@ -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)"

View File

@ -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"

View File

@ -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"

View File

@ -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<string, string | undefined>,
): { stdout: string; stderr: string; status: number } {
const cleaned: Record<string, string> = {};
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);
});
});