Skip to content

fix(ci): cross-platform Windows CI and path handling#810

Open
Tibsfox wants to merge 5 commits intogsd-build:mainfrom
Tibsfox:fix/cross-platform-windows-ci
Open

fix(ci): cross-platform Windows CI and path handling#810
Tibsfox wants to merge 5 commits intogsd-build:mainfrom
Tibsfox:fix/cross-platform-windows-ci

Conversation

@Tibsfox
Copy link
Contributor

@Tibsfox Tibsfox commented Feb 28, 2026

Summary

GSD tools fail on Windows in three distinct ways: (1) npm test and npm run test:coverage silently find zero test files because PowerShell/cmd do not expand shell globs like tests/*.test.cjs, (2) path.join() produces backslash-separated paths (.planning\phases\01-setup) in JSON output that downstream consumers (Claude Code agents, CLAUDE.md file-read instructions) cannot resolve on Windows, and (3) test assertions that pass JSON strings containing $ or ["a","b"] through execSync shell interpolation get corrupted on Windows (and can fail on any platform depending on shell state).

Root Cause

1. Shell glob expansion in npm scripts (CI failure)

package.json used a bare shell glob:

"test": "node --test tests/*.test.cjs"

On Linux/macOS, bash expands tests/*.test.cjs before invoking node. On Windows, PowerShell and cmd pass the literal string tests/*.test.cjs to Node, which does not recognize it as a glob — resulting in zero test files found and a silent pass.

2. path.join() producing backslash paths in output (runtime bug)

Multiple functions in core.cjs and init.cjs used path.join() to construct paths returned in JSON output:

// core.cjs — searchPhaseInDir
directory: path.join(relBase, match),

// init.cjs — cmdInitTodos
path: path.join('.planning', 'todos', 'pending', file),

// init.cjs — cmdInitPlanPhase, cmdInitPhaseOp
result.context_path = path.join(phaseInfo.directory, contextFile);

On Windows, path.join('.planning', 'phases', '01-setup') returns .planning\phases\01-setup. Consumers that read these paths (Claude Code agents, downstream commands) expect forward slashes and fail to locate files.

3. Shell interpolation of $ and JSON in test commands (test reliability)

Tests passed arguments containing dollar signs and JSON through execSync with shell interpolation:

runGsdTools("state add-decision --summary 'Benchmark prices moved from $0.50 to $2.00'", tmpDir);
runGsdTools(`frontmatter set ${file} --field tags --value '["a","b"]'`);

Shells interpret $0, $2 as positional parameters (expanding to empty strings or the shell name). On Windows, single quotes are not special — the JSON brackets and quotes get mangled. Both cases silently corrupt the argument before it reaches the CLI.

Fix

Commit 1 (ea0e712): Add shell: bash to both test steps in .github/workflows/test.yml. This ensures GitHub Actions uses bash on Windows runners, providing reliable glob expansion as a quick mitigation.

Commit 2 (edaa673): Create scripts/run-tests.cjs — a cross-platform test runner that resolves test files via fs.readdirSync() instead of shell globs. Update package.json to use node scripts/run-tests.cjs for both test and test:coverage scripts. This eliminates the dependency on shell glob expansion entirely.

Commit 3 (4c91f74): Propagate NODE_V8_COVERAGE environment variable in the test runner by spreading process.env into the child process. Without this, c8 cannot collect coverage data from the subprocess.

Commit 4 (13a8b0e): Three sub-fixes in one commit:

  • Path separators: Add toPosixPath() helper to core.cjs that normalizes path.sep to /. Apply it to all 10 path-construction sites in core.cjs (searchPhaseInDir, findPhaseInternal) and init.cjs (cmdInitPlanPhase, cmdInitPhaseOp, cmdInitTodos, cmdInitProgress). For paths that are purely string literals with no OS-dependent segments, use direct string concatenation ('.planning/phases/' + dir) instead of path.join().

  • JSON quoting: Convert 3 test calls in frontmatter-cli.test.cjs and 2 in state.test.cjs from string-based runGsdTools() to array-based form, bypassing shell interpolation entirely.

  • Test helper: Extend runGsdTools() in tests/helpers.cjs to accept string[] in addition to string. When an array is passed, it uses execFileSync (no shell) instead of execSync (shell), preventing all shell-interpolation issues.

Commit 5 (c58d9fe): Update the cmdInitTodos test assertion from path.join('.planning', 'todos', 'pending', 'task-1.md') to the literal '.planning/todos/pending/task-1.md', matching the new forward-slash output.

Edge cases handled:

  • toPosixPath() is a no-op on Linux/macOS (separator is already /)
  • The runGsdTools() string overload is preserved for backward compatibility with existing tests that don't need shell-safe arguments
  • env: { ...process.env } in the test runner copies the full environment including NODE_V8_COVERAGE, PATH, and any user-set variables

Relationship to Other PRs

This is PR 3 of 6 from the dev-bugfix branch, split for independent review:

PR Scope Status
#2 Milestone completion bugs (stats scoping, MILESTONES.md ordering) Open
#3 (this) Cross-platform Windows CI and path handling Open
#4 Feature enhancements (Codex, discuss, agents) Open
#5 Agent frontmatter + heredoc fix Open
#6 CLI/config bug fixes (9 issues) Open
#1 MCP migration helper (targets dev-bugfix) Open

No code overlap between PRs. Each can be merged independently in any order. This PR touches files (core.cjs, init.cjs, helpers.cjs, run-tests.cjs, test.yml, package.json) that no other PR modifies.

Testing

Tests directly exercising the fixes

Test File What it verifies
cmdInitTodos > returns all pending todos tests/init.test.cjs Forward-slash path in todo JSON output
frontmatter set > handles JSON array value tests/frontmatter-cli.test.cjs Array-form runGsdTools bypasses shell JSON mangling
frontmatter merge > merges multiple fields tests/frontmatter-cli.test.cjs Array-form runGsdTools with JSON --data argument
frontmatter merge > overwrites existing fields tests/frontmatter-cli.test.cjs Array-form merge with conflict resolution
state add-decision > dollar signs preserved tests/state.test.cjs $0.50, $2.00, $5.00 survive without shell expansion
state add-blocker > dollar strings preserved tests/state.test.cjs $1.00 survives without shell expansion
searchPhaseInDir (6 tests) tests/commands.test.cjs Forward-slash directory paths from phase search
findPhaseInternal (8 tests) tests/commands.test.cjs Forward-slash paths in current and archived phase lookups
cmdInitProgress (3 tests) tests/init.test.cjs Forward-slash directory in progress JSON
cmdInitPhaseOp fallback (4 tests) tests/init.test.cjs Forward-slash context/research/verification/UAT paths

Full suite results

Node.js test runner (v22, Linux)
────────────────────────────────
Test files:    7 (commands, config, frontmatter-cli, init, phases, state, verify)
Test suites:  79
Tests:       419 passed, 0 failed, 0 skipped
Duration:     10.6s

Coverage (c8):
─────────────────────────────────────────────────────
File             │ Stmts  │ Branch │ Funcs  │ Lines
─────────────────────────────────────────────────────
All files        │ 90.36% │ 81.50% │ 98.03% │ 90.36%
core.cjs         │ 90.95% │ 92.09% │  100%  │ 90.95%
init.cjs         │ 98.59% │ 80.57% │  100%  │ 98.59%
frontmatter.cjs  │ 92.64% │ 82.20% │  100%  │ 92.64%
state.cjs        │ 96.90% │ 82.98% │  100%  │ 96.90%
─────────────────────────────────────────────────────
Coverage threshold: 70% lines ✅

Impact

Scenario Before After
npm test on Windows (PowerShell) Silently passes with 0 tests run — glob not expanded Runs all 419 tests via Node-based file discovery
npm run test:coverage on Windows Same silent pass; coverage reports 0% Full coverage collection via NODE_V8_COVERAGE propagation
Phase lookup JSON on Windows ".planning\\phases\\01-setup" — downstream agents fail to read ".planning/phases/01-setup" — consistent cross-platform paths
Todo path JSON on Windows ".planning\\todos\\pending\\task-1.md" ".planning/todos/pending/task-1.md"
state add-decision with $0.50 Shell expands $0 to shell name, $5 to empty Dollar signs preserved verbatim via execFileSync
frontmatter set --value '["a","b"]' Quotes mangled on Windows; may fail on some Linux shells Passed as array element, no shell interpretation
Linux/macOS behavior No change — toPosixPath() is a no-op when path.sep === '/' No change

9 files changed, 84 insertions(+), 29 deletions(-)

glittercowboy and others added 5 commits February 28, 2026 02:23
Windows PowerShell doesn't expand `tests/*.test.cjs` globs, causing
the test runner to fail with "Could not find" on Windows Node 20.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
npm scripts pass `tests/*.test.cjs` to node/c8 as a literal string on
Windows (PowerShell/cmd don't expand globs). Adding `shell: bash` to CI
steps doesn't help because c8 spawns node as a child process using the
system shell. Use a Node script to enumerate test files cross-platform.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
The run-tests.cjs child process now inherits NODE_V8_COVERAGE from the
parent so c8 collects coverage data. Also restores npm scripts to use
the cross-platform runner for both test and test:coverage commands.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…r signs

- Add toPosixPath() helper to normalize output paths to forward slashes
- Use string concatenation for relative base paths instead of path.join()
- Apply toPosixPath() to all user-facing file paths in init.cjs output
- Use array-based execFileSync in test helpers to bypass shell quoting
  issues with JSON args and dollar signs on Windows cmd.exe

Fixes 7 test failures on Windows: frontmatter set/merge (3), init
path assertions (2), and state dollar-amount corruption (2).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
The source now outputs posix paths; update the test to match instead
of using path.join (which produces backslashes on Windows).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants