fix(#872): make roadmap-phase-fallback tests hermetic against ambient GSD env (#873)

extractCurrentMilestone reads STATE.md via planningDir(cwd), which is
workstream-aware (honours GSD_PROJECT/GSD_WORKSTREAM). The fixtures write
STATE.md to the plain <tmp>/.planning/STATE.md, so a developer shell inside a
GSD workstream (GSD_WORKSTREAM exported) redirected the read to a non-existent
workstream subdir -> version=null -> closed milestone sections leaked into the
slice and assertions failed. Clean CI/Docker env never hit it. Not a Node-26
regex bug; reproduces identically on any Node with GSD_WORKSTREAM set.

- scripts/run-tests.cjs: strip GSD_PROJECT/GSD_WORKSTREAM before spawning test
  children so the local runner env matches clean CI/Docker.
- tests/roadmap-phase-fallback.test.cjs: file-level beforeEach/afterEach
  save/delete/restore of both vars; new regression test pinning workstream-aware
  STATE.md resolution.
- tests/run-tests-harness.test.cjs: guard asserting the runner strips both vars
  (so removing the deletion fails clean CI).

Closes #872

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-08 12:04:26 -04:00
committed by GitHub
parent 35174ce9b0
commit a480510f54
3 changed files with 99 additions and 0 deletions

View File

@@ -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 <tmp>/.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.

View File

@@ -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 <tmp>/.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 <cwd>/.planning/workstreams/<ws>/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

View File

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