Files
msd-core/tests/commands.test.cjs
Enes Yağız 8748e95ed1 fix(#666): pr-branch silently ignored planning.sub_repos (#667)
* fix(pr-branch): handle sub_repos from config with git -C (#666)

Adds a `handle_sub_repos` step between `detect_state` and
`analyze_commits`. When `planning.sub_repos` is set in config, the
workflow now:

- Reads sub-repo paths via `gsd_run query config-get sub_repos`
- Skips the step entirely when the list is empty/null/[]
- Scans each repo with `git -C "$REPO" status --porcelain`
- Offers the user all/select/skip choices
- For selected repos: creates a PR branch, commits all staged/unstaged
  changes, pushes, and opens a companion PR via `gh pr create`

All git commands use `git -C "$REPO"` — never `cd "$REPO"` — because
shell state does not persist between agent-executed commands.

Closes #666

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: update changeset pr number to 667

* fix(pr-branch): address maintainer review — correct seam, behavioral tests, robustness

Resolves all three blockers and seven robustness issues raised in PR #667 review:

Blockers:
- Use `planning.sub_repos` (not top-level `sub_repos`) so config-get actually resolves
- Replace prose grep test with behavioral fixture tests using runGsdTools + local bare repo
- Extract sub-repo git work into new `cmdPrSubrepo` seam in src/commands.cts;
  never uses git add -A — stages explicit files only (universal-anti-patterns.md:44)

Robustness:
- Dirty-repo list persisted via mktemp/cat, not bash arrays (cross-block safe)
- Branch name embeds repo slug (${CURRENT_BRANCH}-${REPO_SAFE}-pr) to avoid collision
- push --set-upstream so gh pr create finds the branch
- Sub-repo base branch resolved via ls-remote with fallback to repo's default branch
- Remote slug parsed with /github\.com[:/]/ (handles SSH + HTTPS + .git-less URLs)
- rollback() cleans up branch on any mid-sequence failure
- node -e replaces jq (always available, no undeclared hard dep)

Refs: #666

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(pr-branch): security guard, push timeout, rollback fix, porcelain fix

Security (Blocker 1):
- Use security.cjs validatePath() in cmdPrSubrepo for symlink-safe workspace
  containment check — rejects ../escape, absolute paths, and symlink traversal
- Add negative regression test: '../escape' repo path must be rejected

Robustness:
- Push uses timeout: 60_000 ms (network op needs more than the 10 s default)
- Capture prevBranchName before checkout -b so rollback uses explicit name
  instead of git checkout - (fails on fresh single-branch repos)
- Porcelain path parse: line.trimStart().slice(2).trim() handles all XY
  combinations and the execGit global-trim edge case uniformly

Tests: 17/17 pass, lint: 0 errors

Refs: #666

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(pr-branch): move regression tests to commands.test.cjs, add core.quotePath=false

- Move cmdPrSubrepo behavioral + workflow source-invariant tests from
  standalone bug-666-*.test.cjs into tests/commands.test.cjs under
  describe('pr-subrepo') per TESTING-SUITES.md policy (no new bug-* files).
  Adds allow-test-rule: source-text-is-the-product see #666 for the
  workflow-source-invariant suite.
- Add -c core.quotePath=false to git status --porcelain call so non-ASCII
  filenames (e.g. café) are not C-escaped, keeping slice(2) parse correct.

* fix(pr-branch): remove obsolete regression tests for sub-repos handling

* fix(pr-branch): update workflow-size-baseline, add dirty-scan timeout

- Regenerate tests/workflow-size-baseline.json for pr-branch.md growth
  (+handle_sub_repos step, +timeout addition).
- Add { timeout: 10_000 } to the execFileSync git status --porcelain
  call in the handle_sub_repos dirty-scan (repo convention: every git
  subprocess is bounded, never hangs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: regenerate INVENTORY-MANIFEST after rebase onto next

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#666): handle rename staging and split changedFiles from filesToStage

For git mv renames, the old path no longer exists in the worktree after
the move — staging it with git add fails. Split parsing into changedFiles
(both paths, for result.files) and filesToStage (new path only for
renames; old is already staged by git mv). Also adds porcelain tests
for staged renames, non-ASCII filenames, and a fast-check property test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#666): rollback on push failure in cmdPrSubrepo

If push fails the branch only exists locally; rollback cleans it up so
the sub-repo is not left in a half-committed state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#666): do not rollback after commit on push failure; add push-fail regression test

Post-commit push failures are network/auth/policy issues — the user's work
is already committed on the local branch. Calling rollback() at that point
force-deletes the only ref holding the commit (data loss). Leave the branch
in place and emit a retry instruction instead.

Adds a regression test (pre-receive hook that rejects all pushes) asserting
the branch and commit survive a push rejection so the failure path stays
covered going forward.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: regenerate INVENTORY-MANIFEST after rebase onto next

Rebased onto current next (#1267 retired core.cjs). Stale tsbuildinfo and
a leftover bin/lib/core.cjs build artifact were masking the drift — wiped
both, rebuilt clean, and regenerated the manifest. gen-inventory-manifest
--check now exits 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#666): validate sub-repo paths before git invocation in pr-branch.md

The handle_sub_repos workflow ran git -C on raw planning.sub_repos config
values at two points before the pr-subrepo seam's validatePath guard ever
ran: the dirty-scan detection (git status) and the base-branch resolution
(git ls-remote / remote show). A traversal entry could point git outside
the workspace; an embedded newline could inject a spurious record into
the newline-joined dirty-file output and into the shell-interpolated
commit message.

Adds a containment check + character allowlist to the dirty-scan node
script (reject before any execFileSync), and a defense-in-depth shell
case guard on the same value before the second, independent git -C
invocation in the base-branch resolution block.

Adds a behavioral test that extracts and executes the actual shipped
node script from pr-branch.md (not a mirror) against a real traversal
target and an embedded-newline entry, asserting neither reaches git or
the dirty-file output.

Also updates the stale cmdPrSubrepo doc comment: push failures no longer
delete the branch (see prior commit), only stage/commit failures do.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(#666): make sub-repo traversal scan test genuinely fail-first

The outside repo's only change was an untracked file, which the ?? filter
excludes — so the repo looked clean even with the guard removed, making the
traversal assertion vacuous (it passed against a neutered guard). Commit the
file first, then modify it, so the outside repo has a tracked dirty change:
without the path guard it WOULD be reported dirty, so the test now fails-first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#666): symlink-safe (realpath) sub-repo containment in pr-branch.md

Finding A from re-review: the workflow guard used path.resolve, which only
normalizes '..' textually and does not follow symlinks — so an in-tree symlink
whose name has no '..' or '/' (e.g. "evil" -> /outside) passed both the charset
filter and the resolve+startsWith check, letting git status / ls-remote /
remote show run against a directory outside the workspace. The pr-subrepo seam
already used fs.realpathSync (validatePath); this brings the workflow layer to
parity.

- dirty-scan: realpathSync the root once, and realpathSync each candidate before
  the containment check; skip on throw.
- base-branch resolution: replace the weak `case *..*|/*` guard with a realpath
  containment check that yields a validated absolute SUB_REPO_DIR, and run git -C
  against that instead of re-concatenating $ROOT/$REPO_REL.
- security test: add a symlink-escape entry and a positive control (legit in-root
  backend must still be reported). Confirmed fails-first — regressing the scan to
  path.resolve makes the symlink case leak.

Also fixes a misleading-fallback minor: the workflow now checks the seam's exit
status and skips the companion-PR step on failure, instead of printing
"branch pushed, open PR manually" after a real stage/commit/push failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#666): harden pr-branch sub-repo flow against round-12 edge cases

Pre-emptive hardening of the workflow changes from the symlink fix:

- continue-outside-loop: the "skip companion PR on seam failure" block used a
  bash `continue`, but the per-sub-repo iteration is prose-driven (the agent
  loops, not a literal `for`), so `continue` would warn and no-op. Reframed as
  prose-gated control flow keyed on $SUBREPO_EXIT — no bash loop assumption.
- Windows portability: the new symlink security case now degrades gracefully
  (try/catch around fs.symlinkSync; skip just the symlink assertion when symlink
  creation lacks privileges) so it doesn't hard-fail on Windows CI.

Verified: seam exits 1 on error / 0 on success (error() → process.exit(1),
propagated through the shim), so the $SUBREPO_EXIT check is meaningful; bash -n
clean on the touched blocks; commands 156/156; lint:ci green; manifest in sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-06-21 22:34:00 -04:00

2781 lines
113 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// allow-test-rule: source-text-is-the-product
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* GSD Tools Tests - Commands
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { execSync, execFileSync } = require('node:child_process');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, createTempDir, cleanup } = require('./helpers.cjs');
const fc = require('./helpers/fast-check-setup.cjs');
describe('history-digest command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('empty phases directory returns valid schema', () => {
const result = runGsdTools('history-digest', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const digest = JSON.parse(result.output);
assert.deepStrictEqual(digest.phases, {}, 'phases should be empty object');
assert.deepStrictEqual(digest.decisions, [], 'decisions should be empty array');
assert.deepStrictEqual(digest.tech_stack, [], 'tech_stack should be empty array');
});
test('nested frontmatter fields extracted correctly', () => {
// Create phase directory with SUMMARY containing nested frontmatter
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
const summaryContent = `---
phase: "01"
name: "Foundation Setup"
dependency-graph:
provides:
- "Database schema"
- "Auth system"
affects:
- "API layer"
tech-stack:
added:
- "prisma"
- "jose"
patterns-established:
- "Repository pattern"
- "JWT auth flow"
key-decisions:
- "Use Prisma over Drizzle"
- "JWT in httpOnly cookies"
---
# Summary content here
`;
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), summaryContent);
const result = runGsdTools('history-digest', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const digest = JSON.parse(result.output);
// Check nested dependency-graph.provides
assert.ok(digest.phases['01'], 'Phase 01 should exist');
assert.deepStrictEqual(
digest.phases['01'].provides.sort(),
['Auth system', 'Database schema'],
'provides should contain nested values'
);
// Check nested dependency-graph.affects
assert.deepStrictEqual(
digest.phases['01'].affects,
['API layer'],
'affects should contain nested values'
);
// Check nested tech-stack.added
assert.deepStrictEqual(
digest.tech_stack.sort(),
['jose', 'prisma'],
'tech_stack should contain nested values'
);
// Check patterns-established (flat array)
assert.deepStrictEqual(
digest.phases['01'].patterns.sort(),
['JWT auth flow', 'Repository pattern'],
'patterns should be extracted'
);
// Check key-decisions
assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions');
assert.ok(
digest.decisions.some(d => d.decision === 'Use Prisma over Drizzle'),
'Should contain first decision'
);
});
test('multiple phases merged into single digest', () => {
// Create phase 01
const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phase01Dir, { recursive: true });
fs.writeFileSync(
path.join(phase01Dir, '01-01-SUMMARY.md'),
`---
phase: "01"
name: "Foundation"
provides:
- "Database"
patterns-established:
- "Pattern A"
key-decisions:
- "Decision 1"
---
`
);
// Create phase 02
const phase02Dir = path.join(tmpDir, '.planning', 'phases', '02-api');
fs.mkdirSync(phase02Dir, { recursive: true });
fs.writeFileSync(
path.join(phase02Dir, '02-01-SUMMARY.md'),
`---
phase: "02"
name: "API"
provides:
- "REST endpoints"
patterns-established:
- "Pattern B"
key-decisions:
- "Decision 2"
tech-stack:
added:
- "zod"
---
`
);
const result = runGsdTools('history-digest', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const digest = JSON.parse(result.output);
// Both phases present
assert.ok(digest.phases['01'], 'Phase 01 should exist');
assert.ok(digest.phases['02'], 'Phase 02 should exist');
// Decisions merged
assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions total');
// Tech stack merged
assert.deepStrictEqual(digest.tech_stack, ['zod'], 'tech_stack should have zod');
});
test('malformed SUMMARY.md skipped gracefully', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
// Valid summary
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
phase: "01"
provides:
- "Valid feature"
---
`
);
// Malformed summary (no frontmatter)
fs.writeFileSync(
path.join(phaseDir, '01-02-SUMMARY.md'),
`# Just a heading
No frontmatter here
`
);
// Another malformed summary (broken YAML)
fs.writeFileSync(
path.join(phaseDir, '01-03-SUMMARY.md'),
`---
broken: [unclosed
---
`
);
const result = runGsdTools('history-digest', tmpDir);
assert.ok(result.success, `Command should succeed despite malformed files: ${result.error}`);
const digest = JSON.parse(result.output);
assert.ok(digest.phases['01'], 'Phase 01 should exist');
assert.ok(
digest.phases['01'].provides.includes('Valid feature'),
'Valid feature should be extracted'
);
});
test('flat provides field still works (backward compatibility)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
phase: "01"
provides:
- "Direct provides"
---
`
);
const result = runGsdTools('history-digest', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const digest = JSON.parse(result.output);
assert.deepStrictEqual(
digest.phases['01'].provides,
['Direct provides'],
'Direct provides should work'
);
});
test('inline array syntax supported', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
phase: "01"
provides: [Feature A, Feature B]
patterns-established: ["Pattern X", "Pattern Y"]
---
`
);
const result = runGsdTools('history-digest', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const digest = JSON.parse(result.output);
assert.deepStrictEqual(
digest.phases['01'].provides.sort(),
['Feature A', 'Feature B'],
'Inline array should work'
);
assert.deepStrictEqual(
digest.phases['01'].patterns.sort(),
['Pattern X', 'Pattern Y'],
'Inline quoted array should work'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phases list command
// ─────────────────────────────────────────────────────────────────────────────
describe('summary-extract command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('missing file returns error', () => {
const result = runGsdTools('summary-extract .planning/phases/01-test/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.error, 'File not found', 'should report missing file');
});
test('extracts all fields from SUMMARY.md', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
one-liner: Set up Prisma with User and Project models
key-files:
- prisma/schema.prisma
- src/lib/db.ts
tech-stack:
added:
- prisma
- zod
patterns-established:
- Repository pattern
- Dependency injection
key-decisions:
- Use Prisma over Drizzle: Better DX and ecosystem
- Single database: Start simple, shard later
requirements-completed:
- AUTH-01
- AUTH-02
---
# Summary
Full summary content here.
`
);
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.path, '.planning/phases/01-foundation/01-01-SUMMARY.md', 'path correct');
assert.strictEqual(output.one_liner, 'Set up Prisma with User and Project models', 'one-liner extracted');
assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma', 'src/lib/db.ts'], 'key files extracted');
assert.deepStrictEqual(output.tech_added, ['prisma', 'zod'], 'tech added extracted');
assert.deepStrictEqual(output.patterns, ['Repository pattern', 'Dependency injection'], 'patterns extracted');
assert.strictEqual(output.decisions.length, 2, 'decisions extracted');
assert.deepStrictEqual(output.requirements_completed, ['AUTH-01', 'AUTH-02'], 'requirements completed extracted');
});
test('selective extraction with --fields', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
one-liner: Set up database
key-files:
- prisma/schema.prisma
tech-stack:
added:
- prisma
patterns-established:
- Repository pattern
key-decisions:
- Use Prisma: Better DX
requirements-completed:
- AUTH-01
---
`
);
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md --fields one_liner,key_files,requirements_completed', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.one_liner, 'Set up database', 'one_liner included');
assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma'], 'key_files included');
assert.deepStrictEqual(output.requirements_completed, ['AUTH-01'], 'requirements_completed included');
assert.strictEqual(output.tech_added, undefined, 'tech_added excluded');
assert.strictEqual(output.patterns, undefined, 'patterns excluded');
assert.strictEqual(output.decisions, undefined, 'decisions excluded');
});
test('extracts one-liner from body when not in frontmatter', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
phase: "01"
key-files:
- src/lib/db.ts
---
# Phase 1: Foundation Summary
**JWT auth with refresh rotation using jose library**
## Performance
- **Duration:** 28 min
- **Tasks:** 5
`
);
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.one_liner, 'JWT auth with refresh rotation using jose library',
'one-liner should be extracted from body **bold** line');
});
test('handles missing frontmatter fields gracefully', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
one-liner: Minimal summary
---
# Summary
`
);
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.one_liner, 'Minimal summary', 'one-liner extracted');
assert.deepStrictEqual(output.key_files, [], 'key_files defaults to empty');
assert.deepStrictEqual(output.tech_added, [], 'tech_added defaults to empty');
assert.deepStrictEqual(output.patterns, [], 'patterns defaults to empty');
assert.deepStrictEqual(output.decisions, [], 'decisions defaults to empty');
assert.deepStrictEqual(output.requirements_completed, [], 'requirements_completed defaults to empty');
});
test('reads requirements in snake_case form the tool itself emits (#628)', () => {
// Regression: the tool's JSON output key and the milestone-audit `--pick` both use the
// snake form `requirements_completed`, so operators naturally write that into SUMMARY
// frontmatter. The reader must accept it, not silently drop it to [].
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
one-liner: Snake-keyed summary
requirements_completed:
- REQ-1
- REQ-2
---
# Summary
`
);
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.requirements_completed, ['REQ-1', 'REQ-2'],
'snake-case requirements_completed should be read, not dropped to []');
});
test('prefers kebab requirements-completed when both key forms are present (#628)', () => {
// kebab is the documented template form and must win the tolerance fallback.
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
one-liner: Both key forms present
requirements-completed:
- KEBAB-1
requirements_completed:
- SNAKE-1
---
# Summary
`
);
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.deepStrictEqual(output.requirements_completed, ['KEBAB-1'],
'kebab key should take precedence over snake when both are present');
});
test('parses key-decisions with rationale', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
`---
key-decisions:
- Use Prisma: Better DX than alternatives
- JWT tokens: Stateless auth for scalability
---
`
);
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'decision summary parsed');
assert.strictEqual(output.decisions[0].rationale, 'Better DX than alternatives', 'decision rationale parsed');
assert.strictEqual(output.decisions[1].summary, 'JWT tokens', 'second decision summary');
assert.strictEqual(output.decisions[1].rationale, 'Stateless auth for scalability', 'second decision rationale');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// init commands tests
// ─────────────────────────────────────────────────────────────────────────────
describe('progress command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('renders JSON progress', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0 MVP\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done');
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan 2');
const result = runGsdTools('progress json', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.total_plans, 2, '2 total plans');
assert.strictEqual(output.total_summaries, 1, '1 summary');
assert.strictEqual(output.percent, 50, '50%');
assert.strictEqual(output.phases.length, 1, '1 phase');
assert.strictEqual(output.phases[0].status, 'In Progress', 'phase in progress');
});
test('renders bar format', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done');
const result = runGsdTools('progress bar --raw', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
assert.ok(result.output.includes('1/1'), 'should include count');
assert.ok(result.output.includes('100%'), 'should include 100%');
});
test('renders table format', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0 MVP\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
const result = runGsdTools('progress table --raw', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
assert.ok(result.output.includes('Phase'), 'should have table header');
assert.ok(result.output.includes('foundation'), 'should include phase name');
});
test('does not crash when summaries exceed plans (orphaned SUMMARY.md)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0 MVP\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
// 1 plan but 2 summaries (orphaned SUMMARY.md after PLAN.md deletion)
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done');
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Orphaned summary');
// bar format - should not crash with RangeError
const barResult = runGsdTools('progress bar --raw', tmpDir);
assert.ok(barResult.success, `Bar format crashed: ${barResult.error}`);
assert.ok(barResult.output.includes('100%'), 'percent should be clamped to 100%');
// table format - should not crash with RangeError
const tableResult = runGsdTools('progress table --raw', tmpDir);
assert.ok(tableResult.success, `Table format crashed: ${tableResult.error}`);
// json format - percent should be clamped
const jsonResult = runGsdTools('progress json', tmpDir);
assert.ok(jsonResult.success, `JSON format crashed: ${jsonResult.error}`);
const output = JSON.parse(jsonResult.output);
assert.ok(output.percent <= 100, `percent should be <= 100 but got ${output.percent}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// todo complete command
// ─────────────────────────────────────────────────────────────────────────────
describe('todo complete command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('moves todo from pending to completed', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(
path.join(pendingDir, 'add-dark-mode.md'),
`title: Add dark mode\narea: ui\ncreated: 2025-01-01\n`
);
const result = runGsdTools('todo complete add-dark-mode.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.completed, true);
// Verify moved
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', 'add-dark-mode.md')),
'should be removed from pending'
);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md')),
'should be in completed'
);
// Verify completion timestamp added
const content = fs.readFileSync(
path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md'),
'utf-8'
);
assert.ok(content.startsWith('completed:'), 'should have completed timestamp');
});
test('fails for nonexistent todo', () => {
const result = runGsdTools('todo complete nonexistent.md', tmpDir);
assert.ok(!result.success, 'should fail');
assert.ok(result.error.includes('not found'), 'error mentions not found');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// todo match-phase command
// ─────────────────────────────────────────────────────────────────────────────
describe('todo match-phase command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => cleanup(tmpDir));
test('returns empty matches when no todos exist', () => {
const result = runGsdTools('todo match-phase 01', tmpDir);
assert.ok(result.success, 'should succeed');
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 0);
assert.deepStrictEqual(output.matches, []);
});
test('matches todo by keyword overlap with phase name', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'),
'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nNeed to handle token expiry for OAuth flows.');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login and session handling\n');
const result = runGsdTools('todo match-phase 01', tmpDir);
assert.ok(result.success, 'should succeed');
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 1, 'should find 1 todo');
assert.ok(output.matches.length > 0, 'should have matches');
assert.strictEqual(output.matches[0].title, 'Add OAuth token refresh');
assert.ok(output.matches[0].score > 0, 'score should be positive');
assert.ok(output.matches[0].reasons.length > 0, 'should have reasons');
});
test('does not match unrelated todo', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'),
'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nOAuth token expiry.');
fs.writeFileSync(path.join(pendingDir, 'unrelated-todo.md'),
'title: Fix CSS grid layout in dashboard\narea: ui\ncreated: 2026-03-01\n\nGrid columns break on mobile.');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login and session handling\n');
const result = runGsdTools('todo match-phase 01', tmpDir);
assert.ok(result.success, 'should succeed');
const output = JSON.parse(result.output);
const matchTitles = output.matches.map(m => m.title);
assert.ok(matchTitles.includes('Add OAuth token refresh'), 'auth todo should match');
assert.ok(!matchTitles.includes('Fix CSS grid layout in dashboard'), 'unrelated todo should not match');
});
test('matches todo by area overlap', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'),
'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nOAuth token handling.');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 01: Auth System\n\n**Goal:** Build auth module\n');
const result = runGsdTools('todo match-phase 01', tmpDir);
const output = JSON.parse(result.output);
const authMatch = output.matches.find(m => m.title === 'Add OAuth token refresh');
assert.ok(authMatch, 'should find auth todo');
const hasAreaReason = authMatch.reasons.some(r => r.startsWith('area:'));
assert.ok(hasAreaReason, 'should match on area');
});
test('sorts matches by score descending', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'weak-match.md'),
'title: Check token format\narea: general\ncreated: 2026-03-01\n\nToken format validation.');
fs.writeFileSync(path.join(pendingDir, 'strong-match.md'),
'title: Session management authentication OAuth token handling\narea: auth\ncreated: 2026-03-01\n\nSession auth OAuth tokens.');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login, session handling, and token management\n');
const result = runGsdTools('todo match-phase 01', tmpDir);
const output = JSON.parse(result.output);
assert.ok(output.matches.length >= 2, 'should have multiple matches');
for (let i = 1; i < output.matches.length; i++) {
assert.ok(output.matches[i - 1].score >= output.matches[i].score,
`match ${i-1} score (${output.matches[i-1].score}) should be >= match ${i} score (${output.matches[i].score})`);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// scaffold command
// ─────────────────────────────────────────────────────────────────────────────
describe('scaffold command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('scaffolds context file', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
const result = runGsdTools('scaffold context --phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.created, true);
// Verify file content
const content = fs.readFileSync(
path.join(tmpDir, '.planning', 'phases', '03-api', '03-CONTEXT.md'),
'utf-8'
);
assert.ok(content.includes('Phase 3'), 'should reference phase number');
assert.ok(content.includes('Decisions'), 'should have decisions section');
assert.ok(content.includes('Discretion Areas'), 'should have discretion section');
});
test('scaffolds UAT file', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
const result = runGsdTools('scaffold uat --phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.created, true);
const content = fs.readFileSync(
path.join(tmpDir, '.planning', 'phases', '03-api', '03-UAT.md'),
'utf-8'
);
assert.ok(content.includes('User Acceptance Testing'), 'should have UAT heading');
assert.ok(content.includes('Test Results'), 'should have test results section');
});
test('scaffolds verification file', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
const result = runGsdTools('scaffold verification --phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.created, true);
const content = fs.readFileSync(
path.join(tmpDir, '.planning', 'phases', '03-api', '03-VERIFICATION.md'),
'utf-8'
);
assert.ok(content.includes('Goal-Backward Verification'), 'should have verification heading');
});
test('scaffolds phase directory', () => {
const result = runGsdTools('scaffold phase-dir --phase 5 --name User Dashboard', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.created, true);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '05-user-dashboard')),
'directory should be created'
);
});
test('does not overwrite existing files', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Existing content');
const result = runGsdTools('scaffold context --phase 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.created, false, 'should not overwrite');
assert.strictEqual(output.reason, 'already_exists');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdGenerateSlug tests (CMD-01)
// ─────────────────────────────────────────────────────────────────────────────
describe('generate-slug command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('converts normal text to slug', () => {
const result = runGsdTools('generate-slug "Hello World"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.slug, 'hello-world');
});
test('strips special characters', () => {
const result = runGsdTools('generate-slug "Test@#$%^Special!!!"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.slug, 'test-special');
});
test('preserves numbers', () => {
const result = runGsdTools('generate-slug "Phase 3 Plan"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.slug, 'phase-3-plan');
});
test('strips leading and trailing hyphens', () => {
const result = runGsdTools('generate-slug "---leading-trailing---"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.slug, 'leading-trailing');
});
test('fails when no text provided', () => {
const result = runGsdTools('generate-slug', tmpDir);
assert.ok(!result.success, 'should fail without text');
assert.ok(result.error.includes('text required'), 'error should mention text required');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdCurrentTimestamp tests (CMD-01)
// ─────────────────────────────────────────────────────────────────────────────
describe('current-timestamp command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('date format returns YYYY-MM-DD', () => {
const result = runGsdTools('current-timestamp date', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}$/, 'should be YYYY-MM-DD format');
});
test('filename format returns ISO without colons or fractional seconds', () => {
const result = runGsdTools('current-timestamp filename', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/, 'should replace colons with hyphens and strip fractional seconds');
});
test('full format returns full ISO string', () => {
const result = runGsdTools('current-timestamp full', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, 'should be full ISO format');
});
test('default (no format) returns full ISO string', () => {
const result = runGsdTools('current-timestamp', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, 'default should be full ISO format');
});
test('dispatches directly to CJS handler (no SDK bridge) to avoid Windows native crash path', () => {
const sourcePath = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
const source = fs.readFileSync(sourcePath, 'utf8');
const match = source.match(/case 'current-timestamp':\s*\{[\s\S]*?\r?\n\s*break;\r?\n\s*\}/);
assert.ok(match, 'current-timestamp case block must exist in gsd-tools.cjs');
const block = match[0];
assert.ok(
!block.includes('_dispatchNonFamily('),
'current-timestamp must not route through SDK bridge'
);
assert.ok(
block.includes("commands.cmdCurrentTimestamp(args[1] || 'full', raw);"),
'current-timestamp must call the CJS handler directly'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdListTodos tests (CMD-02)
// ─────────────────────────────────────────────────────────────────────────────
describe('list-todos command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('empty directory returns zero count', () => {
const result = runGsdTools('list-todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.count, 0, 'count should be 0');
assert.deepStrictEqual(output.todos, [], 'todos should be empty');
});
test('returns multiple todos with correct fields', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'add-tests.md'), 'title: Add unit tests\narea: testing\ncreated: 2026-01-15\n');
fs.writeFileSync(path.join(pendingDir, 'fix-bug.md'), 'title: Fix login bug\narea: auth\ncreated: 2026-01-20\n');
const result = runGsdTools('list-todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.count, 2, 'should have 2 todos');
assert.strictEqual(output.todos.length, 2, 'todos array should have 2 entries');
const testTodo = output.todos.find(t => t.file === 'add-tests.md');
assert.ok(testTodo, 'add-tests.md should be in results');
assert.strictEqual(testTodo.title, 'Add unit tests');
assert.strictEqual(testTodo.area, 'testing');
assert.strictEqual(testTodo.created, '2026-01-15');
});
test('area filter returns only matching todos', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'ui-task.md'), 'title: UI task\narea: ui\ncreated: 2026-01-01\n');
fs.writeFileSync(path.join(pendingDir, 'api-task.md'), 'title: API task\narea: api\ncreated: 2026-01-01\n');
const result = runGsdTools('list-todos ui', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.count, 1, 'should have 1 matching todo');
assert.strictEqual(output.todos[0].area, 'ui', 'should only return ui area');
});
test('area filter miss returns zero count', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'task.md'), 'title: Some task\narea: backend\ncreated: 2026-01-01\n');
const result = runGsdTools('list-todos nonexistent-area', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.count, 0, 'should have 0 matching todos');
});
test('malformed files use defaults', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
// File with no title or area fields
fs.writeFileSync(path.join(pendingDir, 'malformed.md'), 'some random content\nno fields here\n');
const result = runGsdTools('list-todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.count, 1, 'malformed file should still be counted');
assert.strictEqual(output.todos[0].title, 'Untitled', 'missing title defaults to Untitled');
assert.strictEqual(output.todos[0].area, 'general', 'missing area defaults to general');
assert.strictEqual(output.todos[0].created, 'unknown', 'missing created defaults to unknown');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdVerifyPathExists tests (CMD-02)
// ─────────────────────────────────────────────────────────────────────────────
describe('verify-path-exists command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('existing file returns exists=true with type=file', () => {
fs.writeFileSync(path.join(tmpDir, 'test-file.txt'), 'hello');
const result = runGsdTools('verify-path-exists test-file.txt', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.exists, true);
assert.strictEqual(output.type, 'file');
});
test('existing directory returns exists=true with type=directory', () => {
fs.mkdirSync(path.join(tmpDir, 'test-dir'), { recursive: true });
const result = runGsdTools('verify-path-exists test-dir', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.exists, true);
assert.strictEqual(output.type, 'directory');
});
test('missing path returns exists=false', () => {
const result = runGsdTools('verify-path-exists nonexistent/path', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.exists, false);
assert.strictEqual(output.type, null);
});
test('absolute path resolves correctly', () => {
const absFile = path.join(tmpDir, 'abs-test.txt');
fs.writeFileSync(absFile, 'content');
const result = runGsdTools(['verify-path-exists', absFile], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.exists, true);
assert.strictEqual(output.type, 'file');
});
test('fails when no path provided', () => {
const result = runGsdTools('verify-path-exists', tmpDir);
assert.ok(!result.success, 'should fail without path');
assert.ok(result.error.includes('path required'), 'error should mention path required');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdResolveModel tests (CMD-03)
// ─────────────────────────────────────────────────────────────────────────────
describe('resolve-model command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('known agent returns model and profile without unknown_agent', () => {
const result = runGsdTools('resolve-model gsd-planner', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.model, 'should have model field');
assert.ok(output.profile, 'should have profile field');
assert.strictEqual(output.unknown_agent, undefined, 'should not have unknown_agent for known agent');
});
test('shipped-but-previously-missing agent resolves under quality profile (#3229)', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ model_profile: 'quality' }));
const result = runGsdTools('resolve-model gsd-code-reviewer', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.model, 'opus');
assert.strictEqual(output.profile, 'quality');
assert.strictEqual(output.unknown_agent, undefined);
});
test('unknown agent returns unknown_agent=true', () => {
const result = runGsdTools('resolve-model fake-nonexistent-agent', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.unknown_agent, true, 'should flag unknown agent');
});
test('unknown agent uses quality-semantic fallback (opus)', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ model_profile: 'quality' }));
const result = runGsdTools('resolve-model fake-nonexistent-agent', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.model, 'opus');
assert.strictEqual(output.unknown_agent, true);
});
test('unknown agent uses budget-semantic fallback (haiku)', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ model_profile: 'budget' }));
const result = runGsdTools('resolve-model fake-nonexistent-agent', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.model, 'haiku');
assert.strictEqual(output.unknown_agent, true);
});
test('default profile fallback when no config exists', () => {
// tmpDir has no config.json, so defaults to balanced profile
const result = runGsdTools('resolve-model gsd-executor', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.profile, 'balanced', 'should default to balanced profile');
assert.ok(output.model, 'should resolve a model');
});
// #443: resolve-model now emits unified `effort` instead of `reasoning_effort`.
// reasoning_effort was flavor-text (resolved but consumed by nobody); effort is
// the wired, config-driven universal effort string for all runtimes.
test('emits unified effort (not reasoning_effort) when runtime supports tiered effort', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({
model_profile: 'balanced',
runtime: 'codex',
models: { planning: 'opus' },
}));
const result = runGsdTools('resolve-model gsd-planner', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.model, 'gpt-5.5');
assert.strictEqual(output.profile, 'balanced');
// #443: effort is now the unified field (xhigh for gsd-planner heavy tier default)
assert.strictEqual(output.effort, 'xhigh');
// reasoning_effort must be absent — replaced by unified effort
assert.ok(!Object.prototype.hasOwnProperty.call(output, 'reasoning_effort'),
'reasoning_effort must not appear in resolve-model output (replaced by effort)');
});
test('does not include reasoning_effort for unsupported runtime overrides (effort present instead)', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({
model_profile: 'balanced',
runtime: 'opencode',
models: { planning: 'opus' },
model_profile_overrides: {
opencode: {
opus: { model: 'openrouter/openai/gpt-5.5', reasoning_effort: 'high' },
},
},
}));
const result = runGsdTools('resolve-model gsd-planner', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.model, 'openrouter/openai/gpt-5.5');
assert.strictEqual(output.profile, 'balanced');
// #443: effort always present; reasoning_effort never present
assert.ok(Object.prototype.hasOwnProperty.call(output, 'effort'), 'effort must be present');
assert.ok(!Object.prototype.hasOwnProperty.call(output, 'reasoning_effort'),
'reasoning_effort must not appear (replaced by unified effort)');
});
test('does not include reasoning_effort for per-agent model_overrides (effort present instead)', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({
model_profile: 'balanced',
runtime: 'codex',
models: { planning: 'opus' },
model_overrides: { 'gsd-planner': 'gpt-5.5' },
}));
const result = runGsdTools('resolve-model gsd-planner', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.model, 'gpt-5.5');
assert.strictEqual(output.profile, 'balanced');
// #443: effort always present; reasoning_effort never present
assert.ok(Object.prototype.hasOwnProperty.call(output, 'effort'), 'effort must be present');
assert.ok(!Object.prototype.hasOwnProperty.call(output, 'reasoning_effort'),
'reasoning_effort must not appear (replaced by unified effort)');
});
test('fails when no agent-type provided', () => {
const result = runGsdTools('resolve-model', tmpDir);
assert.ok(!result.success, 'should fail without agent-type');
assert.ok(result.error.includes('agent-type required'), 'error should mention agent-type required');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdCommit tests (CMD-04)
// ─────────────────────────────────────────────────────────────────────────────
describe('commit command', () => {
const { createTempGitProject } = require('./helpers.cjs');
const { execSync } = require('child_process');
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('skips when commit_docs is false', () => {
// Write config with commit_docs: false
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: false })
);
const result = runGsdTools('commit "test message"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.committed, false);
assert.strictEqual(output.reason, 'skipped_commit_docs_false');
});
test('skips when .planning is gitignored', () => {
// Add .planning/ to .gitignore and commit it so git recognizes the ignore
fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.planning/\n');
execSync('git add .gitignore', { cwd: tmpDir, stdio: 'pipe' });
execSync('git commit -m "add gitignore"', { cwd: tmpDir, stdio: 'pipe' });
const result = runGsdTools('commit "test message"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.committed, false);
assert.strictEqual(output.reason, 'skipped_gitignored');
});
test('handles nothing to commit', () => {
// Don't modify any files after initial commit
const result = runGsdTools('commit "test message"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.committed, false);
assert.strictEqual(output.reason, 'nothing_to_commit');
});
test('creates real commit with correct hash', () => {
// Create a new file in .planning/
fs.writeFileSync(path.join(tmpDir, '.planning', 'test-file.md'), '# Test\n');
const result = runGsdTools('commit "test: add test file" --files .planning/test-file.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.committed, true, 'should have committed');
assert.ok(output.hash, 'should have a commit hash');
assert.strictEqual(output.reason, 'committed');
// Verify via git log
const gitLog = execSync('git log --oneline -1', { cwd: tmpDir, encoding: 'utf-8' }).trim();
assert.ok(gitLog.includes('test: add test file'), 'git log should contain the commit message');
assert.ok(gitLog.includes(output.hash), 'git log should contain the returned hash');
});
test('amend mode works without crashing', () => {
// Create a file and commit it first
fs.writeFileSync(path.join(tmpDir, '.planning', 'amend-file.md'), '# Initial\n');
execSync('git add .planning/amend-file.md', { cwd: tmpDir, stdio: 'pipe' });
execSync('git commit -m "initial file"', { cwd: tmpDir, stdio: 'pipe' });
// Modify the file and amend
fs.writeFileSync(path.join(tmpDir, '.planning', 'amend-file.md'), '# Amended\n');
const result = runGsdTools('commit "ignored" --files .planning/amend-file.md --amend', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.committed, true, 'amend should succeed');
// Verify only 2 commits total (initial setup + amended)
const logCount = execSync('git log --oneline', { cwd: tmpDir, encoding: 'utf-8' }).trim().split('\n').length;
assert.strictEqual(logCount, 2, 'should have 2 commits (initial + amended)');
});
test('creates strategy branch before first commit when branching_strategy is milestone', () => {
// Configure milestone branching strategy
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
commit_docs: true,
branching_strategy: 'milestone',
milestone_branch_template: 'gsd/{milestone}-{slug}',
})
);
// getMilestoneInfo reads ROADMAP.md for milestone version/name
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'## v1.0: Initial Release\n\n### Phase 1: Setup\n'
);
// Create a file to commit
fs.writeFileSync(path.join(tmpDir, '.planning', 'test-context.md'), '# Context\n');
const result = runGsdTools('commit "docs: add context" --files .planning/test-context.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.committed, true, 'should have committed');
// Verify we're on the strategy branch
const { execFileSync } = require('child_process');
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim();
assert.strictEqual(branch, 'gsd/v1.0-initial-release', 'should be on milestone branch');
});
test('creates strategy branch before first commit when branching_strategy is phase', () => {
// Configure phase branching strategy
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
commit_docs: true,
branching_strategy: 'phase',
phase_branch_template: 'gsd/phase-{phase}-{slug}',
})
);
// Create ROADMAP.md with a phase
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## Phase 1: Setup\nGoal: Initial setup\n'
);
// Create a context file for phase 1
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '01-setup', '01-CONTEXT.md'), '# Context\n');
const result = runGsdTools(
'commit "docs(01): add context" --files .planning/phases/01-setup/01-CONTEXT.md',
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.committed, true, 'should have committed');
// Verify we're on the strategy branch
const { execFileSync } = require('child_process');
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim();
assert.strictEqual(branch, 'gsd/phase-01-setup', 'should be on phase branch');
});
test('decimal phase numbers are captured correctly in branching strategy', () => {
// Configure phase branching strategy
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
commit_docs: true,
branching_strategy: 'phase',
phase_branch_template: 'gsd/phase-{phase}-{slug}',
})
);
// Create ROADMAP.md with a decimal phase
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '45.14-golden-capture'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## Phase 45.14: Golden Capture\nGoal: Capture golden standard\n'
);
// Create a context file for phase 45.14
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '45.14-golden-capture', '45.14-CONTEXT.md'), '# Context\n');
const result = runGsdTools(
'commit "docs(45.14): add context" --files .planning/phases/45.14-golden-capture/45.14-CONTEXT.md',
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.committed, true, 'should have committed');
// Verify we're on the correct branch (45.14, not 14)
const { execFileSync } = require('child_process');
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim();
assert.strictEqual(branch, 'gsd/phase-45.14-golden-capture', 'should be on decimal phase branch, not integer-only');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// groupFilesBySubrepo tests (#311)
// ─────────────────────────────────────────────────────────────────────────────
describe('groupFilesBySubrepo (#311)', () => {
const { groupFilesBySubrepo } = require('../gsd-core/bin/lib/commands.cjs');
test('single-segment subrepos route files correctly and unmatched collected', () => {
const result = groupFilesBySubrepo(
['packages/a.js', 'docs/x.md', 'README.md'],
['packages', 'docs']
);
assert.deepStrictEqual(result.grouped, { packages: ['packages/a.js'], docs: ['docs/x.md'] });
assert.deepStrictEqual(result.unmatched, ['README.md']);
});
test('multi-segment subrepo matches deep files, not shallow sibling', () => {
const result = groupFilesBySubrepo(
['vendor/pkg/x.js', 'vendor/other.js', 'vendor/pkg/y.js'],
['vendor/pkg']
);
assert.deepStrictEqual(result.grouped, { 'vendor/pkg': ['vendor/pkg/x.js', 'vendor/pkg/y.js'] });
assert.deepStrictEqual(result.unmatched, ['vendor/other.js']);
});
test('longest-prefix wins, not first-match-in-array-order (#391)', () => {
// 'app' precedes 'app/sub' in array order, but 'app/sub' is the more specific
// configured sub-repo, so 'app/sub/f.js' must route to 'app/sub'.
const result = groupFilesBySubrepo(
['app/sub/f.js'],
['app', 'app/sub']
);
assert.deepStrictEqual(result.grouped, { 'app/sub': ['app/sub/f.js'] });
assert.deepStrictEqual(result.unmatched, []);
});
test('longest-prefix selection is independent of sub_repos array order (#391)', () => {
// Reverse array order: longest-prefix must still win (no array-order workaround).
const result = groupFilesBySubrepo(
['app/sub/f.js'],
['app/sub', 'app']
);
assert.deepStrictEqual(result.grouped, { 'app/sub': ['app/sub/f.js'] });
assert.deepStrictEqual(result.unmatched, []);
});
test('nested sub-repos route by specificity; shallow files stay shallow (#391)', () => {
// Exact repro from #391 plus a shallow sibling file under the parent sub-repo.
const result = groupFilesBySubrepo(
['packages/core/widget.js', 'packages/util.js'],
['packages', 'packages/core']
);
assert.deepStrictEqual(result.grouped, {
'packages/core': ['packages/core/widget.js'],
packages: ['packages/util.js'],
});
assert.deepStrictEqual(result.unmatched, []);
});
test('three-level nesting routes to the deepest matching prefix (#391)', () => {
const result = groupFilesBySubrepo(
['a/b/c/f.js', 'a/b/g.js', 'a/h.js'],
['a', 'a/b', 'a/b/c']
);
assert.deepStrictEqual(result.grouped, {
'a/b/c': ['a/b/c/f.js'],
'a/b': ['a/b/g.js'],
a: ['a/h.js'],
});
assert.deepStrictEqual(result.unmatched, []);
});
test('file with no slash does not match a same-name subrepo', () => {
const result = groupFilesBySubrepo(['top'], ['top']);
assert.deepStrictEqual(result.grouped, {});
assert.deepStrictEqual(result.unmatched, ['top']);
});
test('file with slash after prefix routes correctly', () => {
const result = groupFilesBySubrepo(['top/a'], ['top']);
assert.deepStrictEqual(result.grouped, { top: ['top/a'] });
assert.deepStrictEqual(result.unmatched, []);
});
test('empty files list returns empty grouped and unmatched', () => {
const result = groupFilesBySubrepo([], ['a']);
assert.deepStrictEqual(result.grouped, {});
assert.deepStrictEqual(result.unmatched, []);
});
test('empty subRepos list puts all files in unmatched', () => {
const result = groupFilesBySubrepo(['a/b'], []);
assert.deepStrictEqual(result.grouped, {});
assert.deepStrictEqual(result.unmatched, ['a/b']);
});
test('non-string subRepos entry does not throw and string entries still route (#311)', () => {
// Old inline code coerced non-string repos via `repo + '/'` and never threw.
let result;
assert.doesNotThrow(() => {
result = groupFilesBySubrepo(['a/b', 'README.md'], [null, 'a']);
});
assert.deepStrictEqual(result.grouped, { a: ['a/b'] });
assert.deepStrictEqual(result.unmatched, ['README.md']);
});
test('non-string entry in a matched bucket does not throw (#391)', () => {
// A null sub_repos entry shares the 'null' first-segment bucket with a real
// multi-segment entry; longest-prefix selection must not throw reading length.
let result;
assert.doesNotThrow(() => {
result = groupFilesBySubrepo(['null/a/f.js'], [null, 'null/a']);
});
assert.deepStrictEqual(result.grouped, { 'null/a': ['null/a/f.js'] });
assert.deepStrictEqual(result.unmatched, []);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdWebsearch tests (CMD-05)
// ─────────────────────────────────────────────────────────────────────────────
describe('websearch command', () => {
const { cmdWebsearch } = require('../gsd-core/bin/lib/commands.cjs');
let origFetch;
let origApiKey;
let origWriteSync;
let captured;
beforeEach(() => {
origFetch = global.fetch;
origApiKey = process.env.BRAVE_API_KEY;
origWriteSync = fs.writeSync;
captured = '';
// output() uses fs.writeSync(1, data) since #1276 — mock it to capture output
fs.writeSync = (fd, data) => { if (fd === 1) captured += data; return Buffer.byteLength(String(data)); };
});
afterEach(() => {
global.fetch = origFetch;
if (origApiKey !== undefined) {
process.env.BRAVE_API_KEY = origApiKey;
} else {
delete process.env.BRAVE_API_KEY;
}
fs.writeSync = origWriteSync;
});
test('returns available=false when BRAVE_API_KEY is unset', async () => {
delete process.env.BRAVE_API_KEY;
await cmdWebsearch('test query', {}, false);
const output = JSON.parse(captured);
assert.strictEqual(output.available, false);
assert.ok(output.reason.includes('BRAVE_API_KEY'), 'should mention missing API key');
});
test('returns error when no query provided', async () => {
process.env.BRAVE_API_KEY = 'test-key';
await cmdWebsearch(null, {}, false);
const output = JSON.parse(captured);
assert.strictEqual(output.available, false);
assert.ok(output.error.includes('Query required'), 'should mention query required');
});
test('returns results for successful API response', async () => {
process.env.BRAVE_API_KEY = 'test-key';
global.fetch = async () => ({
ok: true,
json: async () => ({
web: {
results: [
{ title: 'Test Result', url: 'https://example.com', description: 'A test result', age: '1d' },
],
},
}),
});
await cmdWebsearch('test query', { limit: 5, freshness: 'pd' }, false);
const output = JSON.parse(captured);
assert.strictEqual(output.available, true);
assert.strictEqual(output.query, 'test query');
assert.strictEqual(output.count, 1);
assert.strictEqual(output.results[0].title, 'Test Result');
assert.strictEqual(output.results[0].url, 'https://example.com');
assert.strictEqual(output.results[0].age, '1d');
});
test('constructs correct URL parameters', async () => {
process.env.BRAVE_API_KEY = 'test-key';
let capturedUrl = '';
global.fetch = async (url) => {
capturedUrl = url;
return {
ok: true,
json: async () => ({ web: { results: [] } }),
};
};
await cmdWebsearch('node.js testing', { limit: 5, freshness: 'pd' }, false);
const parsed = new URL(capturedUrl);
assert.strictEqual(parsed.searchParams.get('q'), 'node.js testing', 'query param should decode to original string');
assert.strictEqual(parsed.searchParams.get('count'), '5', 'count param should be 5');
assert.strictEqual(parsed.searchParams.get('freshness'), 'pd', 'freshness param should be pd');
});
test('handles API error (non-200 status)', async () => {
process.env.BRAVE_API_KEY = 'test-key';
global.fetch = async () => ({
ok: false,
status: 401,
headers: { get: () => null },
});
await cmdWebsearch('test query', {}, false);
const output = JSON.parse(captured);
assert.strictEqual(output.available, false);
assert.ok(output.error.includes('401'), 'error should include status code');
});
test('handles network failure', async () => {
process.env.BRAVE_API_KEY = 'test-key';
global.fetch = async () => {
throw new Error('Network timeout');
};
await cmdWebsearch('test query', {}, false);
const output = JSON.parse(captured);
assert.strictEqual(output.available, false);
assert.strictEqual(output.error, 'Network timeout');
});
// ── New retry/timeout tests (A–E) ──────────────────────────────────────────
test('A. timeout is bounded: AbortSignal fires, resolves with available=false and attempts field', async (t) => {
process.env.BRAVE_API_KEY = 'test-key';
process.env.GSD_WEBSEARCH_TIMEOUT_MS = '20';
t.after(() => { delete process.env.GSD_WEBSEARCH_TIMEOUT_MS; });
global.fetch = async (_url, init) => new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
});
await cmdWebsearch('q', {}, false);
const output = JSON.parse(captured);
assert.strictEqual(output.available, false, 'should be available=false after timeout exhaustion');
assert.ok(typeof output.attempts === 'number', 'should include attempts field');
});
test('B. retry on 503 then success: succeeds on 2nd attempt, fetch called exactly twice', async () => {
process.env.BRAVE_API_KEY = 'test-key';
let callCount = 0;
global.fetch = async () => {
callCount++;
if (callCount === 1) {
return { ok: false, status: 503, headers: { get: () => null } };
}
return {
ok: true,
json: async () => ({
web: { results: [{ title: 'T', url: 'https://example.com', description: 'D' }] },
}),
};
};
await cmdWebsearch('test query', {}, false);
const output = JSON.parse(captured);
assert.strictEqual(output.available, true, 'should succeed after retry');
assert.strictEqual(output.results.length, 1, 'should have one result');
assert.strictEqual(callCount, 2, 'fetch should be called exactly twice');
});
test('C. 429 honors Retry-After then succeeds on 2nd call, fetch called exactly twice', async () => {
process.env.BRAVE_API_KEY = 'test-key';
let callCount = 0;
global.fetch = async () => {
callCount++;
if (callCount === 1) {
return {
ok: false,
status: 429,
headers: { get: (h) => h.toLowerCase() === 'retry-after' ? '0' : null },
};
}
return {
ok: true,
json: async () => ({
web: { results: [{ title: 'T', url: 'https://example.com', description: 'D' }] },
}),
};
};
await cmdWebsearch('test query', {}, false);
const output = JSON.parse(captured);
assert.strictEqual(output.available, true, 'should succeed after 429 retry');
assert.strictEqual(callCount, 2, 'fetch should be called exactly twice');
});
test('D. no retry on 401: fails immediately, fetch called exactly once', async () => {
process.env.BRAVE_API_KEY = 'test-key';
let callCount = 0;
global.fetch = async () => {
callCount++;
return { ok: false, status: 401, headers: { get: () => null } };
};
await cmdWebsearch('test query', {}, false);
const output = JSON.parse(captured);
assert.strictEqual(output.available, false, 'should be available=false');
assert.strictEqual(output.error, 'API error: 401', 'error should be API error: 401');
assert.strictEqual(output.attempts, undefined, 'should NOT have attempts field on immediate fail');
assert.strictEqual(callCount, 1, 'fetch should be called exactly once');
});
test('E. network error retried then exhausted: attempts=3, fetch called 3 times', async () => {
process.env.BRAVE_API_KEY = 'test-key';
let callCount = 0;
global.fetch = async () => {
callCount++;
throw new Error('boom');
};
await cmdWebsearch('test query', {}, false);
const output = JSON.parse(captured);
assert.strictEqual(output.available, false, 'should be available=false');
assert.ok(output.error.includes('boom'), 'error should include boom');
assert.strictEqual(output.attempts, 3, 'attempts should be 3');
assert.strictEqual(callCount, 3, 'fetch should be called 3 times');
});
});
describe('stats command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('returns valid JSON with empty project', () => {
const result = runGsdTools('stats', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const stats = JSON.parse(result.output);
assert.ok(Array.isArray(stats.phases), 'phases should be an array');
assert.strictEqual(stats.total_plans, 0);
assert.strictEqual(stats.total_summaries, 0);
assert.strictEqual(stats.percent, 0);
assert.strictEqual(stats.phases_completed, 0);
assert.strictEqual(stats.phases_total, 0);
assert.strictEqual(stats.requirements_total, 0);
assert.strictEqual(stats.requirements_complete, 0);
});
test('counts phases, plans, and summaries correctly', () => {
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
const p2 = path.join(tmpDir, '.planning', 'phases', '02-api');
fs.mkdirSync(p1, { recursive: true });
fs.mkdirSync(p2, { recursive: true });
// Phase 1: 2 plans, 2 summaries, passing verification (complete)
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification');
// Phase 2: 1 plan, 0 summaries (planned)
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan');
const result = runGsdTools('stats', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const stats = JSON.parse(result.output);
assert.strictEqual(stats.phases_total, 2);
assert.strictEqual(stats.phases_completed, 1);
assert.strictEqual(stats.total_plans, 3);
assert.strictEqual(stats.total_summaries, 2);
assert.strictEqual(stats.percent, 50);
assert.strictEqual(stats.plan_percent, 67);
});
test('counts requirements from REQUIREMENTS.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
`# Requirements
## v1 Requirements
- [x] **AUTH-01**: User can sign up
- [x] **AUTH-02**: User can log in
- [ ] **API-01**: REST endpoints
- [ ] **API-02**: GraphQL support
`
);
const result = runGsdTools('stats', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const stats = JSON.parse(result.output);
assert.strictEqual(stats.requirements_total, 4);
assert.strictEqual(stats.requirements_complete, 2);
});
test('reads last activity from STATE.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Last Activity:** 2025-06-15\n**Last Activity Description:** Working\n`
);
const result = runGsdTools('stats', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const stats = JSON.parse(result.output);
assert.strictEqual(stats.last_activity, '2025-06-15');
});
test('reads last activity from plain STATE.md template format', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n## Current Position\n\nPhase: 1 of 2 (Foundation)\nPlan: 1 of 1 in current phase\nStatus: In progress\nLast activity: 2025-06-16 — Finished plan 01-01\n`
);
const result = runGsdTools('stats', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const stats = JSON.parse(result.output);
assert.strictEqual(stats.last_activity, '2025-06-16 — Finished plan 01-01');
});
test('includes roadmap-only phases in totals and preserves hyphenated names', () => {
const p1 = path.join(tmpDir, '.planning', 'phases', '14-auth-hardening');
const p2 = path.join(tmpDir, '.planning', 'phases', '15-proof-generation');
fs.mkdirSync(p1, { recursive: true });
fs.mkdirSync(p2, { recursive: true });
fs.writeFileSync(path.join(p1, '14-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '14-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
fs.writeFileSync(path.join(p2, '15-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p2, '15-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(p2, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [x] **Phase 14: Auth Hardening**
- [x] **Phase 15: Proof Generation**
- [ ] **Phase 16: Multi-Claim Verification & UX**
## Milestone v1.0 Growth
### Phase 14: Auth Hardening
**Goal:** Improve auth checks
### Phase 15: Proof Generation
**Goal:** Improve proof generation
### Phase 16: Multi-Claim Verification & UX
**Goal:** Support multi-claim verification
`
);
const result = runGsdTools('stats', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const stats = JSON.parse(result.output);
assert.strictEqual(stats.phases_total, 3);
assert.strictEqual(stats.phases_completed, 2);
assert.strictEqual(stats.percent, 67);
assert.strictEqual(stats.plan_percent, 100);
assert.strictEqual(
stats.phases.find(p => p.number === '16')?.name,
'Multi-Claim Verification & UX'
);
assert.strictEqual(
stats.phases.find(p => p.number === '16')?.status,
'Not Started'
);
});
test('reports git commit count and first commit date from repository history', () => {
execSync('git init', { cwd: tmpDir, stdio: 'pipe' });
execSync('git config user.email "test@example.com"', { cwd: tmpDir, stdio: 'pipe' });
execSync('git config user.name "Test User"', { cwd: tmpDir, stdio: 'pipe' });
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n');
execSync('git add -A', { cwd: tmpDir, stdio: 'pipe' });
execSync('git commit -m "initial commit"', {
cwd: tmpDir,
stdio: 'pipe',
env: {
...process.env,
GIT_AUTHOR_DATE: '2026-01-01T00:00:00Z',
GIT_COMMITTER_DATE: '2026-01-01T00:00:00Z',
},
});
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Updated\n');
execSync('git add README.md', { cwd: tmpDir, stdio: 'pipe' });
execSync('git commit -m "second commit"', {
cwd: tmpDir,
stdio: 'pipe',
env: {
...process.env,
GIT_AUTHOR_DATE: '2026-02-01T00:00:00Z',
GIT_COMMITTER_DATE: '2026-02-01T00:00:00Z',
},
});
const result = runGsdTools('stats', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const stats = JSON.parse(result.output);
assert.strictEqual(stats.git_commits, 2);
assert.strictEqual(stats.git_first_commit_date, '2026-01-01');
});
test('table format renders readable output', () => {
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
const result = runGsdTools('stats table', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.ok(parsed.rendered, 'table format should include rendered field');
assert.ok(parsed.rendered.includes('Statistics'), 'should include Statistics header');
assert.ok(parsed.rendered.includes('| Phase |'), 'should include table header');
assert.ok(parsed.rendered.includes('| 1 |'), 'should include phase row');
assert.ok(parsed.rendered.includes('1/1 phases'), 'should report phase progress');
});
test('phase with summaries but no verification is Executed, not Complete', () => {
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
const result = runGsdTools('stats', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const stats = JSON.parse(result.output);
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
assert.strictEqual(phase.status, 'Executed', 'should be Executed without verification');
assert.strictEqual(stats.phases_completed, 0, 'unverified phase should not count as completed');
});
test('phase with passing verification is Complete', () => {
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification');
const result = runGsdTools('stats', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const stats = JSON.parse(result.output);
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
assert.strictEqual(phase.status, 'Complete', 'should be Complete with passing verification');
assert.strictEqual(stats.phases_completed, 1);
});
test('phase with gaps_found verification is Executed', () => {
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: gaps_found\n---\n# Verification');
const result = runGsdTools('stats', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const stats = JSON.parse(result.output);
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
assert.strictEqual(phase.status, 'Executed', 'gaps_found should show as Executed');
});
test('phase with human_needed verification shows Needs Review', () => {
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: human_needed\n---\n# Verification');
const result = runGsdTools('stats', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const stats = JSON.parse(result.output);
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
assert.strictEqual(phase.status, 'Needs Review', 'human_needed should show as Needs Review');
});
test('progress command also uses verification-aware status', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0 MVP\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
const result = runGsdTools('progress json', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].status, 'Executed', 'progress should show Executed without verification');
});
test('does not duplicate phases when ROADMAP uses unpadded numbers and dirs use padded numbers', () => {
// ROADMAP.md uses "Phase 1:" (unpadded) but directory is "01-auth" (padded).
// Without normalization, the Map holds two entries: "1" and "01", doubling phases_total.
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'## Milestone v1',
'',
'### Phase 1: Auth',
'**Goal:** Authentication',
].join('\n')
);
const result = runGsdTools('stats', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const stats = JSON.parse(result.output);
assert.strictEqual(stats.phases_total, 1, 'unpadded ROADMAP heading and padded dir should merge into one phase');
assert.strictEqual(stats.phases_completed, 1);
assert.strictEqual(stats.phases.length, 1);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// check-commit command (#1395)
// ─────────────────────────────────────────────────────────────────────────────
describe('check-commit command', () => {
const { createTempGitProject } = require('./helpers.cjs');
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('allows commit when commit_docs is true', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: true })
);
const result = runGsdTools('check-commit', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.allowed, true);
});
test('allows commit when no .planning/ files staged and commit_docs is false', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: false })
);
// Stage a non-planning file
fs.writeFileSync(path.join(tmpDir, 'src.js'), 'console.log("hi")');
execSync('git add src.js', { cwd: tmpDir, stdio: 'pipe' });
const result = runGsdTools('check-commit', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.allowed, true);
});
test('blocks commit when .planning/ files staged and commit_docs is false', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: false })
);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State');
execSync('git add .planning/STATE.md', { cwd: tmpDir, stdio: 'pipe' });
const result = runGsdTools('check-commit', tmpDir);
assert.ok(!result.success, 'should block commit');
assert.ok(result.error.includes('.planning/'), 'error should mention .planning/ files');
assert.ok(result.error.includes('unstage'), 'error should suggest unstage command');
});
});
describe('_wsParseRetryAfter (#308)', () => {
const { _wsParseRetryAfter } = require('../gsd-core/bin/lib/commands.cjs');
test('integer seconds: "120" → 60000 (capped at 60s)', () => {
assert.strictEqual(_wsParseRetryAfter('120'), 60000);
});
test('leading zero: "01" → 1000', () => {
assert.strictEqual(_wsParseRetryAfter('01'), 1000);
});
test('whitespace: " 5 " → 5000', () => {
assert.strictEqual(_wsParseRetryAfter(' 5 '), 5000);
});
test('"0" → 0', () => {
assert.strictEqual(_wsParseRetryAfter('0'), 0);
});
test('value > 60s cap: "120000" → 60000', () => {
assert.strictEqual(_wsParseRetryAfter('120000'), 60000);
});
test('future HTTP-date → value in (0, 60000]', () => {
const futureDate = new Date(Date.now() + 5000).toUTCString();
const v = _wsParseRetryAfter(futureDate);
assert.ok(typeof v === 'number' && v > 0 && v <= 60000, `expected (0,60000], got ${v}`);
});
test('past HTTP-date → 0', () => {
const pastDate = new Date(Date.now() - 5000).toUTCString();
assert.strictEqual(_wsParseRetryAfter(pastDate), 0);
});
test('"garbage" → null', () => {
assert.strictEqual(_wsParseRetryAfter('garbage'), null);
});
test('"" → null', () => {
assert.strictEqual(_wsParseRetryAfter(''), null);
});
test('null → null', () => {
assert.strictEqual(_wsParseRetryAfter(null), null);
});
});
// ─── Regressions: bug #1145 — query user-story.validate phantom command ────
//
// `query user-story.validate` was invoked by mvp-phase.md and verify-work.md
// but had no CJS handler (phantom command). Every invocation errored with
// "Unknown command: user-story — did you mean: user-story validate?".
//
// Calls the CLI via runGsdTools; no readFileSync source-grep.
describe('user-story validate command (bug #1145)', () => {
// Helper: call `query user-story.validate --story <story>` and parse JSON.
function validateStory(story) {
const result = runGsdTools(['query', 'user-story.validate', '--story', story]);
assert.equal(result.success, true, `user-story.validate exited non-zero: ${result.error || result.output}`);
let parsed;
try { parsed = JSON.parse(result.output); } catch {
assert.fail(`output was not valid JSON: ${result.output}`);
}
return parsed;
}
// Helper: call with --pick valid, return trimmed output string.
function validateStoryPickValid(story) {
const result = runGsdTools(['query', 'user-story.validate', '--story', story, '--pick', 'valid']);
assert.equal(result.success, true, `user-story.validate --pick valid exited non-zero: ${result.error || result.output}`);
return result.output.trim();
}
test('command is reachable — not a phantom (negative proof of bug #1145)', () => {
// Before the fix: exit 1 with "Unknown command: user-story"
const result = runGsdTools(['query', 'user-story.validate', '--story', 'As a user, I want to log in, so that I can access my account.']);
assert.equal(result.success, true, `Expected exit 0 but got: ${result.error || result.output}`);
});
test('canonical well-formed story returns { valid: true, errors: [], slots }', () => {
const out = validateStory('As a new user, I want to register and log in, so that I can access my account.');
assert.equal(typeof out, 'object');
assert.equal(out.valid, true, `expected valid:true, got: ${JSON.stringify(out)}`);
assert.ok(!out.errors || out.errors.length === 0, `unexpected errors: ${JSON.stringify(out.errors)}`);
// Slot extraction (see verify-work.md: "returns slot extractions")
assert.ok(out.slots && typeof out.slots === 'object', `expected slots object, got: ${JSON.stringify(out.slots)}`);
assert.equal(out.slots.role, 'new user');
assert.equal(out.slots.capability, 'register and log in');
assert.equal(out.slots.outcome, 'I can access my account');
});
test('whitespace-only role slot returns { valid: false } (Codex finding: .+ accepted spaces)', () => {
// "As a ," — role is whitespace-only; must be rejected
const out = validateStory('As a , I want to build reports, so that I can share status.');
assert.equal(out.valid, false, `whitespace role must be invalid: ${JSON.stringify(out)}`);
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
assert.equal(out.slots, null, 'slots must be null on invalid story');
});
test('whitespace-only capability slot returns { valid: false }', () => {
// ", I want to ," — capability is whitespace-only
const out = validateStory('As a user, I want to , so that I can share status.');
assert.equal(out.valid, false, `whitespace capability must be invalid: ${JSON.stringify(out)}`);
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
});
test('whitespace-only outcome slot returns { valid: false }', () => {
// ", so that ." — outcome is whitespace-only
const out = validateStory('As a user, I want to build reports, so that .');
assert.equal(out.valid, false, `whitespace outcome must be invalid: ${JSON.stringify(out)}`);
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
});
test('empty string returns { valid: false } with non-empty errors array', () => {
const out = validateStory('');
assert.equal(out.valid, false);
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
});
test('story missing "As a" prefix returns { valid: false }', () => {
const out = validateStory('I want to register so that I can log in.');
assert.equal(out.valid, false);
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
});
test('story missing ", I want to" clause returns { valid: false }', () => {
const out = validateStory('As a user, so that I can log in.');
assert.equal(out.valid, false);
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
});
test('story missing ", so that" clause returns { valid: false }', () => {
const out = validateStory('As a user, I want to register and log in.');
assert.equal(out.valid, false);
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
});
test('story missing trailing period returns { valid: false }', () => {
const out = validateStory('As a user, I want to register, so that I can log in');
assert.equal(out.valid, false);
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
});
test('whitespace-only story returns { valid: false }', () => {
const out = validateStory(' ');
assert.equal(out.valid, false);
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
});
test('--pick valid returns bare "true" for valid story (verify-work.md call shape)', () => {
const out = validateStoryPickValid('As a developer, I want to run tests, so that I can catch regressions.');
assert.equal(out, 'true', `expected bare "true" but got: ${JSON.stringify(out)}`);
});
test('--pick valid returns bare "false" for invalid story (verify-work.md call shape)', () => {
const out = validateStoryPickValid('Not a user story at all.');
assert.equal(out, 'false', `expected bare "false" but got: ${JSON.stringify(out)}`);
});
test('mvp-phase.md call shape: result has .valid boolean, .errors array, and .slots', () => {
// gsd_run query user-story.validate --story "$USER_STORY"
// mvp-phase.md uses: jq -r '.valid' and jq -r '.errors[]'
const out = validateStory('As a product manager, I want to export reports, so that I can share progress with stakeholders.');
assert.ok(Object.prototype.hasOwnProperty.call(out, 'valid'), 'missing "valid" field');
assert.ok(Object.prototype.hasOwnProperty.call(out, 'errors'), 'missing "errors" field');
assert.ok(Object.prototype.hasOwnProperty.call(out, 'slots'), 'missing "slots" field');
assert.equal(typeof out.valid, 'boolean');
assert.ok(Array.isArray(out.errors));
// slots is object on success, null on failure
assert.equal(out.valid, true);
assert.equal(typeof out.slots, 'object');
assert.notEqual(out.slots, null);
});
test('dotted-form (user-story.validate) works identically to spaced form', () => {
// Canonical dotted invocation used by workflows
const result = runGsdTools(['query', 'user-story.validate', '--story', 'As a user, I want to log in, so that I can see my dashboard.']);
assert.equal(result.success, true, `dotted form failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.equal(out.valid, true);
});
test('boundary — minimal valid story passes', () => {
const out = validateStory('As a X, I want to Y, so that Z.');
assert.equal(out.valid, true, `minimal valid story should pass: ${JSON.stringify(out)}`);
});
});
// ---------------------------------------------------------------------------
// pr-subrepo — regressions (#666) + workflow source invariants
// ---------------------------------------------------------------------------
describe('pr-subrepo', () => {
function writePrSubrepoConfig(dir, obj) {
const planningDir = path.join(dir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify(obj, null, 2));
}
function initPrSubrepo(dir) {
fs.mkdirSync(dir, { recursive: true });
execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' });
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'pipe' });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'pipe' });
fs.writeFileSync(path.join(dir, '.gitkeep'), '');
fs.writeFileSync(path.join(dir, 'feature.js'), '// initial\n');
fs.writeFileSync(path.join(dir, 'a.js'), '// initial\n');
fs.writeFileSync(path.join(dir, 'b.js'), '// initial\n');
execFileSync('git', ['add', '.gitkeep', 'feature.js', 'a.js', 'b.js'], { cwd: dir, stdio: 'pipe' });
execFileSync('git', ['commit', '-m', 'chore: initial commit'], { cwd: dir, stdio: 'pipe' });
}
function wirePrSubrepoRemote(repoDir, bareDir) {
fs.mkdirSync(bareDir, { recursive: true });
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' });
execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: repoDir, stdio: 'pipe' });
const branch = execFileSync('git', ['branch', '--show-current'], {
cwd: repoDir, encoding: 'utf8',
}).trim();
execFileSync('git', ['push', 'origin', branch], { cwd: repoDir, stdio: 'pipe' });
}
describe('regressions (#666 — cmdPrSubrepo seam)', () => {
let rootDir;
let subDir;
let bareDir;
beforeEach(() => {
rootDir = createTempDir('gsd-666-root-');
subDir = path.join(rootDir, 'backend');
bareDir = path.join(rootDir, '_bare-backend.git');
writePrSubrepoConfig(rootDir, { planning: { sub_repos: ['backend'] } });
initPrSubrepo(subDir);
wirePrSubrepoRemote(subDir, bareDir);
});
afterEach(() => {
cleanup(rootDir);
});
test('config-get planning.sub_repos resolves canonical config location', () => {
const res = runGsdTools(['query', 'config-get', 'planning.sub_repos'], rootDir);
assert.ok(res.success, `config-get planning.sub_repos failed: ${res.error}`);
assert.deepStrictEqual(JSON.parse(res.output), ['backend']);
});
test('config-get sub_repos (top-level) fails — confirming bug #666 Blocker 1 is gone', () => {
const res = runGsdTools(['query', 'config-get', 'sub_repos'], rootDir);
assert.ok(!res.success, 'top-level sub_repos key must not resolve — fix requires planning.sub_repos');
});
test('pr-subrepo happy path: branch created, files staged explicitly, commit pushed', () => {
fs.writeFileSync(path.join(subDir, 'feature.js'), 'module.exports = 42;\n');
const res = runGsdTools(
['query', 'pr-subrepo', 'fix(backend): add feature',
'--repo', 'backend', '--branch', 'fix-666-backend-pr'],
rootDir
);
assert.ok(res.success, `pr-subrepo failed: ${res.error}`);
const result = JSON.parse(res.output);
assert.strictEqual(result.ok, true);
assert.strictEqual(result.repo, 'backend');
assert.strictEqual(result.branch, 'fix-666-backend-pr');
assert.strictEqual(result.committed, true);
assert.ok(Array.isArray(result.files) && result.files.length > 0);
assert.ok(result.files.includes('feature.js'), `feature.js missing from files: ${JSON.stringify(result.files)}`);
assert.ok(typeof result.commit_hash === 'string' && result.commit_hash.length > 0);
});
test('pr-subrepo stages files explicitly — result.files lists every changed file', () => {
fs.writeFileSync(path.join(subDir, 'a.js'), '1\n');
fs.writeFileSync(path.join(subDir, 'b.js'), '2\n');
const res = runGsdTools(
['query', 'pr-subrepo', 'fix(backend): two files',
'--repo', 'backend', '--branch', 'fix-666-explicit-pr'],
rootDir
);
assert.ok(res.success, `pr-subrepo failed: ${res.error}`);
const result = JSON.parse(res.output);
assert.ok(result.files.includes('a.js'), 'a.js must be staged');
assert.ok(result.files.includes('b.js'), 'b.js must be staged');
});
test('pr-subrepo: nothing_to_commit when sub-repo is clean', () => {
const res = runGsdTools(
['query', 'pr-subrepo', 'fix(backend): nothing',
'--repo', 'backend', '--branch', 'fix-666-clean-pr'],
rootDir
);
assert.ok(res.success, `pr-subrepo should succeed on clean repo: ${res.error}`);
const result = JSON.parse(res.output);
assert.strictEqual(result.ok, true);
assert.strictEqual(result.committed, false);
assert.strictEqual(result.reason, 'nothing_to_commit');
});
test('pr-subrepo: duplicate branch guard — errors when branch already exists', () => {
fs.writeFileSync(path.join(subDir, 'a.js'), '1\n');
const first = runGsdTools(
['query', 'pr-subrepo', 'fix(backend): first',
'--repo', 'backend', '--branch', 'fix-666-dup-pr'],
rootDir
);
assert.ok(first.success, `first call failed: ${first.error}`);
fs.writeFileSync(path.join(subDir, 'b.js'), '2\n');
const second = runGsdTools(
['query', 'pr-subrepo', 'fix(backend): second',
'--repo', 'backend', '--branch', 'fix-666-dup-pr'],
rootDir
);
assert.ok(!second.success, 'Expected failure on duplicate branch name');
assert.ok(second.error.includes('already exists'), `Got: ${second.error}`);
});
test('pr-subrepo: missing --repo returns descriptive error', () => {
const res = runGsdTools(
['query', 'pr-subrepo', 'fix: msg', '--branch', 'some-branch'],
rootDir
);
assert.ok(!res.success);
assert.ok(res.error.includes('--repo required'), `Got: ${res.error}`);
});
test('pr-subrepo: missing --branch returns descriptive error', () => {
const res = runGsdTools(
['query', 'pr-subrepo', 'fix: msg', '--repo', 'backend'],
rootDir
);
assert.ok(!res.success);
assert.ok(res.error.includes('--branch required'), `Got: ${res.error}`);
});
test('pr-subrepo: missing commit message returns descriptive error', () => {
const res = runGsdTools(
['query', 'pr-subrepo', '--repo', 'backend', '--branch', 'some-branch'],
rootDir
);
assert.ok(!res.success);
assert.ok(res.error.includes('commit message required'), `Got: ${res.error}`);
});
test('pr-subrepo: non-existent repo path returns descriptive error', () => {
const res = runGsdTools(
['query', 'pr-subrepo', 'fix: msg', '--repo', 'nonexistent', '--branch', 'some-branch'],
rootDir
);
assert.ok(!res.success);
assert.ok(
res.error.includes('not found') || res.error.includes('nonexistent'),
`Got: ${res.error}`
);
});
test('pr-subrepo: path traversal (../escape) is rejected', () => {
const res = runGsdTools(
['query', 'pr-subrepo', 'fix: msg', '--repo', '../escape', '--branch', 'some-branch'],
rootDir
);
assert.ok(!res.success, 'Expected failure on path traversal attempt');
assert.ok(
res.error.includes('unsafe') || res.error.includes('escape'),
`Got: ${res.error}`
);
});
test('pr-subrepo push failure: branch+commit survive when push is rejected (no data loss)', () => {
// Reproduce the data-loss scenario flagged in review: a rejecting remote must leave
// the local branch+commit intact so the user can retry git push manually.
const branch = 'fix-666-push-fail-pr';
// Wire a bare remote with a pre-receive hook that rejects all pushes.
const rejectingBare = path.join(rootDir, '_rejecting-bare.git');
fs.mkdirSync(rejectingBare, { recursive: true });
execFileSync('git', ['init', '--bare'], { cwd: rejectingBare, stdio: 'pipe' });
const hookPath = path.join(rejectingBare, 'hooks', 'pre-receive');
fs.writeFileSync(hookPath, '#!/bin/sh\nexit 1\n');
fs.chmodSync(hookPath, 0o755);
// Point origin at the rejecting bare (overwrite the working one wired in beforeEach).
execFileSync('git', ['remote', 'set-url', 'origin', rejectingBare], { cwd: subDir, stdio: 'pipe' });
fs.writeFileSync(path.join(subDir, 'feature.js'), 'IMPORTANT USER WORK\n');
const res = runGsdTools(
['query', 'pr-subrepo', 'fix(backend): push-fail test',
'--repo', 'backend', '--branch', branch],
rootDir
);
// Command must fail because push was rejected.
assert.ok(!res.success, `Expected failure on rejected push, got success: ${res.output}`);
// The local branch must still exist — work must not be lost.
const branches = execFileSync('git', ['branch', '--list', branch], {
cwd: subDir, encoding: 'utf8',
});
assert.ok(branches.trim().length > 0, `Branch ${branch} was deleted after push failure — user work lost`);
// The commit on that branch must contain the user's changes.
const log = execFileSync('git', ['log', branch, '--oneline', '-1'], {
cwd: subDir, encoding: 'utf8',
});
assert.ok(log.trim().length > 0, `No commit on ${branch} — staged work was lost`);
});
test('pr-subrepo porcelain: staged rename — both old and new paths in result.files', () => {
// git mv produces "R old -> new" in porcelain v1; both paths must be staged.
execFileSync('git', ['mv', 'feature.js', 'renamed-feature.js'], { cwd: subDir, stdio: 'pipe' });
const res = runGsdTools(
['query', 'pr-subrepo', 'fix(backend): rename',
'--repo', 'backend', '--branch', 'fix-666-rename-pr'],
rootDir
);
assert.ok(res.success, `pr-subrepo failed: ${res.error}`);
const result = JSON.parse(res.output);
assert.ok(result.files.includes('feature.js'), `old path missing: ${JSON.stringify(result.files)}`);
assert.ok(result.files.includes('renamed-feature.js'), `new path missing: ${JSON.stringify(result.files)}`);
});
test('pr-subrepo porcelain: non-ASCII filename (core.quotePath=false)', () => {
// Without -c core.quotePath=false, "café.js" is C-escaped → slice(2) parse breaks.
fs.writeFileSync(path.join(subDir, 'café.js'), '// initial\n');
execFileSync('git', ['add', 'café.js'], { cwd: subDir, stdio: 'pipe' });
execFileSync('git', ['commit', '-m', 'chore: add café.js'], { cwd: subDir, stdio: 'pipe' });
fs.writeFileSync(path.join(subDir, 'café.js'), 'updated\n');
const res = runGsdTools(
['query', 'pr-subrepo', 'fix(backend): non-ascii',
'--repo', 'backend', '--branch', 'fix-666-nonascii-pr'],
rootDir
);
assert.ok(res.success, `pr-subrepo failed: ${res.error}`);
const result = JSON.parse(res.output);
assert.ok(result.files.includes('café.js'), `non-ASCII file missing: ${JSON.stringify(result.files)}`);
});
test('pr-subrepo porcelain: fc property — parsed filenames are always non-empty strings', () => {
// Local mirror of cmdPrSubrepo's porcelain line-parsing logic (commands.cts).
// Tests the transformation contract without needing a real git repo.
function parsePorcelainLine(line) {
const normalized = line.trimStart();
const file = normalized.slice(2).trim();
const arrowIdx = file.indexOf(' -> ');
return arrowIdx !== -1
? [file.slice(0, arrowIdx).trim(), file.slice(arrowIdx + 4).trim()]
: [file];
}
const safeFilename = fc.stringMatching(/^[a-zA-Z0-9._-]+$/);
const xyChar = fc.constantFrom('M', 'A', 'D', 'R', 'C', 'U');
const normalLine = fc.tuple(xyChar, xyChar, safeFilename)
.map(([x, y, f]) => `${x}${y} ${f}`);
const renameLine = fc.tuple(xyChar, safeFilename, safeFilename)
.map(([x, o, n]) => `${x} ${o} -> ${n}`);
// First-line trim edge case: leading space stripped by execGit global trim
const trimmedLine = fc.tuple(xyChar, safeFilename)
.map(([y, f]) => ` ${y} ${f}`);
fc.assert(fc.property(
fc.oneof(normalLine, renameLine, trimmedLine),
(line) => {
const files = parsePorcelainLine(line);
return files.length > 0 && files.every(f => typeof f === 'string' && f.length > 0);
}
));
});
});
describe('workflow source invariants (#666 — pr-branch.md)', () => {
// allow-test-rule: source-text-is-the-product see #666
// pr-branch.md is a workflow file whose deployed text IS the runtime contract.
const workflowPath = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'pr-branch.md');
let wfContent;
test('setup', () => {
wfContent = fs.readFileSync(workflowPath, 'utf-8');
assert.ok(wfContent.length > 0);
});
test('uses planning.sub_repos (canonical key) — not legacy top-level sub_repos', () => {
wfContent = wfContent || fs.readFileSync(workflowPath, 'utf-8');
assert.ok(wfContent.includes('planning.sub_repos'), 'must call config-get planning.sub_repos');
assert.ok(
!/config-get sub_repos(?!\.)/.test(wfContent),
'must not call config-get sub_repos without the planning. prefix'
);
});
test('delegates git work to gsd_run query pr-subrepo — no inline git add -A in code', () => {
wfContent = wfContent || fs.readFileSync(workflowPath, 'utf-8');
assert.ok(wfContent.includes('pr-subrepo'), 'must invoke the pr-subrepo seam');
const hasForbiddenGitAdd = /^\s*git(?:\s+-C\s+\S+)?\s+add\s+(?:-A|\.)\b/m.test(wfContent);
assert.ok(!hasForbiddenGitAdd, 'must not use git add -A or git add . as a shell command');
});
test('persists dirty-repo list without bash arrays (temp file or inline string)', () => {
wfContent = wfContent || fs.readFileSync(workflowPath, 'utf-8');
assert.ok(
!wfContent.includes('DIRTY_REPOS=()') && !wfContent.includes('DIRTY_REPOS+='),
'bash arrays must not be used — they do not survive across command blocks'
);
});
test('branch name includes repo-specific slug to avoid root PR_BRANCH collision', () => {
wfContent = wfContent || fs.readFileSync(workflowPath, 'utf-8');
assert.ok(
/REPO_SAFE|SUB_BRANCH.*REPO/.test(wfContent),
'sub-repo branch name must embed a repo-specific component'
);
});
test('handle_sub_repos positioned before analyze_commits', () => {
wfContent = wfContent || fs.readFileSync(workflowPath, 'utf-8');
const a = wfContent.indexOf('handle_sub_repos');
const b = wfContent.indexOf('analyze_commits');
assert.ok(a !== -1 && b !== -1 && a < b);
});
test('dirty-scan rejects traversal, newline, and symlink entries before invoking git (security)', () => {
// Extracts and executes the ACTUAL node -e script shipped in pr-branch.md — not a
// mirror — so this test fails if the real script regresses, not just a copy of it.
wfContent = wfContent || fs.readFileSync(workflowPath, 'utf-8');
const match = wfContent.match(/node -e "([\s\S]*?)"\s+"\$SUB_REPOS_JSON" "\$ROOT" "\$DIRTY_FILE"/);
assert.ok(match, 'could not extract dirty-scan node script from pr-branch.md');
const script = match[1];
// Helper: init a git repo with a TRACKED dirty change. An untracked file would be
// filtered by the ?? exclusion and the repo would look clean even without the guard,
// making the assertions vacuous. A tracked modification ensures that WITHOUT the
// guard the repo WOULD be reported dirty, so the test genuinely fails-first.
const initDirtyRepo = (dir, file) => {
execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' });
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'pipe' });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'pipe' });
fs.writeFileSync(path.join(dir, file), 'committed\n');
execFileSync('git', ['add', file], { cwd: dir, stdio: 'pipe' });
execFileSync('git', ['-c', 'commit.gpgsign=false', 'commit', '-m', 'init'], { cwd: dir, stdio: 'pipe' });
fs.writeFileSync(path.join(dir, file), 'modified\n');
};
const scanRoot = createTempDir('gsd-666-scan-root-');
const outsideDir = createTempDir('gsd-666-scan-outside-');
initDirtyRepo(outsideDir, 'secret.txt');
// Positive control: a legit dirty sub-repo INSIDE the workspace must still be reported,
// so the test can't pass by a guard that simply rejects everything.
const backendDir = path.join(scanRoot, 'backend');
fs.mkdirSync(backendDir, { recursive: true });
initDirtyRepo(backendDir, 'app.js');
// Symlink escape: an in-tree name with no ".." and no "/" that points outside root.
// path.resolve would keep it "inside"; only realpathSync catches it. Symlink
// creation needs privileges on Windows — skip just this vector if it throws.
let symlinked = true;
try { fs.symlinkSync(outsideDir, path.join(scanRoot, 'evil')); } catch { symlinked = false; }
const traversalEntry = path.relative(scanRoot, outsideDir); // e.g. "../gsd-666-scan-outside-XXXX"
const newlineEntry = 'good\nbad'; // record-separator injection attempt
const dirtyFile = path.join(scanRoot, '_dirty');
const entries = symlinked
? ['evil', traversalEntry, newlineEntry, 'backend']
: [traversalEntry, newlineEntry, 'backend'];
const subReposJson = JSON.stringify(entries);
try {
execFileSync('node', ['-e', script, subReposJson, scanRoot, dirtyFile], { stdio: 'pipe' });
const dirty = fs.existsSync(dirtyFile) ? fs.readFileSync(dirtyFile, 'utf-8') : '';
const lines = dirty.split('\n').filter(Boolean);
assert.ok(
!dirty.includes(path.basename(outsideDir)),
`Path traversal reached git outside the workspace: ${JSON.stringify(dirty)}`
);
if (symlinked) {
assert.ok(
!lines.includes('evil'),
`Symlink entry reached git outside the workspace: ${JSON.stringify(dirty)}`
);
}
assert.ok(
!lines.includes('bad'),
`Embedded-newline entry injected a spurious record: ${JSON.stringify(dirty)}`
);
assert.deepStrictEqual(
lines, ['backend'],
`Positive control failed — expected only 'backend', got: ${JSON.stringify(lines)}`
);
} finally {
cleanup(scanRoot);
cleanup(outsideDir);
}
});
});
});