diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs index 810438534..133d9c9db 100644 --- a/scripts/run-tests.cjs +++ b/scripts/run-tests.cjs @@ -236,6 +236,14 @@ function main() { // Build the gitignored bin/lib artifact if absent, before any test requires it. ensureBuiltArtifacts(); + // Hermeticity: in-process tests resolve `.planning` via planningDir(cwd), which + // honours GSD_PROJECT/GSD_WORKSTREAM. A developer shell inside a GSD workstream + // exports GSD_WORKSTREAM, which would redirect fixture STATE.md reads away from + // each /.planning and silently diverge from the clean CI/Docker env. Strip + // them so the local runner matches CI; tests that need them set them explicitly. + delete process.env.GSD_PROJECT; + delete process.env.GSD_WORKSTREAM; + // Log selected files to stderr for CI / harness-test visibility. // node:test default reporter doesn't echo filenames, so this gives // operators a single stable line they can grep. diff --git a/tests/roadmap-phase-fallback.test.cjs b/tests/roadmap-phase-fallback.test.cjs index 4587b5dd3..beac818ec 100644 --- a/tests/roadmap-phase-fallback.test.cjs +++ b/tests/roadmap-phase-fallback.test.cjs @@ -11,6 +11,26 @@ const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +// The planning-dir resolver (planningDir) is workstream-aware and honours +// GSD_PROJECT / GSD_WORKSTREAM. These suites write STATE.md to /.planning +// and assume that is where it is read from, so a developer shell inside a GSD +// workstream would otherwise redirect the read and break extractCurrentMilestone. +// Isolate the vars so the file is hermetic when run directly via `node --test`. +let savedGsdProject; +let savedGsdWorkstream; +beforeEach(() => { + savedGsdProject = process.env.GSD_PROJECT; + savedGsdWorkstream = process.env.GSD_WORKSTREAM; + delete process.env.GSD_PROJECT; + delete process.env.GSD_WORKSTREAM; +}); +afterEach(() => { + if (savedGsdProject !== undefined) process.env.GSD_PROJECT = savedGsdProject; + else delete process.env.GSD_PROJECT; + if (savedGsdWorkstream !== undefined) process.env.GSD_WORKSTREAM = savedGsdWorkstream; + else delete process.env.GSD_WORKSTREAM; +}); + /** * Helper: write STATE.md with a milestone version so extractCurrentMilestone * will slice the roadmap to only that milestone's section. @@ -365,6 +385,51 @@ This is the active milestone body. ); }); + test('(7) workstream-aware: STATE.md under GSD_WORKSTREAM is read from the workstream subdir', () => { + // Regression guard for the env-leak that made these suites pass in clean CI but + // fail in a developer's GSD_WORKSTREAM shell. planningDir() is workstream-aware, + // so STATE.md lives at /.planning/workstreams//STATE.md. Setting the env + // here makes clean CI exercise the polluted-env resolution path. + process.env.GSD_WORKSTREAM = 'guard-ws'; + try { + const wsPlanning = path.join(tmpDir, '.planning', 'workstreams', 'guard-ws'); + fs.mkdirSync(wsPlanning, { recursive: true }); + fs.writeFileSync(path.join(wsPlanning, 'STATE.md'), '---\nmilestone: v8.0\n---\n'); + const roadmap = `# Project Roadmap + +## v8.0 Overview — v8.0-F (CLOSED FAIL 2026-05-18) + +This is the closed milestone body with some text. + +### Phase 24: ARCHIVED +**Goal:** This phase is done and archived. + +## v8.0-B Overview (STARTED 2026-05-18) + +This is the active milestone body. + +### Phase 31: EVAL +**Goal:** Evaluate the new system. + +## v9.0 Future Milestone + +### Phase 40: FUTURE +**Goal:** Future work. +`; + const slice = core.extractCurrentMilestone(roadmap, tmpDir); + assert.ok( + slice.includes('Phase 31: EVAL'), + 'workstream-scoped STATE.md must select the active v8.0-B section', + ); + assert.ok( + !slice.includes('Phase 24: ARCHIVED'), + 'closed section must still be excluded under a workstream env', + ); + } finally { + delete process.env.GSD_WORKSTREAM; + } + }); + test('(2) double-closed-skip: third sibling (active) selected when first two are closed', () => { writeState(tmpDir, 'v9.0'); const roadmap = `# Project Roadmap diff --git a/tests/run-tests-harness.test.cjs b/tests/run-tests-harness.test.cjs index c819000de..343ca5fb8 100644 --- a/tests/run-tests-harness.test.cjs +++ b/tests/run-tests-harness.test.cjs @@ -260,6 +260,32 @@ test('boom', () => { throw new Error('intentional'); }); }); }); + describe('env hermeticity', () => { + // Regression guard for the two `delete process.env.GSD_PROJECT/GSD_WORKSTREAM` + // lines added in scripts/run-tests.cjs main() right after ensureBuiltArtifacts(). + // If those deletions are removed, the fixture's assertions fail inside the child + // node:test process → non-zero harness exit → this test fails → CI catches it. + test('harness strips GSD_PROJECT and GSD_WORKSTREAM before running child tests', () => { + // Write a fixture that asserts both vars are absent in the child process env. + const FIXTURE = `'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +test('ambient GSD workstream vars are stripped by the runner', () => { + assert.strictEqual(process.env.GSD_PROJECT, undefined); + assert.strictEqual(process.env.GSD_WORKSTREAM, undefined); +}); +`; + fs.writeFileSync(path.join(tmpDir, 'env-hermeticity.test.cjs'), FIXTURE, 'utf8'); + // Pass both vars in the ambient env given to the harness process. + // The harness must delete them before spawning the child node:test process. + const r = runHarness(tmpDir, [], { + GSD_PROJECT: 'ambient-proj', + GSD_WORKSTREAM: 'ambient-ws', + }); + assert.strictEqual(r.status, 0, r.stderr); + }); + }); + describe('Windows argv-overflow chunking (issue #3597)', () => { // Windows CreateProcess caps lpCommandLine at 32,767 chars. With ~550 // tests the unchunked spawn fails instantly on Windows with no test