* 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>
2781 lines
113 KiB
JavaScript
2781 lines
113 KiB
JavaScript
// 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);
|
||
}
|
||
});
|
||
});
|
||
});
|