Merge pull request #763 from ethan-hurst/feat/coverage-hardening

test: comprehensive test suite — 433 tests, 94% line coverage across all modules
This commit is contained in:
TÂCHES
2026-02-26 23:38:21 -06:00
committed by GitHub
21 changed files with 6746 additions and 14 deletions

50
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Tests
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
test:
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: true
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
if: github.event_name != 'pull_request'
run: npm test
- name: Run tests with coverage
# c8 v11 requires Node 20+ (engines: ^20.0.0 || >=22.0.0). Node 18 EOL April 2025.
if: github.event_name == 'pull_request' && matrix.node-version != 18
run: npm run test:coverage
- name: Run tests (Node 18, coverage not supported)
if: github.event_name == 'pull_request' && matrix.node-version == 18
run: npm test

3
.gitignore vendored
View File

@@ -11,6 +11,9 @@ commands.html
# Build artifacts (committed to npm, not git)
hooks/dist/
# Coverage artifacts
coverage/
# Animation assets
animation/
*.gif

View File

@@ -0,0 +1,37 @@
---
phase: 13-verification-milestone-cleanup
plan: "03"
subsystem: documentation
tags: [verification, coverage, cov-tooling, retroactive]
requirements-completed: [COV-01, COV-02, COV-03]
duration: 2min
completed: 2026-02-25
---
# Phase 13 Plan 03: Create 12-VERIFICATION.md Summary
**12-VERIFICATION.md created for Phase 12, retroactively confirming COV-01, COV-02, COV-03 with live test run evidence**
## Performance
- **Duration:** 2 min
- **Completed:** 2026-02-25
- **Tasks:** 2
- **Files modified:** 1
## Accomplishments
- Ran `npm run test:coverage` (433 pass, 0 fail; all 11 modules above 70%) to capture live evidence
- Confirmed CI workflow has `Run tests with coverage` step with `if: github.event_name == 'pull_request'`
- Created 12-VERIFICATION.md at .planning/phases/12-coverage-tooling/12-VERIFICATION.md
- All 3 COV requirements (COV-01–03) confirmed PASS with specific evidence
- Phase 12 Goal verified: c8 integrated, 70% threshold enforced, CI workflow operational
## Files Created/Modified
- `.planning/phases/12-coverage-tooling/12-VERIFICATION.md` — Phase 12 verification (72 lines)
## Self-Check: PASSED
- 12-VERIFICATION.md: created, verified COV-01–03 references present (grep count: 5)
- Commit: bd9bdad (docs)

View File

@@ -8,6 +8,7 @@
[![npm version](https://img.shields.io/npm/v/get-shit-done-cc?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/get-shit-done-cc)
[![npm downloads](https://img.shields.io/npm/dm/get-shit-done-cc?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/get-shit-done-cc)
[![Tests](https://img.shields.io/github/actions/workflow/status/glittercowboy/get-shit-done/test.yml?branch=main&style=for-the-badge&logo=github&label=Tests)](https://github.com/glittercowboy/get-shit-done/actions/workflows/test.yml)
[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/gsd)
[![X (Twitter)](https://img.shields.io/badge/X-@gsd__foundation-000000?style=for-the-badge&logo=x&logoColor=white)](https://x.com/gsd_foundation)
[![$GSD Token](https://img.shields.io/badge/$GSD-Dexscreener-1C1C1C?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIgZmlsbD0iIzAwRkYwMCIvPjwvc3ZnPg==&logoColor=00FF00)](https://dexscreener.com/solana/dwudwjvan7bzkw9zwlbyv6kspdlvhwzrqy6ebk8xzxkv)

View File

@@ -204,17 +204,12 @@ function cmdResolveModel(cwd, agentType, raw) {
const config = loadConfig(cwd);
const profile = config.model_profile || 'balanced';
const model = resolveModelInternal(cwd, agentType);
const agentModels = MODEL_PROFILES[agentType];
if (!agentModels) {
const result = { model: 'sonnet', profile, unknown_agent: true };
output(result, raw, 'sonnet');
return;
}
const resolved = agentModels[profile] || agentModels['balanced'] || 'sonnet';
const model = resolved === 'opus' ? 'inherit' : resolved;
const result = { model, profile };
const result = agentModels
? { model, profile }
: { model, profile, unknown_agent: true };
output(result, raw, model);
}

View File

@@ -104,6 +104,7 @@ function loadConfig(cwd) {
verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
parallelization,
brave_search: get('brave_search') ?? defaults.brave_search,
model_overrides: parsed.model_overrides || null,
};
} catch {
return defaults;

701
package-lock.json generated
View File

@@ -12,12 +12,23 @@
"get-shit-done-cc": "bin/install.js"
},
"devDependencies": {
"c8": "^11.0.0",
"esbuild": "^0.24.0"
},
"engines": {
"node": ">=16.7.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
@@ -443,6 +454,198 @@
"node": ">=18"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"dev": true,
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/c8": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz",
"integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==",
"dev": true,
"license": "ISC",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.1",
"@istanbuljs/schema": "^0.1.3",
"find-up": "^5.0.0",
"foreground-child": "^3.1.1",
"istanbul-lib-coverage": "^3.2.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.1.6",
"test-exclude": "^8.0.0",
"v8-to-istanbul": "^9.0.0",
"yargs": "^17.7.2",
"yargs-parser": "^21.1.1"
},
"bin": {
"c8": "bin/c8.js"
},
"engines": {
"node": "20 || >=22"
},
"peerDependencies": {
"monocart-coverage-reports": "^2"
},
"peerDependenciesMeta": {
"monocart-coverage-reports": {
"optional": true
}
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
@@ -483,6 +686,504 @@
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lru-cache": {
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz",
"integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/test-exclude": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
"integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^13.0.6",
"minimatch": "^10.2.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.12",
"@types/istanbul-lib-coverage": "^2.0.1",
"convert-source-map": "^2.0.0"
},
"engines": {
"node": ">=10.12.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}

View File

@@ -38,13 +38,14 @@
"engines": {
"node": ">=16.7.0"
},
"dependencies": {},
"devDependencies": {
"c8": "^11.0.0",
"esbuild": "^0.24.0"
},
"scripts": {
"build:hooks": "node scripts/build-hooks.js",
"prepublishOnly": "npm run build:hooks",
"test": "node --test tests/*.test.cjs"
"test": "node --test tests/*.test.cjs",
"test:coverage": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node --test tests/*.test.cjs"
}
}

View File

@@ -659,3 +659,530 @@ describe('scaffold command', () => {
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');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 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('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('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');
});
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)');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdWebsearch tests (CMD-05)
// ─────────────────────────────────────────────────────────────────────────────
describe('websearch command', () => {
const { cmdWebsearch } = require('../get-shit-done/bin/lib/commands.cjs');
let origFetch;
let origApiKey;
let origStdoutWrite;
let captured;
beforeEach(() => {
origFetch = global.fetch;
origApiKey = process.env.BRAVE_API_KEY;
origStdoutWrite = process.stdout.write;
captured = '';
process.stdout.write = (chunk) => { captured += chunk; return true; };
});
afterEach(() => {
global.fetch = origFetch;
if (origApiKey !== undefined) {
process.env.BRAVE_API_KEY = origApiKey;
} else {
delete process.env.BRAVE_API_KEY;
}
process.stdout.write = origStdoutWrite;
});
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: 429,
});
await cmdWebsearch('test query', {}, false);
const output = JSON.parse(captured);
assert.strictEqual(output.available, false);
assert.ok(output.error.includes('429'), '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');
});
});

355
tests/config.test.cjs Normal file
View File

@@ -0,0 +1,355 @@
/**
* GSD Tools Tests - config.cjs
*
* CLI integration tests for config-ensure-section, config-set, and config-get
* commands exercised through gsd-tools.cjs via execSync.
*
* Requirements: TEST-13
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── helpers ──────────────────────────────────────────────────────────────────
function readConfig(tmpDir) {
const configPath = path.join(tmpDir, '.planning', 'config.json');
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
function writeConfig(tmpDir, obj) {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify(obj, null, 2), 'utf-8');
}
// ─── config-ensure-section ───────────────────────────────────────────────────
describe('config-ensure-section command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('creates config.json with expected structure and types', () => {
const result = runGsdTools('config-ensure-section', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.created, true);
const config = readConfig(tmpDir);
// Verify structure and types — exact values may vary if ~/.gsd/defaults.json exists
assert.strictEqual(typeof config.model_profile, 'string');
assert.strictEqual(typeof config.commit_docs, 'boolean');
assert.strictEqual(typeof config.parallelization, 'boolean');
assert.strictEqual(typeof config.branching_strategy, 'string');
assert.ok(config.workflow && typeof config.workflow === 'object', 'workflow should be an object');
assert.strictEqual(typeof config.workflow.research, 'boolean');
assert.strictEqual(typeof config.workflow.plan_check, 'boolean');
assert.strictEqual(typeof config.workflow.verifier, 'boolean');
assert.strictEqual(typeof config.workflow.nyquist_validation, 'boolean');
// These hardcoded defaults are always present (may be overridden by user defaults)
assert.ok('model_profile' in config, 'model_profile should exist');
assert.ok('brave_search' in config, 'brave_search should exist');
assert.ok('search_gitignored' in config, 'search_gitignored should exist');
});
test('is idempotent — returns already_exists on second call', () => {
const first = runGsdTools('config-ensure-section', tmpDir);
assert.ok(first.success, `First call failed: ${first.error}`);
const firstOutput = JSON.parse(first.output);
assert.strictEqual(firstOutput.created, true);
const second = runGsdTools('config-ensure-section', tmpDir);
assert.ok(second.success, `Second call failed: ${second.error}`);
const secondOutput = JSON.parse(second.output);
assert.strictEqual(secondOutput.created, false);
assert.strictEqual(secondOutput.reason, 'already_exists');
});
// NOTE: This test touches ~/.gsd/ on the real filesystem. It uses save/restore
// try/finally and skips if the file already exists to avoid corrupting user config.
test('detects Brave Search from file-based key', () => {
const homedir = os.homedir();
const gsdDir = path.join(homedir, '.gsd');
const braveKeyFile = path.join(gsdDir, 'brave_api_key');
// Skip if file already exists (don't mess with user's real config)
if (fs.existsSync(braveKeyFile)) {
return;
}
// Create .gsd dir and brave_api_key file
const gsdDirExisted = fs.existsSync(gsdDir);
try {
if (!gsdDirExisted) {
fs.mkdirSync(gsdDir, { recursive: true });
}
fs.writeFileSync(braveKeyFile, 'test-key', 'utf-8');
const result = runGsdTools('config-ensure-section', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const config = readConfig(tmpDir);
assert.strictEqual(config.brave_search, true);
} finally {
// Clean up
try { fs.unlinkSync(braveKeyFile); } catch { /* ignore */ }
if (!gsdDirExisted) {
try { fs.rmdirSync(gsdDir); } catch { /* ignore if not empty */ }
}
}
});
// NOTE: This test touches ~/.gsd/ on the real filesystem. It uses save/restore
// try/finally and skips if the file already exists to avoid corrupting user config.
test('merges user defaults from defaults.json', () => {
const homedir = os.homedir();
const gsdDir = path.join(homedir, '.gsd');
const defaultsFile = path.join(gsdDir, 'defaults.json');
// Save existing defaults if present
let existingDefaults = null;
const gsdDirExisted = fs.existsSync(gsdDir);
if (fs.existsSync(defaultsFile)) {
existingDefaults = fs.readFileSync(defaultsFile, 'utf-8');
}
try {
if (!gsdDirExisted) {
fs.mkdirSync(gsdDir, { recursive: true });
}
fs.writeFileSync(defaultsFile, JSON.stringify({
model_profile: 'quality',
commit_docs: false,
}), 'utf-8');
const result = runGsdTools('config-ensure-section', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const config = readConfig(tmpDir);
assert.strictEqual(config.model_profile, 'quality', 'model_profile should be overridden');
assert.strictEqual(config.commit_docs, false, 'commit_docs should be overridden');
assert.strictEqual(typeof config.branching_strategy, 'string', 'branching_strategy should be a string');
} finally {
// Restore
if (existingDefaults !== null) {
fs.writeFileSync(defaultsFile, existingDefaults, 'utf-8');
} else {
try { fs.unlinkSync(defaultsFile); } catch { /* ignore */ }
}
if (!gsdDirExisted) {
try { fs.rmdirSync(gsdDir); } catch { /* ignore */ }
}
}
});
// NOTE: This test touches ~/.gsd/ on the real filesystem. It uses save/restore
// try/finally and skips if the file already exists to avoid corrupting user config.
test('merges nested workflow keys from defaults.json preserving unset keys', () => {
const homedir = os.homedir();
const gsdDir = path.join(homedir, '.gsd');
const defaultsFile = path.join(gsdDir, 'defaults.json');
let existingDefaults = null;
const gsdDirExisted = fs.existsSync(gsdDir);
if (fs.existsSync(defaultsFile)) {
existingDefaults = fs.readFileSync(defaultsFile, 'utf-8');
}
try {
if (!gsdDirExisted) {
fs.mkdirSync(gsdDir, { recursive: true });
}
fs.writeFileSync(defaultsFile, JSON.stringify({
workflow: { research: false },
}), 'utf-8');
const result = runGsdTools('config-ensure-section', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const config = readConfig(tmpDir);
assert.strictEqual(config.workflow.research, false, 'research should be overridden');
assert.strictEqual(typeof config.workflow.plan_check, 'boolean', 'plan_check should be a boolean');
assert.strictEqual(typeof config.workflow.verifier, 'boolean', 'verifier should be a boolean');
} finally {
if (existingDefaults !== null) {
fs.writeFileSync(defaultsFile, existingDefaults, 'utf-8');
} else {
try { fs.unlinkSync(defaultsFile); } catch { /* ignore */ }
}
if (!gsdDirExisted) {
try { fs.rmdirSync(gsdDir); } catch { /* ignore */ }
}
}
});
});
// ─── config-set ──────────────────────────────────────────────────────────────
describe('config-set command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
// Create initial config
runGsdTools('config-ensure-section', tmpDir);
});
afterEach(() => {
cleanup(tmpDir);
});
test('sets a top-level string value', () => {
const result = runGsdTools('config-set model_profile quality', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, true);
assert.strictEqual(output.key, 'model_profile');
assert.strictEqual(output.value, 'quality');
const config = readConfig(tmpDir);
assert.strictEqual(config.model_profile, 'quality');
});
test('coerces true to boolean', () => {
const result = runGsdTools('config-set commit_docs true', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const config = readConfig(tmpDir);
assert.strictEqual(config.commit_docs, true);
assert.strictEqual(typeof config.commit_docs, 'boolean');
});
test('coerces false to boolean', () => {
const result = runGsdTools('config-set commit_docs false', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const config = readConfig(tmpDir);
assert.strictEqual(config.commit_docs, false);
assert.strictEqual(typeof config.commit_docs, 'boolean');
});
test('coerces numeric strings to numbers', () => {
const result = runGsdTools('config-set some_number 42', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const config = readConfig(tmpDir);
assert.strictEqual(config.some_number, 42);
assert.strictEqual(typeof config.some_number, 'number');
});
test('preserves plain strings', () => {
const result = runGsdTools('config-set some_string hello', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const config = readConfig(tmpDir);
assert.strictEqual(config.some_string, 'hello');
assert.strictEqual(typeof config.some_string, 'string');
});
test('sets nested values via dot-notation', () => {
const result = runGsdTools('config-set workflow.research false', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const config = readConfig(tmpDir);
assert.strictEqual(config.workflow.research, false);
});
test('auto-creates nested objects for deep dot-notation', () => {
// Start with empty config
writeConfig(tmpDir, {});
const result = runGsdTools('config-set a.b.c deep_value', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const config = readConfig(tmpDir);
assert.strictEqual(config.a.b.c, 'deep_value');
assert.strictEqual(typeof config.a, 'object');
assert.strictEqual(typeof config.a.b, 'object');
});
test('errors when no key path provided', () => {
const result = runGsdTools('config-set', tmpDir);
assert.strictEqual(result.success, false);
});
});
// ─── config-get ──────────────────────────────────────────────────────────────
describe('config-get command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
// Create config with known values
runGsdTools('config-ensure-section', tmpDir);
});
afterEach(() => {
cleanup(tmpDir);
});
test('gets a top-level value', () => {
const result = runGsdTools('config-get model_profile', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output, 'balanced');
});
test('gets a nested value via dot-notation', () => {
const result = runGsdTools('config-get workflow.research', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output, true);
});
test('errors for nonexistent key', () => {
const result = runGsdTools('config-get nonexistent_key', tmpDir);
assert.strictEqual(result.success, false);
assert.ok(
result.error.includes('Key not found'),
`Expected "Key not found" in error: ${result.error}`
);
});
test('errors for deeply nested nonexistent key', () => {
const result = runGsdTools('config-get workflow.nonexistent', tmpDir);
assert.strictEqual(result.success, false);
assert.ok(
result.error.includes('Key not found'),
`Expected "Key not found" in error: ${result.error}`
);
});
test('errors when config.json does not exist', () => {
const emptyTmpDir = createTempProject();
try {
const result = runGsdTools('config-get model_profile', emptyTmpDir);
assert.strictEqual(result.success, false);
assert.ok(
result.error.includes('No config.json'),
`Expected "No config.json" in error: ${result.error}`
);
} finally {
cleanup(emptyTmpDir);
}
});
test('errors when no key path provided', () => {
const result = runGsdTools('config-get', tmpDir);
assert.strictEqual(result.success, false);
});
});

593
tests/core.test.cjs Normal file
View File

@@ -0,0 +1,593 @@
/**
* GSD Tools Tests - core.cjs
*
* Tests for the foundational module's exports including regressions
* for known bugs (REG-01: loadConfig model_overrides, REG-02: getRoadmapPhaseInternal export).
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const {
loadConfig,
resolveModelInternal,
MODEL_PROFILES,
escapeRegex,
generateSlugInternal,
normalizePhaseName,
comparePhaseNum,
safeReadFile,
pathExistsInternal,
getMilestoneInfo,
getRoadmapPhaseInternal,
searchPhaseInDir,
findPhaseInternal,
} = require('../get-shit-done/bin/lib/core.cjs');
// ─── loadConfig ────────────────────────────────────────────────────────────────
describe('loadConfig', () => {
let tmpDir;
let originalCwd;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
originalCwd = process.cwd();
});
afterEach(() => {
process.chdir(originalCwd);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeConfig(obj) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify(obj, null, 2)
);
}
test('returns defaults when config.json is missing', () => {
const config = loadConfig(tmpDir);
assert.strictEqual(config.model_profile, 'balanced');
assert.strictEqual(config.commit_docs, true);
assert.strictEqual(config.research, true);
assert.strictEqual(config.plan_checker, true);
assert.strictEqual(config.brave_search, false);
assert.strictEqual(config.parallelization, true);
});
test('reads model_profile from config.json', () => {
writeConfig({ model_profile: 'quality' });
const config = loadConfig(tmpDir);
assert.strictEqual(config.model_profile, 'quality');
});
test('reads nested config keys', () => {
writeConfig({ planning: { commit_docs: false } });
const config = loadConfig(tmpDir);
assert.strictEqual(config.commit_docs, false);
});
test('reads branching_strategy from git section', () => {
writeConfig({ git: { branching_strategy: 'per-phase' } });
const config = loadConfig(tmpDir);
assert.strictEqual(config.branching_strategy, 'per-phase');
});
// Bug: loadConfig previously omitted model_overrides from return value
test('returns model_overrides when present (REG-01)', () => {
writeConfig({ model_overrides: { 'gsd-executor': 'opus' } });
const config = loadConfig(tmpDir);
assert.deepStrictEqual(config.model_overrides, { 'gsd-executor': 'opus' });
});
test('returns model_overrides as null when not in config', () => {
writeConfig({ model_profile: 'balanced' });
const config = loadConfig(tmpDir);
assert.strictEqual(config.model_overrides, null);
});
test('returns defaults when config.json contains invalid JSON', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
'not valid json {{{{'
);
const config = loadConfig(tmpDir);
assert.strictEqual(config.model_profile, 'balanced');
assert.strictEqual(config.commit_docs, true);
});
test('handles parallelization as boolean', () => {
writeConfig({ parallelization: false });
const config = loadConfig(tmpDir);
assert.strictEqual(config.parallelization, false);
});
test('handles parallelization as object with enabled field', () => {
writeConfig({ parallelization: { enabled: false } });
const config = loadConfig(tmpDir);
assert.strictEqual(config.parallelization, false);
});
test('prefers top-level keys over nested keys', () => {
writeConfig({ commit_docs: false, planning: { commit_docs: true } });
const config = loadConfig(tmpDir);
assert.strictEqual(config.commit_docs, false);
});
});
// ─── resolveModelInternal ──────────────────────────────────────────────────────
describe('resolveModelInternal', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeConfig(obj) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify(obj, null, 2)
);
}
describe('model profile structural validation', () => {
test('all known agents resolve to a valid string for each profile', () => {
const knownAgents = ['gsd-planner', 'gsd-executor', 'gsd-phase-researcher', 'gsd-codebase-mapper'];
const profiles = ['quality', 'balanced', 'budget'];
const validValues = ['inherit', 'sonnet', 'haiku', 'opus'];
for (const profile of profiles) {
writeConfig({ model_profile: profile });
for (const agent of knownAgents) {
const result = resolveModelInternal(tmpDir, agent);
assert.ok(
validValues.includes(result),
`profile=${profile} agent=${agent} returned unexpected value: ${result}`
);
}
}
});
});
describe('override precedence', () => {
test('per-agent override takes precedence over profile', () => {
writeConfig({
model_profile: 'balanced',
model_overrides: { 'gsd-executor': 'haiku' },
});
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'haiku');
});
test('opus override resolves to inherit', () => {
writeConfig({
model_overrides: { 'gsd-executor': 'opus' },
});
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'inherit');
});
test('agents not in override fall back to profile', () => {
writeConfig({
model_profile: 'quality',
model_overrides: { 'gsd-executor': 'haiku' },
});
// gsd-planner not overridden, should use quality profile -> opus -> inherit
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'inherit');
});
});
describe('edge cases', () => {
test('returns sonnet for unknown agent type', () => {
writeConfig({ model_profile: 'balanced' });
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), 'sonnet');
});
test('defaults to balanced profile when model_profile missing', () => {
writeConfig({});
// balanced profile, gsd-planner -> opus -> inherit
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'inherit');
});
});
});
// ─── escapeRegex ───────────────────────────────────────────────────────────────
describe('escapeRegex', () => {
test('escapes dots', () => {
assert.strictEqual(escapeRegex('file.txt'), 'file\\.txt');
});
test('escapes all special regex characters', () => {
const input = '1.0 (alpha) [test] {ok} $100 ^start end$ a+b a*b a?b pipe|or back\\slash';
const result = escapeRegex(input);
// Verify each special char is escaped
assert.ok(result.includes('\\.'));
assert.ok(result.includes('\\('));
assert.ok(result.includes('\\)'));
assert.ok(result.includes('\\['));
assert.ok(result.includes('\\]'));
assert.ok(result.includes('\\{'));
assert.ok(result.includes('\\}'));
assert.ok(result.includes('\\$'));
assert.ok(result.includes('\\^'));
assert.ok(result.includes('\\+'));
assert.ok(result.includes('\\*'));
assert.ok(result.includes('\\?'));
assert.ok(result.includes('\\|'));
assert.ok(result.includes('\\\\'));
});
test('handles empty string', () => {
assert.strictEqual(escapeRegex(''), '');
});
test('returns plain string unchanged', () => {
assert.strictEqual(escapeRegex('hello'), 'hello');
});
});
// ─── generateSlugInternal ──────────────────────────────────────────────────────
describe('generateSlugInternal', () => {
test('converts text to lowercase kebab-case', () => {
assert.strictEqual(generateSlugInternal('Hello World'), 'hello-world');
});
test('removes special characters', () => {
assert.strictEqual(generateSlugInternal('core.cjs Tests!'), 'core-cjs-tests');
});
test('trims leading and trailing hyphens', () => {
assert.strictEqual(generateSlugInternal('---hello---'), 'hello');
});
test('returns null for null input', () => {
assert.strictEqual(generateSlugInternal(null), null);
});
test('returns null for empty string', () => {
assert.strictEqual(generateSlugInternal(''), null);
});
});
// ─── normalizePhaseName ────────────────────────────────────────────────────────
describe('normalizePhaseName', () => {
test('pads single digit', () => {
assert.strictEqual(normalizePhaseName('1'), '01');
});
test('preserves double digit', () => {
assert.strictEqual(normalizePhaseName('12'), '12');
});
test('handles letter suffix', () => {
assert.strictEqual(normalizePhaseName('1A'), '01A');
});
test('handles decimal phases', () => {
assert.strictEqual(normalizePhaseName('2.1'), '02.1');
});
test('handles multi-level decimals', () => {
assert.strictEqual(normalizePhaseName('1.2.3'), '01.2.3');
});
test('returns non-matching input unchanged', () => {
assert.strictEqual(normalizePhaseName('abc'), 'abc');
});
});
// ─── comparePhaseNum ───────────────────────────────────────────────────────────
describe('comparePhaseNum', () => {
test('sorts integer phases numerically', () => {
assert.ok(comparePhaseNum('1', '2') < 0);
assert.ok(comparePhaseNum('10', '2') > 0);
});
test('sorts letter suffixes', () => {
assert.ok(comparePhaseNum('12', '12A') < 0);
assert.ok(comparePhaseNum('12A', '12B') < 0);
});
test('sorts decimal phases', () => {
assert.ok(comparePhaseNum('2', '2.1') < 0);
assert.ok(comparePhaseNum('2.1', '2.2') < 0);
});
test('handles multi-level decimals', () => {
assert.ok(comparePhaseNum('1.1', '1.1.2') < 0);
assert.ok(comparePhaseNum('1.1.2', '1.2') < 0);
});
test('returns 0 for equal phases', () => {
assert.strictEqual(comparePhaseNum('1', '1'), 0);
assert.strictEqual(comparePhaseNum('2.1', '2.1'), 0);
});
});
// ─── safeReadFile ──────────────────────────────────────────────────────────────
describe('safeReadFile', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('reads existing file', () => {
const filePath = path.join(tmpDir, 'test.txt');
fs.writeFileSync(filePath, 'hello world');
assert.strictEqual(safeReadFile(filePath), 'hello world');
});
test('returns null for missing file', () => {
assert.strictEqual(safeReadFile('/nonexistent/path/file.txt'), null);
});
});
// ─── pathExistsInternal ────────────────────────────────────────────────────────
describe('pathExistsInternal', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('returns true for existing path', () => {
assert.strictEqual(pathExistsInternal(tmpDir, '.planning'), true);
});
test('returns false for non-existing path', () => {
assert.strictEqual(pathExistsInternal(tmpDir, 'nonexistent'), false);
});
test('handles absolute paths', () => {
assert.strictEqual(pathExistsInternal(tmpDir, tmpDir), true);
});
});
// ─── getMilestoneInfo ──────────────────────────────────────────────────────────
describe('getMilestoneInfo', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('extracts version and name from roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## Roadmap v1.2: My Cool Project\n\nSome content'
);
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.version, 'v1.2');
assert.strictEqual(info.name, 'My Cool Project');
});
test('returns defaults when roadmap missing', () => {
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.version, 'v1.0');
assert.strictEqual(info.name, 'milestone');
});
});
// ─── searchPhaseInDir ──────────────────────────────────────────────────────────
describe('searchPhaseInDir', () => {
let tmpDir;
let phasesDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
phasesDir = path.join(tmpDir, 'phases');
fs.mkdirSync(phasesDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('finds phase directory by normalized prefix', () => {
fs.mkdirSync(path.join(phasesDir, '01-foundation'));
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_number, '01');
assert.strictEqual(result.phase_name, 'foundation');
});
test('returns plans and summaries', () => {
const phaseDir = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phaseDir);
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary');
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.ok(result.plans.includes('01-01-PLAN.md'));
assert.ok(result.summaries.includes('01-01-SUMMARY.md'));
assert.strictEqual(result.incomplete_plans.length, 0);
});
test('identifies incomplete plans', () => {
const phaseDir = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phaseDir);
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan 2');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary 1');
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.strictEqual(result.incomplete_plans.length, 1);
assert.ok(result.incomplete_plans.includes('01-02-PLAN.md'));
});
test('detects research and context files', () => {
const phaseDir = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phaseDir);
fs.writeFileSync(path.join(phaseDir, '01-RESEARCH.md'), '# Research');
fs.writeFileSync(path.join(phaseDir, '01-CONTEXT.md'), '# Context');
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.strictEqual(result.has_research, true);
assert.strictEqual(result.has_context, true);
});
test('returns null when phase not found', () => {
fs.mkdirSync(path.join(phasesDir, '01-foundation'));
const result = searchPhaseInDir(phasesDir, '.planning/phases', '99');
assert.strictEqual(result, null);
});
test('generates phase_slug from directory name', () => {
fs.mkdirSync(path.join(phasesDir, '01-core-cjs-tests'));
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.strictEqual(result.phase_slug, 'core-cjs-tests');
});
});
// ─── findPhaseInternal ─────────────────────────────────────────────────────────
describe('findPhaseInternal', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('finds phase in current phases directory', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'));
const result = findPhaseInternal(tmpDir, '1');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_number, '01');
});
test('returns null for non-existent phase', () => {
const result = findPhaseInternal(tmpDir, '99');
assert.strictEqual(result, null);
});
test('returns null for null phase', () => {
const result = findPhaseInternal(tmpDir, null);
assert.strictEqual(result, null);
});
test('searches archived milestones when not in current', () => {
// Create archived milestone structure (no current phase match)
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-foundation');
fs.mkdirSync(archiveDir, { recursive: true });
const result = findPhaseInternal(tmpDir, '1');
assert.strictEqual(result.found, true);
assert.strictEqual(result.archived, 'v1.0');
});
});
// ─── getRoadmapPhaseInternal ───────────────────────────────────────────────────
describe('getRoadmapPhaseInternal', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// Bug: getRoadmapPhaseInternal was missing from module.exports
test('is exported from core.cjs (REG-02)', () => {
assert.strictEqual(typeof getRoadmapPhaseInternal, 'function');
// Also verify it works with a real roadmap (note: goal regex expects **Goal:** with colon inside bold)
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 1: Foundation\n**Goal:** Build the base\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '1');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_name, 'Foundation');
assert.strictEqual(result.goal, 'Build the base');
});
test('extracts phase name and goal from roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 2: API Layer\n**Goal:** Create REST endpoints\n**Depends on**: Phase 1\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '2');
assert.strictEqual(result.phase_name, 'API Layer');
assert.strictEqual(result.goal, 'Create REST endpoints');
});
test('returns null goal when Goal uses colon-outside-bold format', () => {
// Actual ROADMAP.md uses **Goal**: (colon outside bold) which the regex does not match
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 1: Foundation\n**Goal**: Build the base\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '1');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_name, 'Foundation');
assert.strictEqual(result.goal, null);
});
test('returns null when roadmap missing', () => {
const result = getRoadmapPhaseInternal(tmpDir, '1');
assert.strictEqual(result, null);
});
test('returns null when phase not in roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 1: Foundation\n**Goal**: Build the base\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '99');
assert.strictEqual(result, null);
});
test('returns null for null phase number', () => {
const result = getRoadmapPhaseInternal(tmpDir, null);
assert.strictEqual(result, null);
});
test('extracts full section text', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'### Phase 1: Foundation\n**Goal**: Build the base\n**Requirements**: TEST-01\nSome details here\n\n### Phase 2: API\n**Goal**: REST\n'
);
const result = getRoadmapPhaseInternal(tmpDir, '1');
assert.ok(result.section.includes('Phase 1: Foundation'));
assert.ok(result.section.includes('Some details here'));
// Should not include Phase 2 content
assert.ok(!result.section.includes('Phase 2: API'));
});
});

277
tests/dispatcher.test.cjs Normal file
View File

@@ -0,0 +1,277 @@
/**
* GSD Tools Tests - Dispatcher
*
* Tests for gsd-tools.cjs dispatch routing and error paths.
* Covers: no-command, unknown command, unknown subcommands for every command group,
* --cwd parsing, and previously untouched routing branches.
*
* Requirements: DISP-01, DISP-02
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── Dispatcher Error Paths ──────────────────────────────────────────────────
describe('dispatcher error paths', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// No command
test('no-command invocation prints usage and exits non-zero', () => {
const result = runGsdTools('', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Usage:'), `Expected "Usage:" in stderr, got: ${result.error}`);
});
// Unknown command
test('unknown command produces clear error and exits non-zero', () => {
const result = runGsdTools('nonexistent-cmd', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Unknown command'), `Expected "Unknown command" in stderr, got: ${result.error}`);
});
// --cwd= form with valid directory
test('--cwd= form overrides working directory', () => {
// Create STATE.md in tmpDir so state load can find it
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n## Current Position\n\nPhase: 1 of 1 (Test)\n'
);
const result = runGsdTools(`--cwd=${tmpDir} state load`, process.cwd());
assert.strictEqual(result.success, true, `Should succeed with --cwd=, got: ${result.error}`);
});
// --cwd= with empty value
test('--cwd= with empty value produces error', () => {
const result = runGsdTools('--cwd= state load', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Missing value for --cwd'), `Expected "Missing value for --cwd" in stderr, got: ${result.error}`);
});
// --cwd with nonexistent path
test('--cwd with invalid path produces error', () => {
const result = runGsdTools('--cwd /nonexistent/path/xyz state load', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Invalid --cwd'), `Expected "Invalid --cwd" in stderr, got: ${result.error}`);
});
// Unknown subcommand: template
test('template unknown subcommand errors', () => {
const result = runGsdTools('template bogus', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Unknown template subcommand'), `Expected "Unknown template subcommand" in stderr, got: ${result.error}`);
});
// Unknown subcommand: frontmatter
test('frontmatter unknown subcommand errors', () => {
const result = runGsdTools('frontmatter bogus file.md', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Unknown frontmatter subcommand'), `Expected "Unknown frontmatter subcommand" in stderr, got: ${result.error}`);
});
// Unknown subcommand: verify
test('verify unknown subcommand errors', () => {
const result = runGsdTools('verify bogus', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Unknown verify subcommand'), `Expected "Unknown verify subcommand" in stderr, got: ${result.error}`);
});
// Unknown subcommand: phases
test('phases unknown subcommand errors', () => {
const result = runGsdTools('phases bogus', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Unknown phases subcommand'), `Expected "Unknown phases subcommand" in stderr, got: ${result.error}`);
});
// Unknown subcommand: roadmap
test('roadmap unknown subcommand errors', () => {
const result = runGsdTools('roadmap bogus', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Unknown roadmap subcommand'), `Expected "Unknown roadmap subcommand" in stderr, got: ${result.error}`);
});
// Unknown subcommand: requirements
test('requirements unknown subcommand errors', () => {
const result = runGsdTools('requirements bogus', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Unknown requirements subcommand'), `Expected "Unknown requirements subcommand" in stderr, got: ${result.error}`);
});
// Unknown subcommand: phase
test('phase unknown subcommand errors', () => {
const result = runGsdTools('phase bogus', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Unknown phase subcommand'), `Expected "Unknown phase subcommand" in stderr, got: ${result.error}`);
});
// Unknown subcommand: milestone
test('milestone unknown subcommand errors', () => {
const result = runGsdTools('milestone bogus', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Unknown milestone subcommand'), `Expected "Unknown milestone subcommand" in stderr, got: ${result.error}`);
});
// Unknown subcommand: validate
test('validate unknown subcommand errors', () => {
const result = runGsdTools('validate bogus', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Unknown validate subcommand'), `Expected "Unknown validate subcommand" in stderr, got: ${result.error}`);
});
// Unknown subcommand: todo
test('todo unknown subcommand errors', () => {
const result = runGsdTools('todo bogus', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Unknown todo subcommand'), `Expected "Unknown todo subcommand" in stderr, got: ${result.error}`);
});
// Unknown subcommand: init
test('init unknown workflow errors', () => {
const result = runGsdTools('init bogus', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Unknown init workflow'), `Expected "Unknown init workflow" in stderr, got: ${result.error}`);
});
});
// ─── Dispatcher Routing Branches ─────────────────────────────────────────────
describe('dispatcher routing branches', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// find-phase
test('find-phase locates phase directory by number', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test-phase');
fs.mkdirSync(phaseDir, { recursive: true });
const result = runGsdTools('find-phase 01', tmpDir);
assert.strictEqual(result.success, true, `find-phase failed: ${result.error}`);
assert.ok(result.output.includes('01-test-phase'), `Expected output to contain "01-test-phase", got: ${result.output}`);
});
// init resume
test('init resume returns valid JSON', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n## Current Position\n\nPhase: 1 of 1 (Test)\nPlan: 01-01 complete\nStatus: Ready\nLast activity: 2026-01-01\n\nProgress: [##########] 100%\n\n## Session Continuity\n\nLast session: 2026-01-01\nStopped at: Test\nResume file: None\n'
);
const result = runGsdTools('init resume', tmpDir);
assert.strictEqual(result.success, true, `init resume failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.ok(typeof parsed === 'object', 'Output should be valid JSON object');
});
// init verify-work
test('init verify-work returns valid JSON', () => {
// Create STATE.md
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n## Current Position\n\nPhase: 1 of 1 (Test)\nPlan: 01-01 complete\nStatus: Ready\nLast activity: 2026-01-01\n\nProgress: [##########] 100%\n\n## Session Continuity\n\nLast session: 2026-01-01\nStopped at: Test\nResume file: None\n'
);
// Create ROADMAP.md with phase section
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## Milestone: v1.0 Test\n\n### Phase 1: Test Phase\n**Goal**: Test goal\n**Depends on**: None\n**Requirements**: TEST-01\n**Success Criteria**:\n 1. Tests pass\n**Plans**: 1 plan\nPlans:\n- [x] 01-01-PLAN.md\n\n## Progress\n\n| Phase | Plans | Status | Date |\n|-------|-------|--------|------|\n| 1 | 1/1 | Complete | 2026-01-01 |\n'
);
// Create phase dir
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
const result = runGsdTools('init verify-work 01', tmpDir);
assert.strictEqual(result.success, true, `init verify-work failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.ok(typeof parsed === 'object', 'Output should be valid JSON object');
});
// roadmap update-plan-progress
test('roadmap update-plan-progress updates phase progress', () => {
// Create ROADMAP.md with progress table
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## Milestone: v1.0 Test\n\n### Phase 1: Test Phase\n**Goal**: Test goal\n**Depends on**: None\n**Requirements**: TEST-01\n**Success Criteria**:\n 1. Tests pass\n**Plans**: 1 plan\nPlans:\n- [ ] 01-01-PLAN.md\n\n## Progress\n\n| Phase | Plans | Status | Date |\n|-------|-------|--------|------|\n| 1 | 0/1 | Not Started | - |\n'
);
// Create phase dir with PLAN and SUMMARY
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test-phase');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '01-01-PLAN.md'),
'---\nphase: 01-test-phase\nplan: "01"\n---\n\n# Plan\n'
);
fs.writeFileSync(
path.join(phaseDir, '01-01-SUMMARY.md'),
'---\nphase: 01-test-phase\nplan: "01"\n---\n\n# Summary\n'
);
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.strictEqual(result.success, true, `roadmap update-plan-progress failed: ${result.error}`);
});
// state (no subcommand) — default load
test('state with no subcommand calls cmdStateLoad', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n## Current Position\n\nPhase: 1 of 1 (Test)\nPlan: 01-01 complete\nStatus: Ready\nLast activity: 2026-01-01\n\nProgress: [##########] 100%\n\n## Session Continuity\n\nLast session: 2026-01-01\nStopped at: Test\nResume file: None\n'
);
const result = runGsdTools('state', tmpDir);
assert.strictEqual(result.success, true, `state load failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.ok(typeof parsed === 'object', 'Output should be valid JSON object');
});
// summary-extract
test('summary-extract parses SUMMARY.md frontmatter', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
const summaryContent = `---
phase: 01-test
plan: "01"
subsystem: testing
tags: [node, test]
duration: 5min
completed: "2026-01-01"
key-decisions:
- "Used node:test"
requirements-completed: [TEST-01]
---
# Phase 1 Plan 01: Test Summary
**Tests added for core module**
`;
const summaryPath = path.join(phaseDir, '01-01-SUMMARY.md');
fs.writeFileSync(summaryPath, summaryContent);
// Use relative path from tmpDir
const result = runGsdTools(`summary-extract .planning/phases/01-test/01-01-SUMMARY.md`, tmpDir);
assert.strictEqual(result.success, true, `summary-extract failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.ok(typeof parsed === 'object', 'Output should be valid JSON object');
assert.strictEqual(parsed.path, '.planning/phases/01-test/01-01-SUMMARY.md', 'Path should match input');
assert.deepStrictEqual(parsed.requirements_completed, ['TEST-01'], 'requirements_completed should contain TEST-01');
});
});

View File

@@ -0,0 +1,271 @@
/**
* GSD Tools Tests - frontmatter CLI integration
*
* Integration tests for the 4 frontmatter subcommands (get, set, merge, validate)
* exercised through gsd-tools.cjs via execSync.
*
* Each test creates its own temp file, runs the CLI command, asserts output,
* and cleans up in afterEach (per-test cleanup with individual temp files).
*/
const { test, describe, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { runGsdTools } = require('./helpers.cjs');
// Track temp files for cleanup
let tempFiles = [];
function writeTempFile(content) {
const tmpFile = path.join(os.tmpdir(), `gsd-fm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
fs.writeFileSync(tmpFile, content, 'utf-8');
tempFiles.push(tmpFile);
return tmpFile;
}
afterEach(() => {
for (const f of tempFiles) {
try { fs.unlinkSync(f); } catch { /* already cleaned */ }
}
tempFiles = [];
});
// ─── frontmatter get ────────────────────────────────────────────────────────
describe('frontmatter get', () => {
test('returns all fields as JSON', () => {
const file = writeTempFile('---\nphase: 01\nplan: 01\ntype: execute\n---\nbody text');
const result = runGsdTools(`frontmatter get ${file}`);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.phase, '01');
assert.strictEqual(parsed.plan, '01');
assert.strictEqual(parsed.type, 'execute');
});
test('returns specific field with --field', () => {
const file = writeTempFile('---\nphase: 01\nplan: 02\ntype: tdd\n---\nbody');
const result = runGsdTools(`frontmatter get ${file} --field phase`);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.phase, '01');
});
test('returns error for missing field', () => {
const file = writeTempFile('---\nphase: 01\n---\n');
const result = runGsdTools(`frontmatter get ${file} --field nonexistent`);
// The command succeeds (exit 0) but returns an error object in JSON
assert.ok(result.success, 'Command should exit 0');
const parsed = JSON.parse(result.output);
assert.ok(parsed.error, 'Should have error field');
assert.ok(parsed.error.includes('Field not found'), 'Error should mention "Field not found"');
});
test('returns error for missing file', () => {
const result = runGsdTools('frontmatter get /nonexistent/path/file.md');
assert.ok(result.success, 'Command should exit 0 with error JSON');
const parsed = JSON.parse(result.output);
assert.ok(parsed.error, 'Should have error field');
});
test('handles file with no frontmatter', () => {
const file = writeTempFile('Plain text with no frontmatter delimiters.');
const result = runGsdTools(`frontmatter get ${file}`);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.deepStrictEqual(parsed, {}, 'Should return empty object for no frontmatter');
});
});
// ─── frontmatter set ────────────────────────────────────────────────────────
describe('frontmatter set', () => {
test('updates existing field', () => {
const file = writeTempFile('---\nphase: 01\ntype: execute\n---\nbody');
const result = runGsdTools(`frontmatter set ${file} --field phase --value "02"`);
assert.ok(result.success, `Command failed: ${result.error}`);
// Read back and verify
const content = fs.readFileSync(file, 'utf-8');
const { extractFrontmatter } = require('../get-shit-done/bin/lib/frontmatter.cjs');
const fm = extractFrontmatter(content);
assert.strictEqual(fm.phase, '02');
});
test('adds new field', () => {
const file = writeTempFile('---\nphase: 01\n---\nbody');
const result = runGsdTools(`frontmatter set ${file} --field status --value "active"`);
assert.ok(result.success, `Command failed: ${result.error}`);
const content = fs.readFileSync(file, 'utf-8');
const { extractFrontmatter } = require('../get-shit-done/bin/lib/frontmatter.cjs');
const fm = extractFrontmatter(content);
assert.strictEqual(fm.status, 'active');
});
test('handles JSON array value', () => {
const file = writeTempFile('---\nphase: 01\n---\nbody');
const result = runGsdTools(`frontmatter set ${file} --field tags --value '["a","b"]'`);
assert.ok(result.success, `Command failed: ${result.error}`);
const content = fs.readFileSync(file, 'utf-8');
const { extractFrontmatter } = require('../get-shit-done/bin/lib/frontmatter.cjs');
const fm = extractFrontmatter(content);
assert.ok(Array.isArray(fm.tags), 'tags should be an array');
assert.deepStrictEqual(fm.tags, ['a', 'b']);
});
test('returns error for missing file', () => {
const result = runGsdTools('frontmatter set /nonexistent/file.md --field phase --value "01"');
assert.ok(result.success, 'Command should exit 0 with error JSON');
const parsed = JSON.parse(result.output);
assert.ok(parsed.error, 'Should have error field');
});
test('preserves body content after set', () => {
const bodyText = '\n\n# My Heading\n\nSome paragraph with special chars: $, %, &.';
const file = writeTempFile('---\nphase: 01\n---' + bodyText);
runGsdTools(`frontmatter set ${file} --field phase --value "02"`);
const content = fs.readFileSync(file, 'utf-8');
assert.ok(content.includes('# My Heading'), 'heading should be preserved');
assert.ok(content.includes('Some paragraph with special chars: $, %, &.'), 'body content should be preserved');
});
});
// ─── frontmatter merge ──────────────────────────────────────────────────────
describe('frontmatter merge', () => {
test('merges multiple fields into frontmatter', () => {
const file = writeTempFile('---\nphase: 01\n---\nbody');
const result = runGsdTools(`frontmatter merge ${file} --data '{"plan":"02","type":"tdd"}'`);
assert.ok(result.success, `Command failed: ${result.error}`);
const content = fs.readFileSync(file, 'utf-8');
const { extractFrontmatter } = require('../get-shit-done/bin/lib/frontmatter.cjs');
const fm = extractFrontmatter(content);
assert.strictEqual(fm.phase, '01', 'original field should be preserved');
assert.strictEqual(fm.plan, '02', 'merged field should be present');
assert.strictEqual(fm.type, 'tdd', 'merged field should be present');
});
test('overwrites existing fields on conflict', () => {
const file = writeTempFile('---\nphase: 01\ntype: execute\n---\nbody');
const result = runGsdTools(`frontmatter merge ${file} --data '{"phase":"02"}'`);
assert.ok(result.success, `Command failed: ${result.error}`);
const content = fs.readFileSync(file, 'utf-8');
const { extractFrontmatter } = require('../get-shit-done/bin/lib/frontmatter.cjs');
const fm = extractFrontmatter(content);
assert.strictEqual(fm.phase, '02', 'conflicting field should be overwritten');
assert.strictEqual(fm.type, 'execute', 'non-conflicting field should be preserved');
});
test('returns error for missing file', () => {
const result = runGsdTools(`frontmatter merge /nonexistent/file.md --data '{"phase":"01"}'`);
assert.ok(result.success, 'Command should exit 0 with error JSON');
const parsed = JSON.parse(result.output);
assert.ok(parsed.error, 'Should have error field');
});
test('returns error for invalid JSON data', () => {
const file = writeTempFile('---\nphase: 01\n---\nbody');
const result = runGsdTools(`frontmatter merge ${file} --data 'not json'`);
// cmdFrontmatterMerge calls error() which exits with code 1
assert.ok(!result.success, 'Command should fail with non-zero exit code');
assert.ok(result.error.includes('Invalid JSON'), 'Error should mention invalid JSON');
});
});
// ─── frontmatter validate ───────────────────────────────────────────────────
describe('frontmatter validate', () => {
test('reports valid for complete plan frontmatter', () => {
const content = `---
phase: 01
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [src/auth.ts]
autonomous: true
must_haves:
truths:
- "All tests pass"
---
body`;
const file = writeTempFile(content);
const result = runGsdTools(`frontmatter validate ${file} --schema plan`);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.valid, true, 'Should be valid');
assert.deepStrictEqual(parsed.missing, [], 'No fields should be missing');
assert.strictEqual(parsed.schema, 'plan');
});
test('reports invalid with missing fields', () => {
const file = writeTempFile('---\nphase: 01\n---\nbody');
const result = runGsdTools(`frontmatter validate ${file} --schema plan`);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.valid, false, 'Should be invalid');
assert.ok(parsed.missing.length > 0, 'Should have missing fields');
// plan schema requires: phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
// phase is present, so 7 should be missing
assert.strictEqual(parsed.missing.length, 7, 'Should have 7 missing required fields');
assert.ok(parsed.missing.includes('plan'), 'plan should be in missing');
assert.ok(parsed.missing.includes('type'), 'type should be in missing');
assert.ok(parsed.missing.includes('must_haves'), 'must_haves should be in missing');
});
test('validates against summary schema', () => {
const content = `---
phase: 01
plan: 01
subsystem: testing
tags: [unit-tests, yaml]
duration: 5min
completed: 2026-02-25
---
body`;
const file = writeTempFile(content);
const result = runGsdTools(`frontmatter validate ${file} --schema summary`);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.valid, true, 'Should be valid for summary schema');
assert.strictEqual(parsed.schema, 'summary');
});
test('validates against verification schema', () => {
const content = `---
phase: 01
verified: 2026-02-25
status: passed
score: 5/5
---
body`;
const file = writeTempFile(content);
const result = runGsdTools(`frontmatter validate ${file} --schema verification`);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.valid, true, 'Should be valid for verification schema');
assert.strictEqual(parsed.schema, 'verification');
});
test('returns error for unknown schema', () => {
const file = writeTempFile('---\nphase: 01\n---\n');
const result = runGsdTools(`frontmatter validate ${file} --schema unknown`);
// cmdFrontmatterValidate calls error() which exits with code 1
assert.ok(!result.success, 'Command should fail with non-zero exit code');
assert.ok(result.error.includes('Unknown schema'), 'Error should mention unknown schema');
});
test('returns error for missing file', () => {
const result = runGsdTools('frontmatter validate /nonexistent/file.md --schema plan');
assert.ok(result.success, 'Command should exit 0 with error JSON');
const parsed = JSON.parse(result.output);
assert.ok(parsed.error, 'Should have error field');
});
});

353
tests/frontmatter.test.cjs Normal file
View File

@@ -0,0 +1,353 @@
/**
* GSD Tools Tests - frontmatter.cjs
*
* Tests for the hand-rolled YAML parser's pure function exports:
* extractFrontmatter, reconstructFrontmatter, spliceFrontmatter,
* parseMustHavesBlock, and FRONTMATTER_SCHEMAS.
*
* Includes REG-04 regression: quoted comma inline array edge case.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert');
const {
extractFrontmatter,
reconstructFrontmatter,
spliceFrontmatter,
parseMustHavesBlock,
FRONTMATTER_SCHEMAS,
} = require('../get-shit-done/bin/lib/frontmatter.cjs');
// ─── extractFrontmatter ─────────────────────────────────────────────────────
describe('extractFrontmatter', () => {
test('parses simple key-value pairs', () => {
const content = '---\nname: foo\ntype: execute\n---\nbody';
const result = extractFrontmatter(content);
assert.strictEqual(result.name, 'foo');
assert.strictEqual(result.type, 'execute');
});
test('strips quotes from values', () => {
const doubleQuoted = '---\nname: "foo"\n---\n';
const singleQuoted = '---\nname: \'foo\'\n---\n';
assert.strictEqual(extractFrontmatter(doubleQuoted).name, 'foo');
assert.strictEqual(extractFrontmatter(singleQuoted).name, 'foo');
});
test('parses nested objects', () => {
const content = '---\ntechstack:\n added: prisma\n patterns: repository\n---\n';
const result = extractFrontmatter(content);
assert.deepStrictEqual(result.techstack, { added: 'prisma', patterns: 'repository' });
});
test('parses block arrays', () => {
const content = '---\nitems:\n - alpha\n - beta\n - gamma\n---\n';
const result = extractFrontmatter(content);
assert.deepStrictEqual(result.items, ['alpha', 'beta', 'gamma']);
});
test('parses inline arrays', () => {
const content = '---\nkey: [a, b, c]\n---\n';
const result = extractFrontmatter(content);
assert.deepStrictEqual(result.key, ['a', 'b', 'c']);
});
test('handles quoted commas in inline arrays — REG-04 known limitation', () => {
// REG-04: The split(',') on line 53 does NOT respect quotes.
// The parser WILL split on commas inside quotes, producing wrong results.
// This test documents the CURRENT (buggy) behavior.
const content = '---\nkey: ["a, b", c]\n---\n';
const result = extractFrontmatter(content);
// Current behavior: splits on ALL commas, producing 3 items instead of 2
// Expected correct behavior would be: ["a, b", "c"]
// Actual current behavior: ["a", "b", "c"] (split ignores quotes)
assert.ok(Array.isArray(result.key), 'should produce an array');
assert.ok(result.key.length >= 2, 'should produce at least 2 items from comma split');
// The bug produces ["a", "b\"", "c"] or similar — the exact output depends on
// how the regex strips quotes after the split.
// We verify the key insight: the result has MORE items than intended (known limitation).
assert.ok(result.key.length > 2, 'REG-04: split produces more items than intended due to quoted comma bug');
});
test('returns empty object for no frontmatter', () => {
const content = 'Just plain content, no frontmatter.';
const result = extractFrontmatter(content);
assert.deepStrictEqual(result, {});
});
test('returns empty object for empty frontmatter', () => {
const content = '---\n---\nBody text.';
const result = extractFrontmatter(content);
assert.deepStrictEqual(result, {});
});
test('parses frontmatter-only content', () => {
const content = '---\nkey: val\n---';
const result = extractFrontmatter(content);
assert.strictEqual(result.key, 'val');
});
test('handles emoji and non-ASCII in values', () => {
const content = '---\nname: "Hello World"\nlabel: "cafe"\n---\n';
const result = extractFrontmatter(content);
assert.strictEqual(result.name, 'Hello World');
assert.strictEqual(result.label, 'cafe');
});
test('converts empty-object placeholders to arrays when dash items follow', () => {
// When a key has no value, it gets an empty {} placeholder.
// When "- item" lines follow, the parser converts {} to [].
const content = '---\nrequirements:\n - REQ-01\n - REQ-02\n---\n';
const result = extractFrontmatter(content);
assert.ok(Array.isArray(result.requirements), 'should convert placeholder object to array');
assert.deepStrictEqual(result.requirements, ['REQ-01', 'REQ-02']);
});
test('skips empty lines in YAML body', () => {
const content = '---\nfirst: one\n\nsecond: two\n\nthird: three\n---\n';
const result = extractFrontmatter(content);
assert.strictEqual(result.first, 'one');
assert.strictEqual(result.second, 'two');
assert.strictEqual(result.third, 'three');
});
});
// ─── reconstructFrontmatter ─────────────────────────────────────────────────
describe('reconstructFrontmatter', () => {
test('serializes simple key-value', () => {
const result = reconstructFrontmatter({ name: 'foo' });
assert.strictEqual(result, 'name: foo');
});
test('serializes empty array as inline []', () => {
const result = reconstructFrontmatter({ items: [] });
assert.strictEqual(result, 'items: []');
});
test('serializes short string arrays inline', () => {
const result = reconstructFrontmatter({ key: ['a', 'b', 'c'] });
assert.strictEqual(result, 'key: [a, b, c]');
});
test('serializes long arrays as block', () => {
const result = reconstructFrontmatter({ key: ['one', 'two', 'three', 'four'] });
assert.ok(result.includes('key:'), 'should have key header');
assert.ok(result.includes(' - one'), 'should have block array items');
assert.ok(result.includes(' - four'), 'should have last item');
});
test('quotes values containing colons or hashes', () => {
const result = reconstructFrontmatter({ url: 'http://example.com' });
assert.ok(result.includes('"http://example.com"'), 'should quote value with colon');
const hashResult = reconstructFrontmatter({ comment: 'value # note' });
assert.ok(hashResult.includes('"value # note"'), 'should quote value with hash');
});
test('serializes nested objects with proper indentation', () => {
const result = reconstructFrontmatter({ tech: { added: 'prisma', patterns: 'repo' } });
assert.ok(result.includes('tech:'), 'should have parent key');
assert.ok(result.includes(' added: prisma'), 'should have indented child');
assert.ok(result.includes(' patterns: repo'), 'should have indented child');
});
test('serializes nested arrays within objects', () => {
const result = reconstructFrontmatter({
tech: { added: ['prisma', 'jose'] },
});
assert.ok(result.includes('tech:'), 'should have parent key');
assert.ok(result.includes(' added: [prisma, jose]'), 'should serialize nested short array inline');
});
test('skips null and undefined values', () => {
const result = reconstructFrontmatter({ name: 'foo', skip: null, also: undefined, keep: 'bar' });
assert.ok(!result.includes('skip'), 'should not include null key');
assert.ok(!result.includes('also'), 'should not include undefined key');
assert.ok(result.includes('name: foo'), 'should include non-null key');
assert.ok(result.includes('keep: bar'), 'should include non-null key');
});
test('round-trip: simple frontmatter', () => {
const original = '---\nname: test\ntype: execute\nwave: 1\n---\n';
const extracted1 = extractFrontmatter(original);
const reconstructed = reconstructFrontmatter(extracted1);
const roundTrip = `---\n${reconstructed}\n---\n`;
const extracted2 = extractFrontmatter(roundTrip);
assert.deepStrictEqual(extracted2, extracted1, 'round-trip should preserve data identity');
});
test('round-trip: nested with arrays', () => {
const original = '---\nphase: 01\ntech:\n added:\n - prisma\n - jose\n patterns:\n - repository\n - jwt\n---\n';
const extracted1 = extractFrontmatter(original);
const reconstructed = reconstructFrontmatter(extracted1);
const roundTrip = `---\n${reconstructed}\n---\n`;
const extracted2 = extractFrontmatter(roundTrip);
assert.deepStrictEqual(extracted2, extracted1, 'round-trip should preserve nested structures');
});
test('round-trip: multiple data types', () => {
const original = '---\nname: testplan\nwave: 2\ntags: [auth, api, db]\ndeps:\n - dep1\n - dep2\nconfig:\n enabled: true\n count: 5\n---\n';
const extracted1 = extractFrontmatter(original);
const reconstructed = reconstructFrontmatter(extracted1);
const roundTrip = `---\n${reconstructed}\n---\n`;
const extracted2 = extractFrontmatter(roundTrip);
assert.deepStrictEqual(extracted2, extracted1, 'round-trip should preserve multiple data types');
});
});
// ─── spliceFrontmatter ──────────────────────────────────────────────────────
describe('spliceFrontmatter', () => {
test('replaces existing frontmatter preserving body', () => {
const content = '---\nphase: 01\ntype: execute\n---\n\n# Body Content\n\nParagraph here.';
const newObj = { phase: '02', type: 'tdd', wave: '1' };
const result = spliceFrontmatter(content, newObj);
// New frontmatter should be present
const extracted = extractFrontmatter(result);
assert.strictEqual(extracted.phase, '02');
assert.strictEqual(extracted.type, 'tdd');
assert.strictEqual(extracted.wave, '1');
// Body should be preserved
assert.ok(result.includes('# Body Content'), 'body heading should be preserved');
assert.ok(result.includes('Paragraph here.'), 'body paragraph should be preserved');
});
test('adds frontmatter to content without any', () => {
const content = 'Plain text with no frontmatter.';
const newObj = { phase: '01', plan: '01' };
const result = spliceFrontmatter(content, newObj);
// Should start with frontmatter delimiters
assert.ok(result.startsWith('---\n'), 'should start with opening delimiter');
assert.ok(result.includes('\n---\n'), 'should have closing delimiter');
// Original content should follow
assert.ok(result.includes('Plain text with no frontmatter.'), 'original content should be preserved');
// Frontmatter should be extractable
const extracted = extractFrontmatter(result);
assert.strictEqual(extracted.phase, '01');
assert.strictEqual(extracted.plan, '01');
});
test('preserves content after frontmatter delimiters exactly', () => {
const body = '\n\nExact content with special chars: $, %, &, <, >\nLine 2\nLine 3';
const content = '---\nold: value\n---' + body;
const newObj = { new: 'value' };
const result = spliceFrontmatter(content, newObj);
// The body after the closing --- should be exactly preserved
const closingIdx = result.indexOf('\n---', 4); // skip the opening ---
const resultBody = result.slice(closingIdx + 4); // skip \n---
assert.strictEqual(resultBody, body, 'body content after frontmatter should be exactly preserved');
});
});
// ─── parseMustHavesBlock ────────────────────────────────────────────────────
describe('parseMustHavesBlock', () => {
test('extracts truths as string array', () => {
const content = `---
phase: 01
must_haves:
truths:
- "All tests pass on CI"
- "Coverage exceeds 80%"
---
Body content.`;
const result = parseMustHavesBlock(content, 'truths');
assert.ok(Array.isArray(result), 'should return an array');
assert.strictEqual(result.length, 2);
assert.strictEqual(result[0], 'All tests pass on CI');
assert.strictEqual(result[1], 'Coverage exceeds 80%');
});
test('extracts artifacts as object array', () => {
const content = `---
phase: 01
must_haves:
artifacts:
- path: "src/auth.ts"
provides: "JWT authentication"
min_lines: 100
- path: "src/middleware.ts"
provides: "Route protection"
min_lines: 50
---
Body.`;
const result = parseMustHavesBlock(content, 'artifacts');
assert.ok(Array.isArray(result), 'should return an array');
assert.strictEqual(result.length, 2);
assert.strictEqual(result[0].path, 'src/auth.ts');
assert.strictEqual(result[0].provides, 'JWT authentication');
assert.strictEqual(result[0].min_lines, 100);
assert.strictEqual(result[1].path, 'src/middleware.ts');
assert.strictEqual(result[1].min_lines, 50);
});
test('extracts key_links with from/to/via/pattern fields', () => {
const content = `---
phase: 01
must_haves:
key_links:
- from: "tests/auth.test.ts"
to: "src/auth.ts"
via: "import statement"
pattern: "import.*auth"
---
`;
const result = parseMustHavesBlock(content, 'key_links');
assert.ok(Array.isArray(result), 'should return an array');
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].from, 'tests/auth.test.ts');
assert.strictEqual(result[0].to, 'src/auth.ts');
assert.strictEqual(result[0].via, 'import statement');
assert.strictEqual(result[0].pattern, 'import.*auth');
});
test('returns empty array when block not found', () => {
const content = `---
phase: 01
must_haves:
truths:
- "Some truth"
---
`;
const result = parseMustHavesBlock(content, 'nonexistent_block');
assert.deepStrictEqual(result, []);
});
test('returns empty array when no frontmatter', () => {
const content = 'Plain text without any frontmatter delimiters.';
const result = parseMustHavesBlock(content, 'truths');
assert.deepStrictEqual(result, []);
});
test('handles nested arrays within artifact objects', () => {
const content = `---
phase: 01
must_haves:
artifacts:
- path: "src/api.ts"
provides: "REST endpoints"
exports:
- "GET"
- "POST"
---
`;
const result = parseMustHavesBlock(content, 'artifacts');
assert.ok(Array.isArray(result), 'should return an array');
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].path, 'src/api.ts');
// The nested array should be captured
assert.ok(result[0].exports !== undefined, 'should have exports field');
});
});

View File

@@ -33,8 +33,28 @@ function createTempProject() {
return tmpDir;
}
// Create temp directory with initialized git repo and at least one commit
function createTempGitProject() {
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-test-'));
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
execSync('git init', { cwd: tmpDir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'pipe' });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
'# Project\n\nTest project.\n'
);
execSync('git add -A', { cwd: tmpDir, stdio: 'pipe' });
execSync('git commit -m "initial commit"', { cwd: tmpDir, stdio: 'pipe' });
return tmpDir;
}
function cleanup(tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
module.exports = { runGsdTools, createTempProject, cleanup, TOOLS_PATH };
module.exports = { runGsdTools, createTempProject, createTempGitProject, cleanup, TOOLS_PATH };

View File

@@ -199,6 +199,650 @@ describe('init commands', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitTodos (INIT-01)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitTodos', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('empty pending dir returns zero count', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'todos', 'pending'), { recursive: true });
const result = runGsdTools('init todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 0);
assert.deepStrictEqual(output.todos, []);
assert.strictEqual(output.pending_dir_exists, true);
});
test('missing pending dir returns zero count', () => {
const result = runGsdTools('init todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 0);
assert.deepStrictEqual(output.todos, []);
assert.strictEqual(output.pending_dir_exists, false);
});
test('multiple todos with fields are read correctly', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'task-1.md'), 'title: Fix bug\narea: backend\ncreated: 2026-02-25');
fs.writeFileSync(path.join(pendingDir, 'task-2.md'), 'title: Add feature\narea: frontend\ncreated: 2026-02-24');
fs.writeFileSync(path.join(pendingDir, 'task-3.md'), 'title: Write docs\narea: backend\ncreated: 2026-02-23');
const result = runGsdTools('init todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 3);
assert.strictEqual(output.todos.length, 3);
const task1 = output.todos.find(t => t.file === 'task-1.md');
assert.ok(task1, 'task-1.md should be in todos');
assert.strictEqual(task1.title, 'Fix bug');
assert.strictEqual(task1.area, 'backend');
assert.strictEqual(task1.created, '2026-02-25');
assert.strictEqual(task1.path, path.join('.planning', 'todos', 'pending', 'task-1.md'));
});
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, 'task-1.md'), 'title: Fix bug\narea: backend\ncreated: 2026-02-25');
fs.writeFileSync(path.join(pendingDir, 'task-2.md'), 'title: Add feature\narea: frontend\ncreated: 2026-02-24');
fs.writeFileSync(path.join(pendingDir, 'task-3.md'), 'title: Write docs\narea: backend\ncreated: 2026-02-23');
const result = runGsdTools('init todos backend', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 2);
assert.strictEqual(output.area_filter, 'backend');
for (const todo of output.todos) {
assert.strictEqual(todo.area, 'backend');
}
});
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-1.md'), 'title: Fix bug\narea: backend\ncreated: 2026-02-25');
const result = runGsdTools('init todos nonexistent', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 0);
assert.strictEqual(output.area_filter, 'nonexistent');
});
test('malformed file uses defaults', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'broken.md'), 'some random content without fields');
const result = runGsdTools('init todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 1);
const todo = output.todos[0];
assert.strictEqual(todo.title, 'Untitled');
assert.strictEqual(todo.area, 'general');
assert.strictEqual(todo.created, 'unknown');
});
test('non-md files are ignored', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'task.md'), 'title: Real task\narea: dev\ncreated: 2026-01-01');
fs.writeFileSync(path.join(pendingDir, 'notes.txt'), 'title: Not a task\narea: dev\ncreated: 2026-01-01');
const result = runGsdTools('init todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 1);
assert.strictEqual(output.todos[0].file, 'task.md');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitMilestoneOp (INIT-02)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitMilestoneOp', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('no phase directories returns zero counts', () => {
const result = runGsdTools('init milestone-op', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 0);
assert.strictEqual(output.completed_phases, 0);
assert.strictEqual(output.all_phases_complete, false);
});
test('multiple phases with no summaries', () => {
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api');
fs.mkdirSync(phase1, { recursive: true });
fs.mkdirSync(phase2, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phase2, '02-01-PLAN.md'), '# Plan');
const result = runGsdTools('init milestone-op', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 2);
assert.strictEqual(output.completed_phases, 0);
assert.strictEqual(output.all_phases_complete, false);
});
test('mix of complete and incomplete phases', () => {
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api');
fs.mkdirSync(phase1, { recursive: true });
fs.mkdirSync(phase2, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(phase2, '02-01-PLAN.md'), '# Plan');
const result = runGsdTools('init milestone-op', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 2);
assert.strictEqual(output.completed_phases, 1);
assert.strictEqual(output.all_phases_complete, false);
});
test('all phases complete', () => {
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
const result = runGsdTools('init milestone-op', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 1);
assert.strictEqual(output.completed_phases, 1);
assert.strictEqual(output.all_phases_complete, true);
});
test('archive directory scanning', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'archive', 'v1.0'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'archive', 'v0.9'), { recursive: true });
const result = runGsdTools('init milestone-op', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.archive_count, 2);
assert.strictEqual(output.archived_milestones.length, 2);
});
test('no archive directory returns empty', () => {
const result = runGsdTools('init milestone-op', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.archive_count, 0);
assert.deepStrictEqual(output.archived_milestones, []);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitPhaseOp fallback (INIT-04)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitPhaseOp fallback', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('normal path with existing directory', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Context');
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n**Plans:** 1 plans\n'
);
const result = runGsdTools('init phase-op 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.ok(output.phase_dir.includes('03-api'), 'phase_dir should contain 03-api');
assert.strictEqual(output.has_context, true);
assert.strictEqual(output.has_plans, true);
});
test('fallback to ROADMAP when no directory exists', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 5: Widget Builder\n**Goal:** Build widgets\n**Plans:** TBD\n'
);
const result = runGsdTools('init phase-op 5', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_dir, null);
assert.strictEqual(output.phase_slug, 'widget-builder');
assert.strictEqual(output.has_research, false);
assert.strictEqual(output.has_context, false);
assert.strictEqual(output.has_plans, false);
});
test('neither directory nor roadmap entry returns not found', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: Setup\n**Goal:** Setup project\n**Plans:** TBD\n'
);
const result = runGsdTools('init phase-op 99', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, false);
assert.strictEqual(output.phase_dir, null);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitProgress (INIT-03)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitProgress', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('no phases returns empty state', () => {
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 0);
assert.deepStrictEqual(output.phases, []);
assert.strictEqual(output.current_phase, null);
assert.strictEqual(output.next_phase, null);
assert.strictEqual(output.has_work_in_progress, false);
});
test('multiple phases with mixed statuses', () => {
// Phase 01: complete (has plan + summary)
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
// Phase 02: in_progress (has plan, no summary)
const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api');
fs.mkdirSync(phase2, { recursive: true });
fs.writeFileSync(path.join(phase2, '02-01-PLAN.md'), '# Plan');
// Phase 03: pending (no plan, no research)
const phase3 = path.join(tmpDir, '.planning', 'phases', '03-ui');
fs.mkdirSync(phase3, { recursive: true });
fs.writeFileSync(path.join(phase3, '03-CONTEXT.md'), '# Context');
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 3);
assert.strictEqual(output.completed_count, 1);
assert.strictEqual(output.in_progress_count, 1);
assert.strictEqual(output.has_work_in_progress, true);
assert.strictEqual(output.current_phase.number, '02');
assert.strictEqual(output.current_phase.status, 'in_progress');
assert.strictEqual(output.next_phase.number, '03');
assert.strictEqual(output.next_phase.status, 'pending');
// Verify phase entries have expected structure
const p1 = output.phases.find(p => p.number === '01');
assert.strictEqual(p1.status, 'complete');
assert.strictEqual(p1.plan_count, 1);
assert.strictEqual(p1.summary_count, 1);
});
test('researched status detected correctly', () => {
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-RESEARCH.md'), '# Research');
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const p1 = output.phases.find(p => p.number === '01');
assert.strictEqual(p1.status, 'researched');
assert.strictEqual(p1.has_research, true);
assert.strictEqual(output.current_phase.number, '01');
});
test('all phases complete returns no current or next', () => {
const phase1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phase1, '01-01-SUMMARY.md'), '# Summary');
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.completed_count, 1);
assert.strictEqual(output.current_phase, null);
assert.strictEqual(output.next_phase, null);
});
test('paused_at detected from STATE.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n**Paused At:** Phase 2, Task 3 — implementing auth\n'
);
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.paused_at, 'paused_at should be set');
assert.ok(output.paused_at.includes('Phase 2, Task 3'), 'paused_at should contain pause location');
});
test('no paused_at when STATE.md has no pause line', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\nSome content without pause.\n'
);
const result = runGsdTools('init progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.paused_at, null);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitQuick (INIT-05)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitQuick', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('with description generates slug and task_dir', () => {
const result = runGsdTools('init quick "Fix login bug"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.slug, 'fix-login-bug');
assert.strictEqual(output.next_num, 1);
assert.strictEqual(output.task_dir, '.planning/quick/1-fix-login-bug');
assert.strictEqual(output.description, 'Fix login bug');
});
test('without description returns null slug and task_dir', () => {
const result = runGsdTools('init quick', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.slug, null);
assert.strictEqual(output.task_dir, null);
assert.strictEqual(output.description, null);
assert.strictEqual(output.next_num, 1);
});
test('next number increments from existing entries', () => {
const quickDir = path.join(tmpDir, '.planning', 'quick');
fs.mkdirSync(path.join(quickDir, '1-old-task'), { recursive: true });
fs.mkdirSync(path.join(quickDir, '3-another-task'), { recursive: true });
const result = runGsdTools('init quick "New task"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.next_num, 4);
});
test('long description truncates slug to 40 chars', () => {
const result = runGsdTools('init quick "This is a very long description that should get truncated to forty characters maximum"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.slug.length <= 40, `Slug should be <= 40 chars, got ${output.slug.length}: "${output.slug}"`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitMapCodebase (INIT-05)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitMapCodebase', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('no codebase dir returns empty', () => {
const result = runGsdTools('init map-codebase', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_maps, false);
assert.deepStrictEqual(output.existing_maps, []);
assert.strictEqual(output.codebase_dir_exists, false);
});
test('with existing maps lists md files only', () => {
const codebaseDir = path.join(tmpDir, '.planning', 'codebase');
fs.mkdirSync(codebaseDir, { recursive: true });
fs.writeFileSync(path.join(codebaseDir, 'STACK.md'), '# Stack');
fs.writeFileSync(path.join(codebaseDir, 'ARCHITECTURE.md'), '# Architecture');
fs.writeFileSync(path.join(codebaseDir, 'notes.txt'), 'not a markdown file');
const result = runGsdTools('init map-codebase', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_maps, true);
assert.strictEqual(output.existing_maps.length, 2);
assert.ok(output.existing_maps.includes('STACK.md'), 'Should include STACK.md');
assert.ok(output.existing_maps.includes('ARCHITECTURE.md'), 'Should include ARCHITECTURE.md');
});
test('empty codebase dir returns no maps', () => {
const codebaseDir = path.join(tmpDir, '.planning', 'codebase');
fs.mkdirSync(codebaseDir, { recursive: true });
const result = runGsdTools('init map-codebase', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_maps, false);
assert.deepStrictEqual(output.existing_maps, []);
assert.strictEqual(output.codebase_dir_exists, true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitNewProject (INIT-06)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitNewProject', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('greenfield project with no code', () => {
const result = runGsdTools('init new-project', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_existing_code, false);
assert.strictEqual(output.has_package_file, false);
assert.strictEqual(output.is_brownfield, false);
assert.strictEqual(output.needs_codebase_map, false);
});
test('brownfield with package.json detected', () => {
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
const result = runGsdTools('init new-project', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.has_package_file, true);
assert.strictEqual(output.is_brownfield, true);
assert.strictEqual(output.needs_codebase_map, true);
});
test('brownfield with codebase map does not need map', () => {
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}');
fs.mkdirSync(path.join(tmpDir, '.planning', 'codebase'), { recursive: true });
const result = runGsdTools('init new-project', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.is_brownfield, true);
assert.strictEqual(output.needs_codebase_map, false);
});
test('planning_exists flag is correct', () => {
const result = runGsdTools('init new-project', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.planning_exists, true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdInitNewMilestone (INIT-06)
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdInitNewMilestone', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('returns expected fields', () => {
const result = runGsdTools('init new-milestone', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok('current_milestone' in output, 'Should have current_milestone');
assert.ok('current_milestone_name' in output, 'Should have current_milestone_name');
assert.ok('researcher_model' in output, 'Should have researcher_model');
assert.ok('synthesizer_model' in output, 'Should have synthesizer_model');
assert.ok('roadmapper_model' in output, 'Should have roadmapper_model');
assert.ok('commit_docs' in output, 'Should have commit_docs');
assert.strictEqual(output.project_path, '.planning/PROJECT.md');
assert.strictEqual(output.roadmap_path, '.planning/ROADMAP.md');
assert.strictEqual(output.state_path, '.planning/STATE.md');
});
test('file existence flags reflect actual state', () => {
// Default: no STATE.md, ROADMAP.md, or PROJECT.md
const result1 = runGsdTools('init new-milestone', tmpDir);
assert.ok(result1.success, `Command failed: ${result1.error}`);
const output1 = JSON.parse(result1.output);
assert.strictEqual(output1.state_exists, false);
assert.strictEqual(output1.roadmap_exists, false);
assert.strictEqual(output1.project_exists, false);
// Create files and verify flags change
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap');
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project');
const result2 = runGsdTools('init new-milestone', tmpDir);
assert.ok(result2.success, `Command failed: ${result2.error}`);
const output2 = JSON.parse(result2.output);
assert.strictEqual(output2.state_exists, true);
assert.strictEqual(output2.roadmap_exists, true);
assert.strictEqual(output2.project_exists, true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// roadmap analyze command
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -90,6 +90,295 @@ describe('milestone complete command', () => {
assert.ok(milestones.includes('v0.9 Alpha'), 'existing entry should be preserved');
assert.ok(milestones.includes('v1.0 Beta'), 'new entry should be appended');
});
test('archives phase directories with --archive-phases flag', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(
path.join(p1, '01-01-SUMMARY.md'),
`---\none-liner: Set up project infrastructure\n---\n# Summary\n`
);
const result = runGsdTools('milestone complete v1.0 --name MVP --archive-phases', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.archived.phases, true, 'phases should be archived');
// Phase directory moved to milestones/v1.0-phases/
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-foundation')),
'archived phase directory should exist in milestones/v1.0-phases/'
);
// Original phase directory no longer exists
assert.ok(
!fs.existsSync(p1),
'original phase directory should no longer exist'
);
});
test('archived REQUIREMENTS.md contains archive header', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
`# Requirements\n\n- [ ] **TEST-01**: core.cjs has tests\n- [ ] **TEST-02**: more tests\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const result = runGsdTools('milestone complete v1.0 --name MVP', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const archivedReq = fs.readFileSync(
path.join(tmpDir, '.planning', 'milestones', 'v1.0-REQUIREMENTS.md'), 'utf-8'
);
assert.ok(archivedReq.includes('Requirements Archive: v1.0'), 'should contain archive version');
assert.ok(archivedReq.includes('SHIPPED'), 'should contain SHIPPED status');
assert.ok(archivedReq.includes('Archived:'), 'should contain Archived: date line');
// Original content preserved after header
assert.ok(archivedReq.includes('# Requirements'), 'original content should be preserved');
assert.ok(archivedReq.includes('**TEST-01**'), 'original requirement items should be preserved');
});
test('STATE.md gets updated during milestone complete', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.state_updated, true, 'state_updated should be true');
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(state.includes('v1.0 milestone complete'), 'status should be updated to milestone complete');
assert.ok(
state.includes('v1.0 milestone completed and archived'),
'last activity description should reference milestone completion'
);
});
test('handles missing ROADMAP.md gracefully', () => {
// Only STATE.md — no ROADMAP.md, no REQUIREMENTS.md
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const result = runGsdTools('milestone complete v1.0 --name NoRoadmap', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.archived.roadmap, false, 'roadmap should not be archived');
assert.strictEqual(output.archived.requirements, false, 'requirements should not be archived');
assert.strictEqual(output.milestones_updated, true, 'MILESTONES.md should still be created');
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md')),
'MILESTONES.md should be created even without ROADMAP.md'
);
});
test('handles empty phases directory', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
// phases directory exists but is empty (from createTempProject)
const result = runGsdTools('milestone complete v1.0 --name EmptyPhases', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases, 0, 'phase count should be 0');
assert.strictEqual(output.plans, 0, 'plan count should be 0');
assert.strictEqual(output.tasks, 0, 'task count should be 0');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// requirements mark-complete command
// ─────────────────────────────────────────────────────────────────────────────
describe('requirements mark-complete command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ─── helpers ──────────────────────────────────────────────────────────────
function writeRequirements(tmpDir, content) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), content, 'utf-8');
}
function readRequirements(tmpDir) {
return fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
}
const STANDARD_REQUIREMENTS = `# Requirements
## Test Coverage
- [ ] **TEST-01**: core.cjs has tests for loadConfig
- [ ] **TEST-02**: core.cjs has tests for resolveModelInternal
- [x] **TEST-03**: core.cjs has tests for escapeRegex (already complete)
## Bug Regressions
- [ ] **REG-01**: Test confirms loadConfig returns model_overrides
## Infrastructure
- [ ] **INFRA-01**: GitHub Actions workflow runs tests
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| TEST-01 | Phase 1 | Pending |
| TEST-02 | Phase 1 | Pending |
| TEST-03 | Phase 1 | Complete |
| REG-01 | Phase 1 | Pending |
| INFRA-01 | Phase 6 | Pending |
`;
// ─── tests ────────────────────────────────────────────────────────────────
test('marks single requirement complete (checkbox + table)', () => {
writeRequirements(tmpDir, STANDARD_REQUIREMENTS);
const result = runGsdTools('requirements mark-complete TEST-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, true);
assert.ok(output.marked_complete.includes('TEST-01'), 'TEST-01 should be marked complete');
const content = readRequirements(tmpDir);
assert.ok(content.includes('- [x] **TEST-01**'), 'checkbox should be checked');
assert.ok(content.includes('| TEST-01 | Phase 1 | Complete |'), 'table row should be Complete');
// Other checkboxes unchanged
assert.ok(content.includes('- [ ] **TEST-02**'), 'TEST-02 should remain unchecked');
});
test('handles mixed prefixes in single call (TEST-XX, REG-XX, INFRA-XX)', () => {
writeRequirements(tmpDir, STANDARD_REQUIREMENTS);
const result = runGsdTools('requirements mark-complete TEST-01,REG-01,INFRA-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.marked_complete.length, 3, 'should mark 3 requirements complete');
assert.ok(output.marked_complete.includes('TEST-01'));
assert.ok(output.marked_complete.includes('REG-01'));
assert.ok(output.marked_complete.includes('INFRA-01'));
const content = readRequirements(tmpDir);
assert.ok(content.includes('- [x] **TEST-01**'), 'TEST-01 checkbox should be checked');
assert.ok(content.includes('- [x] **REG-01**'), 'REG-01 checkbox should be checked');
assert.ok(content.includes('- [x] **INFRA-01**'), 'INFRA-01 checkbox should be checked');
assert.ok(content.includes('| TEST-01 | Phase 1 | Complete |'), 'TEST-01 table should be Complete');
assert.ok(content.includes('| REG-01 | Phase 1 | Complete |'), 'REG-01 table should be Complete');
assert.ok(content.includes('| INFRA-01 | Phase 6 | Complete |'), 'INFRA-01 table should be Complete');
});
test('accepts space-separated IDs', () => {
writeRequirements(tmpDir, STANDARD_REQUIREMENTS);
const result = runGsdTools('requirements mark-complete TEST-01 TEST-02', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.marked_complete.length, 2, 'should mark 2 requirements complete');
const content = readRequirements(tmpDir);
assert.ok(content.includes('- [x] **TEST-01**'), 'TEST-01 should be checked');
assert.ok(content.includes('- [x] **TEST-02**'), 'TEST-02 should be checked');
});
test('accepts bracket-wrapped IDs [REQ-01, REQ-02]', () => {
writeRequirements(tmpDir, STANDARD_REQUIREMENTS);
const result = runGsdTools('requirements mark-complete [TEST-01,TEST-02]', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.marked_complete.length, 2, 'should mark 2 requirements complete');
const content = readRequirements(tmpDir);
assert.ok(content.includes('- [x] **TEST-01**'), 'TEST-01 should be checked');
assert.ok(content.includes('- [x] **TEST-02**'), 'TEST-02 should be checked');
});
test('returns not_found for invalid IDs while updating valid ones', () => {
writeRequirements(tmpDir, STANDARD_REQUIREMENTS);
const result = runGsdTools('requirements mark-complete TEST-01,FAKE-99', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, true, 'should still update valid IDs');
assert.ok(output.marked_complete.includes('TEST-01'), 'TEST-01 should be marked complete');
assert.ok(output.not_found.includes('FAKE-99'), 'FAKE-99 should be in not_found');
assert.strictEqual(output.total, 2, 'total should reflect all IDs attempted');
});
test('idempotent — re-marking already-complete requirement does not corrupt', () => {
writeRequirements(tmpDir, STANDARD_REQUIREMENTS);
// TEST-03 already has [x] and Complete in the fixture
const result = runGsdTools('requirements mark-complete TEST-03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Regex only matches [ ] (space), not [x], so TEST-03 goes to not_found
assert.ok(output.not_found.includes('TEST-03'), 'already-complete ID should be in not_found');
const content = readRequirements(tmpDir);
// File should not be corrupted — no [xx] or doubled markers
assert.ok(content.includes('- [x] **TEST-03**'), 'existing [x] should remain intact');
assert.ok(!content.includes('[xx]'), 'should not have doubled x markers');
assert.ok(!content.includes('- [x] [x]'), 'should not have duplicate checkbox');
});
test('missing REQUIREMENTS.md returns expected error structure', () => {
// createTempProject does not create REQUIREMENTS.md — so it's already missing
const result = runGsdTools('requirements mark-complete TEST-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, false, 'updated should be false');
assert.strictEqual(output.reason, 'REQUIREMENTS.md not found', 'should report file not found');
});
});
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -259,6 +259,411 @@ describe('roadmap analyze command', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// roadmap analyze disk status variants
// ─────────────────────────────────────────────────────────────────────────────
describe('roadmap analyze disk status variants', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('returns researched status for phase dir with only RESEARCH.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Exploration
**Goal:** Research the domain
`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-exploration');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-RESEARCH.md'), '# Research notes');
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].disk_status, 'researched', 'disk_status should be researched');
assert.strictEqual(output.phases[0].has_research, true, 'has_research should be true');
});
test('returns discussed status for phase dir with only CONTEXT.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Discussion
**Goal:** Gather context
`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-discussion');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-CONTEXT.md'), '# Context notes');
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].disk_status, 'discussed', 'disk_status should be discussed');
assert.strictEqual(output.phases[0].has_context, true, 'has_context should be true');
});
test('returns empty status for phase dir with no recognized files', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Empty
**Goal:** Nothing yet
`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-empty');
fs.mkdirSync(p1, { recursive: true });
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].disk_status, 'empty', 'disk_status should be empty');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// roadmap analyze milestone extraction
// ─────────────────────────────────────────────────────────────────────────────
describe('roadmap analyze milestone extraction', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('extracts milestone headings and version numbers', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
## v1.0 Test Infrastructure
### Phase 1: Foundation
**Goal:** Set up base
## v1.1 Coverage Hardening
### Phase 2: Coverage
**Goal:** Add coverage
`
);
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(Array.isArray(output.milestones), 'milestones should be an array');
assert.strictEqual(output.milestones.length, 2, 'should find 2 milestones');
assert.strictEqual(output.milestones[0].version, 'v1.0', 'first milestone version');
assert.ok(output.milestones[0].heading.includes('v1.0'), 'first milestone heading contains v1.0');
assert.strictEqual(output.milestones[1].version, 'v1.1', 'second milestone version');
assert.ok(output.milestones[1].heading.includes('v1.1'), 'second milestone heading contains v1.1');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// roadmap analyze missing phase details
// ─────────────────────────────────────────────────────────────────────────────
describe('roadmap analyze missing phase details', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('detects checklist-only phases missing detail sections', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] **Phase 1: Foundation** - Set up project
- [ ] **Phase 2: API** - Build REST API
### Phase 2: API
**Goal:** Build REST API
`
);
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(Array.isArray(output.missing_phase_details), 'missing_phase_details should be an array');
assert.ok(output.missing_phase_details.includes('1'), 'phase 1 should be in missing details');
assert.ok(!output.missing_phase_details.includes('2'), 'phase 2 should not be in missing details');
});
test('returns null when all checklist phases have detail sections', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] **Phase 1: Foundation** - Set up project
- [ ] **Phase 2: API** - Build REST API
### Phase 1: Foundation
**Goal:** Set up project
### Phase 2: API
**Goal:** Build REST API
`
);
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.missing_phase_details, null, 'missing_phase_details should be null');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// roadmap get-phase success criteria
// ─────────────────────────────────────────────────────────────────────────────
describe('roadmap get-phase success criteria', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('extracts success_criteria array from phase section', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Test
**Goal:** Test goal
**Success Criteria** (what must be TRUE):
1. First criterion
2. Second criterion
3. Third criterion
### Phase 2: Other
**Goal:** Other goal
`
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'phase should be found');
assert.ok(Array.isArray(output.success_criteria), 'success_criteria should be an array');
assert.strictEqual(output.success_criteria.length, 3, 'should have 3 criteria');
assert.ok(output.success_criteria[0].includes('First criterion'), 'first criterion matches');
assert.ok(output.success_criteria[1].includes('Second criterion'), 'second criterion matches');
assert.ok(output.success_criteria[2].includes('Third criterion'), 'third criterion matches');
});
test('returns empty array when no success criteria present', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Simple
**Goal:** No criteria here
`
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'phase should be found');
assert.ok(Array.isArray(output.success_criteria), 'success_criteria should be an array');
assert.strictEqual(output.success_criteria.length, 0, 'should have empty criteria');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// roadmap update-plan-progress command
// ─────────────────────────────────────────────────────────────────────────────
describe('roadmap update-plan-progress command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('missing phase number returns error', () => {
const result = runGsdTools('roadmap update-plan-progress', tmpDir);
assert.strictEqual(result.success, false, 'should fail without phase number');
assert.ok(result.error.includes('phase number required'), 'error should mention phase number required');
});
test('nonexistent phase returns error', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Test
**Goal:** Test goal
`
);
const result = runGsdTools('roadmap update-plan-progress 99', tmpDir);
assert.strictEqual(result.success, false, 'should fail for nonexistent phase');
assert.ok(result.error.includes('not found'), 'error should mention not found');
});
test('no plans found returns updated false', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Test
**Goal:** Test goal
`
);
// Create phase dir with only a context file (no plans)
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-CONTEXT.md'), '# Context');
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, false, 'should not update');
assert.ok(output.reason.includes('No plans'), 'reason should mention no plans');
assert.strictEqual(output.plan_count, 0, 'plan_count should be 0');
});
test('updates progress for partial completion', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Test
**Goal:** Test goal
**Plans:** TBD
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Test | v1.0 | 0/2 | Planned | - |
`
);
// Create phase dir with 2 plans, 1 summary
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan 2');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1');
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, true, 'should update');
assert.strictEqual(output.plan_count, 2, 'plan_count should be 2');
assert.strictEqual(output.summary_count, 1, 'summary_count should be 1');
assert.strictEqual(output.status, 'In Progress', 'status should be In Progress');
assert.strictEqual(output.complete, false, 'should not be complete');
// Verify file was actually modified
const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmapContent.includes('1/2'), 'roadmap should contain updated plan count');
});
test('updates progress and checks checkbox on completion', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] **Phase 1: Test** - description
### Phase 1: Test
**Goal:** Test goal
**Plans:** TBD
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Test | v1.0 | 0/1 | Planned | - |
`
);
// Create phase dir with 1 plan, 1 summary (complete)
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1');
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, true, 'should update');
assert.strictEqual(output.complete, true, 'should be complete');
assert.strictEqual(output.status, 'Complete', 'status should be Complete');
// Verify file was actually modified
const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmapContent.includes('[x]'), 'checkbox should be checked');
assert.ok(roadmapContent.includes('completed'), 'should contain completion date text');
assert.ok(roadmapContent.includes('1/1'), 'roadmap should contain updated plan count');
});
test('missing ROADMAP.md returns updated false', () => {
// Create phase dir with plans and summaries but NO ROADMAP.md
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1');
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, false, 'should not update');
assert.ok(output.reason.includes('ROADMAP.md not found'), 'reason should mention missing ROADMAP.md');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase add command
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -502,6 +502,753 @@ describe('STATE.md frontmatter sync', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// stateExtractField and stateReplaceField helpers
// ─────────────────────────────────────────────────────────────────────────────
const { stateExtractField, stateReplaceField } = require('../get-shit-done/bin/lib/state.cjs');
describe('stateExtractField and stateReplaceField helpers', () => {
// stateExtractField tests
test('extracts simple field value', () => {
const content = '# State\n\n**Status:** In progress\n';
const result = stateExtractField(content, 'Status');
assert.strictEqual(result, 'In progress', 'should extract simple field value');
});
test('extracts field with colon in value', () => {
const content = '# State\n\n**Last Activity:** 2024-01-15 — Completed plan\n';
const result = stateExtractField(content, 'Last Activity');
assert.strictEqual(result, '2024-01-15 — Completed plan', 'should return full value after field pattern');
});
test('returns null for missing field', () => {
const content = '# State\n\n**Phase:** 03\n';
const result = stateExtractField(content, 'Status');
assert.strictEqual(result, null, 'should return null when field not present');
});
test('is case-insensitive on field name', () => {
const content = '# State\n\n**status:** Active\n';
const result = stateExtractField(content, 'Status');
assert.strictEqual(result, 'Active', 'should match field name case-insensitively');
});
// stateReplaceField tests
test('replaces field value', () => {
const content = '# State\n\n**Status:** Old\n';
const result = stateReplaceField(content, 'Status', 'New');
assert.ok(result !== null, 'should return updated content, not null');
assert.ok(result.includes('**Status:** New'), 'output should contain updated field value');
assert.ok(!result.includes('**Status:** Old'), 'output should not contain old field value');
});
test('returns null when field not found', () => {
const content = '# State\n\n**Phase:** 03\n';
const result = stateReplaceField(content, 'Status', 'New');
assert.strictEqual(result, null, 'should return null when field not present');
});
test('preserves surrounding content', () => {
const content = [
'# Project State',
'',
'**Phase:** 03',
'**Status:** Old',
'**Last Activity:** 2024-01-15',
'',
'## Notes',
'Some notes here.',
].join('\n');
const result = stateReplaceField(content, 'Status', 'New');
assert.ok(result !== null, 'should return updated content');
assert.ok(result.includes('**Phase:** 03'), 'Phase line should be unchanged');
assert.ok(result.includes('**Status:** New'), 'Status should be updated');
assert.ok(result.includes('**Last Activity:** 2024-01-15'), 'Last Activity line should be unchanged');
assert.ok(result.includes('## Notes'), 'Notes heading should be unchanged');
assert.ok(result.includes('Some notes here.'), 'Notes content should be unchanged');
});
test('round-trip: extract then replace then extract', () => {
const content = '# State\n\n**Phase:** 3\n';
const extracted = stateExtractField(content, 'Phase');
assert.strictEqual(extracted, '3', 'initial extract should return "3"');
const updated = stateReplaceField(content, 'Phase', '4');
assert.ok(updated !== null, 'replace should succeed');
const reExtracted = stateExtractField(updated, 'Phase');
assert.strictEqual(reExtracted, '4', 'extract after replace should return "4"');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdStateLoad, cmdStateGet, cmdStatePatch, cmdStateUpdate CLI tests
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdStateLoad (state load)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('returns config and state when STATE.md exists', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n**Status:** Active\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ mode: 'yolo' })
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n'
);
const result = runGsdTools('state load', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.state_exists, true, 'state_exists should be true');
assert.strictEqual(output.config_exists, true, 'config_exists should be true');
assert.strictEqual(output.roadmap_exists, true, 'roadmap_exists should be true');
assert.ok(output.state_raw.includes('**Status:** Active'), 'state_raw should contain STATE.md content');
});
test('returns state_exists false when STATE.md missing', () => {
const result = runGsdTools('state load', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.state_exists, false, 'state_exists should be false');
assert.strictEqual(output.state_raw, '', 'state_raw should be empty string');
});
test('returns raw key=value format with --raw flag', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n**Status:** Active\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ mode: 'yolo' })
);
const result = runGsdTools('state load --raw', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
assert.ok(result.output.includes('state_exists=true'), 'raw output should include state_exists=true');
assert.ok(result.output.includes('config_exists=true'), 'raw output should include config_exists=true');
});
});
describe('cmdStateGet (state get)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('returns full content when no section specified', () => {
const stateContent = '# Project State\n\n**Status:** Active\n**Phase:** 03\n';
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent);
const result = runGsdTools('state get', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.content !== undefined, 'output should have content field');
assert.ok(output.content.includes('**Status:** Active'), 'content should include full STATE.md text');
});
test('extracts bold field value', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n**Status:** Active\n'
);
const result = runGsdTools('state get Status', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output['Status'], 'Active', 'should extract Status field value');
});
test('extracts markdown section content', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n**Status:** Active\n\n## Blockers\n\n- item1\n- item2\n'
);
const result = runGsdTools('state get Blockers', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output['Blockers'] !== undefined, 'should have Blockers key in output');
assert.ok(output['Blockers'].includes('item1'), 'section content should include item1');
assert.ok(output['Blockers'].includes('item2'), 'section content should include item2');
});
test('returns error for nonexistent field', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n**Status:** Active\n'
);
const result = runGsdTools('state get Missing', tmpDir);
assert.ok(result.success, `Command should exit 0 even for missing field: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error !== undefined, 'output should have error field');
assert.ok(output.error.toLowerCase().includes('not found'), 'error should mention "not found"');
});
test('returns error when STATE.md missing', () => {
const result = runGsdTools('state get Status', tmpDir);
assert.ok(!result.success, 'command should fail when STATE.md is missing');
assert.ok(
result.error.includes('STATE.md') || result.output.includes('STATE.md'),
'error message should mention STATE.md'
);
});
});
describe('cmdStatePatch and cmdStateUpdate (state patch, state update)', () => {
let tmpDir;
const stateMd = [
'# Project State',
'',
'**Current Phase:** 03',
'**Status:** In progress',
'**Last Activity:** 2024-01-15',
].join('\n') + '\n';
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('state patch updates multiple fields at once', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
const result = runGsdTools('state patch --Status Complete --"Current Phase" 04', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(updated.includes('**Status:** Complete'), 'Status should be updated to Complete');
assert.ok(updated.includes('**Last Activity:** 2024-01-15'), 'Last Activity should be unchanged');
});
test('state patch reports failed fields that do not exist', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
const result = runGsdTools('state patch --Status Done --Missing value', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(Array.isArray(output.updated), 'updated should be an array');
assert.ok(output.updated.includes('Status'), 'Status should be in updated list');
assert.ok(Array.isArray(output.failed), 'failed should be an array');
assert.ok(output.failed.includes('Missing'), 'Missing should be in failed list');
});
test('state update changes a single field', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
const result = runGsdTools('state update Status "Phase complete"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, true, 'updated should be true');
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(updated.includes('**Status:** Phase complete'), 'Status should be updated');
assert.ok(updated.includes('**Current Phase:** 03'), 'Current Phase should be unchanged');
assert.ok(updated.includes('**Last Activity:** 2024-01-15'), 'Last Activity should be unchanged');
});
test('state update reports field not found', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
const result = runGsdTools('state update Missing value', tmpDir);
assert.ok(result.success, `Command should exit 0 for not-found field: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, false, 'updated should be false');
assert.ok(output.reason !== undefined, 'should include a reason');
});
test('state update returns error when STATE.md missing', () => {
const result = runGsdTools('state update Status value', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, false, 'updated should be false');
assert.ok(
output.reason.includes('STATE.md'),
'reason should mention STATE.md'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdStateAdvancePlan, cmdStateRecordMetric, cmdStateUpdateProgress
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdStateAdvancePlan (state advance-plan)', () => {
let tmpDir;
const advanceFixture = [
'# Project State',
'',
'**Current Plan:** 1',
'**Total Plans in Phase:** 3',
'**Status:** Executing',
'**Last Activity:** 2024-01-10',
].join('\n') + '\n';
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('advances plan counter when not on last plan', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), advanceFixture);
const before = new Date().toISOString().split('T')[0];
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.advanced, true, 'advanced should be true');
assert.strictEqual(output.previous_plan, 1, 'previous_plan should be 1');
assert.strictEqual(output.current_plan, 2, 'current_plan should be 2');
assert.strictEqual(output.total_plans, 3, 'total_plans should be 3');
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(updated.includes('**Current Plan:** 2'), 'Current Plan should be updated to 2');
assert.ok(updated.includes('**Status:** Ready to execute'), 'Status should be Ready to execute');
const after = new Date().toISOString().split('T')[0];
assert.ok(
updated.includes(`**Last Activity:** ${before}`) || updated.includes(`**Last Activity:** ${after}`),
`Last Activity should be today (${before}) or next day if midnight boundary (${after})`
);
});
test('marks phase complete on last plan', () => {
const lastPlanFixture = advanceFixture.replace('**Current Plan:** 1', '**Current Plan:** 3');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), lastPlanFixture);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.advanced, false, 'advanced should be false');
assert.strictEqual(output.reason, 'last_plan', 'reason should be last_plan');
assert.strictEqual(output.status, 'ready_for_verification', 'status should be ready_for_verification');
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(updated.includes('Phase complete'), 'Status should contain Phase complete');
});
test('returns error when STATE.md missing', () => {
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error !== undefined, 'output should have error field');
assert.ok(output.error.includes('STATE.md'), 'error should mention STATE.md');
});
test('returns error when plan fields not parseable', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n**Status:** Active\n'
);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error !== undefined, 'output should have error field');
assert.ok(output.error.toLowerCase().includes('cannot parse'), 'error should mention Cannot parse');
});
});
describe('cmdStateRecordMetric (state record-metric)', () => {
let tmpDir;
const metricsFixture = [
'# Project State',
'',
'## Performance Metrics',
'',
'| Plan | Duration | Tasks | Files |',
'|------|----------|-------|-------|',
'| Phase 1 P1 | 3min | 2 tasks | 3 files |',
'',
'## Session Continuity',
].join('\n') + '\n';
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('appends metric row to existing table', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), metricsFixture);
const result = runGsdTools('state record-metric --phase 2 --plan 1 --duration 5min --tasks 3 --files 4', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.recorded, true, 'recorded should be true');
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(updated.includes('| Phase 2 P1 | 5min | 3 tasks | 4 files |'), 'new row should be present');
assert.ok(updated.includes('| Phase 1 P1 | 3min | 2 tasks | 3 files |'), 'existing row should still be present');
});
test('replaces None yet placeholder with first metric', () => {
const noneYetFixture = [
'# Project State',
'',
'## Performance Metrics',
'',
'| Plan | Duration | Tasks | Files |',
'|------|----------|-------|-------|',
'None yet',
'',
'## Session Continuity',
].join('\n') + '\n';
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), noneYetFixture);
const result = runGsdTools('state record-metric --phase 1 --plan 1 --duration 2min --tasks 1 --files 2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(!updated.includes('None yet'), 'None yet placeholder should be removed');
assert.ok(updated.includes('| Phase 1 P1 | 2min | 1 tasks | 2 files |'), 'new row should be present');
});
test('returns error when required fields missing', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), metricsFixture);
const result = runGsdTools('state record-metric --phase 1', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error !== undefined, 'output should have error field');
assert.ok(
output.error.includes('phase') || output.error.includes('plan') || output.error.includes('duration'),
'error should mention missing required fields'
);
});
test('returns error when STATE.md missing', () => {
const result = runGsdTools('state record-metric --phase 1 --plan 1 --duration 2min', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error !== undefined, 'output should have error field');
assert.ok(output.error.includes('STATE.md'), 'error should mention STATE.md');
});
});
describe('cmdStateUpdateProgress (state update-progress)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('calculates progress from plan/summary counts', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n**Progress:** [░░░░░░░░░░] 0%\n'
);
// Phase 01: 1 PLAN + 1 SUMMARY = completed
const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01');
fs.mkdirSync(phase01Dir, { recursive: true });
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary\n');
// Phase 02: 1 PLAN only = not completed
const phase02Dir = path.join(tmpDir, '.planning', 'phases', '02');
fs.mkdirSync(phase02Dir, { recursive: true });
fs.writeFileSync(path.join(phase02Dir, '02-01-PLAN.md'), '# Plan\n');
const result = runGsdTools('state update-progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, true, 'updated should be true');
assert.strictEqual(output.percent, 50, 'percent should be 50');
assert.strictEqual(output.completed, 1, 'completed should be 1');
assert.strictEqual(output.total, 2, 'total should be 2');
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(updated.includes('50%'), 'STATE.md Progress should contain 50%');
});
test('handles zero plans gracefully', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n**Progress:** [░░░░░░░░░░] 0%\n'
);
const result = runGsdTools('state update-progress', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.percent, 0, 'percent should be 0 when no plans found');
});
test('returns error when Progress field missing', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n**Status:** Active\n'
);
const result = runGsdTools('state update-progress', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, false, 'updated should be false');
assert.ok(output.reason !== undefined, 'should have a reason');
});
test('returns error when STATE.md missing', () => {
const result = runGsdTools('state update-progress', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error !== undefined, 'output should have error field');
assert.ok(output.error.includes('STATE.md'), 'error should mention STATE.md');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// cmdStateResolveBlocker, cmdStateRecordSession
// ─────────────────────────────────────────────────────────────────────────────
describe('cmdStateResolveBlocker (state resolve-blocker)', () => {
let tmpDir;
const blockerFixture = [
'# Project State',
'',
'## Blockers',
'',
'- Waiting for API credentials',
'- Need design review for dashboard',
'- Pending vendor approval',
'',
'## Session Continuity',
].join('\n') + '\n';
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('removes matching blocker line (case-insensitive substring match)', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), blockerFixture);
const result = runGsdTools('state resolve-blocker --text "api credentials"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.resolved, true, 'resolved should be true');
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(!updated.includes('Waiting for API credentials'), 'matched blocker should be removed');
assert.ok(updated.includes('Need design review for dashboard'), 'other blocker should still be present');
assert.ok(updated.includes('Pending vendor approval'), 'other blocker should still be present');
});
test('adds None placeholder when last blocker resolved', () => {
const singleBlockerFixture = [
'# Project State',
'',
'## Blockers',
'',
'- Single blocker',
'',
'## Session Continuity',
].join('\n') + '\n';
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), singleBlockerFixture);
const result = runGsdTools('state resolve-blocker --text "single blocker"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(!updated.includes('- Single blocker'), 'resolved blocker should be removed');
// Section should contain "None" placeholder, not be empty
const sectionMatch = updated.match(/## Blockers\n([\s\S]*?)(?=\n##|$)/i);
assert.ok(sectionMatch, 'Blockers section should still exist');
assert.ok(sectionMatch[1].includes('None'), 'Blockers section should contain None placeholder');
});
test('returns error when text not provided', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), blockerFixture);
const result = runGsdTools('state resolve-blocker', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error !== undefined, 'output should have error field');
assert.ok(
output.error.toLowerCase().includes('text'),
'error should mention text required'
);
});
test('returns error when STATE.md missing', () => {
const result = runGsdTools('state resolve-blocker --text "anything"', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error !== undefined, 'output should have error field');
assert.ok(output.error.includes('STATE.md'), 'error should mention STATE.md');
});
test('returns resolved true even if no line matches', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), blockerFixture);
const result = runGsdTools('state resolve-blocker --text "nonexistent blocker text"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.resolved, true, 'resolved should be true even when no line matches');
});
});
describe('cmdStateRecordSession (state record-session)', () => {
let tmpDir;
const sessionFixture = [
'# Project State',
'',
'## Session Continuity',
'',
'**Last session:** 2024-01-10',
'**Stopped at:** Phase 2, Plan 1',
'**Resume file:** None',
].join('\n') + '\n';
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('updates session fields with stopped-at and resume-file', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), sessionFixture);
const result = runGsdTools(
'state record-session --stopped-at "Phase 3, Plan 2" --resume-file ".planning/phases/03/03-02-PLAN.md"',
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.recorded, true, 'recorded should be true');
assert.ok(Array.isArray(output.updated), 'updated should be an array');
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(updated.includes('Phase 3, Plan 2'), 'Stopped at should be updated');
assert.ok(updated.includes('.planning/phases/03/03-02-PLAN.md'), 'Resume file should be updated');
const today = new Date().toISOString().split('T')[0];
assert.ok(updated.includes(today), 'Last session should be updated to today');
});
test('updates Last session timestamp even with no other options', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), sessionFixture);
const result = runGsdTools('state record-session', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.recorded, true, 'recorded should be true');
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
const today = new Date().toISOString().split('T')[0];
assert.ok(updated.includes(today), 'Last session should contain today\'s date');
});
test('sets Resume file to None when not specified', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), sessionFixture);
const result = runGsdTools('state record-session --stopped-at "Phase 1 complete"', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(updated.includes('Phase 1 complete'), 'Stopped at should be updated');
// Resume file should be set to None (default)
const resumeMatch = updated.match(/\*\*Resume file:\*\*\s*(.*)/i);
assert.ok(resumeMatch, 'Resume file field should exist');
assert.ok(resumeMatch[1].trim() === 'None', 'Resume file should be None when not specified');
});
test('returns error when STATE.md missing', () => {
const result = runGsdTools('state record-session', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error !== undefined, 'output should have error field');
assert.ok(output.error.includes('STATE.md'), 'error should mention STATE.md');
});
test('returns recorded false when no session fields found', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n**Status:** Active\n**Phase:** 03\n'
);
const result = runGsdTools('state record-session', tmpDir);
assert.ok(result.success, `Command should exit 0: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.recorded, false, 'recorded should be false when no session fields found');
assert.ok(output.reason !== undefined, 'should have a reason');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// summary-extract command
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,529 @@
/**
* GSD Tools Tests - Validate Health Command
*
* Comprehensive tests for validate-health covering all 8 health checks
* and the repair path.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── Helpers for setting up minimal valid projects ────────────────────────────
function writeMinimalRoadmap(tmpDir, phases = ['1']) {
const lines = phases.map(n => `### Phase ${n}: Phase ${n} Description`).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n${lines}\n`
);
}
function writeMinimalProjectMd(tmpDir, sections = ['## What This Is', '## Core Value', '## Requirements']) {
const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
`# Project\n\n${content}`
);
}
function writeMinimalStateMd(tmpDir, content) {
const defaultContent = content || `# Session State\n\n## Current Position\n\nPhase: 1\n`;
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
defaultContent
);
}
function writeValidConfigJson(tmpDir) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2)
);
}
// ─────────────────────────────────────────────────────────────────────────────
// validate health command — all 8 checks
// ─────────────────────────────────────────────────────────────────────────────
describe('validate health command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ─── Check 1: .planning/ exists ───────────────────────────────────────────
test("returns 'broken' when .planning directory is missing", () => {
// createTempProject creates .planning/phases — remove it entirely
fs.rmSync(path.join(tmpDir, '.planning'), { recursive: true, force: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.status, 'broken', 'should be broken');
assert.ok(
output.errors.some(e => e.code === 'E001'),
`Expected E001 in errors: ${JSON.stringify(output.errors)}`
);
});
// ─── Check 2: PROJECT.md exists and has required sections ─────────────────
test('warns when PROJECT.md is missing', () => {
// No PROJECT.md in .planning
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir);
writeValidConfigJson(tmpDir);
// Create valid phase dir so no W007
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.errors.some(e => e.code === 'E002'),
`Expected E002 in errors: ${JSON.stringify(output.errors)}`
);
});
test('warns when PROJECT.md missing required sections', () => {
// PROJECT.md missing "## Core Value" section
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
'# Project\n\n## What This Is\n\nFoo\n\n## Requirements\n\nBar\n'
);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir);
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const w001s = output.warnings.filter(w => w.code === 'W001');
assert.ok(w001s.length > 0, `Expected W001 warnings: ${JSON.stringify(output.warnings)}`);
assert.ok(
w001s.some(w => w.message.includes('## Core Value')),
`Expected W001 mentioning "## Core Value": ${JSON.stringify(w001s)}`
);
});
test('passes when PROJECT.md has all required sections', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir);
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.errors.some(e => e.code === 'E002'),
`Should not have E002: ${JSON.stringify(output.errors)}`
);
assert.ok(
!output.warnings.some(w => w.code === 'W001'),
`Should not have W001: ${JSON.stringify(output.warnings)}`
);
});
// ─── Check 3: ROADMAP.md exists ───────────────────────────────────────────
test('errors when ROADMAP.md is missing', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalStateMd(tmpDir);
writeValidConfigJson(tmpDir);
// No ROADMAP.md
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.errors.some(e => e.code === 'E003'),
`Expected E003 in errors: ${JSON.stringify(output.errors)}`
);
});
// ─── Check 4: STATE.md exists and references valid phases ─────────────────
test('errors when STATE.md is missing with repairable true', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
// No STATE.md
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const e004 = output.errors.find(e => e.code === 'E004');
assert.ok(e004, `Expected E004 in errors: ${JSON.stringify(output.errors)}`);
assert.strictEqual(e004.repairable, true, 'E004 should be repairable');
});
test('warns when STATE.md references nonexistent phase', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeValidConfigJson(tmpDir);
// STATE.md mentions Phase 99 but only 01-a dir exists
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Session State\n\nPhase 99 is the current phase.\n'
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W002'),
`Expected W002 in warnings: ${JSON.stringify(output.warnings)}`
);
});
// ─── Check 5: config.json valid JSON + valid schema ───────────────────────
test('warns when config.json is missing with repairable true', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
// No config.json
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const w003 = output.warnings.find(w => w.code === 'W003');
assert.ok(w003, `Expected W003 in warnings: ${JSON.stringify(output.warnings)}`);
assert.strictEqual(w003.repairable, true, 'W003 should be repairable');
});
test('errors when config.json has invalid JSON', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
'{broken json'
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.errors.some(e => e.code === 'E005'),
`Expected E005 in errors: ${JSON.stringify(output.errors)}`
);
});
test('warns when config.json has invalid model_profile', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'invalid' })
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W004'),
`Expected W004 in warnings: ${JSON.stringify(output.warnings)}`
);
});
// ─── Check 6: Phase directory naming (NN-name format) ─────────────────────
test('warns about incorrectly named phase directories', () => {
writeMinimalProjectMd(tmpDir);
// Roadmap with no phases to avoid W006
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\nNo phases yet.\n'
);
writeMinimalStateMd(tmpDir, '# Session State\n\nNo phase references.\n');
writeValidConfigJson(tmpDir);
// Create a badly named dir
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'bad_name'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W005'),
`Expected W005 in warnings: ${JSON.stringify(output.warnings)}`
);
});
// ─── Check 7: Orphaned plans (PLAN without SUMMARY) ───────────────────────
test('reports orphaned plans (PLAN without SUMMARY) as info', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir);
writeValidConfigJson(tmpDir);
// Create 01-test phase dir with a PLAN but no matching SUMMARY
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
// No 01-01-SUMMARY.md
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.info.some(i => i.code === 'I001'),
`Expected I001 in info: ${JSON.stringify(output.info)}`
);
});
// ─── Check 8: Consistency (roadmap/disk sync) ─────────────────────────────
test('warns about phase in ROADMAP but not on disk', () => {
writeMinimalProjectMd(tmpDir);
// ROADMAP mentions Phase 5 but no 05-xxx dir
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 5: Future Phase\n'
);
writeMinimalStateMd(tmpDir, '# Session State\n\nNo phase refs.\n');
writeValidConfigJson(tmpDir);
// No phase dirs
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W006'),
`Expected W006 in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('warns about phase on disk but not in ROADMAP', () => {
writeMinimalProjectMd(tmpDir);
// ROADMAP has no phases
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\nNo phases listed.\n'
);
writeMinimalStateMd(tmpDir, '# Session State\n\nNo phase refs.\n');
writeValidConfigJson(tmpDir);
// Orphan phase dir not in ROADMAP
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '99-orphan'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W007'),
`Expected W007 in warnings: ${JSON.stringify(output.warnings)}`
);
});
// ─── Overall status ────────────────────────────────────────────────────────
test("returns 'healthy' when all checks pass", () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir);
// Create valid phase dir matching ROADMAP
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-a');
fs.mkdirSync(phaseDir, { recursive: true });
// Add PLAN+SUMMARY so no I001
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.status, 'healthy', `Expected healthy, got ${output.status}. Errors: ${JSON.stringify(output.errors)}, Warnings: ${JSON.stringify(output.warnings)}`);
assert.deepStrictEqual(output.errors, [], 'should have no errors');
assert.deepStrictEqual(output.warnings, [], 'should have no warnings');
});
test("returns 'degraded' when only warnings exist", () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir);
// No config.json → W003 (warning, not error)
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.status, 'degraded', `Expected degraded, got ${output.status}`);
assert.strictEqual(output.errors.length, 0, 'should have no errors');
assert.ok(output.warnings.length > 0, 'should have warnings');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// validate health --repair command
// ─────────────────────────────────────────────────────────────────────────────
describe('validate health --repair command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
// Set up base project with ROADMAP and PROJECT.md so repairs are triggered
// (E001, E003 are not repairable so we always need .planning/ and ROADMAP.md)
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
test('creates config.json with defaults when missing', () => {
// STATE.md present so no STATE repair; no config.json
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
// Ensure no config.json
const configPath = path.join(tmpDir, '.planning', 'config.json');
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
const result = runGsdTools('validate health --repair', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
Array.isArray(output.repairs_performed),
`Expected repairs_performed array: ${JSON.stringify(output)}`
);
const createAction = output.repairs_performed.find(r => r.action === 'createConfig');
assert.ok(createAction, `Expected createConfig action: ${JSON.stringify(output.repairs_performed)}`);
assert.strictEqual(createAction.success, true, 'createConfig should succeed');
// Verify config.json now exists on disk with valid JSON and balanced profile
assert.ok(fs.existsSync(configPath), 'config.json should now exist on disk');
const diskConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.strictEqual(diskConfig.model_profile, 'balanced', 'default model_profile should be balanced');
});
test('resets config.json when JSON is invalid', () => {
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, '{broken json');
const result = runGsdTools('validate health --repair', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
Array.isArray(output.repairs_performed),
`Expected repairs_performed: ${JSON.stringify(output)}`
);
const resetAction = output.repairs_performed.find(r => r.action === 'resetConfig');
assert.ok(resetAction, `Expected resetConfig action: ${JSON.stringify(output.repairs_performed)}`);
// Verify config.json is now valid JSON
const diskConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.ok(typeof diskConfig === 'object', 'config.json should be valid JSON after repair');
});
test('regenerates STATE.md when missing', () => {
writeValidConfigJson(tmpDir);
// No STATE.md
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
if (fs.existsSync(statePath)) fs.unlinkSync(statePath);
const result = runGsdTools('validate health --repair', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
Array.isArray(output.repairs_performed),
`Expected repairs_performed: ${JSON.stringify(output)}`
);
const regenerateAction = output.repairs_performed.find(r => r.action === 'regenerateState');
assert.ok(regenerateAction, `Expected regenerateState action: ${JSON.stringify(output.repairs_performed)}`);
assert.strictEqual(regenerateAction.success, true, 'regenerateState should succeed');
// Verify STATE.md now exists and contains "# Session State"
assert.ok(fs.existsSync(statePath), 'STATE.md should now exist on disk');
const stateContent = fs.readFileSync(statePath, 'utf-8');
assert.ok(stateContent.includes('# Session State'), 'regenerated STATE.md should contain "# Session State"');
});
test('backs up existing STATE.md before regenerating', () => {
writeValidConfigJson(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const originalContent = '# Session State\n\nOriginal content here.\n';
fs.writeFileSync(statePath, originalContent);
// Make STATE.md reference a nonexistent phase so repair is triggered
fs.writeFileSync(
statePath,
'# Session State\n\nPhase 99 is current.\n'
);
const result = runGsdTools('validate health --repair', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
Array.isArray(output.repairs_performed),
`Expected repairs_performed: ${JSON.stringify(output)}`
);
// Verify a .bak- file exists alongside STATE.md
const planningDir = path.join(tmpDir, '.planning');
const planningFiles = fs.readdirSync(planningDir);
const backupFile = planningFiles.find(f => f.startsWith('STATE.md.bak-'));
assert.ok(backupFile, `Expected a STATE.md.bak- file. Found files: ${planningFiles.join(', ')}`);
// Verify backup contains the original content
const backupContent = fs.readFileSync(path.join(planningDir, backupFile), 'utf-8');
assert.ok(backupContent.includes('Phase 99'), 'backup should contain the original STATE.md content');
});
test('reports repairable_count correctly', () => {
// No config.json (W003, repairable=true) and no STATE.md (E004, repairable=true)
const configPath = path.join(tmpDir, '.planning', 'config.json');
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
if (fs.existsSync(statePath)) fs.unlinkSync(statePath);
// Run WITHOUT --repair to just check repairable_count
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.repairable_count >= 2,
`Expected repairable_count >= 2, got ${output.repairable_count}. Full output: ${JSON.stringify(output)}`
);
});
});

View File

@@ -6,7 +6,41 @@ const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const { runGsdTools, createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
const { execSync } = require('child_process');
// ─── helpers ──────────────────────────────────────────────────────────────────
// Build a minimal valid PLAN.md content with all required frontmatter fields
function validPlanContent({ wave = 1, dependsOn = '[]', autonomous = 'true', extraTasks = '' } = {}) {
return [
'---',
'phase: 01-test',
'plan: 01',
'type: execute',
`wave: ${wave}`,
`depends_on: ${dependsOn}`,
'files_modified: [some/file.ts]',
`autonomous: ${autonomous}`,
'must_haves:',
' truths:',
' - "something is true"',
'---',
'',
'<tasks>',
'',
'<task type="auto">',
' <name>Task 1: Do something</name>',
' <files>some/file.ts</files>',
' <action>Do the thing</action>',
' <verify><automated>echo ok</automated></verify>',
' <done>Thing is done</done>',
'</task>',
extraTasks,
'',
'</tasks>',
].join('\n');
}
describe('validate consistency command', () => {
let tmpDir;
@@ -75,6 +109,905 @@ describe('validate consistency command', () => {
});
// ─────────────────────────────────────────────────────────────────────────────
// progress command
// verify plan-structure command
// ─────────────────────────────────────────────────────────────────────────────
describe('verify plan-structure command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
test('reports missing required frontmatter fields', () => {
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md');
fs.writeFileSync(planPath, '# No frontmatter here\n\nJust a plan without YAML.\n');
const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid, false, 'should be invalid');
assert.ok(
output.errors.some(e => e.includes('Missing required frontmatter field')),
`Expected "Missing required frontmatter field" in errors: ${JSON.stringify(output.errors)}`
);
});
test('validates complete plan with all required fields and tasks', () => {
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md');
fs.writeFileSync(planPath, validPlanContent());
const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid, true, `should be valid, errors: ${JSON.stringify(output.errors)}`);
assert.deepStrictEqual(output.errors, [], 'should have no errors');
assert.strictEqual(output.task_count, 1, 'should have 1 task');
});
test('reports task missing name element', () => {
const content = [
'---',
'phase: 01-test',
'plan: 01',
'type: execute',
'wave: 1',
'depends_on: []',
'files_modified: [some/file.ts]',
'autonomous: true',
'must_haves:',
' truths:',
' - "something"',
'---',
'',
'<tasks>',
'<task type="auto">',
' <action>Do it</action>',
' <verify><automated>echo ok</automated></verify>',
' <done>Done</done>',
'</task>',
'</tasks>',
].join('\n');
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md');
fs.writeFileSync(planPath, content);
const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.errors.some(e => e.includes('Task missing <name>')),
`Expected "Task missing <name>" in errors: ${JSON.stringify(output.errors)}`
);
});
test('reports task missing action element', () => {
const content = [
'---',
'phase: 01-test',
'plan: 01',
'type: execute',
'wave: 1',
'depends_on: []',
'files_modified: [some/file.ts]',
'autonomous: true',
'must_haves:',
' truths:',
' - "something"',
'---',
'',
'<tasks>',
'<task type="auto">',
' <name>Task 1: No action</name>',
' <verify><automated>echo ok</automated></verify>',
' <done>Done</done>',
'</task>',
'</tasks>',
].join('\n');
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md');
fs.writeFileSync(planPath, content);
const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.errors.some(e => e.includes('missing <action>')),
`Expected "missing <action>" in errors: ${JSON.stringify(output.errors)}`
);
});
test('warns about wave > 1 with empty depends_on', () => {
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md');
fs.writeFileSync(planPath, validPlanContent({ wave: 2, dependsOn: '[]' }));
const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.includes('Wave > 1 but depends_on is empty')),
`Expected "Wave > 1 but depends_on is empty" in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('errors when checkpoint task but autonomous is true', () => {
const content = [
'---',
'phase: 01-test',
'plan: 01',
'type: execute',
'wave: 1',
'depends_on: []',
'files_modified: [some/file.ts]',
'autonomous: true',
'must_haves:',
' truths:',
' - "something"',
'---',
'',
'<tasks>',
'<task type="auto">',
' <name>Task 1: Normal</name>',
' <files>some/file.ts</files>',
' <action>Do it</action>',
' <verify><automated>echo ok</automated></verify>',
' <done>Done</done>',
'</task>',
'<task type="checkpoint:human-verify">',
' <name>Task 2: Verify UI</name>',
' <files>some/file.ts</files>',
' <action>Check the UI</action>',
' <verify><human>Visit the app</human></verify>',
' <done>UI verified</done>',
'</task>',
'</tasks>',
].join('\n');
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md');
fs.writeFileSync(planPath, content);
const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.errors.some(e => e.includes('checkpoint tasks but autonomous is not false')),
`Expected checkpoint/autonomous error in errors: ${JSON.stringify(output.errors)}`
);
});
test('returns error for nonexistent file', () => {
const result = runGsdTools('verify plan-structure .planning/phases/01-test/nonexistent.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error, `Expected error field in output: ${JSON.stringify(output)}`);
assert.ok(
output.error.includes('File not found'),
`Expected "File not found" in error: ${output.error}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// verify phase-completeness command
// ─────────────────────────────────────────────────────────────────────────────
describe('verify phase-completeness command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
// Create ROADMAP.md referencing phase 01 so findPhaseInternal can locate it
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: Test\n**Goal**: Test phase\n'
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
test('reports complete phase with matching plans and summaries', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
const result = runGsdTools('verify phase-completeness 01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.complete, true, `should be complete, errors: ${JSON.stringify(output.errors)}`);
assert.strictEqual(output.plan_count, 1, 'should have 1 plan');
assert.strictEqual(output.summary_count, 1, 'should have 1 summary');
assert.deepStrictEqual(output.incomplete_plans, [], 'should have no incomplete plans');
});
test('reports incomplete phase with plan missing summary', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
const result = runGsdTools('verify phase-completeness 01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.complete, false, 'should be incomplete');
assert.ok(
output.incomplete_plans.some(id => id.includes('01-01')),
`Expected "01-01" in incomplete_plans: ${JSON.stringify(output.incomplete_plans)}`
);
assert.ok(
output.errors.some(e => e.includes('Plans without summaries')),
`Expected "Plans without summaries" in errors: ${JSON.stringify(output.errors)}`
);
});
test('warns about orphan summaries', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
const result = runGsdTools('verify phase-completeness 01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.includes('Summaries without plans')),
`Expected "Summaries without plans" in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('returns error for nonexistent phase', () => {
const result = runGsdTools('verify phase-completeness 99', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error, `Expected error field in output: ${JSON.stringify(output)}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// verify-summary command
// ─────────────────────────────────────────────────────────────────────────────
describe('verify summary command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject();
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
test('returns not found for nonexistent summary', () => {
const result = runGsdTools('verify-summary .planning/phases/01-test/nonexistent.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.passed, false, 'should not pass');
assert.strictEqual(output.checks.summary_exists, false, 'summary should not exist');
assert.ok(
output.errors.some(e => e.includes('SUMMARY.md not found')),
`Expected "SUMMARY.md not found" in errors: ${JSON.stringify(output.errors)}`
);
});
test('passes for valid summary with real files and commits', () => {
// Create a source file and commit it
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'console.log("hello");\n');
execSync('git add -A', { cwd: tmpDir, stdio: 'pipe' });
execSync('git commit -m "add app.js"', { cwd: tmpDir, stdio: 'pipe' });
const hash = execSync('git rev-parse --short HEAD', { cwd: tmpDir, encoding: 'utf-8' }).trim();
// Write SUMMARY.md referencing the file and commit hash
const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md');
fs.writeFileSync(summaryPath, [
'# Summary',
'',
`Created: \`src/app.js\``,
'',
`Commit: ${hash}`,
].join('\n'));
const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.passed, true, `should pass, errors: ${JSON.stringify(output.errors)}`);
assert.strictEqual(output.checks.summary_exists, true, 'summary should exist');
assert.strictEqual(output.checks.commits_exist, true, 'commits should exist');
});
test('reports missing files mentioned in summary', () => {
const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md');
fs.writeFileSync(summaryPath, [
'# Summary',
'',
'Created: `src/nonexistent.js`',
].join('\n'));
const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.checks.files_created.missing.includes('src/nonexistent.js'),
`Expected missing to include "src/nonexistent.js": ${JSON.stringify(output.checks.files_created.missing)}`
);
});
test('detects self-check section with pass indicators', () => {
const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md');
fs.writeFileSync(summaryPath, [
'# Summary',
'',
'## Self-Check',
'',
'All tests pass',
].join('\n'));
const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.checks.self_check, 'passed', `Expected self_check "passed": ${JSON.stringify(output.checks)}`);
});
test('detects self-check section with fail indicators', () => {
const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md');
fs.writeFileSync(summaryPath, [
'# Summary',
'',
'## Verification',
'',
'Tests failed',
].join('\n'));
const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.checks.self_check, 'failed', `Expected self_check "failed": ${JSON.stringify(output.checks)}`);
});
test('REG-03: returns self_check "not_found" when no self-check section exists', () => {
const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md');
fs.writeFileSync(summaryPath, [
'# Summary',
'',
'## Accomplishments',
'',
'Everything went well.',
].join('\n'));
const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.checks.self_check, 'not_found', `Expected self_check "not_found": ${JSON.stringify(output.checks)}`);
assert.strictEqual(output.passed, true, `Missing self-check should not fail: ${JSON.stringify(output)}`);
});
test('search(-1) regression: self-check guard prevents entry when no heading', () => {
// No Self-Check/Verification/Quality Check heading — guard on line 79 prevents
// content.search(selfCheckPattern) from ever being called, so -1 is impossible
const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md');
fs.writeFileSync(summaryPath, [
'# Summary',
'',
'## Notes',
'',
'Some content here without a self-check heading.',
].join('\n'));
const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Guard works: selfCheckPattern.test() is false, if block not entered, selfCheck stays 'not_found'
assert.strictEqual(output.checks.self_check, 'not_found', `Expected not_found since no heading: ${JSON.stringify(output.checks)}`);
});
test('respects checkFileCount parameter', () => {
// Write summary referencing 5 files (none exist)
const summaryPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-SUMMARY.md');
fs.writeFileSync(summaryPath, [
'# Summary',
'',
'Files: `src/a.js`, `src/b.js`, `src/c.js`, `src/d.js`, `src/e.js`',
].join('\n'));
// Pass checkFileCount = 1 so only 1 file is checked
const result = runGsdTools('verify-summary .planning/phases/01-test/01-01-SUMMARY.md --check-count 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.checks.files_created.checked <= 1,
`Expected checked <= 1, got ${output.checks.files_created.checked}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// verify references command
// ─────────────────────────────────────────────────────────────────────────────
describe('verify references command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.mkdirSync(path.join(tmpDir, 'src', 'utils'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
test('reports valid when all referenced files exist', () => {
fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'console.log("app");\n');
const filePath = path.join(tmpDir, '.planning', 'phases', '01-test', 'doc.md');
fs.writeFileSync(filePath, '@src/app.js\n');
const result = runGsdTools('verify references .planning/phases/01-test/doc.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid, true, `should be valid: ${JSON.stringify(output)}`);
assert.strictEqual(output.found, 1, `should find 1 file: ${JSON.stringify(output)}`);
});
test('reports missing for nonexistent referenced files', () => {
const filePath = path.join(tmpDir, '.planning', 'phases', '01-test', 'doc.md');
fs.writeFileSync(filePath, '@src/missing.js\n');
const result = runGsdTools('verify references .planning/phases/01-test/doc.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid, false, 'should be invalid');
assert.ok(
output.missing.includes('src/missing.js'),
`Expected missing to include "src/missing.js": ${JSON.stringify(output.missing)}`
);
});
test('detects backtick file paths', () => {
fs.writeFileSync(path.join(tmpDir, 'src', 'utils', 'helper.js'), 'module.exports = {};\n');
const filePath = path.join(tmpDir, '.planning', 'phases', '01-test', 'doc.md');
fs.writeFileSync(filePath, 'See `src/utils/helper.js` for details.\n');
const result = runGsdTools('verify references .planning/phases/01-test/doc.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.found >= 1, `Expected at least 1 found, got ${output.found}`);
});
test('skips backtick template expressions', () => {
// Template expressions like ${variable} in backtick paths are skipped
// @-refs with http are processed but not found on disk
const filePath = path.join(tmpDir, '.planning', 'phases', '01-test', 'doc.md');
fs.writeFileSync(filePath, '`${variable}/path/file.js`\n');
const result = runGsdTools('verify references .planning/phases/01-test/doc.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Template expression is skipped entirely — total should be 0
assert.strictEqual(output.total, 0, `Expected total 0 (template skipped): ${JSON.stringify(output)}`);
});
test('returns error for nonexistent file', () => {
const result = runGsdTools('verify references .planning/phases/01-test/nonexistent.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error, `Expected error field: ${JSON.stringify(output)}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// verify commits command
// ─────────────────────────────────────────────────────────────────────────────
describe('verify commits command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('validates real commit hashes', () => {
const hash = execSync('git rev-parse --short HEAD', { cwd: tmpDir, encoding: 'utf-8' }).trim();
const result = runGsdTools(`verify commits ${hash}`, tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_valid, true, `Expected all_valid true: ${JSON.stringify(output)}`);
assert.ok(output.valid.includes(hash), `Expected valid to include ${hash}: ${JSON.stringify(output.valid)}`);
});
test('reports invalid for fake hashes', () => {
const result = runGsdTools('verify commits abcdef1234567', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_valid, false, `Expected all_valid false: ${JSON.stringify(output)}`);
assert.ok(
output.invalid.includes('abcdef1234567'),
`Expected invalid to include "abcdef1234567": ${JSON.stringify(output.invalid)}`
);
});
test('handles mixed valid and invalid hashes', () => {
const hash = execSync('git rev-parse --short HEAD', { cwd: tmpDir, encoding: 'utf-8' }).trim();
const result = runGsdTools(`verify commits ${hash} abcdef1234567`, tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.valid.length, 1, `Expected 1 valid: ${JSON.stringify(output)}`);
assert.strictEqual(output.invalid.length, 1, `Expected 1 invalid: ${JSON.stringify(output)}`);
assert.strictEqual(output.all_valid, false, `Expected all_valid false: ${JSON.stringify(output)}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// verify artifacts command
// ─────────────────────────────────────────────────────────────────────────────
describe('verify artifacts command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
function writePlanWithArtifacts(tmpDir, artifactsYaml) {
// parseMustHavesBlock expects 4-space indent for block name, 6-space for items, 8-space for keys
const content = [
'---',
'phase: 01-test',
'plan: 01',
'type: execute',
'wave: 1',
'depends_on: []',
'files_modified: [src/app.js]',
'autonomous: true',
'must_haves:',
' artifacts:',
...artifactsYaml.map(line => ` ${line}`),
'---',
'',
'<tasks>',
'<task type="auto">',
' <name>Task 1: Do thing</name>',
' <files>src/app.js</files>',
' <action>Do it</action>',
' <verify><automated>echo ok</automated></verify>',
' <done>Done</done>',
'</task>',
'</tasks>',
].join('\n');
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md');
fs.writeFileSync(planPath, content);
}
test('passes when all artifacts exist and match criteria', () => {
writePlanWithArtifacts(tmpDir, [
'- path: "src/app.js"',
' min_lines: 2',
' contains: "export"',
]);
fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'const x = 1;\nexport default x;\nconst y = 2;\n');
const result = runGsdTools('verify artifacts .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_passed, true, `Expected all_passed true: ${JSON.stringify(output)}`);
});
test('reports missing artifact file', () => {
writePlanWithArtifacts(tmpDir, [
'- path: "src/nonexistent.js"',
]);
const result = runGsdTools('verify artifacts .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_passed, false, 'Expected all_passed false');
assert.ok(
output.artifacts[0].issues.some(i => i.includes('File not found')),
`Expected "File not found" in issues: ${JSON.stringify(output.artifacts[0].issues)}`
);
});
test('reports insufficient line count', () => {
writePlanWithArtifacts(tmpDir, [
'- path: "src/app.js"',
' min_lines: 10',
]);
fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'const x = 1;\n');
const result = runGsdTools('verify artifacts .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_passed, false, 'Expected all_passed false');
assert.ok(
output.artifacts[0].issues.some(i => i.includes('Only') && i.includes('lines, need 10')),
`Expected line count issue: ${JSON.stringify(output.artifacts[0].issues)}`
);
});
test('reports missing pattern', () => {
writePlanWithArtifacts(tmpDir, [
'- path: "src/app.js"',
' contains: "module.exports"',
]);
fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'const x = 1;\n');
const result = runGsdTools('verify artifacts .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_passed, false, 'Expected all_passed false');
assert.ok(
output.artifacts[0].issues.some(i => i.includes('Missing pattern')),
`Expected "Missing pattern" in issues: ${JSON.stringify(output.artifacts[0].issues)}`
);
});
test('reports missing export', () => {
writePlanWithArtifacts(tmpDir, [
'- path: "src/app.js"',
' exports:',
' - GET',
]);
fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'const x = 1;\nexport const POST = () => {};\n');
const result = runGsdTools('verify artifacts .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_passed, false, 'Expected all_passed false');
assert.ok(
output.artifacts[0].issues.some(i => i.includes('Missing export')),
`Expected "Missing export" in issues: ${JSON.stringify(output.artifacts[0].issues)}`
);
});
test('returns error when no artifacts in frontmatter', () => {
const content = [
'---',
'phase: 01-test',
'plan: 01',
'type: execute',
'wave: 1',
'depends_on: []',
'files_modified: [src/app.js]',
'autonomous: true',
'must_haves:',
' truths:',
' - "something is true"',
'---',
'',
'<tasks></tasks>',
].join('\n');
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md');
fs.writeFileSync(planPath, content);
const result = runGsdTools('verify artifacts .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error, `Expected error field: ${JSON.stringify(output)}`);
assert.ok(
output.error.includes('No must_haves.artifacts'),
`Expected "No must_haves.artifacts" in error: ${output.error}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// verify key-links command
// ─────────────────────────────────────────────────────────────────────────────
describe('verify key-links command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
function writePlanWithKeyLinks(tmpDir, keyLinksYaml) {
// parseMustHavesBlock expects 4-space indent for block name, 6-space for items, 8-space for keys
const content = [
'---',
'phase: 01-test',
'plan: 01',
'type: execute',
'wave: 1',
'depends_on: []',
'files_modified: [src/a.js]',
'autonomous: true',
'must_haves:',
' key_links:',
...keyLinksYaml.map(line => ` ${line}`),
'---',
'',
'<tasks>',
'<task type="auto">',
' <name>Task 1: Do thing</name>',
' <files>src/a.js</files>',
' <action>Do it</action>',
' <verify><automated>echo ok</automated></verify>',
' <done>Done</done>',
'</task>',
'</tasks>',
].join('\n');
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md');
fs.writeFileSync(planPath, content);
}
test('verifies link when pattern found in source', () => {
writePlanWithKeyLinks(tmpDir, [
'- from: "src/a.js"',
' to: "src/b.js"',
' pattern: "import.*b"',
]);
fs.writeFileSync(path.join(tmpDir, 'src', 'a.js'), "import { x } from './b';\n");
fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'exports.x = 1;\n');
const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_verified, true, `Expected all_verified true: ${JSON.stringify(output)}`);
});
test('verifies link when pattern found in target', () => {
writePlanWithKeyLinks(tmpDir, [
'- from: "src/a.js"',
' to: "src/b.js"',
' pattern: "exports\\.targetFunc"',
]);
// pattern NOT in source, but found in target
fs.writeFileSync(path.join(tmpDir, 'src', 'a.js'), 'const x = 1;\n');
fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'exports.targetFunc = () => {};\n');
const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_verified, true, `Expected verified via target: ${JSON.stringify(output)}`);
assert.ok(
output.links[0].detail.includes('target'),
`Expected detail about target: ${output.links[0].detail}`
);
});
test('fails when pattern not found in source or target', () => {
writePlanWithKeyLinks(tmpDir, [
'- from: "src/a.js"',
' to: "src/b.js"',
' pattern: "missingPattern"',
]);
fs.writeFileSync(path.join(tmpDir, 'src', 'a.js'), 'const x = 1;\n');
fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'const y = 2;\n');
const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_verified, false, `Expected all_verified false: ${JSON.stringify(output)}`);
assert.strictEqual(output.links[0].verified, false, 'link should not be verified');
});
test('verifies link without pattern using string inclusion', () => {
writePlanWithKeyLinks(tmpDir, [
'- from: "src/a.js"',
' to: "src/b.js"',
]);
// source file contains the 'to' value as a string
fs.writeFileSync(path.join(tmpDir, 'src', 'a.js'), "const b = require('./src/b.js');\n");
fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'module.exports = {};\n');
const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_verified, true, `Expected all_verified true: ${JSON.stringify(output)}`);
assert.ok(
output.links[0].detail.includes('Target referenced in source'),
`Expected "Target referenced in source" in detail: ${output.links[0].detail}`
);
});
test('reports source file not found', () => {
writePlanWithKeyLinks(tmpDir, [
'- from: "src/nonexistent.js"',
' to: "src/b.js"',
' pattern: "something"',
]);
fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'module.exports = {};\n');
const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.links[0].detail.includes('Source file not found'),
`Expected "Source file not found" in detail: ${output.links[0].detail}`
);
});
test('returns error when no key_links in frontmatter', () => {
const content = [
'---',
'phase: 01-test',
'plan: 01',
'type: execute',
'wave: 1',
'depends_on: []',
'files_modified: [src/a.js]',
'autonomous: true',
'must_haves:',
' truths:',
' - "something is true"',
'---',
'',
'<tasks></tasks>',
].join('\n');
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md');
fs.writeFileSync(planPath, content);
const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.error, `Expected error field: ${JSON.stringify(output)}`);
assert.ok(
output.error.includes('No must_haves.key_links'),
`Expected "No must_haves.key_links" in error: ${output.error}`
);
});
});