Merge pull request #1985 from open-gsd/test/1975-cli-scripts-consolidation
test(#1975): consolidate CLI remainder + scripts-tooling tests into module suites
This commit is contained in:
@@ -849,7 +849,7 @@ Migration plan: Phase 1 (#3465) seam additions complete; Phase 2 (#3466) targets
|
||||
`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.examples=#996/88e30d53 — bug-969 hardening tests fs.unlinkSync'd + restored the real gsd-core/bin/lib/core.cjs and set tsBuildInfoFile inside gsd-core/bin/ → next red across the full-test matrix (macOS/Windows) + ubuntu-24 coverage leg, ~40-50 MODULE_NOT_FOUND/ENOENT per leg; reproduced locally on iteration 1; fixed #1001/#1002`
|
||||
`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.detect=grep tests for fs.unlinkSync|rmSync|writeFileSync|renameSync|cpSync targeting paths resolved from the repo root (join(__dirname,'..',...)) under gsd-core/bin/lib or a shared committed fixture, instead of a mkdtempSync temp dir; any build helper (e.g. ensureBuiltArtifacts) invoked with real-tree paths during the concurrent test phase; any tsBuildInfoFile / build-cache path that lands inside a copied/shipped dir (gsd-core/bin/)`
|
||||
`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.fix-forward=tests mutate ONLY isolated mkdtempSync copies — never delete/rewrite shared real build outputs while node --test runs files concurrently; parameterize build helpers to accept {root,srcDir,outDir,tsBuildInfoPath,tsconfigPath} overrides and point the test at a throwaway temp project (precedent: #1002 ensureBuiltArtifacts(overrides)); keep mutable build state (tsbuildinfo) OUTSIDE copied/shipped trees (repo root, gitignored) + best-effort self-heal of stale bin-local copies; this is the concrete instance of the RULESET.TESTS.delete-bad-tests real-race class`
|
||||
`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.test-anchor=tests/bug-969-test-infra-flake-hardening.test.cjs (hermetic temp-project rewrite); regression gate = 10x concurrent run of that suite + tests/state.test.cjs + tests/install.test.cjs must be clean (reproduces on iter 1 when racy)`
|
||||
`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.test-anchor=tests/run-tests-harness.test.cjs (hermetic temp-project rewrite); regression gate = 10x concurrent run of that suite + tests/state.test.cjs + tests/install.test.cjs must be clean (reproduces on iter 1 when racy)`
|
||||
|
||||
`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.symptom=patch PR was authored against scaffolding (handler files, lint scripts, generated modules) that exists only on an unmerged upstream feature branch; the PR's "base" on GitHub is the feature branch, not main; merging requires the upstream PR to land first`
|
||||
`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.examples=#3639 + #3637 both targeted base=feat/3575-enforcement-hardening (the Phase 6 PR #3577); #3639 modifies SDK-bridge calls in 6 family-router files that on main do NOT have any SDK-bridge call yet; #3637 patches scripts/lint-shared-module-handsync.cjs which does not exist on main at all`
|
||||
@@ -903,8 +903,8 @@ Migration plan: Phase 1 (#3465) seam additions complete; Phase 2 (#3466) targets
|
||||
`DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.fix-forward=keep gsd-test-summary --both at the top-level orchestrator; sub-agents either run it foreground with timeout: 1500000 (25min) and block, OR delegate the test step back to the orchestrator (write commits + return); never have a sub-agent fire-and-await a backgrounded long task`
|
||||
`DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.anchor=project CLAUDE.md "Top-level orchestrator (cross-turn notifications available) vs Sub-agent worker (no cross-turn notifications)" guidance — load-bearing for multi-worktree parallel fix dispatch`
|
||||
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.symptom=sub-agent writes /gsd-<cmd> (legacy hyphen syntax) in code comments or doc strings while implementing a fix; lands as part of the implementation diff`
|
||||
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.examples=#3541 implementation included a typical /gsd-update path comment in installer-migration-report.cjs; caught by tests/bug-2543-gsd-slash-namespace.test.cjs (#3443 invariant)`
|
||||
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.detect=tests/bug-2543-gsd-slash-namespace.test.cjs prints "Found N retired /gsd-<cmd> reference(s) — use /gsd:<cmd> instead" with line-number-precise violations`
|
||||
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.examples=#3541 implementation included a typical /gsd-update path comment in installer-migration-report.cjs; caught by tests/slash-command-namespace.test.cjs (#3443 invariant)`
|
||||
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.detect=tests/slash-command-namespace.test.cjs prints "Found N retired /gsd-<cmd> reference(s) — use /gsd:<cmd> instead" with line-number-precise violations`
|
||||
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.fix-forward=replace /gsd-<cmd> with /gsd:<cmd> at the cited file:line; healthy emergent property — project-wide invariant test catches drift agents would never self-correct`
|
||||
`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.lesson=agent-trust-but-verify is load-bearing — sub-agent reporting "done" is not a substitute for running the full suite; the invariant test surfaces drift even in doc-only changes`
|
||||
`PROC.PARALLEL-FIX-DISPATCH.pattern=bot triage brief → worktree per branch → parallel sub-agents do rubber-duck/RCA/TDD implementation only → top-level orchestrator owns commit + gsd-test + push + PR + changeset-pr-backfill`
|
||||
@@ -931,7 +931,7 @@ Full detail in `~/.claude/skills/gsd-pr-fix-discipline/SKILL.md`. AI agents MUST
|
||||
|
||||
### Slash command two-tier confusion
|
||||
|
||||
- **Symptom:** `tests/bug-2543-gsd-slash-namespace.test.cjs` or `tests/init-manager.test.cjs` (folds former `bug-3584-runtime-slash-emitters`, consolidation epic #1969) fails
|
||||
- **Symptom:** `tests/slash-command-namespace.test.cjs` (folds former `bug-2543-gsd-slash-namespace`, consolidation epic #1969) or `tests/init-manager.test.cjs` (folds former `bug-3584-runtime-slash-emitters`, consolidation epic #1969) fails
|
||||
- **Affected this session:** #154 (three passes), #164 (added the authoritative matrix)
|
||||
- **Fix:** Consult `## Slash-command form` section of this file before touching any `/gsd-` or `/gsd:` token — colon for `agents/`/`commands/`, hyphen for runtime emitters
|
||||
|
||||
|
||||
@@ -1718,7 +1718,7 @@ A lint gate enforces the budget:
|
||||
npm run lint:descriptions
|
||||
```
|
||||
|
||||
The check is also run as part of `npm test` via `tests/enh-2789-description-budget.test.cjs`.
|
||||
The check is also run as part of `npm test` via `tests/skill-frontmatter-contract.test.cjs`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1834,7 +1834,7 @@ Test suite that scans all agent, workflow, and command files for embedded inject
|
||||
- REQ-CTXRED-01: System MUST truncate oversized markdown artifacts to fit within context budgets
|
||||
- REQ-CTXRED-02: System MUST order prompts for cache-friendly assembly (stable prefixes first)
|
||||
- REQ-CTXRED-03: Reduction MUST preserve essential information (headings, requirements, task structure)
|
||||
- REQ-CTXRED-04: Skill `description:` fields MUST be ≤ 100 chars; enforced by `npm run lint:descriptions` (see `scripts/lint-descriptions.cjs` and `tests/enh-2789-description-budget.test.cjs`)
|
||||
- REQ-CTXRED-04: Skill `description:` fields MUST be ≤ 100 chars; enforced by `npm run lint:descriptions` (see `scripts/lint-descriptions.cjs` and `tests/skill-frontmatter-contract.test.cjs`)
|
||||
|
||||
**Process:**
|
||||
1. **Measure** — Calculate total prompt size for the workflow
|
||||
|
||||
@@ -18,7 +18,7 @@ The command file contract defines what makes a valid `commands/gsd/*.md`:
|
||||
## Context
|
||||
|
||||
Before this ADR, the command contract was enforced inconsistently:
|
||||
- `tests/enh-2790-skill-consolidation.test.cjs` checked existence and frontmatter of specific post-consolidation commands
|
||||
- `tests/skill-frontmatter-contract.test.cjs` (folds former `enh-2790-skill-consolidation`, consolidation epic #1969) checked existence and frontmatter of specific post-consolidation commands
|
||||
- `tests/docs-update.test.cjs` (folds former `bug-3135-capture-backlog-workflow`, consolidation epic #1969) checked `execution_context` @-ref resolution (added 2026-05-05)
|
||||
- No test checked `allowed-tools` validity, `name:` convention, or `description:` non-emptiness across all commands simultaneously
|
||||
|
||||
|
||||
@@ -1509,7 +1509,7 @@ GSD Discord コミュニティに参加するには、GSD README 内のリンク
|
||||
npm run lint:descriptions
|
||||
```
|
||||
|
||||
このチェックは `tests/enh-2789-description-budget.test.cjs` を介して `npm test` の一部としても実行されます。
|
||||
このチェックは `tests/skill-frontmatter-contract.test.cjs` を介して `npm test` の一部としても実行されます。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1762,7 +1762,7 @@ Claude が GSD ワークフローコンテキスト外でファイル編集を
|
||||
- REQ-CTXRED-01: システムはコンテキスト予算内に収まるよう、大きすぎる Markdown アーティファクトを切り詰めなければならない
|
||||
- REQ-CTXRED-02: キャッシュフレンドリーなアセンブリのためにプロンプトを順序付けなければならない(安定したプレフィックスを先頭に)
|
||||
- REQ-CTXRED-03: 削減は必須情報(見出し、要件、タスク構造)を保持しなければならない
|
||||
- REQ-CTXRED-04: スキルの `description:` フィールドは ≤ 100 文字でなければならない;`npm run lint:descriptions` で強制(`scripts/lint-descriptions.cjs` と `tests/enh-2789-description-budget.test.cjs` 参照)
|
||||
- REQ-CTXRED-04: スキルの `description:` フィールドは ≤ 100 文字でなければならない;`npm run lint:descriptions` で強制(`scripts/lint-descriptions.cjs` と `tests/skill-frontmatter-contract.test.cjs` 参照)
|
||||
|
||||
**プロセス:**
|
||||
1. **計測** — ワークフローの総プロンプトサイズを計算
|
||||
|
||||
@@ -1517,7 +1517,7 @@ GSD Discord 커뮤니티에 참여하려면 GSD README의 링크를 방문하거
|
||||
npm run lint:descriptions
|
||||
```
|
||||
|
||||
이 검사는 `tests/enh-2789-description-budget.test.cjs`를 통해 `npm test`의 일부로도 실행됩니다.
|
||||
이 검사는 `tests/skill-frontmatter-contract.test.cjs`를 통해 `npm test`의 일부로도 실행됩니다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1514,7 +1514,7 @@ Um portão de lint impõe o orçamento:
|
||||
npm run lint:descriptions
|
||||
```
|
||||
|
||||
A verificação também é executada como parte de `npm test` via `tests/enh-2789-description-budget.test.cjs`.
|
||||
A verificação também é executada como parte de `npm test` via `tests/skill-frontmatter-contract.test.cjs`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1509,7 +1509,7 @@ node gsd-tools.cjs state planned-phase --phase 3 --plans 2
|
||||
npm run lint:descriptions
|
||||
```
|
||||
|
||||
该检查也作为 `npm test` 的一部分通过 `tests/enh-2789-description-budget.test.cjs` 运行。
|
||||
该检查也作为 `npm test` 的一部分通过 `tests/skill-frontmatter-contract.test.cjs` 运行。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1778,7 +1778,7 @@ PreToolUse 钩子,检测 Claude 在 GSD 工作流上下文之外尝试文件
|
||||
- REQ-CTXRED-01:系统必须截断超大 Markdown 构件以适应上下文预算
|
||||
- REQ-CTXRED-02:系统必须为缓存友好的组装对提示进行排序(稳定的前缀优先)
|
||||
- REQ-CTXRED-03:压缩必须保留必要信息(标题、需求、任务结构)
|
||||
- REQ-CTXRED-04:技能 `description:` 字段必须 ≤ 100 个字符;由 `npm run lint:descriptions` 强制执行(参见 `scripts/lint-descriptions.cjs` 和 `tests/enh-2789-description-budget.test.cjs`)
|
||||
- REQ-CTXRED-04:技能 `description:` 字段必须 ≤ 100 个字符;由 `npm run lint:descriptions` 强制执行(参见 `scripts/lint-descriptions.cjs` 和 `tests/skill-frontmatter-contract.test.cjs`)
|
||||
|
||||
**流程:**
|
||||
1. **测量** — 计算工作流的总提示大小
|
||||
|
||||
@@ -133,7 +133,14 @@ const RULES = [
|
||||
'tests/install-regressions.test.cjs',
|
||||
'tests/install-runtime-artifacts.test.cjs',
|
||||
'tests/install-path-detection.test.cjs',
|
||||
'tests/release-tarball-smoke.install.test.cjs',
|
||||
// NOTE: release-tarball-smoke.install.test.cjs is intentionally NOT here.
|
||||
// It is a 3–6 min `npm pack` + `npm install -g` integration test with its
|
||||
// OWN dedicated workflow (.github/workflows/install-smoke.yml, triggered on
|
||||
// the production install paths). Running it in the scoped/targeted lane too
|
||||
// is redundant and blows the per-chunk Windows timeout when a broad PR
|
||||
// bundles it with many other changed test files (epic #1969). See the
|
||||
// SCOPED_LANE_EXCLUDE guard below, which also drops it when it is itself a
|
||||
// changed test file.
|
||||
'tests/runtime-artifact-layout.test.cjs',
|
||||
'tests/golden-install-parity.test.cjs', // any src/installer change can alter emitted install artifacts → re-verify golden install parity (drift guard)
|
||||
],
|
||||
@@ -398,6 +405,17 @@ function classify(files) {
|
||||
}
|
||||
}
|
||||
|
||||
// Heavy integration tests that own a dedicated workflow must never run in the
|
||||
// scoped/targeted lane — they carry a multi-minute cost that overruns the
|
||||
// per-chunk timeout (worst on Windows) when a broad PR bundles them with many
|
||||
// other changed test files, and their production paths already trigger their
|
||||
// own workflow. Drop them however they entered (matched rule OR changed-file).
|
||||
const SCOPED_LANE_EXCLUDE = new Set([
|
||||
// covered by .github/workflows/install-smoke.yml
|
||||
'tests/release-tarball-smoke.install.test.cjs',
|
||||
]);
|
||||
for (const f of SCOPED_LANE_EXCLUDE) { targeted.delete(f); windows.delete(f); }
|
||||
|
||||
// code_changed: true when product/pipeline OR inert CI changed.
|
||||
// Docs-only PRs (neither flag set) get code_changed=false → full matrix skip.
|
||||
const codeChanged = productOrPipelineChanged || inertCiChanged;
|
||||
|
||||
@@ -14,27 +14,6 @@
|
||||
"tests/autonomous-decomposition.test.cjs :: source-text-is-the-product",
|
||||
"tests/autonomous-interactive.test.cjs :: source-text-is-the-product",
|
||||
"tests/autonomous-to-flag.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-131-release-tarball-smoke-explicit-home.test.cjs :: integration-test-input",
|
||||
"tests/bug-211-launcher-home-fallback.test.cjs :: structural/behavioral regression for the ~/.claude fallback arm in",
|
||||
"tests/bug-2136-sh-hook-version.test.cjs :: structural-regression-guard",
|
||||
"tests/bug-2543-gsd-slash-namespace.test.cjs :: structural-regression-guard",
|
||||
"tests/bug-2772-gitmodules-path-intersection.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2808-skill-hyphen-name.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3446-resume-continue-here-discovery.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3491-nested-git-worktree.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs :: validates runtime CLI stdout/stderr warning behavior, not source grep",
|
||||
"tests/bug-3542-executor-git-stash-prohibition.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3677-agent-colon-namespace-leak.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3678-executor-commit-docs-respect.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3683-command-colon-namespace-leak.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3683-workflow-colon-namespace-leak.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3689-resume-glob-nomatch.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-444-resolver-local-claude-install.test.cjs :: structural/behavioral regression for the repo-local .claude/ install",
|
||||
"tests/bug-619-codebase-drift-gate-shim.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-622-graphify-optional-graph-html.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-630-wave-cleanup-orchestrator-root.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-685-windowshide-spawn.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-891-non-claude-runtime-home-fallback.test.cjs :: structural/behavioral regression for non-Claude runtime-home",
|
||||
"tests/chain-flag-plan-phase.test.cjs :: source-text-is-the-product",
|
||||
"tests/changeset-cli.test.cjs :: reads a product workflow .md file (not CJS source) to verify",
|
||||
"tests/check-update-config-dir.test.cjs :: structural-regression-guard",
|
||||
@@ -72,10 +51,6 @@
|
||||
"tests/edge-probe-planner-contract.test.cjs :: runtime-contract-is-the-product — plan-phase.md's planner prompt is the deployed runtime contract under assertion",
|
||||
"tests/edge-probe-spec-phase-contract.test.cjs :: runtime-contract-is-the-product — spec-phase.md Step 5.5 is the deployed workflow runtime contract under assertion",
|
||||
"tests/edit-phase.test.cjs :: source-text-is-the-product",
|
||||
"tests/enh-2380-sync-skills.test.cjs :: source-text-is-the-product",
|
||||
"tests/enh-2789-description-budget.test.cjs :: source-text-is-the-product",
|
||||
"tests/enh-2790-skill-consolidation.test.cjs :: source-text-is-the-product",
|
||||
"tests/enh-48-cwd-drift-guard-e2e.test.cjs :: integration-test-input",
|
||||
"tests/eslint-rules.test.cjs :: <source-grep reason> must still error",
|
||||
"tests/eslint-rules.test.cjs :: pending migration",
|
||||
"tests/eslint-rules.test.cjs :: source-text-is-the-product",
|
||||
@@ -88,7 +63,6 @@
|
||||
"tests/execute-phase-worktree-artifacts.test.cjs :: source-text-is-the-product",
|
||||
"tests/explore-command.test.cjs :: source-text-is-the-product",
|
||||
"tests/extract-learnings.test.cjs :: source-text-is-the-product",
|
||||
"tests/feat-3039-help-tiered.test.cjs :: source-text-is-the-product",
|
||||
"tests/forensics.test.cjs :: source-text-is-the-product",
|
||||
"tests/frontmatter-cli.test.cjs :: source-text-is-the-product",
|
||||
"tests/gates-taxonomy.test.cjs :: source-text-is-the-product",
|
||||
|
||||
@@ -1,42 +1 @@
|
||||
[
|
||||
"bug-131-release-tarball-smoke-explicit-home.test.cjs",
|
||||
"bug-1367-claude-local-flat-command-layout.test.cjs",
|
||||
"bug-1834-sh-hooks-installed.test.cjs",
|
||||
"bug-1974-context-exhaustion-record.test.cjs",
|
||||
"bug-211-launcher-home-fallback.test.cjs",
|
||||
"bug-2136-sh-hook-version.test.cjs",
|
||||
"bug-2344-read-guard-claudecode-env.test.cjs",
|
||||
"bug-2451-context-monitor-over-report.test.cjs",
|
||||
"bug-2520-read-guard-hook-subprocess-env.test.cjs",
|
||||
"bug-2543-gsd-slash-namespace.test.cjs",
|
||||
"bug-260-worktree-path-guard.test.cjs",
|
||||
"bug-261-worktree-force-add-guard.test.cjs",
|
||||
"bug-2772-gitmodules-path-intersection.test.cjs",
|
||||
"bug-2808-skill-hyphen-name.test.cjs",
|
||||
"bug-2916-handle-branching-default-base.test.cjs",
|
||||
"bug-2995-post-install-script-paths.test.cjs",
|
||||
"bug-3019-help-passthrough.test.cjs",
|
||||
"bug-3442-shim-projection-drift-guard.test.cjs",
|
||||
"bug-3446-resume-continue-here-discovery.test.cjs",
|
||||
"bug-3491-nested-git-worktree.test.cjs",
|
||||
"bug-3509-path-spaces.test.cjs",
|
||||
"bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs",
|
||||
"bug-3542-executor-git-stash-prohibition.test.cjs",
|
||||
"bug-3588-npm-audit-clean.test.cjs",
|
||||
"bug-3668-workflow-runtime-resolution.test.cjs",
|
||||
"bug-3677-agent-colon-namespace-leak.test.cjs",
|
||||
"bug-3678-executor-commit-docs-respect.test.cjs",
|
||||
"bug-3683-command-colon-namespace-leak.test.cjs",
|
||||
"bug-3683-workflow-colon-namespace-leak.test.cjs",
|
||||
"bug-3689-resume-glob-nomatch.test.cjs",
|
||||
"bug-444-resolver-local-claude-install.test.cjs",
|
||||
"bug-619-codebase-drift-gate-shim.test.cjs",
|
||||
"bug-622-graphify-optional-graph-html.test.cjs",
|
||||
"bug-630-wave-cleanup-orchestrator-root.test.cjs",
|
||||
"bug-641-files-from-suite-token.test.cjs",
|
||||
"bug-685-windowshide-spawn.test.cjs",
|
||||
"bug-891-non-claude-runtime-home-fallback.test.cjs",
|
||||
"bug-925-context-monitor-hook-event-name.test.cjs",
|
||||
"bug-941-managed-hooks-registry-manifest.test.cjs",
|
||||
"bug-969-test-infra-flake-hardening.test.cjs"
|
||||
]
|
||||
[]
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
},
|
||||
"graphify": {
|
||||
"files": [
|
||||
"bug-622-graphify-optional-graph-html.test.cjs",
|
||||
"graphify-auto-update.slow.test.cjs",
|
||||
"graphify-command-cutover.test.cjs",
|
||||
"graphify-query.test.cjs",
|
||||
@@ -113,14 +112,6 @@
|
||||
],
|
||||
"issue": "#1165"
|
||||
},
|
||||
"docs": {
|
||||
"files": [
|
||||
"docs-parity-live-registry.test.cjs",
|
||||
"docs-update.test.cjs",
|
||||
"fix-1464-docs-manifest-validation.test.cjs"
|
||||
],
|
||||
"issue": "1496"
|
||||
},
|
||||
"host-integration": {
|
||||
"files": [
|
||||
"host-integration.test.cjs",
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
// allow-test-rule: integration-test-input
|
||||
// Regression test for #131: runNpm() must not fail when HOME points at an
|
||||
// unwritable directory. The before() hook in release-tarball-smoke.install.test.cjs
|
||||
// calls runNpm(['pack', ...]) and runNpm(['install', '-g', ...]) — if those inherit
|
||||
// an unwritable HOME from the environment (common in constrained Docker hosts),
|
||||
// the entire hook fails and all 6 subtests are cancelled.
|
||||
//
|
||||
// Fix: runNpm() must inject an explicit HOME, npm_config_cache, and
|
||||
// npm_config_userconfig that point into a temp directory it owns, so that npm
|
||||
// never reads from or writes to the caller's HOME.
|
||||
//
|
||||
// Test 3 (added in the second fix pass) verifies that isolatedNpmEnv() — the
|
||||
// companion export that lets runSmoke() apply the same isolation — also redirects
|
||||
// HOME away from the caller's HOME. Without this, subtests A-F of
|
||||
// release-tarball-smoke.install.test.cjs still fail because runSmoke() calls
|
||||
// spawnSync('npm', ...) internally and was not covered by the runNpm() fix.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
// The helpers under test.
|
||||
const { isolatedNpmEnv, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// Resolve a filesystem path to its canonical (symlink-free) form even if the
|
||||
// leaf does not exist yet (e.g. ~/.npm before npm has written its cache).
|
||||
// Walks up to the nearest existing ancestor, resolves that, then re-appends
|
||||
// the trailing segments. This handles macOS /var → /private/var symlinks for
|
||||
// paths created under os.tmpdir() where the leaf directory may not exist yet.
|
||||
function safeRealpath(p) {
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch (_) {
|
||||
// Leaf does not exist — resolve the nearest existing ancestor then
|
||||
// reconstruct the original suffix so the result is still canonical.
|
||||
const segments = [];
|
||||
let cur = p;
|
||||
for (;;) {
|
||||
const parent = path.dirname(cur);
|
||||
if (parent === cur) {
|
||||
// Reached filesystem root — return original path unchanged.
|
||||
return p;
|
||||
}
|
||||
segments.unshift(path.basename(cur));
|
||||
cur = parent;
|
||||
try {
|
||||
return path.join(fs.realpathSync(cur), ...segments);
|
||||
} catch (__) {
|
||||
// Keep walking up.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('bug-131: runNpm isolates HOME from the caller environment', () => {
|
||||
// ── Test 1 — runNpm works with an unwritable HOME ────────────────────────
|
||||
// Spawn a child Node process that sets HOME to a chmod-0500 directory, then
|
||||
// invokes runNpm(['--version']). Without the fix, npm tries to read/write
|
||||
// HOME/.npmrc and HOME/.npm, fails with EACCES, and runNpm throws.
|
||||
// With the fix, runNpm injects its own isolated HOME and npm succeeds.
|
||||
test('runNpm succeeds even when process HOME is unwritable', () => {
|
||||
// Create an unwritable dir to serve as a poisoned HOME.
|
||||
const poisonedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-bug131-poison-'));
|
||||
try {
|
||||
fs.chmodSync(poisonedHome, 0o500); // r-x only — not writable
|
||||
|
||||
// We exercise the real runNpm() path by running a tiny inline Node script
|
||||
// that requires helpers.cjs and calls runNpm(['--version']) with HOME set
|
||||
// to the unwritable dir. The script exits 0 on success, non-zero on throw.
|
||||
const script = `
|
||||
process.env.HOME = ${JSON.stringify(poisonedHome)};
|
||||
process.env.USERPROFILE = ${JSON.stringify(poisonedHome)};
|
||||
const { runNpm } = require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});
|
||||
try {
|
||||
const out = runNpm(['--version']);
|
||||
if (!out || out.trim() === '') process.exit(2); // vacuous success guard
|
||||
process.stdout.write(out);
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
process.stderr.write(e.message + '\\n');
|
||||
process.exit(1);
|
||||
}
|
||||
`;
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let exitCode = 0;
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, ['-e', script], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch (err) {
|
||||
stdout = err.stdout || '';
|
||||
stderr = err.stderr || '';
|
||||
exitCode = err.status ?? 1;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
exitCode,
|
||||
0,
|
||||
`runNpm should succeed with an unwritable HOME but exited ${exitCode}. stderr: ${stderr}`,
|
||||
);
|
||||
// npm --version returns something like "10.x.y"
|
||||
assert.match(
|
||||
stdout.trim(),
|
||||
/^\d+\.\d+/,
|
||||
`expected semver output from npm --version, got: ${stdout}`,
|
||||
);
|
||||
} finally {
|
||||
// Restore write permission before cleanup so the directory can be deleted.
|
||||
try { fs.chmodSync(poisonedHome, 0o700); } catch (_) { /* best-effort */ }
|
||||
cleanup(poisonedHome);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Test 2 — runNpm does not leak a caller-supplied HOME into npm ────────
|
||||
// Even if the caller exports HOME=/some/real/path, the injected HOME must be
|
||||
// a different (temp) path so npm writes never touch the caller's $HOME.
|
||||
test('runNpm injects a HOME distinct from process.env.HOME', () => {
|
||||
// Capture what HOME runNpm actually passes to npm by asking npm to print
|
||||
// the value it sees for the $HOME env var. We do this via `npm config get
|
||||
// cache` which reveals the cache path — if it's under process.env.HOME,
|
||||
// the fix is absent; if it's under a tmp dir, the fix is present.
|
||||
|
||||
const script = `
|
||||
const { runNpm } = require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});
|
||||
try {
|
||||
// npm config get cache prints the effective cache directory.
|
||||
const out = runNpm(['config', 'get', 'cache']);
|
||||
process.stdout.write(out.trim());
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
process.stderr.write(e.message + '\\n');
|
||||
process.exit(1);
|
||||
}
|
||||
`;
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let exitCode = 0;
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, ['-e', script], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch (err) {
|
||||
stdout = err.stdout || '';
|
||||
stderr = err.stderr || '';
|
||||
exitCode = err.status ?? 1;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
exitCode,
|
||||
0,
|
||||
`runNpm config get cache failed with exit ${exitCode}. stderr: ${stderr}`,
|
||||
);
|
||||
|
||||
const effectiveCacheDir = stdout.trim();
|
||||
|
||||
// The effective npm cache must NOT be inside the calling process's HOME.
|
||||
// If it is, the fix was not applied and the Docker regression can still occur.
|
||||
const callerHome = os.homedir();
|
||||
assert.ok(
|
||||
!effectiveCacheDir.startsWith(callerHome),
|
||||
`npm cache dir ${effectiveCacheDir} is still under caller HOME ${callerHome} — fix not applied`,
|
||||
);
|
||||
|
||||
// It must be somewhere under the system tmp dir, confirming isolation.
|
||||
// Use safeRealpath on both sides so that macOS /var→/private/var symlinks
|
||||
// do not cause a false mismatch when os.tmpdir() and the resolved cache
|
||||
// path differ only in symlink expansion. The cache sub-directory (.npm) may
|
||||
// not exist yet; safeRealpath walks up to the nearest existing ancestor.
|
||||
const sysTmp = safeRealpath(os.tmpdir());
|
||||
const realCacheDir = safeRealpath(effectiveCacheDir);
|
||||
assert.ok(
|
||||
realCacheDir.startsWith(sysTmp),
|
||||
`npm cache dir ${realCacheDir} should be under tmpdir ${sysTmp}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 3 — isolatedNpmEnv() redirects HOME away from the caller's HOME ──
|
||||
// runSmoke() calls spawnSync('npm', ...) with npmEnv from isolatedNpmEnv().
|
||||
// If isolatedNpmEnv() didn't redirect HOME, subtests A-F would still fail on
|
||||
// Docker hosts with an unwritable HOME (the original bug #131 root cause,
|
||||
// manifesting via the sibling runSmoke() path). (#131)
|
||||
test('isolatedNpmEnv() HOME is distinct from the caller HOME and lives under tmpdir', () => {
|
||||
const env = isolatedNpmEnv();
|
||||
|
||||
// Must expose a HOME key.
|
||||
assert.ok(
|
||||
typeof env.HOME === 'string' && env.HOME.length > 0,
|
||||
'isolatedNpmEnv() must set HOME',
|
||||
);
|
||||
|
||||
// Must not be the caller's HOME.
|
||||
const callerHome = os.homedir();
|
||||
assert.notEqual(
|
||||
env.HOME,
|
||||
callerHome,
|
||||
`isolatedNpmEnv() HOME must differ from caller HOME ${callerHome}`,
|
||||
);
|
||||
|
||||
// Must live under the system tmpdir, confirming it is an isolated temp directory.
|
||||
// Use safeRealpath on both sides so that macOS /var→/private/var symlinks
|
||||
// do not cause a false mismatch.
|
||||
const sysTmp = safeRealpath(os.tmpdir());
|
||||
const realHome = safeRealpath(env.HOME);
|
||||
assert.ok(
|
||||
realHome.startsWith(sysTmp),
|
||||
`isolatedNpmEnv() HOME ${realHome} should be under tmpdir ${sysTmp}`,
|
||||
);
|
||||
|
||||
// npm_config_cache and npm_config_userconfig must also be set and under the isolated HOME.
|
||||
assert.ok(
|
||||
typeof env.npm_config_cache === 'string' && env.npm_config_cache.startsWith(env.HOME),
|
||||
`npm_config_cache ${env.npm_config_cache} should be under isolated HOME ${env.HOME}`,
|
||||
);
|
||||
assert.ok(
|
||||
typeof env.npm_config_userconfig === 'string' && env.npm_config_userconfig.startsWith(env.HOME),
|
||||
`npm_config_userconfig ${env.npm_config_userconfig} should be under isolated HOME ${env.HOME}`,
|
||||
);
|
||||
assert.equal(
|
||||
env.npm_config_loglevel,
|
||||
'error',
|
||||
'isolatedNpmEnv() should suppress npm notice/warn chatter in test gates',
|
||||
);
|
||||
assert.equal(
|
||||
env.npm_config_update_notifier,
|
||||
'false',
|
||||
'isolatedNpmEnv() should disable npm update-notifier notices in test gates',
|
||||
);
|
||||
assert.equal(
|
||||
env.NO_UPDATE_NOTIFIER,
|
||||
'1',
|
||||
'isolatedNpmEnv() should disable npm update-notifier notices for npm versions that honor NO_UPDATE_NOTIFIER',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product #1367
|
||||
// Installed command `.md` files — their on-disk path determines the slash-command
|
||||
// namespace registered by Claude Code. Asserting the layout (flat vs. subdirectory)
|
||||
// IS a behavioral test of the deploy contract, not source-grep theater.
|
||||
|
||||
/**
|
||||
* Regression for #1367 — project-local Claude Code install writes command files to
|
||||
* `.claude/commands/gsd/<cmd>.md` (subdirectory, bare names), causing Claude Code
|
||||
* to register them as `/gsd:<cmd>` (colon namespace). The fix changes the layout to
|
||||
* write flat `gsd-<cmd>.md` files at `.claude/commands/` level so Claude Code
|
||||
* registers `/gsd-<cmd>` (hyphen form, matching hooks, statusline, and cross-command
|
||||
* references everywhere in the framework).
|
||||
*
|
||||
* Root cause: `bin/install.js` (the `else` branch for claude local) wrote to a
|
||||
* `commands/gsd/` subdirectory using `copyWithPathReplacement`. Claude Code treats
|
||||
* the directory name as a namespace, so `commands/gsd/update.md` became `/gsd:update`.
|
||||
*
|
||||
* Fix: write each command as `gsd-<stem>.md` directly in `commands/` (flat layout).
|
||||
* This is the same approach used for OpenCode/Kilo (see `copyFlattenedCommands`).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js');
|
||||
// hooks/dist/ is a gitignored build artifact; the test must ensure it exists before
|
||||
// invoking the installer (mirrors golden-install-parity's BUILD_SCRIPT pattern). Without
|
||||
// this, the unit lane — whose ensureBuiltArtifacts() builds only bin/lib, not hooks —
|
||||
// leaves hooks/dist empty and install.js hard-fails "directory is empty" (#1926).
|
||||
const BUILD_HOOKS = path.join(REPO_ROOT, 'scripts', 'build-hooks.js');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run `node install.js --claude --local --no-sdk` in cwd.
|
||||
* GSD_TEST_MODE must be cleared so the install() main block executes.
|
||||
*/
|
||||
function runClaudeLocalInstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_TEST_MODE;
|
||||
execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite — #1367 regression: flat gsd-<cmd>.md layout for claude local install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #1367 — Claude local install uses flat gsd-<cmd>.md command layout', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
// #1926: build hooks/dist/ so the installer's verifyInstalled(hooks) doesn't hit an
|
||||
// empty directory. Self-contained — no dependency on the lane having pre-built hooks.
|
||||
execFileSync(process.execPath, [BUILD_HOOKS], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1367-'));
|
||||
runClaudeLocalInstall(tmpDir);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('L0: commands/ directory exists after local claude install', () => {
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(
|
||||
fs.existsSync(commandsDir),
|
||||
`commands/ must be created by local claude install at ${commandsDir}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('L1: command files use flat gsd-<cmd>.md names (not bare names in a subdirectory)', () => {
|
||||
// The fix: commands land as .claude/commands/gsd-<cmd>.md (flat, hyphen-prefixed).
|
||||
// Claude Code reads the stem of each file in commands/ as the command name,
|
||||
// so gsd-update.md → /gsd-update (hyphen). The old layout (commands/gsd/update.md)
|
||||
// made Claude Code use the directory as a namespace → /gsd:update (colon).
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(fs.existsSync(commandsDir), 'commands/ must exist for this check to be meaningful');
|
||||
|
||||
const flatGsdFiles = fs.readdirSync(commandsDir, { withFileTypes: true })
|
||||
.filter(e => e.isFile() && e.name.startsWith('gsd-') && e.name.endsWith('.md'));
|
||||
|
||||
assert.ok(
|
||||
flatGsdFiles.length > 0,
|
||||
`commands/ must contain flat gsd-*.md files (e.g. gsd-help.md, gsd-update.md). ` +
|
||||
`Found none. Install may still be writing to commands/gsd/<cmd>.md subdirectory ` +
|
||||
`which causes /gsd:<cmd> colon namespace in Claude Code.`,
|
||||
);
|
||||
});
|
||||
|
||||
test('L2: known commands land as flat gsd-<cmd>.md files', () => {
|
||||
// Spot-check: the three commands mentioned in the issue must be present
|
||||
// as flat hyphen-prefixed files.
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
const knownCommands = ['gsd-update.md', 'gsd-plan-phase.md', 'gsd-help.md'];
|
||||
for (const name of knownCommands) {
|
||||
const filePath = path.join(commandsDir, name);
|
||||
assert.ok(
|
||||
fs.existsSync(filePath),
|
||||
`${name} must exist as a flat file at commands/${name}. ` +
|
||||
`If missing, the flat layout is not being written correctly.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('L3: commands/gsd/ subdirectory does NOT exist (old colon-namespace layout)', () => {
|
||||
// The old layout wrote to commands/gsd/<cmd>.md. That directory must not
|
||||
// exist after a fresh install with the fix applied.
|
||||
const oldSubdir = path.join(tmpDir, '.claude', 'commands', 'gsd');
|
||||
assert.ok(
|
||||
!fs.existsSync(oldSubdir),
|
||||
`commands/gsd/ subdir must NOT exist after install. ` +
|
||||
`Its presence means the old layout is still being used — Claude Code would ` +
|
||||
`register commands as /gsd:<cmd> (colon) instead of /gsd-<cmd> (hyphen).`,
|
||||
);
|
||||
});
|
||||
|
||||
test('L4: total flat command file count matches the staged source', () => {
|
||||
// There should be a substantial number of commands (not 0, not 1).
|
||||
// The exact count varies with profile but must be >= 20 for a full install.
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
const count = fs.readdirSync(commandsDir, { withFileTypes: true })
|
||||
.filter(e => e.isFile() && e.name.startsWith('gsd-') && e.name.endsWith('.md'))
|
||||
.length;
|
||||
assert.ok(
|
||||
count >= 20,
|
||||
`commands/ must have >= 20 flat gsd-*.md files for a full install. ` +
|
||||
`Got ${count}. Install may be silently dropping commands.`,
|
||||
);
|
||||
});
|
||||
|
||||
test('L5: legacy migration — re-install on a pre-#1367 tree removes old commands/gsd/ subdir', () => {
|
||||
// Simulate a pre-#1367 install: create a commands/gsd/ subdirectory with a bare-name file.
|
||||
// Then re-run the installer and verify the old subdir is cleaned up.
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
const legacyDir = path.join(commandsDir, 'gsd');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir, 'update.md'), '# legacy update');
|
||||
|
||||
// Re-run install — should remove commands/gsd/ and write flat gsd-*.md
|
||||
runClaudeLocalInstall(tmpDir);
|
||||
|
||||
assert.ok(
|
||||
!fs.existsSync(legacyDir),
|
||||
`commands/gsd/ legacy subdir must be removed by re-install. ` +
|
||||
`The installer's legacy cleanup must remove old commands/gsd/ on upgrade.`,
|
||||
);
|
||||
// Flat form must still be present
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(commandsDir, 'gsd-update.md')),
|
||||
`gsd-update.md must exist as flat file after re-install.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #1834
|
||||
*
|
||||
* The installer must copy all three .sh hook files to the target hooks/
|
||||
* directory during installation. In v1.32.0, only .js hooks were deployed
|
||||
* because the install loop did not handle non-.js files from hooks/dist/.
|
||||
*
|
||||
* This test runs the actual installer (not a simulation) and verifies that
|
||||
* gsd-session-state.sh, gsd-validate-commit.sh, and gsd-phase-boundary.sh
|
||||
* are present and executable in the target hooks directory.
|
||||
*
|
||||
* Distinct from:
|
||||
* #1656 — .sh files missing from build-hooks.js HOOKS_TO_COPY
|
||||
* #1817 — settings.json registration ran even when .sh files were absent
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
const SH_HOOKS = [
|
||||
'gsd-session-state.sh',
|
||||
'gsd-validate-commit.sh',
|
||||
'gsd-phase-boundary.sh',
|
||||
];
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before any install test ────────────────
|
||||
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup wrapper; try/catch swallows ENOENT so runInstaller teardown never fails the test
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the installer targeting a temp directory.
|
||||
* Uses CLAUDE_CONFIG_DIR to redirect the global install target.
|
||||
* Returns the path to the installed hooks directory.
|
||||
*/
|
||||
function runInstaller(configDir) {
|
||||
// --no-sdk: this test covers hook deployment only; skip SDK build to avoid
|
||||
// flakiness and keep the test fast (SDK install path has dedicated coverage
|
||||
// in install-smoke.yml).
|
||||
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_CONFIG_DIR: configDir,
|
||||
},
|
||||
});
|
||||
return path.join(configDir, 'hooks');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. End-to-end install: .sh hooks are deployed
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#1834: installer deploys .sh hooks alongside .js hooks', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-install-1834-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('gsd-session-state.sh is present after install', () => {
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
const target = path.join(hooksDir, 'gsd-session-state.sh');
|
||||
assert.ok(
|
||||
fs.existsSync(target),
|
||||
'gsd-session-state.sh must be installed to hooks/ — missing file causes SessionStart hook errors'
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-validate-commit.sh is present after install', () => {
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
const target = path.join(hooksDir, 'gsd-validate-commit.sh');
|
||||
assert.ok(
|
||||
fs.existsSync(target),
|
||||
'gsd-validate-commit.sh must be installed to hooks/ — missing file causes PreToolUse hook errors'
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-phase-boundary.sh is present after install', () => {
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
const target = path.join(hooksDir, 'gsd-phase-boundary.sh');
|
||||
assert.ok(
|
||||
fs.existsSync(target),
|
||||
'gsd-phase-boundary.sh must be installed to hooks/ — missing file causes PostToolUse hook errors'
|
||||
);
|
||||
});
|
||||
|
||||
test('all three .sh hooks are present after a single install', () => {
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
for (const hook of SH_HOOKS) {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(hooksDir, hook)),
|
||||
`${hook} must be present in hooks/ after install`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('.sh hooks are executable after install', {
|
||||
skip: isWindows ? 'Windows does not support POSIX file permissions' : false,
|
||||
}, () => {
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
for (const hook of SH_HOOKS) {
|
||||
const stat = fs.statSync(path.join(hooksDir, hook));
|
||||
assert.ok(
|
||||
(stat.mode & 0o111) !== 0,
|
||||
`${hook} must be executable (chmod +x) after install — missing +x causes hook invocation failures`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,258 +0,0 @@
|
||||
/**
|
||||
* Integration tests for gsd-context-monitor.js auto-record on CRITICAL (#1974).
|
||||
*
|
||||
* Verifies:
|
||||
* 1. On CRITICAL + active GSD project, the hook sets criticalRecorded in the
|
||||
* warn sentinel AND the state record-session command writes the "Stopped At"
|
||||
* field to STATE.md.
|
||||
* 2. Subsequent CRITICAL firings within the same session do NOT re-fire
|
||||
* the subprocess (sentinel guard prevents repeated overwrites).
|
||||
* 3. When no .planning/STATE.md exists, the subprocess is not spawned.
|
||||
* 4. Path resolution uses __dirname, not hardcoded ~/.claude/.
|
||||
* 5. A WARNING-only fire does NOT set criticalRecorded (selectivity counter-test).
|
||||
*
|
||||
* Design note (#3726, #3775): the original test used a short wall-clock poll
|
||||
* against a fire-and-forget spawn().unref() subprocess and flaked under load.
|
||||
* We keep one deterministic assertion (criticalRecorded sentinel is written
|
||||
* before hook exit), and use a bounded poll window for the detached writer's
|
||||
* STATE.md update. A separate test verifies direct record-session invocation.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { cleanup, delay } = require('./helpers.cjs');
|
||||
|
||||
const HOOK_PATH = path.resolve(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
|
||||
const GSD_TOOLS = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
// Windows can hold a transient handle on the temp dir after a spawnSync child
|
||||
// exits (AV scanner / handle-release lag), so cleanup()'s internal rmSync retry
|
||||
// (~5s) occasionally still throws EBUSY/EPERM/ENOTEMPTY under CI load. Restore a
|
||||
// bounded outer retry with async backoff via the shared delay() helper.
|
||||
// Re-adds the guard removed in #482. Refs #490.
|
||||
async function cleanupWithRetry(dir, attempts = 8) {
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
try { cleanup(dir); return; }
|
||||
catch (err) {
|
||||
const transient = err && (err.code === 'EBUSY' || err.code === 'EPERM' || err.code === 'ENOTEMPTY');
|
||||
if (!transient || i === attempts - 1) throw err;
|
||||
await delay(100 * (i + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the hook with a given session id and context percentage.
|
||||
* Writes a bridge metrics file first, then pipes the hook input via stdin.
|
||||
* Returns after the hook exits.
|
||||
*/
|
||||
function runHook(sessionId, remainingPct, cwd) {
|
||||
// Write the bridge metrics file the hook reads
|
||||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||||
fs.writeFileSync(bridgePath, JSON.stringify({
|
||||
session_id: sessionId,
|
||||
remaining_percentage: remainingPct,
|
||||
used_pct: 100 - remainingPct,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
}));
|
||||
|
||||
const input = JSON.stringify({
|
||||
session_id: sessionId,
|
||||
cwd,
|
||||
});
|
||||
|
||||
const result = spawnSync(process.execPath, [HOOK_PATH], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
env: { ...process.env, HOME: process.env.HOME },
|
||||
});
|
||||
|
||||
return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run gsd-tools state record-session synchronously.
|
||||
* Returns { exitCode, stdout, stderr }.
|
||||
* Used to verify the persistence seam deterministically without relying on
|
||||
* the fire-and-forget subprocess timing that caused flake (#3726).
|
||||
*/
|
||||
function runRecordSession(cwd, stoppedAt) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[GSD_TOOLS, 'state', 'record-session', '--stopped-at', stoppedAt, '--cwd', cwd],
|
||||
{ encoding: 'utf-8', timeout: 30000 }
|
||||
);
|
||||
return {
|
||||
exitCode: result.status,
|
||||
signal: result.signal,
|
||||
error: result.error,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse the warn sentinel file for a session.
|
||||
* Returns the parsed object, or null if the file does not exist.
|
||||
*/
|
||||
function readWarnData(sessionId) {
|
||||
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(warnPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe('#1974 context exhaustion auto-record', () => {
|
||||
let tmpDir;
|
||||
let statePath;
|
||||
let sessionId;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1974-'));
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
|
||||
// Minimal STATE.md with Stopped At field
|
||||
statePath = path.join(planningDir, 'STATE.md');
|
||||
fs.writeFileSync(statePath, [
|
||||
'# Session State',
|
||||
'',
|
||||
'**Current Phase:** 1',
|
||||
'**Status:** executing',
|
||||
'**Last session:** unset',
|
||||
'**Last Date:** unset',
|
||||
'**Stopped At:** None',
|
||||
'**Resume File:** None',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
// Minimal config.json required by gsd-tools
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'TEST' }));
|
||||
|
||||
sessionId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// cleanupWithRetry wraps cleanup() with a bounded outer retry (async setTimeout
|
||||
// backoff, no Atomics.wait) to handle cases where windows-2022 CI load keeps
|
||||
// the temp dir EBUSY beyond rmSync's internal ~5s retry window. Refs #490.
|
||||
await cleanupWithRetry(tmpDir);
|
||||
// Clean up bridge files
|
||||
try {
|
||||
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
|
||||
if (fs.existsSync(warnPath)) fs.unlinkSync(warnPath);
|
||||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||||
if (fs.existsSync(bridgePath)) fs.unlinkSync(bridgePath);
|
||||
} catch { /* noop */ }
|
||||
});
|
||||
|
||||
test('sets criticalRecorded sentinel on CRITICAL (synchronous assertion only)', () => {
|
||||
// Trigger CRITICAL — remaining <= 25
|
||||
// The detached record-session subprocess timing assertion (waitForStateMatch,
|
||||
// 45s poll) was removed per #453 (clock-seam): flaky under load. The
|
||||
// deterministic coverage for STATE.md persistence lives in the
|
||||
// 'state record-session command persists Stopped At when invoked directly'
|
||||
// test below, which uses spawnSync instead of a fire-and-forget subprocess.
|
||||
const result = runHook(sessionId, 20, tmpDir);
|
||||
assert.strictEqual(result.exitCode, 0, `hook should exit 0: ${result.stderr}`);
|
||||
|
||||
// Deterministic: hook writes criticalRecorded:true to warnPath SYNCHRONOUSLY
|
||||
// before the hook process exits, before the fire-and-forget subprocess runs.
|
||||
// Since runHook() uses spawnSync, this is guaranteed readable now.
|
||||
const warnData = readWarnData(sessionId);
|
||||
assert.ok(warnData, 'warn sentinel file must exist after CRITICAL fire');
|
||||
assert.strictEqual(
|
||||
warnData.criticalRecorded,
|
||||
true,
|
||||
'hook must set criticalRecorded:true in warn sentinel on CRITICAL'
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT spawn subprocess when .planning/STATE.md is absent', () => {
|
||||
// Delete STATE.md to simulate non-GSD project
|
||||
fs.unlinkSync(statePath);
|
||||
|
||||
const result = runHook(sessionId, 20, tmpDir);
|
||||
assert.strictEqual(result.exitCode, 0);
|
||||
|
||||
// The hook checks isGsdActive via fs.existsSync(STATE.md) before setting
|
||||
// criticalRecorded. If STATE.md is absent, criticalRecorded must NOT be set.
|
||||
const warnData = readWarnData(sessionId);
|
||||
// warnData may exist (hook still debounces) but criticalRecorded must be absent/falsy.
|
||||
const criticalRecorded = warnData && warnData.criticalRecorded;
|
||||
assert.ok(!criticalRecorded, 'criticalRecorded must not be set when STATE.md is absent');
|
||||
assert.ok(!fs.existsSync(statePath), 'STATE.md should not be recreated when absent');
|
||||
});
|
||||
|
||||
test('sentinel prevents repeated firing within same session', () => {
|
||||
// First CRITICAL fire — should set criticalRecorded synchronously.
|
||||
const result1 = runHook(sessionId, 20, tmpDir);
|
||||
assert.strictEqual(result1.exitCode, 0, `first hook fire should exit 0: ${result1.stderr}`);
|
||||
|
||||
const warnData1 = readWarnData(sessionId);
|
||||
assert.ok(warnData1, 'warn sentinel must exist after first CRITICAL fire');
|
||||
assert.strictEqual(warnData1.criticalRecorded, true, 'first fire must set criticalRecorded:true');
|
||||
|
||||
// Second CRITICAL fire — same session, criticalRecorded already true in
|
||||
// warnPath. Advance callsSinceWarn past DEBOUNCE_CALLS (5, see hook
|
||||
// line 29) so the hook processes the warning message path and exercises
|
||||
// the sentinel guard. Using 10 (2× DEBOUNCE_CALLS) ensures we clear the
|
||||
// debounce threshold regardless of any future DEBOUNCE_CALLS adjustment.
|
||||
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
|
||||
const warnDataPatched = { ...warnData1, callsSinceWarn: 10 };
|
||||
fs.writeFileSync(warnPath, JSON.stringify(warnDataPatched));
|
||||
|
||||
const result2 = runHook(sessionId, 18, tmpDir);
|
||||
assert.strictEqual(result2.exitCode, 0, `second hook fire should exit 0: ${result2.stderr}`);
|
||||
|
||||
// The warnData must still carry criticalRecorded:true — the guard was
|
||||
// active and the hook did not reset or clear it.
|
||||
const warnData2 = readWarnData(sessionId);
|
||||
assert.strictEqual(warnData2 && warnData2.criticalRecorded, true, 'sentinel must remain true after second fire');
|
||||
|
||||
// The hook's stdout must still emit a CRITICAL warning message (so the
|
||||
// agent sees context warnings) even though record-session was NOT re-fired.
|
||||
const output2 = result2.stdout ? (() => { try { return JSON.parse(result2.stdout); } catch { return null; } })() : null;
|
||||
assert.ok(
|
||||
output2 && output2.hookSpecificOutput && /CONTEXT CRITICAL/.test(output2.hookSpecificOutput.additionalContext),
|
||||
'second CRITICAL fire must still emit CONTEXT CRITICAL warning to the agent'
|
||||
);
|
||||
});
|
||||
|
||||
test('state record-session command persists Stopped At when invoked directly', () => {
|
||||
const recordResult = runRecordSession(tmpDir, 'context exhaustion at 80% (2026-01-01)');
|
||||
assert.strictEqual(
|
||||
recordResult.exitCode,
|
||||
0,
|
||||
`record-session should exit 0 (signal=${recordResult.signal || 'none'} error=${recordResult.error ? recordResult.error.message : 'none'}): ${recordResult.stderr}`
|
||||
);
|
||||
const content = fs.readFileSync(statePath, 'utf-8');
|
||||
assert.match(content, /context exhaustion at 80% \(2026-01-01\)/, 'STATE.md must contain direct record-session value');
|
||||
});
|
||||
|
||||
test('WARNING-only fire does NOT set criticalRecorded (selectivity counter-test)', () => {
|
||||
// Trigger WARNING (remaining 30% — below WARNING_THRESHOLD=35, above CRITICAL_THRESHOLD=25)
|
||||
const result = runHook(sessionId, 30, tmpDir);
|
||||
assert.strictEqual(result.exitCode, 0, `hook should exit 0: ${result.stderr}`);
|
||||
|
||||
// criticalRecorded must NOT be set on a WARNING-only fire
|
||||
const warnData = readWarnData(sessionId);
|
||||
const criticalRecorded = warnData && warnData.criticalRecorded;
|
||||
assert.ok(!criticalRecorded, 'WARNING-only fire must not set criticalRecorded');
|
||||
});
|
||||
|
||||
// 'hook uses __dirname-based path (runtime-agnostic)' deleted per #453 (clock-seam):
|
||||
// source-grep of HOOK_PATH for path.join(__dirname is brittle. The behavioral equivalent
|
||||
// (hook successfully resolves gsd-tools.cjs from any working directory) is already covered
|
||||
// by the runHook() helper throughout this test file — it calls the hook from an arbitrary
|
||||
// tmpDir and all tests pass, proving __dirname-relative resolution works.
|
||||
});
|
||||
@@ -1,189 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression test for bug #211: gsd_run launcher must probe
|
||||
* $HOME/.claude/gsd-core/bin/gsd-tools.cjs before emitting the hard error.
|
||||
*
|
||||
* Asserts:
|
||||
* (A) The canonical snippet file contains the ~/.claude fallback arm.
|
||||
* (B) A representative propagated workflow file contains the ~/.claude fallback arm.
|
||||
* (C) Behavioral: when RUNTIME_DIR misses and gsd-tools is NOT on PATH,
|
||||
* a stub at $HOME/.claude/gsd-core/bin/gsd-tools.cjs is resolved and invoked.
|
||||
* (D) The resolution order is preserved: local -> PATH -> ~/.claude -> hard error.
|
||||
* When all three miss, exit non-zero.
|
||||
*/
|
||||
|
||||
// allow-test-rule: structural/behavioral regression for the ~/.claude fallback arm in
|
||||
// the gsd_run launcher snippet -- asserts literal substring presence and exercises the
|
||||
// bash resolution path via execFileSync; there is no typed IR for "snippet contains arm X".
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
|
||||
// Representative propagated workflow file (has a gsd_run call):
|
||||
const REPRESENTATIVE_FILE = path.join(WORKFLOWS_DIR, 'add-backlog.md');
|
||||
|
||||
const CLAUDE_HOME_PROBE = '.claude/gsd-core/bin/';
|
||||
|
||||
describe('bug-211: launcher ~/.claude home fallback', () => {
|
||||
// --- (A) Snippet contains the arm ----------------------------------------
|
||||
test('(A) snippet file contains the $HOME/.claude fallback arm', () => {
|
||||
const content = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
assert.ok(
|
||||
content.includes(CLAUDE_HOME_PROBE),
|
||||
`_runtime-launcher.snippet.sh must contain "${CLAUDE_HOME_PROBE}" (the ~/.claude fallback arm). ` +
|
||||
`Found snippet content:\n${content.trim()}`,
|
||||
);
|
||||
});
|
||||
|
||||
// --- (B) Representative propagated file contains the arm ------------------
|
||||
test('(B) add-backlog.md (representative propagated file) contains the $HOME/.claude fallback arm', () => {
|
||||
const content = fs.readFileSync(REPRESENTATIVE_FILE, 'utf8');
|
||||
assert.ok(
|
||||
content.includes(CLAUDE_HOME_PROBE),
|
||||
`add-backlog.md must contain "${CLAUDE_HOME_PROBE}" after propagation. ` +
|
||||
`Run \`node scripts/sync-runtime-launcher.cjs\` to propagate the updated snippet.`,
|
||||
);
|
||||
});
|
||||
|
||||
// --- (C) Behavioral: ~/.claude stub is resolved when local and PATH both miss
|
||||
test('(C) gsd_run resolves $HOME/.claude/gsd-core/bin/ stub when no local install and gsd-tools not on PATH', () => {
|
||||
// Build a fake $HOME with a stub at .claude/gsd-core/bin/gsd-tools.cjs
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-211-home-'));
|
||||
// RUNTIME_DIR points to a directory with no gsd-tools.cjs
|
||||
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-211-rt-'));
|
||||
try {
|
||||
const claudeBinDir = path.join(fakeHome, '.claude', 'gsd-core', 'bin');
|
||||
fs.mkdirSync(claudeBinDir, { recursive: true });
|
||||
|
||||
// Stub gsd-tools.cjs that prints a marker
|
||||
const stubPath = path.join(claudeBinDir, 'gsd-tools.cjs');
|
||||
fs.writeFileSync(
|
||||
stubPath,
|
||||
'#!/usr/bin/env node\nconsole.log("CLAUDE_HOME_STUB:" + process.argv.slice(2).join(","));\n',
|
||||
);
|
||||
fs.chmodSync(stubPath, 0o755);
|
||||
|
||||
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
const scriptContent =
|
||||
`unset GSD_TOOLS\n` +
|
||||
`export RUNTIME_DIR=${JSON.stringify(fakeRuntime)}\n` +
|
||||
`export HOME=${JSON.stringify(fakeHome)}\n` +
|
||||
snippet +
|
||||
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
|
||||
`gsd_run ping test\n`;
|
||||
|
||||
const scriptPath = path.join(fakeRuntime, 'test-home-fb.sh');
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
// Build a PATH with no gsd-tools binary to force the ~/.claude arm.
|
||||
// Filter out directories that contain a gsd-tools executable. If node lives
|
||||
// in the same directory as gsd-tools, create a dedicated shim dir with a
|
||||
// symlink to node only (no gsd-tools there).
|
||||
const nodeBin = execFileSync('which', ['node'], { encoding: 'utf8' }).trim();
|
||||
const systemPaths = (process.env.PATH || '/usr/bin:/bin')
|
||||
.split(path.delimiter)
|
||||
.filter((p) => {
|
||||
try {
|
||||
fs.accessSync(path.join(p, 'gsd-tools'), fs.constants.X_OK);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
// If node's dir was filtered (it contained gsd-tools), create a shim dir
|
||||
// with just a node symlink so the stub's shebang (#!/usr/bin/env node) resolves.
|
||||
const nodeShimDir = path.join(fakeRuntime, 'node-shim');
|
||||
if (!systemPaths.some((p) => {
|
||||
try { fs.accessSync(path.join(p, 'node'), fs.constants.X_OK); return true; }
|
||||
catch { return false; }
|
||||
})) {
|
||||
fs.mkdirSync(nodeShimDir, { recursive: true });
|
||||
fs.symlinkSync(nodeBin, path.join(nodeShimDir, 'node'));
|
||||
systemPaths.unshift(nodeShimDir);
|
||||
}
|
||||
|
||||
const stdout = execFileSync('bash', [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: systemPaths.join(path.delimiter), HOME: fakeHome },
|
||||
});
|
||||
|
||||
// GSD_TOOLS must point into the fake ~/.claude dir
|
||||
const normStdout = stdout.replace(/\\/g, '/');
|
||||
assert.ok(
|
||||
normStdout.includes('.claude/gsd-core/bin/'),
|
||||
`Expected GSD_TOOLS to resolve into .claude/gsd-core/bin/, got:\n${stdout.trim()}`,
|
||||
);
|
||||
// The stub must have been invoked
|
||||
assert.ok(
|
||||
stdout.includes('CLAUDE_HOME_STUB:ping,test'),
|
||||
`Expected stub output "CLAUDE_HOME_STUB:ping,test", got:\n${stdout.trim()}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(fakeHome);
|
||||
cleanup(fakeRuntime);
|
||||
}
|
||||
});
|
||||
|
||||
// --- (D) All three miss -> hard error -------------------------------------
|
||||
test('(D) hard error when local, PATH, and ~/.claude all miss', () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-211-nohome-'));
|
||||
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-211-nort-'));
|
||||
// noToolsBin so PATH check finds nothing
|
||||
const noToolsBin = path.join(fakeHome, 'nobin');
|
||||
fs.mkdirSync(noToolsBin, { recursive: true });
|
||||
// NO .claude/gsd-core/bin stub created in fakeHome
|
||||
try {
|
||||
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
const scriptContent =
|
||||
`unset GSD_TOOLS\n` +
|
||||
`export RUNTIME_DIR=${JSON.stringify(fakeRuntime)}\n` +
|
||||
`export HOME=${JSON.stringify(fakeHome)}\n` +
|
||||
snippet +
|
||||
`\ngsd_run ping test\n`;
|
||||
|
||||
const scriptPath = path.join(fakeRuntime, 'test-allfail.sh');
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
const systemPaths = (process.env.PATH || '/usr/bin:/bin')
|
||||
.split(path.delimiter)
|
||||
.filter((p) => {
|
||||
try {
|
||||
fs.accessSync(path.join(p, 'gsd-tools'), fs.constants.X_OK);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const isolatedPath = [noToolsBin, ...systemPaths].join(path.delimiter);
|
||||
|
||||
let threw = false;
|
||||
let stderrOutput = '';
|
||||
try {
|
||||
execFileSync('bash', [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome },
|
||||
});
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
stderrOutput = err.stderr || '';
|
||||
}
|
||||
|
||||
assert.ok(threw, 'Expected non-zero exit when all three resolution arms miss');
|
||||
assert.ok(
|
||||
stderrOutput.includes('not found') || stderrOutput.includes('ERROR'),
|
||||
`Expected stderr to contain "not found" or "ERROR", got: ${stderrOutput.trim()}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(fakeHome);
|
||||
cleanup(fakeRuntime);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,308 +0,0 @@
|
||||
|
||||
// allow-test-rule: structural-regression-guard
|
||||
// The shebang line must be `#!/usr/bin/env bash` (PATH-resolved) rather than
|
||||
// `#!/bin/bash` for cross-distro portability (NixOS, minimal Alpine do not
|
||||
// ship /bin/bash). This is an architectural constraint that cannot be verified
|
||||
// by executing the hooks — they run fine with either shebang on distros that
|
||||
// have /bin/bash, so only a source assertion catches a future regression.
|
||||
|
||||
/**
|
||||
* Regression tests for bug #2136 / #2206
|
||||
*
|
||||
* Root cause: three bash hooks (gsd-phase-boundary.sh, gsd-session-state.sh,
|
||||
* gsd-validate-commit.sh) shipped without a gsd-hook-version header, and the
|
||||
* stale-hook detector in gsd-check-update.js only matched JavaScript comment
|
||||
* syntax (//) — not bash comment syntax (#).
|
||||
*
|
||||
* Result: every session showed "⚠ stale hooks — run /gsd-update" immediately
|
||||
* after a fresh install, because the detector saw hookVersion: 'unknown' for
|
||||
* all three bash hooks.
|
||||
*
|
||||
* This fix requires THREE parts working in concert:
|
||||
* 1. Bash hooks ship with "# gsd-hook-version: {{GSD_VERSION}}"
|
||||
* 2. install.js substitutes {{GSD_VERSION}} in .sh files at install time
|
||||
* 3. gsd-check-update.js regex matches both "//" and "#" comment styles
|
||||
*
|
||||
* Neither fix alone is sufficient:
|
||||
* - Headers + regex fix only (no install.js fix): installed hooks contain
|
||||
* literal "{{GSD_VERSION}}" — the {{-guard silently skips them, making
|
||||
* bash hook staleness permanently undetectable after future updates.
|
||||
* - Headers + install.js fix only (no regex fix): installed hooks are
|
||||
* stamped correctly but the detector still can't read bash "#" comments,
|
||||
* so they still land in the "unknown / stale" branch on every session.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// NOTE: Do NOT set GSD_TEST_MODE here — the E2E install tests spawn the
|
||||
// real installer subprocess, which skips all install logic when GSD_TEST_MODE=1.
|
||||
|
||||
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
|
||||
const _CHECK_UPDATE_FILE = path.join(HOOKS_DIR, 'gsd-check-update.js');
|
||||
const WORKER_FILE = path.join(HOOKS_DIR, 'gsd-check-update-worker.js');
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
|
||||
const SH_HOOKS = [
|
||||
'gsd-phase-boundary.sh',
|
||||
'gsd-session-state.sh',
|
||||
'gsd-validate-commit.sh',
|
||||
];
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
||||
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup() helper wrapping rmSync; cannot use imported cleanup() without naming collision
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function runInstaller(configDir) {
|
||||
// --no-sdk: this test covers .sh hook version stamping only; skip SDK
|
||||
// build (covered by install-smoke.yml).
|
||||
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
|
||||
});
|
||||
return path.join(configDir, 'hooks');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Part 1: Bash hook sources carry the version header placeholder
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #2136 part 1: bash hook sources carry gsd-hook-version placeholder', () => {
|
||||
for (const sh of SH_HOOKS) {
|
||||
test(`${sh} contains "# gsd-hook-version: {{GSD_VERSION}}"`, () => {
|
||||
const content = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8');
|
||||
assert.ok(
|
||||
content.includes('# gsd-hook-version: {{GSD_VERSION}}'),
|
||||
`${sh} must include "# gsd-hook-version: {{GSD_VERSION}}" so the ` +
|
||||
`installer can stamp it and gsd-check-update.js can detect staleness`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('version header is on line 2 (immediately after shebang)', () => {
|
||||
// Placing the header immediately after the shebang ensures it is always
|
||||
// found regardless of how much of the file is read. The shebang itself
|
||||
// must use `#!/usr/bin/env bash` (PATH-resolved) rather than `#!/bin/bash`
|
||||
// — POSIX guarantees /bin/sh but not /bin/bash, and distros like NixOS
|
||||
// do not ship /bin/bash by default.
|
||||
for (const sh of SH_HOOKS) {
|
||||
const lines = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8').split(/\r?\n/);
|
||||
assert.strictEqual(
|
||||
lines[0],
|
||||
'#!/usr/bin/env bash',
|
||||
`${sh} line 1 must be "#!/usr/bin/env bash" for cross-distro portability`
|
||||
);
|
||||
assert.ok(
|
||||
lines[1].startsWith('# gsd-hook-version:'),
|
||||
`${sh} line 2 must be the gsd-hook-version header (got: "${lines[1]}")`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Part 2: gsd-check-update-worker.js regex handles bash "#" comment syntax
|
||||
// (Logic moved from inline -e template literal to dedicated worker file)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #2136 part 2: stale-hook detector handles bash comment syntax', () => {
|
||||
let src;
|
||||
|
||||
before(() => {
|
||||
src = fs.readFileSync(WORKER_FILE, 'utf8');
|
||||
});
|
||||
|
||||
test('version regex in source matches "#" comment syntax in addition to "//"', () => {
|
||||
// The regex string in the source must contain the alternation for "#".
|
||||
// The worker uses plain JS (no template-literal escaping), so the form is
|
||||
// "(?:\/\/|#)" directly in source.
|
||||
const hasBashAlternative =
|
||||
src.includes('(?:\\/\\/|#)') || // escaped form (old template-literal style)
|
||||
src.includes('(?://|#)'); // direct form in plain JS worker
|
||||
assert.ok(
|
||||
hasBashAlternative,
|
||||
'gsd-check-update-worker.js version regex must include an alternative for bash "#" comments. ' +
|
||||
'Expected to find (?:\\/\\/|#) or (?://|#) in the source. ' +
|
||||
'The original "//" only regex causes bash hooks to always report hookVersion: "unknown"'
|
||||
);
|
||||
});
|
||||
|
||||
test('version regex does not use the old JS-only form as the sole pattern', () => {
|
||||
// The old regex inside the template literal was the string:
|
||||
// /\\/\\/ gsd-hook-version:\\s*(.+)/
|
||||
// which, when evaluated in the subprocess, produced: /\/\/ gsd-hook-version:\s*(.+)/
|
||||
// That only matched JS "//" comments — never bash "#".
|
||||
// We verify that the old exact string no longer appears.
|
||||
assert.ok(
|
||||
!src.includes('\\/\\/ gsd-hook-version'),
|
||||
'gsd-check-update-worker.js must not use the old JS-only (\\/\\/ gsd-hook-version) ' +
|
||||
'escape form as the sole version matcher — it cannot match bash "#" comments'
|
||||
);
|
||||
});
|
||||
|
||||
test('version regex correctly matches both bash and JS hook version headers', () => {
|
||||
// Verify that the versionMatch line in the source uses a regex that matches
|
||||
// both bash "#" and JS "//" comment styles. We check the source contains the
|
||||
// expected alternation, then directly test the known required pattern.
|
||||
//
|
||||
// We do NOT try to extract and evaluate the regex from source (it contains ")"
|
||||
// which breaks simple extraction), so instead we confirm the source matches
|
||||
// our expectation and run the regex itself.
|
||||
assert.ok(
|
||||
src.includes('gsd-hook-version'),
|
||||
'gsd-check-update-worker.js must contain a gsd-hook-version version check'
|
||||
);
|
||||
|
||||
// The fixed regex that must be present: matches both comment styles
|
||||
const fixedRegex = /(?:\/\/|#) gsd-hook-version:\s*(.+)/;
|
||||
|
||||
assert.ok(
|
||||
fixedRegex.test('# gsd-hook-version: 1.36.0'),
|
||||
'bash-style "# gsd-hook-version: X" must be matchable by the required regex'
|
||||
);
|
||||
assert.ok(
|
||||
fixedRegex.test('// gsd-hook-version: 1.36.0'),
|
||||
'JS-style "// gsd-hook-version: X" must still match (no regression)'
|
||||
);
|
||||
assert.ok(
|
||||
!fixedRegex.test('gsd-hook-version: 1.36.0'),
|
||||
'line without a comment prefix must not match (prevents false positives)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Part 4: End-to-end — installed .sh hooks have stamped version, not placeholder
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #2136 part 4: installed .sh hooks contain stamped concrete version', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-2136-install-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('installed .sh hooks contain a concrete version string, not the template placeholder', () => {
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
|
||||
for (const sh of SH_HOOKS) {
|
||||
const hookPath = path.join(hooksDir, sh);
|
||||
assert.ok(fs.existsSync(hookPath), `${sh} must be installed`);
|
||||
|
||||
const content = fs.readFileSync(hookPath, 'utf8');
|
||||
|
||||
assert.ok(
|
||||
content.includes('# gsd-hook-version:'),
|
||||
`installed ${sh} must contain a "# gsd-hook-version:" header`
|
||||
);
|
||||
assert.ok(
|
||||
!content.includes('{{GSD_VERSION}}'),
|
||||
`installed ${sh} must not contain literal "{{GSD_VERSION}}" — ` +
|
||||
`install.js must substitute it with the concrete package version`
|
||||
);
|
||||
|
||||
const versionMatch = content.match(/# gsd-hook-version:\s*(\S+)/);
|
||||
assert.ok(versionMatch, `installed ${sh} version header must have a version value`);
|
||||
assert.match(
|
||||
versionMatch[1],
|
||||
/^\d+\.\d+\.\d+/,
|
||||
`installed ${sh} version "${versionMatch[1]}" must be a semver-like string`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('stale-hook detector reports zero stale bash hooks immediately after fresh install', () => {
|
||||
// This is the definitive end-to-end proof: after install, run the actual
|
||||
// version-check logic (extracted from gsd-check-update.js) against the
|
||||
// installed hooks and verify none are flagged stale.
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
const pkg = require(path.join(__dirname, '..', 'package.json'));
|
||||
const installedVersion = pkg.version;
|
||||
|
||||
// Build a subprocess that runs the staleness check logic in isolation.
|
||||
// We pass the installed version, hooks dir, and hook filenames as JSON
|
||||
// to avoid any injection risk.
|
||||
const checkScript = `
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function isNewer(a, b) {
|
||||
const pa = (a || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
||||
const pb = (b || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (pa[i] > pb[i]) return true;
|
||||
if (pa[i] < pb[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const hooksDir = ${JSON.stringify(hooksDir)};
|
||||
const installed = ${JSON.stringify(installedVersion)};
|
||||
const shHooks = ${JSON.stringify(SH_HOOKS)};
|
||||
// Use the same regex that the fixed gsd-check-update.js uses
|
||||
const versionRe = /(?:\\/\\/|#) gsd-hook-version:\\s*(.+)/;
|
||||
|
||||
const staleHooks = [];
|
||||
for (const hookFile of shHooks) {
|
||||
const hookPath = path.join(hooksDir, hookFile);
|
||||
if (!fs.existsSync(hookPath)) {
|
||||
staleHooks.push({ file: hookFile, hookVersion: 'missing' });
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(hookPath, 'utf8');
|
||||
const m = content.match(versionRe);
|
||||
if (m) {
|
||||
const hookVersion = m[1].trim();
|
||||
if (isNewer(installed, hookVersion) && !hookVersion.includes('{{')) {
|
||||
staleHooks.push({ file: hookFile, hookVersion, installedVersion: installed });
|
||||
}
|
||||
} else {
|
||||
staleHooks.push({ file: hookFile, hookVersion: 'unknown', installedVersion: installed });
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(staleHooks));
|
||||
`;
|
||||
|
||||
const result = execFileSync(process.execPath, ['-e', checkScript], { encoding: 'utf8' });
|
||||
const staleHooks = JSON.parse(result);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
staleHooks,
|
||||
[],
|
||||
`Fresh install must produce zero stale bash hooks.\n` +
|
||||
`Got: ${JSON.stringify(staleHooks, null, 2)}\n` +
|
||||
`This indicates either the version header was not stamped by install.js, ` +
|
||||
`or the detector regex cannot match bash "#" comment syntax.`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2344
|
||||
*
|
||||
* gsd-read-guard.js checked process.env.CLAUDE_SESSION_ID to detect the
|
||||
* Claude Code runtime and skip its advisory. However, Claude Code CLI exports
|
||||
* CLAUDECODE=1, not CLAUDE_SESSION_ID. The skip never fired, so the
|
||||
* READ-BEFORE-EDIT advisory injected on every Edit/Write call inside Claude
|
||||
* Code — producing noise in long-running sessions.
|
||||
*
|
||||
* Fix: check CLAUDECODE (and CLAUDE_SESSION_ID for back-compat) before
|
||||
* emitting the advisory.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-guard.js');
|
||||
|
||||
function runHook(payload, envOverrides = {}) {
|
||||
const input = JSON.stringify(payload);
|
||||
const env = {
|
||||
...process.env,
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDECODE: '',
|
||||
CLAUDE_CODE_ENTRYPOINT: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
CLAUDE_PROJECT_DIR: '',
|
||||
...envOverrides,
|
||||
};
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
|
||||
} catch (err) {
|
||||
return {
|
||||
exitCode: err.status ?? 1,
|
||||
stdout: (err.stdout || '').toString().trim(),
|
||||
stderr: (err.stderr || '').toString().trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('bug #2344: read guard skips on CLAUDECODE env var', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempDir('gsd-read-guard-2344-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('skips advisory when CLAUDECODE=1 is set (Claude Code CLI env)', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
const result = runHook(
|
||||
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
||||
{ CLAUDECODE: '1' }
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDECODE=1');
|
||||
});
|
||||
|
||||
test('skips advisory when CLAUDE_SESSION_ID is set (back-compat)', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
const result = runHook(
|
||||
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
||||
{ CLAUDE_SESSION_ID: 'test-session-123' }
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDE_SESSION_ID is set');
|
||||
});
|
||||
|
||||
test('still injects advisory when neither CLAUDECODE nor CLAUDE_SESSION_ID is set', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
const result = runHook(
|
||||
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
||||
{ CLAUDECODE: '', CLAUDE_SESSION_ID: '' }
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.ok(result.stdout.length > 0, 'advisory should fire on non-Claude-Code runtimes');
|
||||
const output = JSON.parse(result.stdout);
|
||||
assert.ok(output.hookSpecificOutput?.additionalContext?.includes('Read'));
|
||||
});
|
||||
});
|
||||
@@ -1,185 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2451
|
||||
*
|
||||
* The GSD context monitor hook over-reports usage by ~13 percentage points
|
||||
* compared to Claude Code's native /context command. The root cause:
|
||||
*
|
||||
* gsd-statusline.js writes two values to the bridge file:
|
||||
* - remaining_percentage: raw remaining from CC (e.g. 35%)
|
||||
* - used_pct: normalized "usable" percentage (e.g. 78%) — accounts for
|
||||
* the 16.5% autocompact buffer by scaling: (100 - remaining - buffer) /
|
||||
* (100 - buffer) * 100
|
||||
*
|
||||
* gsd-context-monitor.js displays used_pct (78%) in warning messages.
|
||||
* But CC's native /context shows raw used = 100 - remaining = 65%.
|
||||
* The 13-point gap is exactly the buffer normalization overhead.
|
||||
*
|
||||
* Fix: the bridge must write used_pct as the raw value (Math.round(100 -
|
||||
* remaining)), not the buffer-normalized value. The statusline progress bar
|
||||
* continues to use the normalized value for its own display; only the bridge
|
||||
* value that feeds the context monitor needs to be raw/CC-consistent.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js');
|
||||
const MONITOR_PATH = path.join(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
|
||||
|
||||
/**
|
||||
* Run the statusline hook with a synthetic payload and return the full
|
||||
* bridge JSON object written to /tmp/claude-ctx-{sessionId}.json.
|
||||
*/
|
||||
function runStatuslineHook(remainingPct, totalTokens = 1_000_000, acwEnv = null) {
|
||||
const sessionId = `test-2451-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const payload = JSON.stringify({
|
||||
model: { display_name: 'Claude' },
|
||||
workspace: { current_dir: os.tmpdir() },
|
||||
session_id: sessionId,
|
||||
context_window: {
|
||||
remaining_percentage: remainingPct,
|
||||
total_tokens: totalTokens,
|
||||
},
|
||||
});
|
||||
|
||||
const env = { ...process.env };
|
||||
if (acwEnv != null) {
|
||||
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(acwEnv);
|
||||
} else {
|
||||
delete env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(process.execPath, [HOOK_PATH], {
|
||||
input: payload,
|
||||
env,
|
||||
timeout: 4000,
|
||||
});
|
||||
} catch { /* non-zero exit is fine; we only need the bridge file */ }
|
||||
|
||||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||||
const bridge = JSON.parse(fs.readFileSync(bridgePath, 'utf-8'));
|
||||
fs.unlinkSync(bridgePath);
|
||||
return bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the context monitor hook with a pre-written bridge file and return
|
||||
* the parsed additionalContext string from its stdout.
|
||||
*/
|
||||
function runMonitorHook(remainingPct, usedPct) {
|
||||
const sessionId = `test-2451-mon-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||||
fs.writeFileSync(bridgePath, JSON.stringify({
|
||||
session_id: sessionId,
|
||||
remaining_percentage: remainingPct,
|
||||
used_pct: usedPct,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
}));
|
||||
|
||||
const input = JSON.stringify({ session_id: sessionId, cwd: os.tmpdir() });
|
||||
let stdout = '';
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, [MONITOR_PATH], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (e) {
|
||||
stdout = e.stdout || '';
|
||||
} finally {
|
||||
try { fs.unlinkSync(bridgePath); } catch { /* noop */ }
|
||||
try { fs.unlinkSync(path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`)); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
if (!stdout) return null;
|
||||
const out = JSON.parse(stdout);
|
||||
return out?.hookSpecificOutput?.additionalContext || null;
|
||||
}
|
||||
|
||||
// ─── Bridge file used_pct accuracy ──────────────────────────────────────────
|
||||
|
||||
describe('bug #2451: bridge used_pct matches CC native reporting', () => {
|
||||
test('used_pct is raw (100 - remaining), not buffer-normalized', () => {
|
||||
// CC reports remaining_percentage=35 → CC native "used" = 100-35 = 65%
|
||||
// Buffer-normalized would give: (100 - (35-16.5)/(100-16.5)*100) ≈ 78%
|
||||
// The bridge used_pct must be 65 (raw), not 78 (normalized).
|
||||
const bridge = runStatuslineHook(35);
|
||||
assert.strictEqual(
|
||||
bridge.used_pct,
|
||||
65,
|
||||
`used_pct should be 65 (raw: 100 - 35) but got ${bridge.used_pct}. ` +
|
||||
'Buffer normalization must NOT be applied to the bridge used_pct, ' +
|
||||
'otherwise context monitor messages over-report usage by ~13 points ' +
|
||||
'compared to CC native /context (root cause of #2451).'
|
||||
);
|
||||
});
|
||||
|
||||
test('used_pct is raw for high remaining (low usage scenario)', () => {
|
||||
// remaining=80 → raw used = 20
|
||||
const bridge = runStatuslineHook(80);
|
||||
assert.strictEqual(bridge.used_pct, 20,
|
||||
`used_pct should be 20 (raw: 100-80) but got ${bridge.used_pct}`);
|
||||
});
|
||||
|
||||
test('used_pct is raw for near-critical remaining', () => {
|
||||
// remaining=20 → raw used = 80
|
||||
const bridge = runStatuslineHook(20);
|
||||
assert.strictEqual(bridge.used_pct, 80,
|
||||
`used_pct should be 80 (raw: 100-20) but got ${bridge.used_pct}`);
|
||||
});
|
||||
|
||||
test('remaining_percentage in bridge matches raw CC value', () => {
|
||||
// The bridge remaining_percentage should be the exact raw value from CC
|
||||
const bridge = runStatuslineHook(42);
|
||||
assert.strictEqual(bridge.remaining_percentage, 42,
|
||||
'bridge remaining_percentage must be the raw CC value (no normalization)');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Context monitor message accuracy ───────────────────────────────────────
|
||||
|
||||
describe('bug #2451: context monitor warning messages show CC-consistent percentages', () => {
|
||||
test('WARNING message shows raw used_pct consistent with CC reporting', () => {
|
||||
// remaining=30 → raw used=70; bridge stores used_pct=70
|
||||
// Monitor message must say "Usage at 70%", not a buffer-inflated value
|
||||
const msg = runMonitorHook(30, 70);
|
||||
assert.ok(msg, 'hook should emit a warning when remaining=30 (below WARNING_THRESHOLD=35)');
|
||||
assert.match(
|
||||
msg,
|
||||
/Usage at 70%/,
|
||||
`Warning message should say "Usage at 70%" (raw), got: ${msg}`
|
||||
);
|
||||
});
|
||||
|
||||
test('CRITICAL message shows raw used_pct consistent with CC reporting', () => {
|
||||
// remaining=20 → raw used=80
|
||||
const msg = runMonitorHook(20, 80);
|
||||
assert.ok(msg, 'hook should emit a critical warning when remaining=20 (below CRITICAL_THRESHOLD=25)');
|
||||
assert.match(
|
||||
msg,
|
||||
/Usage at 80%/,
|
||||
`Critical message should say "Usage at 80%" (raw), got: ${msg}`
|
||||
);
|
||||
});
|
||||
|
||||
test('gap between hook used_pct and raw CC value is at most 1 (rounding)', () => {
|
||||
// With the fix, the only acceptable deviation is ±1 due to Math.round
|
||||
const rawRemaining = 35;
|
||||
const bridge = runStatuslineHook(rawRemaining);
|
||||
const ccNativeUsed = 100 - rawRemaining; // 65
|
||||
const gap = Math.abs(bridge.used_pct - ccNativeUsed);
|
||||
assert.ok(
|
||||
gap <= 1,
|
||||
`Gap between hook used_pct (${bridge.used_pct}) and CC native used (${ccNativeUsed}) ` +
|
||||
`is ${gap} points — must be ≤1 (rounding). Larger gaps indicate buffer normalization ` +
|
||||
'is still being applied to bridge used_pct (root cause of #2451).'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2520
|
||||
*
|
||||
* The fix for #2344 added `|| process.env.CLAUDECODE` to the Claude Code
|
||||
* skip check. That works in principle — CLAUDECODE=1 is propagated to Bash
|
||||
* tool subprocesses — but it does NOT reach hook subprocesses on Claude Code
|
||||
* v2.1.116. Claude Code applies a separate env filter when spawning
|
||||
* PreToolUse hook commands; that filter drops bare CLAUDECODE and
|
||||
* CLAUDE_SESSION_ID and keeps only CLAUDE_CODE_*-prefixed vars plus
|
||||
* CLAUDE_PROJECT_DIR. `data.session_id` is, however, reliably delivered via
|
||||
* the hook's stdin JSON payload (documented part of Claude Code's hook
|
||||
* input schema).
|
||||
*
|
||||
* Fix: use `data.session_id` as the primary Claude Code signal, with
|
||||
* CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SSE_PORT as env-var fallbacks, and
|
||||
* keep legacy CLAUDECODE / CLAUDE_SESSION_ID for back-compat and
|
||||
* future-proofing.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-guard.js');
|
||||
|
||||
/**
|
||||
* Spawn the hook with an env that mirrors the actual Claude Code hook
|
||||
* subprocess env: CLAUDECODE and CLAUDE_SESSION_ID are stripped, only
|
||||
* CLAUDE_CODE_*-prefixed vars (plus CLAUDE_PROJECT_DIR) remain. Extra env
|
||||
* overrides can be supplied via `envOverrides`.
|
||||
*/
|
||||
function runHookInClaudeCodeSubprocess(payload, envOverrides = {}) {
|
||||
const input = JSON.stringify(payload);
|
||||
const baseEnv = { ...process.env };
|
||||
// Strip env vars Claude Code does NOT propagate to hook subprocesses.
|
||||
delete baseEnv.CLAUDECODE;
|
||||
delete baseEnv.CLAUDE_SESSION_ID;
|
||||
const env = {
|
||||
...baseEnv,
|
||||
// Env vars Claude Code DOES propagate to hook subprocesses (observed on
|
||||
// Claude Code CLI 2.1.116).
|
||||
CLAUDE_CODE_ENTRYPOINT: 'cli',
|
||||
CLAUDE_CODE_SSE_PORT: '51291',
|
||||
CLAUDE_PROJECT_DIR: process.cwd(),
|
||||
...envOverrides,
|
||||
};
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
|
||||
} catch (err) {
|
||||
return {
|
||||
exitCode: err.status ?? 1,
|
||||
stdout: (err.stdout || '').toString().trim(),
|
||||
stderr: (err.stderr || '').toString().trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('bug #2520: read guard detects Claude Code without relying on CLAUDECODE env', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempDir('gsd-read-guard-2520-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('skips advisory when stdin payload includes session_id (Claude Code hook-subprocess env)', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
// Isolate the stdin `session_id` signal by clearing the CLAUDE_CODE_*
|
||||
// env fallbacks the helper normally provides. Without this the env
|
||||
// fallback would rescue the skip even if session_id detection broke,
|
||||
// hiding a regression of the primary signal.
|
||||
const result = runHookInClaudeCodeSubprocess(
|
||||
{
|
||||
session_id: 'e7123e54-0977-45dd-848a-b9c8a45a5cd3',
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' },
|
||||
},
|
||||
{ CLAUDE_CODE_ENTRYPOINT: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_PROJECT_DIR: '' },
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(
|
||||
result.stdout,
|
||||
'',
|
||||
'advisory must not fire when session_id is present on stdin (real Claude Code hook env)',
|
||||
);
|
||||
});
|
||||
|
||||
test('skips advisory when CLAUDE_CODE_ENTRYPOINT is set (env-var fallback, no session_id on stdin)', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
const result = runHookInClaudeCodeSubprocess(
|
||||
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
||||
{ CLAUDE_CODE_ENTRYPOINT: 'cli', CLAUDE_CODE_SSE_PORT: '' },
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDE_CODE_ENTRYPOINT is set');
|
||||
});
|
||||
|
||||
test('still injects advisory when no Claude Code signal is present (non-Claude host)', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
const result = runHookInClaudeCodeSubprocess(
|
||||
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
||||
{ CLAUDE_CODE_ENTRYPOINT: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_PROJECT_DIR: '' },
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.ok(result.stdout.length > 0, 'advisory should fire on non-Claude-Code hosts');
|
||||
const output = JSON.parse(result.stdout);
|
||||
assert.ok(output.hookSpecificOutput?.additionalContext?.includes('Read'));
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: structural-regression-guard
|
||||
|
||||
/**
|
||||
* Slash-command namespace invariant (#3443) — SCOPED ACTIVE VARIANT.
|
||||
*
|
||||
* History:
|
||||
* #3443 re-establishes `/gsd:<cmd>` as canonical in Claude-facing source text.
|
||||
* The source repo is authored for Claude command registration under
|
||||
* `.claude/commands/gsd/` (namespaced slash commands), while non-Claude runtimes
|
||||
* perform install-time conversion (for example `/gsd:<cmd>` -> `/gsd-<cmd>`).
|
||||
*
|
||||
* Two-tier model (current — see CONTEXT.md § "Slash-command form: directory-level matrix"):
|
||||
* • Claude-facing SOURCE TEXT (commands/, agents/, workflows/, references/,
|
||||
* templates/, hooks/, .clinerules): uses `/gsd:<cmd>` (colon).
|
||||
* THIS test enforces the colon invariant over those directories.
|
||||
* • Runtime-emitter contexts (runtime-slash.cjs, phase-lifecycle-policy.ts,
|
||||
* *.generated.cjs, bug-3584 test file): use `/gsd-<cmd>` (hyphen) per
|
||||
* bug-3584's contract. Those files are EXCLUDED from this scan.
|
||||
*
|
||||
* Scoped invariant enforced here:
|
||||
* No `/gsd-<cmd>` pattern in Claude-facing source files, EXCLUDING the
|
||||
* runtime-emitter contexts listed in RUNTIME_EMITTER_EXCLUDES below.
|
||||
*
|
||||
* Canonical reference for the runtime-emitter (hyphen-form) contract:
|
||||
* tests/bug-3584-runtime-slash-emitters.test.cjs
|
||||
*
|
||||
* DO NOT expand RUNTIME_EMITTER_EXCLUDES without also updating the bug-3584
|
||||
* test and CONTEXT.md § "Slash-command form: directory-level matrix".
|
||||
*
|
||||
* See also: PR #154 first-pass incident (agent applied outdated invariant,
|
||||
* broke bug-3584 contract); PR #164 Codex adversarial review (surfaced the
|
||||
* need to re-activate this test with explicit exclusions).
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
// Runtime-emitter contexts: these files intentionally emit `/gsd-<cmd>` (hyphen)
|
||||
// as part of the bug-3584 runtime contract. They must NOT be scanned by this
|
||||
// invariant — doing so caused PR #154 first-pass to revert correct hyphen form
|
||||
// to colon form, breaking bug-3584-runtime-slash-emitters.test.cjs.
|
||||
//
|
||||
// Expand this list only if a new runtime-emitter module is introduced AND the
|
||||
// bug-3584 test is updated to cover it.
|
||||
|
||||
const SEARCH_DIRS = [
|
||||
// NOTE: gsd-core/bin/lib is intentionally EXCLUDED from SEARCH_DIRS.
|
||||
// runtime-slash.cjs and *.generated.cjs live there and use the hyphen form
|
||||
// per bug-3584's runtime-emitter contract. The full bin/lib tree is
|
||||
// runtime-emitter territory — scanning it would cause false positives.
|
||||
path.join(ROOT, 'gsd-core', 'workflows'),
|
||||
path.join(ROOT, 'gsd-core', 'references'),
|
||||
path.join(ROOT, 'gsd-core', 'templates'),
|
||||
COMMANDS_DIR,
|
||||
path.join(ROOT, 'agents'),
|
||||
path.join(ROOT, 'hooks'),
|
||||
];
|
||||
|
||||
const TOP_LEVEL_FILES = [
|
||||
path.join(ROOT, '.clinerules'),
|
||||
];
|
||||
|
||||
// Re-use SKIP_DIRS from the production script so the test's directory walker
|
||||
// stays in lockstep with the fixer's. EXTENSIONS legitimately diverges (the
|
||||
// guard scans only `.md`/`.cjs`/`.js` per the no-source-grep standard, while
|
||||
// the fixer also rewrites `.ts`/`.tsx`), so it is not shared.
|
||||
const { SKIP_DIRS } = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
|
||||
|
||||
const EXTENSIONS = new Set(['.md', '.cjs', '.js']);
|
||||
|
||||
function collectFiles(dir, results = []) {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (SKIP_DIRS.has(e.name)) continue;
|
||||
collectFiles(full, results);
|
||||
}
|
||||
else if (EXTENSIONS.has(path.extname(e.name))) results.push(full);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const cmdNames = fs.readdirSync(COMMANDS_DIR)
|
||||
.filter(f => f.endsWith('.md'))
|
||||
.map(f => f.replace(/\.md$/, ''))
|
||||
.sort((a, b) => b.length - a.length);
|
||||
|
||||
const retiredPattern = new RegExp(`/gsd-(${cmdNames.join('|')})(?=[^a-zA-Z0-9_-]|$)`);
|
||||
|
||||
const allFiles = SEARCH_DIRS.flatMap(d => collectFiles(d));
|
||||
const topLevelFiles = TOP_LEVEL_FILES.filter((file) => fs.existsSync(file));
|
||||
const allUserFacingFiles = allFiles.concat(topLevelFiles);
|
||||
|
||||
describe('slash-command namespace invariant (#3443)', () => {
|
||||
test('commands/gsd/ directory contains known command files', () => {
|
||||
assert.ok(cmdNames.length > 0, 'commands/gsd/ must contain .md files');
|
||||
assert.ok(cmdNames.includes('plan-phase'), 'plan-phase must be a known command');
|
||||
assert.ok(cmdNames.includes('execute-phase'), 'execute-phase must be a known command');
|
||||
});
|
||||
|
||||
// SCOPED ACTIVE INVARIANT (2026-05-23 re-activation after Codex adversarial review of PR #164).
|
||||
//
|
||||
// Scan is scoped to Claude-facing source directories only (SEARCH_DIRS above).
|
||||
// gsd-core/bin/lib/ is excluded entirely — runtime-slash.cjs and
|
||||
// *.generated.cjs there use hyphen form per bug-3584's runtime-emitter contract.
|
||||
//
|
||||
// If this test fails: check CONTEXT.md § "Slash-command form: directory-level matrix"
|
||||
// before deciding whether to update the file or add to RUNTIME_EMITTER_EXCLUDES.
|
||||
test('no /gsd-<cmd> retired syntax in Claude-facing source files (scoped — excludes runtime-emitter contexts)', () => {
|
||||
const violations = [];
|
||||
for (const file of allUserFacingFiles) {
|
||||
const src = fs.readFileSync(file, 'utf-8');
|
||||
const lines = src.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (retiredPattern.test(lines[i])) {
|
||||
violations.push(`${path.relative(ROOT, file)}:${i + 1}: ${lines[i].trim().slice(0, 80)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.strictEqual(
|
||||
violations.length,
|
||||
0,
|
||||
`Found ${violations.length} retired /gsd-<cmd> reference(s) — use /gsd:<cmd> instead:\n${violations.slice(0, 10).join('\n')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('command filenames use canonical hyphenated command slugs', () => {
|
||||
const underscoreFiles = fs.readdirSync(COMMANDS_DIR)
|
||||
.filter((f) => f.endsWith('.md') && f.includes('_'));
|
||||
assert.deepStrictEqual(
|
||||
underscoreFiles,
|
||||
[],
|
||||
'command filenames feed generated skill/autocomplete names and must not contain underscores',
|
||||
);
|
||||
});
|
||||
|
||||
describe('fix-slash-commands transformer behavior', () => {
|
||||
const { transformContent } = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
|
||||
// Use the live command names so the transformer matches the same surface
|
||||
// the production CLI rewrites.
|
||||
const liveCmdNames = cmdNames;
|
||||
|
||||
test('rewrites /gsd-<cmd> to /gsd:<cmd>', () => {
|
||||
const out = transformContent('See /gsd-plan-phase for details.', liveCmdNames);
|
||||
assert.ok(out.includes('/gsd:plan-phase'), `expected /gsd:plan-phase, got: ${out}`);
|
||||
assert.ok(!out.includes('/gsd-plan-phase'), `dash form must not survive, got: ${out}`);
|
||||
});
|
||||
|
||||
test('rewrites multiple occurrences in one pass', () => {
|
||||
const out = transformContent('Run /gsd-plan-phase then /gsd-execute-phase.', liveCmdNames);
|
||||
assert.ok(out.includes('/gsd:plan-phase'));
|
||||
assert.ok(out.includes('/gsd:execute-phase'));
|
||||
assert.ok(!out.match(/\/gsd-[a-z]/), `no dash form may remain, got: ${out}`);
|
||||
});
|
||||
|
||||
test('does not rewrite canonical colon form (idempotent)', () => {
|
||||
const input = '/gsd:plan-phase is the canonical name.';
|
||||
assert.strictEqual(transformContent(input, liveCmdNames), input,
|
||||
'transformer must be a no-op when input is already canonical');
|
||||
});
|
||||
|
||||
test('does not rewrite gsd-sdk or gsd-tools (not slash commands)', () => {
|
||||
const input = 'Run /gsd-sdk query and /gsd-tools init.';
|
||||
assert.strictEqual(transformContent(input, liveCmdNames), input,
|
||||
'transformer must leave non-command identifiers alone');
|
||||
});
|
||||
|
||||
test('respects word boundary — does not rewrite /gsd-plan-phase-extra', () => {
|
||||
const out = transformContent('/gsd-plan-phase-extra', liveCmdNames);
|
||||
assert.strictEqual(out, '/gsd-plan-phase-extra',
|
||||
'word-boundary lookahead must prevent partial matches');
|
||||
});
|
||||
});
|
||||
|
||||
test('transformer leaves non-command identifiers untouched', () => {
|
||||
const { transformContent } = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
|
||||
const sample = 'Use /gsd-sdk query and node bin/gsd-tools.cjs';
|
||||
assert.strictEqual(
|
||||
transformContent(sample, cmdNames),
|
||||
sample,
|
||||
'gsd-sdk and gsd-tools are not slash commands and must remain untouched'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,660 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #260 — gsd-worktree-path-guard.js
|
||||
*
|
||||
* Executor agents spawned with isolation="worktree" sometimes issue Edit/Write
|
||||
* calls with absolute paths rooted at the MAIN repository instead of the
|
||||
* worktree. The prose guard in gsd-executor.md step 0b is skipped under load,
|
||||
* so we enforce the constraint at the tooling layer with a PreToolUse hook.
|
||||
*
|
||||
* This file verifies all guard behaviours:
|
||||
* 1. No-op in the main repo (.git is a directory)
|
||||
* 2. Relative path always passes
|
||||
* 3. Non-Edit/Write tools always pass
|
||||
* 4. Absolute path inside worktree root passes
|
||||
* 5. Absolute path outside worktree root is BLOCKED (exit 2)
|
||||
* 6. Sibling path that merely shares a prefix is BLOCKED (/ boundary check)
|
||||
* 7. install.js has an fs.existsSync guard for gsd-worktree-path-guard.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync, execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-worktree-path-guard.js');
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts.
|
||||
const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts');
|
||||
|
||||
/**
|
||||
* Resolve symlinks in a path so that we compare the same canonical form
|
||||
* that `git rev-parse --show-toplevel` returns. On macOS /tmp is a symlink
|
||||
* to /private/tmp, which causes path prefix checks to fail without this.
|
||||
*/
|
||||
function realp(p) {
|
||||
try { return fs.realpathSync(p); } catch { return p; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function git(cwd, args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a plain git repo (main repo — .git is a directory).
|
||||
*/
|
||||
function makeMainRepo() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-260-main-'));
|
||||
git(dir, ['init', '-q']);
|
||||
git(dir, ['config', 'user.email', 'test@example.com']);
|
||||
git(dir, ['config', 'user.name', 'Test User']);
|
||||
git(dir, ['config', 'commit.gpgsign', 'false']);
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
|
||||
git(dir, ['add', 'README.md']);
|
||||
git(dir, ['commit', '-q', '-m', 'chore: init']);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a worktree off mainRepo and return its path.
|
||||
* In the worktree, .git is a FILE (the gitdir pointer).
|
||||
* @param {string} mainRepo - path to the main repo
|
||||
* @param {string} [branchName] - branch name to use (default: 'worktree-agent-test')
|
||||
*/
|
||||
function makeWorktree(mainRepo, branchName) {
|
||||
const branch = branchName || 'worktree-agent-test';
|
||||
const wtDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-260-wt-'));
|
||||
fs.rmdirSync(wtDir); // git worktree add creates the dir itself
|
||||
git(mainRepo, ['worktree', 'add', '-q', '-b', branch, wtDir]);
|
||||
return wtDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the hook with a given payload, returning the spawnSync result.
|
||||
*/
|
||||
function runHook(cwd, payload) {
|
||||
return spawnSync(process.execPath, [HOOK_PATH], {
|
||||
cwd,
|
||||
input: JSON.stringify(payload),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let mainRepo;
|
||||
let worktreeDir;
|
||||
|
||||
before(() => {
|
||||
mainRepo = realp(makeMainRepo());
|
||||
worktreeDir = realp(makeWorktree(mainRepo));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
// Remove worktree registration before deleting the directory
|
||||
try { git(mainRepo, ['worktree', 'remove', '--force', worktreeDir]); } catch { /* ignore */ }
|
||||
cleanup(mainRepo);
|
||||
cleanup(worktreeDir);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #260: gsd-worktree-path-guard.js', () => {
|
||||
|
||||
// 1. No-op in main repo
|
||||
describe('no-op in main repo', () => {
|
||||
test('Edit call in main repo (.git is a directory) exits 0', () => {
|
||||
const payload = {
|
||||
cwd: mainRepo,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(mainRepo, 'src', 'foo.ts') },
|
||||
};
|
||||
const result = runHook(mainRepo, payload);
|
||||
assert.strictEqual(result.status, 0, `Expected exit 0 in main repo, got ${result.status}. stderr: ${result.stderr}`);
|
||||
assert.strictEqual(result.stdout, '', 'Expected no stdout in main repo no-op');
|
||||
});
|
||||
|
||||
test('Write call in main repo exits 0', () => {
|
||||
const payload = {
|
||||
cwd: mainRepo,
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: path.join(mainRepo, 'out.txt') },
|
||||
};
|
||||
const result = runHook(mainRepo, payload);
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Relative path always passes
|
||||
describe('relative path', () => {
|
||||
test('Edit with relative file_path exits 0 even in worktree', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: 'src/foo.ts' },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0, `Relative path should always pass. stderr: ${result.stderr}`);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
});
|
||||
|
||||
test('Write with relative file_path exits 0 in worktree', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: 'dist/bundle.js' },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Non-Edit/Write tools always pass
|
||||
describe('non-Edit/Write tools', () => {
|
||||
test('Bash tool exits 0', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'ls' },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0);
|
||||
});
|
||||
|
||||
test('Read tool exits 0', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: path.join(mainRepo, 'README.md') },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0);
|
||||
});
|
||||
|
||||
test('Grep tool exits 0', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Grep',
|
||||
tool_input: { pattern: 'foo', path: mainRepo },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// 4. Absolute path inside worktree passes
|
||||
describe('path inside worktree', () => {
|
||||
test('Edit with absolute path inside worktree root exits 0', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(worktreeDir, 'src', 'foo.ts') },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0, `Path inside worktree should pass. stderr: ${result.stderr}`);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
});
|
||||
|
||||
test('Edit targeting exactly the worktree root exits 0', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: worktreeDir },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// 5. Absolute path outside worktree is BLOCKED
|
||||
describe('path outside worktree is blocked', () => {
|
||||
test('Edit targeting main repo root exits 2 with block decision', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(mainRepo, 'src', 'index.ts') },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 2, `Expected exit 2 (block), got ${result.status}. stderr: ${result.stderr}`);
|
||||
let parsed;
|
||||
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); }, 'stdout must be valid JSON');
|
||||
assert.strictEqual(parsed.decision, 'block', 'Expected decision:"block" in output');
|
||||
});
|
||||
|
||||
test('Write targeting main repo root exits 2 with block decision', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: path.join(mainRepo, 'out.txt') },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 2);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.decision, 'block');
|
||||
});
|
||||
|
||||
test('block output includes the offending path in reason', () => {
|
||||
const offendingPath = path.join(mainRepo, 'src', 'leak.ts');
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: offendingPath },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 2);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.ok(
|
||||
parsed.reason && parsed.reason.includes(offendingPath),
|
||||
`block reason should include the offending path. Got: ${parsed.reason}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 6. Sibling directory path is BLOCKED (validates the '/' boundary check AND prefix-overlap)
|
||||
describe('sibling path is blocked', () => {
|
||||
test('path that shares prefix with worktree root but is a sibling exits 2', () => {
|
||||
// This test exercises BOTH the prefix-overlap boundary check AND the different-git-root block:
|
||||
// worktree = <base>/wt
|
||||
// sibling = <base>/wt-sibling ← shares "wt" prefix with the worktree root
|
||||
// target = <base>/wt-sibling/file.ts
|
||||
//
|
||||
// A naive startsWith(wtRoot) check would wrongly classify "<base>/wt-sibling/..." as inside
|
||||
// the worktree (it doesn't include the '/' boundary). The hook resolves the sibling's git
|
||||
// toplevel (a different repo) so the different-git-root block fires regardless.
|
||||
// (#1342: paths outside all git repos now fail open; only different-git-root blocks.)
|
||||
const base = realp(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-260-sib-base-')));
|
||||
const wtDir = path.join(base, 'wt');
|
||||
const siblingRepoDir = path.join(base, 'wt-sibling');
|
||||
// We need a genuine linked worktree at <base>/wt and a separate git repo at <base>/wt-sibling.
|
||||
// Create a fresh main repo to host this worktree (the fixture worktree is already allocated).
|
||||
const sibMainRepo = realp(makeMainRepo());
|
||||
try {
|
||||
fs.mkdirSync(base, { recursive: true });
|
||||
// Create linked worktree at <base>/wt (using sibMainRepo as its host).
|
||||
git(sibMainRepo, ['worktree', 'add', '-q', '-b', 'worktree-agent-sib-test', wtDir]);
|
||||
// Create a separate git repo at <base>/wt-sibling (shares "wt" prefix).
|
||||
fs.mkdirSync(siblingRepoDir, { recursive: true });
|
||||
git(siblingRepoDir, ['init', '-q']);
|
||||
git(siblingRepoDir, ['config', 'user.email', 'test@example.com']);
|
||||
git(siblingRepoDir, ['config', 'user.name', 'Test User']);
|
||||
git(siblingRepoDir, ['config', 'commit.gpgsign', 'false']);
|
||||
fs.writeFileSync(path.join(siblingRepoDir, 'README.md'), '# sibling\n');
|
||||
git(siblingRepoDir, ['add', 'README.md']);
|
||||
git(siblingRepoDir, ['commit', '-q', '-m', 'chore: sibling init']);
|
||||
|
||||
// Confirm prefix-overlap: siblingRepoDir starts with wtDir (without trailing sep).
|
||||
assert.ok(
|
||||
siblingRepoDir.startsWith(wtDir),
|
||||
`Sibling "${siblingRepoDir}" must share a string prefix with worktree "${wtDir}" for this test to be meaningful`
|
||||
);
|
||||
// Confirm they are genuinely distinct (different toplevel).
|
||||
assert.notStrictEqual(
|
||||
realp(siblingRepoDir), realp(wtDir),
|
||||
'sibling and worktree must be different directories'
|
||||
);
|
||||
|
||||
const siblingPath = path.join(realp(siblingRepoDir), 'file.ts');
|
||||
const payload = {
|
||||
cwd: realp(wtDir),
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: siblingPath },
|
||||
};
|
||||
const result = runHook(realp(wtDir), payload);
|
||||
assert.strictEqual(result.status, 2,
|
||||
`Path inside a prefix-sibling git repo "${siblingPath}" must be blocked (exit 2), got ${result.status}. ` +
|
||||
`This validates both the prefix-overlap boundary and the different-git-root block. stderr: ${result.stderr}`
|
||||
);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.decision, 'block');
|
||||
} finally {
|
||||
try { git(sibMainRepo, ['worktree', 'remove', '--force', wtDir]); } catch { /* ignore */ }
|
||||
cleanup(sibMainRepo);
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 7. Adversarial: subdirectory cwd still guards correctly (Codex finding #2)
|
||||
describe('subdirectory cwd', () => {
|
||||
test('hook fires when cwd is a subdirectory of the worktree, not just its root', () => {
|
||||
// The orchestrator may set cwd to a subdirectory. The hook must still
|
||||
// detect the worktree context via git rev-parse --git-dir and block.
|
||||
const subDir = path.join(worktreeDir, 'src');
|
||||
fs.mkdirSync(subDir, { recursive: true });
|
||||
const payload = {
|
||||
cwd: subDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(mainRepo, 'src', 'index.ts') },
|
||||
};
|
||||
const result = runHook(subDir, payload);
|
||||
assert.strictEqual(result.status, 2,
|
||||
`Hook must block even when cwd is a subdirectory of the worktree. ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.decision, 'block');
|
||||
});
|
||||
|
||||
test('path inside worktree passes even when cwd is a subdirectory', () => {
|
||||
const subDir = path.join(worktreeDir, 'src');
|
||||
fs.mkdirSync(subDir, { recursive: true });
|
||||
const payload = {
|
||||
cwd: subDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(worktreeDir, 'src', 'foo.ts') },
|
||||
};
|
||||
const result = runHook(subDir, payload);
|
||||
assert.strictEqual(result.status, 0,
|
||||
`Absolute path inside worktree should pass regardless of cwd. ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 8. Adversarial: `..` traversal is normalised before the containment check (Codex finding #1)
|
||||
describe('dot-dot traversal is blocked', () => {
|
||||
test('path with .. that escapes the worktree is blocked', () => {
|
||||
// Construct the traversal target inside a SEPARATE git repo that is
|
||||
// guaranteed to be outside the worktree on every platform (no symlink
|
||||
// ambiguity). The hook finds the external dir's git toplevel (a different
|
||||
// repo → different-git-root block).
|
||||
// (#1342: paths outside all git repos now fail open; only different-git-root blocks,
|
||||
// so externalDir must be inside a real different git repo to exercise the block.)
|
||||
const externalDir = realp(makeMainRepo());
|
||||
try {
|
||||
// Sanity: the external directory must not be inside the worktree.
|
||||
assert.ok(
|
||||
!externalDir.startsWith(worktreeDir + path.sep) && externalDir !== worktreeDir,
|
||||
`externalDir "${externalDir}" must be outside worktreeDir "${worktreeDir}"`
|
||||
);
|
||||
|
||||
// Build a traversal path that uses ../ segments to climb out of the
|
||||
// worktree and into externalDir. path.resolve() will normalise it to
|
||||
// externalDir/file.ts, which is outside the worktree by construction.
|
||||
// We compute the number of segments needed to reach the filesystem root
|
||||
// from worktreeDir so the traversal always lands at the right level
|
||||
// regardless of how deep the worktree path is.
|
||||
// Build a file_path containing literal `..` segments that climb out of the
|
||||
// worktree into externalDir. path.relative() yields a ..-laden relative path
|
||||
// between two same-drive absolute paths (both live under os.tmpdir()); we
|
||||
// re-anchor it at worktreeDir via STRING CONCAT (NOT path.join, which would
|
||||
// normalise the `..` away) so the hook's path.resolve() must collapse it.
|
||||
// Windows-safe: avoids the drive-letter doubling that
|
||||
// path.join(worktreeDir, '..', absolutePath) produces on win32 (#1342).
|
||||
const externalTarget = path.join(externalDir, 'file.ts');
|
||||
const traversalPath = worktreeDir + path.sep + path.relative(worktreeDir, externalTarget);
|
||||
|
||||
// Confirm the resolved path is truly outside the worktree (test integrity guard).
|
||||
const resolved = path.resolve(traversalPath);
|
||||
assert.ok(
|
||||
!resolved.startsWith(worktreeDir + path.sep) && resolved !== worktreeDir,
|
||||
`Traversal resolved to "${resolved}" which is still inside worktreeDir "${worktreeDir}". ` +
|
||||
`This means the test itself is broken, not a production bug.`
|
||||
);
|
||||
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: traversalPath },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 2,
|
||||
`Traversal path "${traversalPath}" resolves to "${resolved}" which is outside the worktree. ` +
|
||||
`Must be blocked (exit 2). Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.decision, 'block',
|
||||
`Expected decision:"block", got: ${JSON.stringify(parsed)}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(externalDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 9. MultiEdit is also guarded (Codex finding #5)
|
||||
describe('MultiEdit tool is guarded', () => {
|
||||
test('MultiEdit with outside absolute path is blocked', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: { file_path: path.join(mainRepo, 'src', 'index.ts') },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 2,
|
||||
`MultiEdit targeting outside path must be blocked. Got ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.decision, 'block');
|
||||
});
|
||||
|
||||
test('MultiEdit with inside absolute path passes', () => {
|
||||
const payload = {
|
||||
cwd: worktreeDir,
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: { file_path: path.join(worktreeDir, 'src', 'foo.ts') },
|
||||
};
|
||||
const result = runHook(worktreeDir, payload);
|
||||
assert.strictEqual(result.status, 0,
|
||||
`MultiEdit inside worktree should pass. Got ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1342 — GSD-activity gate + fail-open for no-repo targets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#1342 — GSD-activity gate + fail-open for no-repo targets', () => {
|
||||
// Fixtures: one non-agent linked worktree (plain user branch) + one agent worktree
|
||||
let mainRepo1342;
|
||||
let nonAgentWorktree; // on branch 'feature-x' — non-GSD
|
||||
let agentWorktree; // on branch 'worktree-agent-foo' — GSD-managed
|
||||
|
||||
before(() => {
|
||||
mainRepo1342 = realp(makeMainRepo());
|
||||
nonAgentWorktree = realp(makeWorktree(mainRepo1342, 'feature-x'));
|
||||
agentWorktree = realp(makeWorktree(mainRepo1342, 'worktree-agent-foo'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try { git(mainRepo1342, ['worktree', 'remove', '--force', nonAgentWorktree]); } catch { /* ignore */ }
|
||||
try { git(mainRepo1342, ['worktree', 'remove', '--force', agentWorktree]); } catch { /* ignore */ }
|
||||
cleanup(mainRepo1342);
|
||||
cleanup(nonAgentWorktree);
|
||||
cleanup(agentWorktree);
|
||||
});
|
||||
|
||||
// Test 1 — reporter repro: non-agent worktree writing outside all git repos → exit 0
|
||||
test('(1) non-agent linked worktree: Write to a path outside all git repos exits 0 (no block)', () => {
|
||||
// Simulates Claude Code plan-mode writing ~/.claude/plans/<slug>.md from a
|
||||
// manually-created linked worktree that is NOT on a worktree-agent-* branch.
|
||||
const plansDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1342-plans-'));
|
||||
try {
|
||||
const targetPath = path.join(plansDir, 'my-plan.md');
|
||||
const payload = {
|
||||
cwd: nonAgentWorktree,
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: targetPath },
|
||||
};
|
||||
const result = runHook(nonAgentWorktree, payload);
|
||||
assert.strictEqual(result.status, 0,
|
||||
`Non-agent linked worktree writing outside git repos must exit 0 (reporter repro). ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
assert.strictEqual(result.stdout, '', 'Expected no block output');
|
||||
} finally {
|
||||
cleanup(plansDir);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 2 — non-agent linked worktree: Edit targeting MAIN repo root → exit 0 (gate no-op)
|
||||
test('(2) non-agent linked worktree: Edit targeting main repo root exits 0 (gate no-op, not #260 block)', () => {
|
||||
const payload = {
|
||||
cwd: nonAgentWorktree,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(mainRepo1342, 'src', 'index.ts') },
|
||||
};
|
||||
const result = runHook(nonAgentWorktree, payload);
|
||||
assert.strictEqual(result.status, 0,
|
||||
`Non-agent linked worktree must exit 0 (GSD-activity gate fires before #260 check). ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
assert.strictEqual(result.stdout, '', 'Expected no block output');
|
||||
});
|
||||
|
||||
// Test 3 — GSD-managed worktree (worktree-agent-foo): Edit targeting main repo root → exit 2 (block)
|
||||
test('(3) GSD-managed worktree: Edit targeting main repo root exits 2 with block decision', () => {
|
||||
const payload = {
|
||||
cwd: agentWorktree,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(mainRepo1342, 'src', 'index.ts') },
|
||||
};
|
||||
const result = runHook(agentWorktree, payload);
|
||||
assert.strictEqual(result.status, 2,
|
||||
`GSD-managed worktree targeting main repo root must be blocked (exit 2). ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
let parsed;
|
||||
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); }, 'stdout must be valid JSON');
|
||||
assert.strictEqual(parsed.decision, 'block', 'Expected decision:"block" in output');
|
||||
});
|
||||
|
||||
// Test 4 — GSD-managed worktree: absolute target INSIDE the active worktree → exit 0
|
||||
test('(4) GSD-managed worktree: absolute target inside the active worktree exits 0', () => {
|
||||
const payload = {
|
||||
cwd: agentWorktree,
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: path.join(agentWorktree, 'src', 'foo.ts') },
|
||||
};
|
||||
const result = runHook(agentWorktree, payload);
|
||||
assert.strictEqual(result.status, 0,
|
||||
`GSD-managed worktree targeting its own subtree must pass. ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
assert.strictEqual(result.stdout, '', 'Expected no block output');
|
||||
});
|
||||
|
||||
// Test 5 — GSD-managed worktree: target OUTSIDE all git repos (tmpdir) → exit 0 (fail open)
|
||||
test('(5) GSD-managed worktree: target outside all git repos exits 0 (fail open, not #260 vector)', () => {
|
||||
// Create a temp dir that is NOT a git repository (no .git).
|
||||
// This is the ~/.claude/plans/ scenario — a path that has a real ancestor
|
||||
// directory but is outside every git repo.
|
||||
// IMPORTANT: this dir must NOT be inside any .git directory — it must be a plain tempdir
|
||||
// so the fail-open path (truly outside all repos) is exercised, not the .git-internals block.
|
||||
const externalDir = realp(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1342-ext-')));
|
||||
try {
|
||||
const targetPath = path.join(externalDir, 'notes.md');
|
||||
const payload = {
|
||||
cwd: agentWorktree,
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: targetPath },
|
||||
};
|
||||
const result = runHook(agentWorktree, payload);
|
||||
assert.strictEqual(result.status, 0,
|
||||
`GSD-managed worktree writing to a path outside all git repos must fail open (exit 0). ` +
|
||||
`Only the different-git-root vector (#260) blocks; no-repo targets are not that vector. ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
assert.strictEqual(result.stdout, '', 'Expected no block output');
|
||||
} finally {
|
||||
cleanup(externalDir);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 6 — GSD-managed worktree: Write to .git/config of the MAIN repo → exit 2 (block)
|
||||
test('(6) blocks absolute writes into the main repo .git internals from a GSD worktree (#1342)', () => {
|
||||
// A target like /main-repo/.git/config or /main-repo/.git/hooks/pre-commit causes
|
||||
// `git rev-parse --show-toplevel` to FAIL (a .git dir is not a work tree), so the
|
||||
// "file not in any git repo" branch fires. Previously that branch failed open — but
|
||||
// writing into repository internals via an absolute path is still a #260-class escape
|
||||
// (and dangerous, e.g. injecting a git hook). The fix checks --is-inside-git-dir and
|
||||
// blocks when true.
|
||||
const gitConfigPath = path.join(mainRepo1342, '.git', 'config');
|
||||
const payload = {
|
||||
cwd: agentWorktree,
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: gitConfigPath },
|
||||
};
|
||||
const result = runHook(agentWorktree, payload);
|
||||
assert.strictEqual(result.status, 2,
|
||||
`GSD-managed worktree targeting .git/config of another repo must be blocked (exit 2). ` +
|
||||
`Got exit ${result.status}. stderr: ${result.stderr}`
|
||||
);
|
||||
let parsed;
|
||||
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); }, 'stdout must be valid JSON');
|
||||
assert.strictEqual(parsed.decision, 'block', 'Expected decision:"block" in output');
|
||||
assert.ok(
|
||||
parsed.reason && parsed.reason.includes('.git'),
|
||||
`Block reason should mention .git internals. Got: ${parsed.reason}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static analysis: install.js guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('install.js guard for gsd-worktree-path-guard.js', () => {
|
||||
let src;
|
||||
|
||||
before(() => {
|
||||
// ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts.
|
||||
// Concatenate both sources so structural assertions find patterns in either file.
|
||||
const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8');
|
||||
let hooksSurfaceSrc = '';
|
||||
try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ }
|
||||
src = installSrc + '\n' + hooksSurfaceSrc;
|
||||
});
|
||||
|
||||
test('install.js has hasWorktreePathGuardHook variable', () => {
|
||||
assert.ok(
|
||||
src.includes('hasWorktreePathGuardHook'),
|
||||
'hasWorktreePathGuardHook variable not found in install.js'
|
||||
);
|
||||
});
|
||||
|
||||
test('install.js checks fs.existsSync before registering gsd-worktree-path-guard.js', () => {
|
||||
const anchorIdx = src.indexOf('hasWorktreePathGuardHook');
|
||||
assert.ok(anchorIdx !== -1, 'hasWorktreePathGuardHook not found in install.js');
|
||||
|
||||
const blockStart = anchorIdx;
|
||||
const blockEnd = Math.min(src.length, anchorIdx + 1200);
|
||||
const block = src.slice(blockStart, blockEnd);
|
||||
|
||||
assert.ok(
|
||||
block.includes('fs.existsSync') || block.includes('existsSync'),
|
||||
'install.js must call fs.existsSync on the target path before registering ' +
|
||||
'gsd-worktree-path-guard.js in settings.json. Without this guard, the hook ' +
|
||||
'is registered even when the .js file was never copied (root cause of #1754).'
|
||||
);
|
||||
});
|
||||
|
||||
test('install.js emits a skip warning when gsd-worktree-path-guard.js is missing', () => {
|
||||
const anchorIdx = src.indexOf('hasWorktreePathGuardHook');
|
||||
assert.ok(anchorIdx !== -1, 'hasWorktreePathGuardHook not found in install.js');
|
||||
|
||||
const block = src.slice(anchorIdx, Math.min(src.length, anchorIdx + 1200));
|
||||
|
||||
assert.ok(
|
||||
block.includes('Skipped') && block.includes('gsd-worktree-path-guard'),
|
||||
'install.js must emit a skip warning mentioning gsd-worktree-path-guard when the file is not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-workflow-guard.js');
|
||||
|
||||
function git(cwd, args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
}
|
||||
|
||||
function makeRepo(branch) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-bug-261-'));
|
||||
git(dir, ['init', '-q']);
|
||||
git(dir, ['config', 'user.email', 'test@example.com']);
|
||||
git(dir, ['config', 'user.name', 'Test User']);
|
||||
git(dir, ['config', 'commit.gpgsign', 'false']);
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
|
||||
git(dir, ['add', 'README.md']);
|
||||
git(dir, ['commit', '-q', '-m', 'chore: init']);
|
||||
git(dir, ['checkout', '-q', '-b', branch]);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function setWorkflowGuard(dir, enabled) {
|
||||
const planningDir = path.join(dir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ hooks: { workflow_guard: enabled } }, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
function runHookInput(cwd, input) {
|
||||
return spawnSync(process.execPath, [HOOK_PATH], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
input: JSON.stringify({ cwd, ...input }),
|
||||
});
|
||||
}
|
||||
|
||||
function runBashHook(cwd, command) {
|
||||
return runHookInput(cwd, {
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command },
|
||||
});
|
||||
}
|
||||
|
||||
describe('bug #261: workflow guard blocks forced git add on worktree-agent branches', () => {
|
||||
test('blocks git add -f on worktree-agent branch when workflow guard is enabled', () => {
|
||||
const dir = makeRepo('worktree-agent-a1');
|
||||
try {
|
||||
setWorkflowGuard(dir, true);
|
||||
const result = runBashHook(dir, 'git add -f .planning/phases/01/01-01-SUMMARY.md');
|
||||
assert.strictEqual(result.status, 2);
|
||||
const envelope = JSON.parse(result.stdout);
|
||||
assert.strictEqual(envelope.decision, 'block');
|
||||
assert.strictEqual(envelope.code, 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('blocks git add --force with git global options on worktree-agent branch', () => {
|
||||
const dir = makeRepo('worktree-agent-b2');
|
||||
try {
|
||||
setWorkflowGuard(dir, true);
|
||||
const result = runBashHook(dir, `git -C "${dir}" add --force .planning/SUMMARY.md`);
|
||||
assert.strictEqual(result.status, 2);
|
||||
assert.strictEqual(JSON.parse(result.stdout).code, 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('allows ordinary git add on worktree-agent branch', () => {
|
||||
const dir = makeRepo('worktree-agent-c3');
|
||||
try {
|
||||
setWorkflowGuard(dir, true);
|
||||
const result = runBashHook(dir, 'git add .planning/SUMMARY.md');
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('allows pathspecs named like force flags after git add -- terminator', () => {
|
||||
const dir = makeRepo('worktree-agent-d4');
|
||||
try {
|
||||
setWorkflowGuard(dir, true);
|
||||
const result = runBashHook(dir, 'git add -- -f');
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('allows git add -f outside worktree-agent branches', () => {
|
||||
const dir = makeRepo('feature-docs');
|
||||
try {
|
||||
setWorkflowGuard(dir, true);
|
||||
const result = runBashHook(dir, 'git add -f .planning/SUMMARY.md');
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('allows git add -f on worktree-agent branch when workflow guard is disabled', () => {
|
||||
const dir = makeRepo('worktree-agent-e5');
|
||||
try {
|
||||
setWorkflowGuard(dir, false);
|
||||
const result = runBashHook(dir, 'git add -f .planning/SUMMARY.md');
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('allows git add -f on worktree-agent branch when no GSD config exists', () => {
|
||||
const dir = makeRepo('worktree-agent-f6');
|
||||
try {
|
||||
const result = runBashHook(dir, 'git add -f .planning/SUMMARY.md');
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('applies the advisory path to MultiEdit when workflow guard is enabled', () => {
|
||||
const dir = makeRepo('feature-multiedit');
|
||||
try {
|
||||
setWorkflowGuard(dir, true);
|
||||
const result = runHookInput(dir, {
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: {
|
||||
file_path: path.join(dir, 'src.js'),
|
||||
edits: [],
|
||||
},
|
||||
});
|
||||
assert.strictEqual(result.status, 0);
|
||||
const envelope = JSON.parse(result.stdout);
|
||||
assert.match(
|
||||
envelope.hookSpecificOutput.additionalContext,
|
||||
/WORKFLOW ADVISORY/
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,568 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for #2772: worktree isolation is unconditionally disabled
|
||||
* when `.gitmodules` exists in the repo, even when the plan does not touch
|
||||
* any submodule path.
|
||||
*
|
||||
* Behavioral test: the bash decision pipeline from
|
||||
* gsd-core/workflows/execute-phase.md is extracted verbatim into an
|
||||
* executable snippet here, then run via execFileSync('bash', ...) against
|
||||
* real fixture projects built with `createTempGitProject()`. We assert
|
||||
* the resulting USE_WORKTREES_FOR_PLAN value (printed on the final line
|
||||
* of stdout) and the presence/absence of the [worktree] log line for each
|
||||
* scenario.
|
||||
*
|
||||
* If execute-phase.md's bash gate is ever rewritten so the extracted
|
||||
* snippet stops matching real behavior, this test must be updated to
|
||||
* track the new pipeline — never replaced with a source grep.
|
||||
*
|
||||
* In addition to the per-plan gate behavior, this file also asserts:
|
||||
* - The workflow markdown actually wires USE_WORKTREES_FOR_PLAN into
|
||||
* each of the four dispatch sites (worktree-mode gate, sequential-mode
|
||||
* gate, "worktrees disabled" prose, post-wave cleanup gate). Without
|
||||
* this, the per-plan computation would be dead code (the original
|
||||
* #2772 fix shipped in this state — CodeRabbit caught it).
|
||||
* - The quick.md executor prompt injects SUBMODULE_PATHS and a fail-loud
|
||||
* pre-commit guard, and the guard actually aborts when staged paths
|
||||
* fall inside a submodule.
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { createTempGitProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// Bash snippet extracted from execute-phase.md (the SUBMODULE_PATHS parse +
|
||||
// per-plan intersection logic with normalization + bidirectional matching).
|
||||
// Inputs come from env vars: PLAN_FILES (whitespace-separated) and plan_id.
|
||||
// Output: log lines on stdout, then a final line
|
||||
// `USE_WORKTREES_FOR_PLAN=<true|false>` for the test to parse.
|
||||
const GATE_SNIPPET = [
|
||||
'set -e',
|
||||
'USE_WORKTREES="${USE_WORKTREES:-true}"',
|
||||
'if [ -f .gitmodules ]; then',
|
||||
" SUBMODULE_PATHS=$(git config --file .gitmodules --get-regexp '^submodule\\..*\\.path$' 2>/dev/null | awk '{print $2}')",
|
||||
'else',
|
||||
' SUBMODULE_PATHS=""',
|
||||
'fi',
|
||||
'USE_WORKTREES_FOR_PLAN="$USE_WORKTREES"',
|
||||
'if [ -n "$SUBMODULE_PATHS" ] && [ "$USE_WORKTREES_FOR_PLAN" != "false" ]; then',
|
||||
' if [ -z "$PLAN_FILES" ]; then',
|
||||
' echo "[worktree] Plan ${plan_id}: files_modified missing/unparseable — disabling worktree isolation as a safety fallback (submodule project)"',
|
||||
' USE_WORKTREES_FOR_PLAN=false',
|
||||
' else',
|
||||
' INTERSECT=""',
|
||||
' set -f',
|
||||
' for sm_raw in $SUBMODULE_PATHS; do',
|
||||
' sm="${sm_raw#./}"',
|
||||
' sm="${sm%/}"',
|
||||
' [ -z "$sm" ] && continue',
|
||||
' for pf_raw in $PLAN_FILES; do',
|
||||
' pf="${pf_raw#./}"',
|
||||
' pf="${pf%/}"',
|
||||
' [ -z "$pf" ] && continue',
|
||||
' matched=0',
|
||||
' case "$pf" in',
|
||||
' "$sm"|"$sm"/*) matched=1 ;;',
|
||||
' esac',
|
||||
' if [ "$matched" -eq 0 ]; then',
|
||||
' case "$sm" in',
|
||||
' "$pf"|"$pf"/*) matched=1 ;;',
|
||||
' esac',
|
||||
' fi',
|
||||
' if [ "$matched" -eq 0 ]; then',
|
||||
' case "$pf" in',
|
||||
" *'*'*|*'?'*|*'['*)",
|
||||
' prefix="${pf%%[*?[]*}"',
|
||||
' prefix="${prefix%/}"',
|
||||
' if [ -n "$prefix" ]; then',
|
||||
' case "$sm" in',
|
||||
' "$prefix"|"$prefix"/*) matched=1 ;;',
|
||||
' esac',
|
||||
' if [ "$matched" -eq 0 ]; then',
|
||||
' case "$prefix" in',
|
||||
' "$sm"|"$sm"/*) matched=1 ;;',
|
||||
' esac',
|
||||
' fi',
|
||||
' fi',
|
||||
' ;;',
|
||||
' esac',
|
||||
' fi',
|
||||
' if [ "$matched" -eq 1 ]; then',
|
||||
' INTERSECT="$INTERSECT $pf_raw"',
|
||||
' fi',
|
||||
' done',
|
||||
' done',
|
||||
' set +f',
|
||||
' if [ -n "$INTERSECT" ]; then',
|
||||
' echo "[worktree] Plan ${plan_id}: planned paths intersect submodule paths (${INTERSECT# }) — disabling worktree isolation for this plan"',
|
||||
' USE_WORKTREES_FOR_PLAN=false',
|
||||
' fi',
|
||||
' fi',
|
||||
'fi',
|
||||
'echo "USE_WORKTREES_FOR_PLAN=$USE_WORKTREES_FOR_PLAN"',
|
||||
].join('\n');
|
||||
|
||||
function runGate(cwd, env) {
|
||||
const out = execFileSync('bash', ['-c', GATE_SNIPPET], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
const lines = out.trim().split('\n');
|
||||
const last = lines[lines.length - 1];
|
||||
const m = last.match(/^USE_WORKTREES_FOR_PLAN=(true|false)$/);
|
||||
assert.ok(
|
||||
m,
|
||||
`expected final line to be USE_WORKTREES_FOR_PLAN=<bool>, got: ${last}\nfull stdout:\n${out}`
|
||||
);
|
||||
return { decision: m[1], stdout: out, logLines: lines.slice(0, -1) };
|
||||
}
|
||||
|
||||
function writeGitmodulesWithSubmodule(repo, submodulePath) {
|
||||
const content = [
|
||||
`[submodule "${submodulePath}"]`,
|
||||
`\tpath = ${submodulePath}`,
|
||||
`\turl = https://example.invalid/${submodulePath}.git`,
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(repo, '.gitmodules'), content);
|
||||
}
|
||||
|
||||
describe('Submodule worktree-isolation gate intersects planned paths (#2772)', () => {
|
||||
let repo;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = createTempGitProject('gsd-test-2772-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(repo);
|
||||
});
|
||||
|
||||
test('plan touching only src/ in a submodule project keeps worktree isolation ENABLED', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, logLines } = runGate(repo, {
|
||||
PLAN_FILES: 'src/index.ts src/lib/util.ts',
|
||||
plan_id: 'plan-001',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'true');
|
||||
assert.equal(logLines.filter((l) => l.startsWith('[worktree]')).length, 0);
|
||||
});
|
||||
|
||||
test('plan touching vendor/foo/bar.ts in a submodule project DISABLES worktree isolation', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, stdout } = runGate(repo, {
|
||||
PLAN_FILES: 'src/index.ts vendor/foo/bar.ts',
|
||||
plan_id: 'plan-002',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'false');
|
||||
assert.match(stdout, /\[worktree\] Plan plan-002: planned paths intersect submodule paths/);
|
||||
assert.match(stdout, /vendor\/foo\/bar\.ts/);
|
||||
});
|
||||
|
||||
test('plan whose path equals the submodule root (vendor/foo) DISABLES worktree isolation', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, stdout } = runGate(repo, {
|
||||
PLAN_FILES: 'vendor/foo',
|
||||
plan_id: 'plan-003',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'false');
|
||||
assert.match(stdout, /\[worktree\] Plan plan-003: planned paths intersect submodule paths/);
|
||||
});
|
||||
|
||||
test('missing files_modified in a submodule project falls back to DISABLE with a logged reason', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, stdout } = runGate(repo, {
|
||||
PLAN_FILES: '',
|
||||
plan_id: 'plan-004',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'false');
|
||||
assert.match(stdout, /\[worktree\] Plan plan-004: files_modified missing\/unparseable/);
|
||||
assert.match(stdout, /safety fallback/);
|
||||
});
|
||||
|
||||
test('repo with no .gitmodules at all keeps worktree isolation ENABLED regardless of plan paths', () => {
|
||||
const { decision, logLines } = runGate(repo, {
|
||||
PLAN_FILES: 'vendor/foo/bar.ts src/index.ts',
|
||||
plan_id: 'plan-005',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'true');
|
||||
assert.equal(logLines.filter((l) => l.startsWith('[worktree]')).length, 0);
|
||||
});
|
||||
|
||||
test('multiple submodules, plan touches only one of them — DISABLE with that path in the log', () => {
|
||||
const gitmodules = [
|
||||
'[submodule "vendor/foo"]',
|
||||
'\tpath = vendor/foo',
|
||||
'\turl = https://example.invalid/foo.git',
|
||||
'[submodule "third_party/bar"]',
|
||||
'\tpath = third_party/bar',
|
||||
'\turl = https://example.invalid/bar.git',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(repo, '.gitmodules'), gitmodules);
|
||||
|
||||
const { decision, stdout } = runGate(repo, {
|
||||
PLAN_FILES: 'src/a.ts third_party/bar/b.ts',
|
||||
plan_id: 'plan-006',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'false');
|
||||
assert.match(stdout, /third_party\/bar\/b\.ts/);
|
||||
});
|
||||
|
||||
test('planned path that merely shares a prefix with a submodule (vendor/foobar) does NOT count as intersection', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, logLines } = runGate(repo, {
|
||||
PLAN_FILES: 'vendor/foobar/x.ts',
|
||||
plan_id: 'plan-007',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'true');
|
||||
assert.equal(logLines.filter((l) => l.startsWith('[worktree]')).length, 0);
|
||||
});
|
||||
|
||||
// ---- Path-normalization & glob coverage (CodeRabbit MAJOR finding) ----
|
||||
|
||||
test('planned path with leading "./" normalizes and DISABLES isolation when inside a submodule', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, stdout } = runGate(repo, {
|
||||
PLAN_FILES: './vendor/foo/bar.c',
|
||||
plan_id: 'plan-norm-1',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'false', './vendor/foo/bar.c must normalize and intersect vendor/foo');
|
||||
assert.match(stdout, /vendor\/foo\/bar\.c/);
|
||||
});
|
||||
|
||||
test('planned path with trailing slash equal to submodule DISABLES isolation', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision } = runGate(repo, {
|
||||
PLAN_FILES: 'vendor/foo/',
|
||||
plan_id: 'plan-norm-2',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'false', 'trailing slash must not defeat the submodule-root match');
|
||||
});
|
||||
|
||||
test('globby planned path "vendor/**/*.c" DISABLES isolation when submodule sits inside vendor/', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, stdout } = runGate(repo, {
|
||||
PLAN_FILES: 'vendor/**/*.c',
|
||||
plan_id: 'plan-norm-3',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
decision,
|
||||
'false',
|
||||
'glob whose literal prefix "vendor" contains submodule vendor/foo must intersect'
|
||||
);
|
||||
assert.match(stdout, /vendor\/\*\*\/\*\.c/);
|
||||
});
|
||||
|
||||
test('plan declares a parent directory of the submodule (e.g. "vendor") — DISABLES isolation', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision } = runGate(repo, {
|
||||
PLAN_FILES: 'vendor',
|
||||
plan_id: 'plan-norm-4',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
decision,
|
||||
'false',
|
||||
'planned path that contains the submodule must intersect (bidirectional matching)'
|
||||
);
|
||||
});
|
||||
|
||||
test('submodule path declared with leading "./" in .gitmodules still matches a plain planned path', () => {
|
||||
const gitmodules = [
|
||||
'[submodule "vendor/foo"]',
|
||||
'\tpath = ./vendor/foo',
|
||||
'\turl = https://example.invalid/foo.git',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(repo, '.gitmodules'), gitmodules);
|
||||
|
||||
const { decision } = runGate(repo, {
|
||||
PLAN_FILES: 'vendor/foo/bar.ts',
|
||||
plan_id: 'plan-norm-5',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
decision,
|
||||
'false',
|
||||
'submodule "./vendor/foo" must normalize and match plain planned path vendor/foo/bar.ts'
|
||||
);
|
||||
});
|
||||
|
||||
test('globby planned path that does NOT overlap the submodule keeps isolation ENABLED', () => {
|
||||
writeGitmodulesWithSubmodule(repo, 'vendor/foo');
|
||||
|
||||
const { decision, logLines } = runGate(repo, {
|
||||
PLAN_FILES: 'src/**/*.ts',
|
||||
plan_id: 'plan-norm-6',
|
||||
});
|
||||
|
||||
assert.equal(decision, 'true');
|
||||
assert.equal(logLines.filter((l) => l.startsWith('[worktree]')).length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Workflow-markdown wiring assertions (CodeRabbit CRITICAL finding) ----
|
||||
//
|
||||
// The original PR computed USE_WORKTREES_FOR_PLAN but never read it at the
|
||||
// dispatch sites — the dispatch still branched on the project-level
|
||||
// USE_WORKTREES, so the per-plan decision was dead code. Assert the markdown
|
||||
// actually wires the variable into the four dispatch sites.
|
||||
|
||||
describe('execute-phase.md dispatch wires USE_WORKTREES_FOR_PLAN (#2772)', () => {
|
||||
const workflowPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'execute-phase.md'
|
||||
);
|
||||
const gatePath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'execute-phase',
|
||||
'steps',
|
||||
'per-plan-worktree-gate.md'
|
||||
);
|
||||
|
||||
test('workflow file exists and is readable', () => {
|
||||
assert.ok(fs.existsSync(workflowPath), `expected ${workflowPath} to exist`);
|
||||
});
|
||||
|
||||
test('per-plan worktree gate steps file exists and is readable', () => {
|
||||
assert.ok(fs.existsSync(gatePath), `expected ${gatePath} to exist`);
|
||||
});
|
||||
|
||||
test('Worktree-mode dispatch gate reads USE_WORKTREES_FOR_PLAN, not USE_WORKTREES', () => {
|
||||
const md = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.match(
|
||||
md,
|
||||
/\*\*Worktree mode\*\*\s*\(`USE_WORKTREES_FOR_PLAN`/,
|
||||
'Worktree-mode header must gate on USE_WORKTREES_FOR_PLAN per-plan'
|
||||
);
|
||||
});
|
||||
|
||||
test('Sequential-mode dispatch gate reads USE_WORKTREES_FOR_PLAN', () => {
|
||||
const md = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.match(
|
||||
md,
|
||||
/\*\*Sequential mode\*\*\s*\(`USE_WORKTREES_FOR_PLAN`/,
|
||||
'Sequential-mode header must gate on USE_WORKTREES_FOR_PLAN per-plan'
|
||||
);
|
||||
});
|
||||
|
||||
test('"Worktrees disabled" sequential rule is documented per-plan, not project-level', () => {
|
||||
const md = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.match(
|
||||
md,
|
||||
/worktrees are disabled for a plan/i,
|
||||
'sequential-execution rule must be expressed per-plan'
|
||||
);
|
||||
});
|
||||
|
||||
test('execute-phase.md hooks the per-plan gate steps file at sub-step 2.5', () => {
|
||||
const md = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.match(md, /Per-plan worktree decision/, 'sub-step header must exist in execute_waves');
|
||||
assert.match(
|
||||
md,
|
||||
/execute-phase\/steps\/per-plan-worktree-gate\.md/,
|
||||
'execute-phase.md must reference the extracted gate file'
|
||||
);
|
||||
});
|
||||
|
||||
test('per-plan gate file documents PLAN_FILES extraction from plan_json', () => {
|
||||
const md = fs.readFileSync(gatePath, 'utf-8');
|
||||
assert.match(
|
||||
md,
|
||||
/jq -r '\.files_modified \/\/ \[\] \| join\(" "\)' <<<"\$plan_json"/,
|
||||
'PLAN_FILES extraction from plan_json must be documented in the gate file'
|
||||
);
|
||||
});
|
||||
|
||||
test('per-plan gate file uses bidirectional case + glob-prefix handling + set -f discipline', () => {
|
||||
const md = fs.readFileSync(gatePath, 'utf-8');
|
||||
assert.match(md, /set -f/, 'matcher must disable globbing while iterating');
|
||||
assert.match(md, /set \+f/, 'matcher must re-enable globbing after iteration');
|
||||
const pfFirst = md.match(/case "\$pf" in\s+"\$sm"\|"\$sm"\/\*\)/);
|
||||
const smFirst = md.match(/case "\$sm" in\s+"\$pf"\|"\$pf"\/\*\)/);
|
||||
assert.ok(pfFirst, 'matcher must check pf inside sm');
|
||||
assert.ok(smFirst, 'matcher must check sm inside pf (bidirectional)');
|
||||
assert.match(md, /sm="\$\{sm_raw#\.\/\}"/, 'submodule path must strip leading ./');
|
||||
assert.match(md, /pf="\$\{pf_raw#\.\/\}"/, 'planned path must strip leading ./');
|
||||
assert.match(md, /sm="\$\{sm%\/\}"/, 'submodule path must strip trailing /');
|
||||
assert.match(md, /pf="\$\{pf%\/\}"/, 'planned path must strip trailing /');
|
||||
});
|
||||
|
||||
test('Post-wave worktree-cleanup gate is per-plan, not blanket project-level', () => {
|
||||
const md = fs.readFileSync(workflowPath, 'utf-8');
|
||||
assert.match(
|
||||
md,
|
||||
/WAVE_WORKTREE_PLANS/,
|
||||
'post-wave cleanup must track which plans actually used worktrees'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- quick.md SUBMODULE_PATHS executor guard (CodeRabbit CRITICAL #3) ----
|
||||
//
|
||||
// Quick mode does NOT have a pre-declared files_modified list. The fail-loud
|
||||
// guard must (a) be present in the markdown of the executor prompt, and
|
||||
// (b) actually abort when run against a fixture that stages a submodule path.
|
||||
|
||||
describe('quick.md executor pre-commit submodule guard (#2772)', () => {
|
||||
const quickPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
|
||||
|
||||
test('quick.md executor prompt injects SUBMODULE_PATHS', () => {
|
||||
const md = fs.readFileSync(quickPath, 'utf-8');
|
||||
assert.match(
|
||||
md,
|
||||
/SUBMODULE_PATHS for this project: \$\{SUBMODULE_PATHS\}/,
|
||||
'executor prompt must inline SUBMODULE_PATHS so the agent can run the guard'
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md executor prompt contains a fail-loud pre-commit guard with ABORT message', () => {
|
||||
const md = fs.readFileSync(quickPath, 'utf-8');
|
||||
assert.match(md, /<submodule_commit_guard>/, 'guard block must exist');
|
||||
assert.match(
|
||||
md,
|
||||
/git diff --cached --name-only/,
|
||||
'guard must inspect staged paths before commit'
|
||||
);
|
||||
assert.match(
|
||||
md,
|
||||
/ABORT: staged path/,
|
||||
'guard must surface a fail-loud ABORT message on intersection'
|
||||
);
|
||||
assert.match(
|
||||
md,
|
||||
/workflow\.use_worktrees=false/,
|
||||
'guard must tell the user how to recover (re-run without worktrees)'
|
||||
);
|
||||
});
|
||||
|
||||
// Behavioral: extract the guard logic and run it against a fixture repo.
|
||||
// We simulate the executor's commit-time guard and assert it aborts when a
|
||||
// staged path falls inside a SUBMODULE_PATHS entry, and passes otherwise.
|
||||
const QUICK_GUARD_SNIPPET = [
|
||||
'set +e',
|
||||
'STAGED=$(git diff --cached --name-only)',
|
||||
'if [ -n "$SUBMODULE_PATHS" ]; then',
|
||||
' for sm_raw in $SUBMODULE_PATHS; do',
|
||||
' sm="${sm_raw#./}"',
|
||||
' sm="${sm%/}"',
|
||||
' [ -z "$sm" ] && continue',
|
||||
' for f_raw in $STAGED; do',
|
||||
' f="${f_raw#./}"',
|
||||
' f="${f%/}"',
|
||||
' case "$f" in',
|
||||
' "$sm"|"$sm"/*)',
|
||||
' echo "ABORT: staged path $f_raw falls inside submodule $sm — re-run with workflow.use_worktrees=false" >&2',
|
||||
' exit 1 ;;',
|
||||
' esac',
|
||||
' done',
|
||||
' done',
|
||||
'fi',
|
||||
'echo "OK"',
|
||||
].join('\n');
|
||||
|
||||
test('guard ABORTs when a staged path falls inside a submodule', () => {
|
||||
const repo = createTempGitProject('gsd-test-2772-quick-abort-');
|
||||
try {
|
||||
// Create a file inside the submodule path and stage it.
|
||||
fs.mkdirSync(path.join(repo, 'vendor', 'foo'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, 'vendor', 'foo', 'bar.ts'), 'export {};\n');
|
||||
execFileSync('git', ['add', 'vendor/foo/bar.ts'], { cwd: repo });
|
||||
|
||||
let err;
|
||||
try {
|
||||
execFileSync('bash', ['-c', QUICK_GUARD_SNIPPET], {
|
||||
cwd: repo,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, SUBMODULE_PATHS: 'vendor/foo' },
|
||||
});
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
assert.ok(err, 'guard must exit non-zero when staged path is inside submodule');
|
||||
assert.equal(err.status, 1, 'guard must exit with status 1');
|
||||
const stderr = err.stderr ? err.stderr.toString() : '';
|
||||
assert.match(stderr, /ABORT: staged path vendor\/foo\/bar\.ts/);
|
||||
assert.match(stderr, /vendor\/foo/);
|
||||
} finally {
|
||||
cleanup(repo);
|
||||
}
|
||||
});
|
||||
|
||||
test('guard passes when no staged path falls inside a submodule', () => {
|
||||
const repo = createTempGitProject('gsd-test-2772-quick-pass-');
|
||||
try {
|
||||
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, 'src', 'index.ts'), 'export {};\n');
|
||||
execFileSync('git', ['add', 'src/index.ts'], { cwd: repo });
|
||||
|
||||
const out = execFileSync('bash', ['-c', QUICK_GUARD_SNIPPET], {
|
||||
cwd: repo,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, SUBMODULE_PATHS: 'vendor/foo' },
|
||||
});
|
||||
assert.match(out, /OK/);
|
||||
} finally {
|
||||
cleanup(repo);
|
||||
}
|
||||
});
|
||||
|
||||
test('guard normalizes leading "./" on staged paths and still ABORTs', () => {
|
||||
const repo = createTempGitProject('gsd-test-2772-quick-norm-');
|
||||
try {
|
||||
fs.mkdirSync(path.join(repo, 'vendor', 'foo'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, 'vendor', 'foo', 'bar.ts'), 'export {};\n');
|
||||
execFileSync('git', ['add', 'vendor/foo/bar.ts'], { cwd: repo });
|
||||
|
||||
let err;
|
||||
try {
|
||||
// Submodule path declared with ./ prefix — must still match.
|
||||
execFileSync('bash', ['-c', QUICK_GUARD_SNIPPET], {
|
||||
cwd: repo,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, SUBMODULE_PATHS: './vendor/foo' },
|
||||
});
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
assert.ok(err, 'guard must abort even when SUBMODULE_PATHS uses ./ prefix');
|
||||
assert.equal(err.status, 1);
|
||||
} finally {
|
||||
cleanup(repo);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,288 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
||||
// runtime loads — testing text content tests the deployed contract.
|
||||
|
||||
/**
|
||||
* Regression test for bug #2808
|
||||
*
|
||||
* All 85 GSD SKILL.md files declared `name: gsd:<cmd>` (colon), the deprecated
|
||||
* form. Claude Code surfaces the `name:` frontmatter field in autocomplete, so
|
||||
* users saw `/gsd:add-phase` suggestions instead of the canonical `/gsd-add-phase`.
|
||||
*
|
||||
* Root cause: skillFrontmatterName() in bin/install.js converted hyphenated
|
||||
* skill dir names to colon form (gsd-add-phase → gsd:add-phase) because
|
||||
* workflows called Skill(skill="gsd:<cmd>"). That was the original fix for
|
||||
* #2643. Since then, workflows have been updated to use hyphen form (#2808).
|
||||
*
|
||||
* Fix: skillFrontmatterName() now returns the hyphen form unchanged.
|
||||
* Workflow Skill() colon calls are updated to hyphen.
|
||||
*
|
||||
* This test verifies:
|
||||
* 1. skillFrontmatterName returns hyphen form (not colon).
|
||||
* 2. Installed SKILL.md would emit name: gsd-<cmd> (not gsd:<cmd>).
|
||||
* 3. No workflow contains a Skill(skill="gsd:<cmd>") colon call.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { cleanup, createTempDir } = require('./helpers.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const { convertClaudeCommandToClaudeSkill, skillFrontmatterName } =
|
||||
require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
const { installRuntimeArtifacts } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-engine.cjs'));
|
||||
|
||||
const {
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs'));
|
||||
|
||||
// Full resolved profile — installs all available skills from the source dir
|
||||
const _manifest = loadSkillsManifest();
|
||||
const resolvedProfileFull = resolveProfile({ modes: [], manifest: _manifest });
|
||||
|
||||
const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows');
|
||||
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
function walkMd(dir) {
|
||||
const files = [];
|
||||
try {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) files.push(...walkMd(full));
|
||||
else if (e.name.endsWith('.md')) files.push(full);
|
||||
}
|
||||
} catch (err) {
|
||||
assert.fail(`failed to read markdown files from ${dir}: ${err.message}`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
describe('bug-2808: SKILL.md name: uses hyphen form', () => {
|
||||
test('skillFrontmatterName returns hyphen form (not colon)', () => {
|
||||
assert.strictEqual(skillFrontmatterName('gsd-add-phase'), 'gsd-add-phase');
|
||||
assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd-plan-phase');
|
||||
assert.strictEqual(skillFrontmatterName('gsd-autonomous'), 'gsd-autonomous');
|
||||
});
|
||||
|
||||
test('generated SKILL.md contains name: gsd-<cmd> (not gsd:<cmd>)', () => {
|
||||
const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md'));
|
||||
assert.ok(cmdFiles.length > 0, 'expected GSD command files');
|
||||
|
||||
for (const cmd of cmdFiles) {
|
||||
const base = cmd.replace(/\.md$/, '');
|
||||
const skillDirName = 'gsd-' + base;
|
||||
const src = fs.readFileSync(path.join(COMMANDS_DIR, cmd), 'utf-8');
|
||||
const skillContent = convertClaudeCommandToClaudeSkill(src, skillDirName);
|
||||
|
||||
// Parse frontmatter structurally: extract name: line from the --- block.
|
||||
const fmMatch = skillContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(fmMatch, `${cmd}: generated skill content must have a frontmatter block`);
|
||||
const fmLines = fmMatch[1].split(/\r?\n/);
|
||||
const nameEntry = fmLines.find((l) => l.startsWith('name:'));
|
||||
assert.ok(nameEntry, `${cmd}: generated SKILL.md is missing required name: field`);
|
||||
|
||||
const name = nameEntry.replace(/^name:\s*/, '').trim();
|
||||
assert.ok(
|
||||
!name.includes(':'),
|
||||
`${cmd}: SKILL.md name should be hyphen form, got "${name}"`
|
||||
);
|
||||
assert.ok(
|
||||
name.startsWith('gsd-'),
|
||||
`${cmd}: SKILL.md name should start with gsd-, got "${name}"`
|
||||
);
|
||||
|
||||
// #3583 regression guard: the *body* must not leak retired colon-form
|
||||
// command references (e.g. /gsd:plan-phase or gsd:review). The converter
|
||||
// now uses transformContentToHyphen from the shared transformer.
|
||||
//
|
||||
// We explicitly scope to the body (after stripping the leading frontmatter
|
||||
// block) so that descriptions or other frontmatter fields containing example
|
||||
// gsd: references do not cause spurious failures.
|
||||
//
|
||||
// gsd:sdk and gsd:tools are intentionally excluded: they are not slash commands
|
||||
// (no commands/gsd/sdk.md or tools.md exist), so the transformer correctly leaves
|
||||
// them alone. They are benign and should not trigger this assertion.
|
||||
const bodyContent = skillContent.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
|
||||
const colonRefs = (bodyContent.match(/\bgsd:[a-z][a-z0-9-]*\b/g) || [])
|
||||
.filter(r => !/gsd:(sdk|tools)/.test(r));
|
||||
assert.strictEqual(
|
||||
colonRefs.length, 0,
|
||||
`${cmd}: generated SKILL.md body must not contain gsd: command references (found: ${colonRefs.join(', ')})`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('no workflow contains Skill(skill="gsd:<cmd>") colon form', () => {
|
||||
const workflowFiles = walkMd(WORKFLOWS_DIR);
|
||||
assert.ok(
|
||||
workflowFiles.length > 0,
|
||||
`expected workflow markdown files under ${WORKFLOWS_DIR}`
|
||||
);
|
||||
const colonCalls = [];
|
||||
for (const f of workflowFiles) {
|
||||
const src = fs.readFileSync(f, 'utf-8');
|
||||
// Strip HTML comments to avoid matching commented-out examples.
|
||||
// regex-free HTML-comment stripper (CodeQL: avoid incomplete-multi-character-sanitization)
|
||||
let stripped = '';
|
||||
{
|
||||
let rest = src;
|
||||
let idx;
|
||||
while ((idx = rest.indexOf('<!--')) !== -1) {
|
||||
stripped += rest.slice(0, idx);
|
||||
const end = rest.indexOf('-->', idx + 4);
|
||||
if (end === -1) { rest = ''; break; }
|
||||
rest = rest.slice(end + 3);
|
||||
}
|
||||
stripped += rest;
|
||||
}
|
||||
// Scan each line for Skill() calls using the colon form.
|
||||
// Parsing line-by-line is more precise than a multi-line regex
|
||||
// and avoids false positives from incidental matches in prose.
|
||||
for (const line of stripped.split(/\r?\n/)) {
|
||||
// Tolerate whitespace around the parenthesis, the `skill` keyword,
|
||||
// and the `=` so variants like `Skill( skill = "gsd:foo" )` are still
|
||||
// flagged. Without the `\s*` allowances, drift slips through this guard.
|
||||
//
|
||||
// The local-name capture must be permissive (`[^'"\s)]+`, not
|
||||
// `[a-z0-9-]+`) — the whole purpose of this guard is to surface
|
||||
// *malformed* drift, including legacy underscore-form names like
|
||||
// `gsd:extract_learnings`. A character-class that excludes the very
|
||||
// characters we need to flag would silently let drift through.
|
||||
const colonCallRe = /Skill\(\s*skill\s*=\s*\\?['"]gsd:([^'"\s)]+)\\?['"]/gi;
|
||||
let m;
|
||||
while ((m = colonCallRe.exec(line)) !== null) {
|
||||
colonCalls.push(`${path.basename(f)}: Skill(skill="gsd:${m[1]}")`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
colonCalls,
|
||||
[],
|
||||
'deprecated colon-form Skill() calls found — update to gsd-<cmd>: ' + colonCalls.join(', ')
|
||||
);
|
||||
});
|
||||
|
||||
test('generated autocomplete skill surface uses hyphen names without underscores', (t) => {
|
||||
const tmp = createTempDir('gsd-autocomplete-surface-');
|
||||
t.after(() => cleanup(tmp));
|
||||
|
||||
// Use the real COMMANDS_DIR as the source via .gsd-source marker.
|
||||
// installRuntimeArtifacts('claude', configDir, 'global') writes to
|
||||
// configDir/skills/ using the same converter as the shim did.
|
||||
// With the full profile (#924 fix), skills are FLAT: gsd-<stem>/SKILL.md
|
||||
// (nested layout reverted for Claude — Claude Code scans only one level).
|
||||
const configDir = path.join(tmp, 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), COMMANDS_DIR + '\n');
|
||||
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
|
||||
// Recursively collect all SKILL.md files under skills/ (handles both flat and
|
||||
// nested layouts). Don't filter any paths — that would silently hide exactly
|
||||
// the kind of drift this test exists to catch (a `gsd:extract-learnings`
|
||||
// colon variant or a bare `extract-learnings` without the namespace prefix
|
||||
// would never be collected, and the loop below would never see them).
|
||||
function collectSkillMds(dir) {
|
||||
const results = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...collectSkillMds(full));
|
||||
} else if (entry.name === 'SKILL.md') {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const allSkillMdPaths = collectSkillMds(skillsDir);
|
||||
assert.ok(allSkillMdPaths.length > 0, 'expected generated SKILL.md files under skillsDir');
|
||||
|
||||
// Validate every SKILL.md's name: field (the consumer-facing name used in
|
||||
// autocomplete). We also check that the containing dir name doesn't use
|
||||
// banned characters at any level of nesting.
|
||||
const allNames = [];
|
||||
for (const skillMdPath of allSkillMdPaths) {
|
||||
const relPath = path.relative(skillsDir, skillMdPath);
|
||||
const skillContent = fs.readFileSync(skillMdPath, 'utf-8');
|
||||
// Scope the name: lookup to the YAML frontmatter block so a stray
|
||||
// `name:` line in the body cannot satisfy the assertion.
|
||||
const fmMatch = skillContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(fmMatch, `${relPath}: generated SKILL.md must include frontmatter`);
|
||||
const nameLine = fmMatch[1].split(/\r?\n/).find((l) => /^name:\s*/.test(l));
|
||||
assert.ok(nameLine, `${relPath}: generated SKILL.md is missing name: frontmatter`);
|
||||
const name = nameLine.replace(/^name:\s*/, '').trim();
|
||||
assert.ok(name.startsWith('gsd-'), `${relPath}: autocomplete name must start with gsd-, got ${name}`);
|
||||
assert.ok(!name.includes(':'), `${relPath}: autocomplete name must not contain colon, got ${name}`);
|
||||
assert.ok(!name.includes('_'), `${relPath}: autocomplete name must not contain underscore, got ${name}`);
|
||||
allNames.push(name);
|
||||
|
||||
// Also validate each path segment (dir name) in the relative path doesn't
|
||||
// contain the banned characters — catches mislabeled directory names.
|
||||
const segments = relPath.split(path.sep).slice(0, -1); // exclude 'SKILL.md' filename
|
||||
for (const seg of segments) {
|
||||
assert.ok(!seg.includes(':'), `${relPath}: dir segment "${seg}" must not contain colon`);
|
||||
assert.ok(!seg.includes('_'), `${relPath}: dir segment "${seg}" must use hyphens, not underscores`);
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(allNames.includes('gsd-extract-learnings'), 'autocomplete surface must include gsd-extract-learnings');
|
||||
assert.ok(!allNames.includes('gsd-extract_learnings'), 'autocomplete surface must not include gsd-extract_learnings');
|
||||
});
|
||||
|
||||
test('transformContentToHyphen (from fix-slash-commands.cjs) rewrites colon to hyphen for known commands', () => {
|
||||
const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
|
||||
const { transformContentToHyphen, readCmdNames } = transformer;
|
||||
const liveCmdNames = readCmdNames();
|
||||
|
||||
const input = 'Run /gsd:plan-phase then gsd:execute-phase. Also see /gsd:review and gsd-sdk query.';
|
||||
const out = transformContentToHyphen(input, liveCmdNames);
|
||||
|
||||
assert.ok(out.includes('/gsd-plan-phase'), 'leading-/ colon form must become hyphen');
|
||||
assert.ok(out.includes('gsd-execute-phase'), 'bare colon form must become hyphen');
|
||||
assert.ok(out.includes('/gsd-review'), 'another command reference must be rewritten');
|
||||
assert.ok(out.includes('gsd-sdk'), 'non-command gsd-sdk must be left untouched');
|
||||
assert.ok(!out.match(/\bgsd:[a-z]/), 'no colon-form command reference may survive');
|
||||
});
|
||||
|
||||
test('respects word boundary — does not rewrite gsd:plan-phase-extra (partial match guard)', () => {
|
||||
const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
|
||||
const { transformContentToHyphen, readCmdNames } = transformer;
|
||||
const liveCmdNames = readCmdNames();
|
||||
|
||||
const out = transformContentToHyphen('gsd:plan-phase-extra and /gsd:execute-phase-extra', liveCmdNames);
|
||||
assert.strictEqual(out, 'gsd:plan-phase-extra and /gsd:execute-phase-extra',
|
||||
'word-boundary lookahead must prevent partial matches on the reverse transform');
|
||||
});
|
||||
|
||||
test('respects left word boundary — does not rewrite inside larger tokens (e.g. mygsd:cmd)', () => {
|
||||
const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
|
||||
const { transformContentToHyphen, readCmdNames } = transformer;
|
||||
const liveCmdNames = readCmdNames();
|
||||
|
||||
const input = 'See mygsd:plan-phase or prefix-gsd:execute in the docs.';
|
||||
const out = transformContentToHyphen(input, liveCmdNames);
|
||||
assert.strictEqual(out, input, 'negative lookbehind must prevent left-side in-word matches');
|
||||
});
|
||||
|
||||
test('leaves already-hyphen-form references untouched (idempotent on output)', () => {
|
||||
const transformer = require(path.join(ROOT, 'scripts', 'fix-slash-commands.cjs'));
|
||||
const { transformContentToHyphen, readCmdNames } = transformer;
|
||||
const liveCmdNames = readCmdNames();
|
||||
|
||||
const input = 'Run gsd-plan-phase and /gsd-execute-phase then gsd:review.'; // mixed, only colon should change
|
||||
const out = transformContentToHyphen(input, liveCmdNames);
|
||||
assert.ok(out.includes('gsd-plan-phase'), 'pre-existing hyphen stays');
|
||||
assert.ok(out.includes('/gsd-execute-phase'), 'pre-existing hyphen stays');
|
||||
assert.ok(out.includes('gsd-review'), 'colon form was normalized');
|
||||
assert.ok(!out.includes('gsd:review'), 'no colon form remains');
|
||||
});
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
/**
|
||||
* Regression test for #2916: execute-phase `handle_branching` step creates the
|
||||
* per-phase branch off whatever HEAD is currently checked out (typically the
|
||||
* previous phase's unmerged branch) instead of off `origin/HEAD`.
|
||||
*
|
||||
* The bug compounded phases on top of each other and stranded them unpushed
|
||||
* for weeks. The fix:
|
||||
* 1. Detect the default branch via `git symbolic-ref refs/remotes/origin/HEAD`.
|
||||
* 2. If $BRANCH_NAME exists, switch to it (preserve existing behavior).
|
||||
* 3. Otherwise, ff-update the default branch from origin and create the new
|
||||
* phase branch off the default-branch tip.
|
||||
* 4. Refuse-or-warn on dirty working tree.
|
||||
* 5. Post-creation, assert `git rev-list --count $DEFAULT_BRANCH..HEAD == 0`.
|
||||
*
|
||||
* This test extracts the bash payload from the <step name="handle_branching">
|
||||
* block in execute-phase.md (parsed structurally — no regex on prose), executes
|
||||
* it inside a fixture git repo where HEAD sits on a previous-phase branch with
|
||||
* extra commits, and asserts that the new phase branch's tip equals
|
||||
* `origin/main` (no commits inherited from the previous phase).
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const EXECUTE_PHASE_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'execute-phase.md'
|
||||
);
|
||||
|
||||
const GIT_ENV = Object.freeze({
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'Test',
|
||||
GIT_AUTHOR_EMAIL: 'test@test.com',
|
||||
GIT_COMMITTER_NAME: 'Test',
|
||||
GIT_COMMITTER_EMAIL: 'test@test.com',
|
||||
});
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
env: GIT_ENV,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Structurally extract the bash code that the handle_branching step instructs
|
||||
* the agent to run. We:
|
||||
* 1. Locate the <step name="handle_branching"> ... </step> block.
|
||||
* 2. Walk its body looking for fenced ```bash blocks.
|
||||
* 3. Concatenate every bash block in the step (the fix may use more than one).
|
||||
*
|
||||
* No `.includes()` content checks — we parse fence-delimited code blocks the
|
||||
* same way a markdown parser would.
|
||||
*/
|
||||
function extractHandleBranchingBash() {
|
||||
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
let start = -1;
|
||||
let end = -1;
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
if (start === -1 && /^<step\s+name="handle_branching">\s*$/.test(lines[i])) {
|
||||
start = i + 1;
|
||||
} else if (start !== -1 && /^<\/step>\s*$/.test(lines[i])) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error(
|
||||
'execute-phase.md does not contain a <step name="handle_branching"> ... </step> block'
|
||||
);
|
||||
}
|
||||
|
||||
const bashBlocks = [];
|
||||
let inBash = false;
|
||||
let buffer = [];
|
||||
for (let i = start; i < end; i += 1) {
|
||||
const line = lines[i];
|
||||
if (!inBash && /^```bash\s*$/.test(line)) {
|
||||
inBash = true;
|
||||
buffer = [];
|
||||
continue;
|
||||
}
|
||||
if (inBash && /^```\s*$/.test(line)) {
|
||||
bashBlocks.push(buffer.join('\n'));
|
||||
inBash = false;
|
||||
continue;
|
||||
}
|
||||
if (inBash) buffer.push(line);
|
||||
}
|
||||
if (bashBlocks.length === 0) {
|
||||
throw new Error(
|
||||
'handle_branching step contains no ```bash code blocks to execute'
|
||||
);
|
||||
}
|
||||
return bashBlocks.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fixture: a bare "origin" repo with the named default branch (one
|
||||
* commit), a clone with `origin/HEAD` pointed at it, and a checked-out
|
||||
* previous-phase branch carrying its own unmerged commit.
|
||||
*
|
||||
* `defaultBranch` is parameterized so callers can lock in that the workflow
|
||||
* honors `git symbolic-ref refs/remotes/origin/HEAD` rather than silently
|
||||
* defaulting to `main` (#2921 CR feedback — quick-branching.test.cjs got the
|
||||
* same treatment in 80f14cac; this test deserves the same coverage).
|
||||
*/
|
||||
function setupFixture(defaultBranch = 'main') {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2916-'));
|
||||
const seedPath = path.join(root, 'seed');
|
||||
const originPath = path.join(root, 'origin.git');
|
||||
const clonePath = path.join(root, 'clone');
|
||||
|
||||
fs.mkdirSync(seedPath);
|
||||
git(seedPath, 'init', '-b', defaultBranch);
|
||||
git(seedPath, 'config', 'commit.gpgsign', 'false');
|
||||
fs.writeFileSync(path.join(seedPath, 'README.md'), '# seed\n');
|
||||
git(seedPath, 'add', 'README.md');
|
||||
git(seedPath, 'commit', '-m', 'initial');
|
||||
|
||||
git(root, 'clone', '--bare', seedPath, originPath);
|
||||
git(originPath, 'symbolic-ref', 'HEAD', `refs/heads/${defaultBranch}`);
|
||||
|
||||
git(root, 'clone', originPath, clonePath);
|
||||
git(clonePath, 'config', 'commit.gpgsign', 'false');
|
||||
git(clonePath, 'config', 'user.email', 'test@test.com');
|
||||
git(clonePath, 'config', 'user.name', 'Test');
|
||||
|
||||
// Simulate finishing a previous phase: branch off the default branch, add
|
||||
// a commit, and *stay* on it (the failure scenario described in the bug).
|
||||
git(clonePath, 'checkout', '-b', 'feature/phase-01-foundation');
|
||||
fs.writeFileSync(path.join(clonePath, 'phase01.txt'), 'phase 1 work\n');
|
||||
git(clonePath, 'add', 'phase01.txt');
|
||||
git(clonePath, 'commit', '-m', 'phase 01 work');
|
||||
|
||||
return { root, clonePath, defaultBranch };
|
||||
}
|
||||
|
||||
function runHandleBranchingStep(bash, cwd, branchName) {
|
||||
// Write the script to a sibling tempdir, not inside the repo — putting it in
|
||||
// `cwd` would create an untracked file that trips `git status --porcelain`
|
||||
// and steers the step into its dirty-tree fallback path.
|
||||
const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2916-step-'));
|
||||
const scriptPath = path.join(scriptDir, 'handle-branching.sh');
|
||||
const script = `#!/usr/bin/env bash\nset -uo pipefail\nBRANCH_NAME="${branchName}"\n${bash}\n`;
|
||||
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
|
||||
try {
|
||||
return execFileSync('bash', [scriptPath], {
|
||||
cwd,
|
||||
env: GIT_ENV,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).toString();
|
||||
} finally {
|
||||
cleanup(scriptDir);
|
||||
}
|
||||
}
|
||||
|
||||
describe('handle_branching branches off origin/HEAD, not current HEAD (#2916)', () => {
|
||||
// Run against `main` (conventional default) and `trunk` (non-main default
|
||||
// exercising the symbolic-ref code path) so a regression that hard-codes
|
||||
// `main` instead of consulting origin/HEAD will fail the trunk variant.
|
||||
for (const defaultBranch of ['main', 'trunk']) {
|
||||
test(`new phase branch branches off origin/${defaultBranch} with 0 inherited commits`, () => {
|
||||
const bash = extractHandleBranchingBash();
|
||||
const { root, clonePath } = setupFixture(defaultBranch);
|
||||
|
||||
try {
|
||||
const upstream = `origin/${defaultBranch}`;
|
||||
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
||||
'feature/phase-01-foundation'
|
||||
);
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`),
|
||||
'1',
|
||||
`fixture should be 1 commit ahead of ${upstream}`
|
||||
);
|
||||
|
||||
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
|
||||
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
||||
'feature/phase-02-content-sync',
|
||||
'handle_branching should switch to the new phase branch'
|
||||
);
|
||||
|
||||
const inherited = git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`);
|
||||
assert.equal(
|
||||
inherited,
|
||||
'0',
|
||||
`new phase branch must branch off ${upstream}, but inherited ${inherited} commit(s) from previous-phase HEAD`
|
||||
);
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', 'HEAD'),
|
||||
git(clonePath, 'rev-parse', upstream),
|
||||
`new phase branch tip must equal ${upstream} tip`
|
||||
);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('handle_branching reuses an existing branch instead of forking again', () => {
|
||||
const bash = extractHandleBranchingBash();
|
||||
const { root, clonePath } = setupFixture();
|
||||
|
||||
try {
|
||||
// Pre-create the target branch off origin/main with its own commit, then
|
||||
// walk away to a different branch — the step must switch back to it.
|
||||
git(clonePath, 'checkout', '-B', 'feature/phase-02-content-sync', 'origin/main');
|
||||
fs.writeFileSync(path.join(clonePath, 'phase02.txt'), 'phase 2 work\n');
|
||||
git(clonePath, 'add', 'phase02.txt');
|
||||
git(clonePath, 'commit', '-m', 'phase 02 wip');
|
||||
const phase02Sha = git(clonePath, 'rev-parse', 'HEAD');
|
||||
git(clonePath, 'checkout', 'feature/phase-01-foundation');
|
||||
|
||||
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
|
||||
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
||||
'feature/phase-02-content-sync'
|
||||
);
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', 'HEAD'),
|
||||
phase02Sha,
|
||||
'existing-branch tip must be preserved (no rebase/reset)'
|
||||
);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
'use strict';
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const { auditWorkflowScriptPaths, AUDIT_FINDING } = require(
|
||||
path.join(ROOT, 'scripts', 'audit-workflow-script-paths.cjs'),
|
||||
);
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// auditWorkflowScriptPaths is a pure function: it walks workflowsDir,
|
||||
// extracts every ${GSD_HOME}/<path> script reference, and returns a
|
||||
// structured report. Tests assert on the typed report — no regex on
|
||||
// console output.
|
||||
|
||||
// #2996 CR: per-fixture repos are rooted under a single tmpRoot so the
|
||||
// after()-hook actually cleans them up. The previous shape created tmpRoot
|
||||
// in before() but never used it, leaking each fixture's mkdtempSync dir.
|
||||
let tmpRoot;
|
||||
function fixtureRepo({ workflows, files }) {
|
||||
// workflows: { 'foo.md': '...content with ${GSD_HOME}/...' }
|
||||
// files: [ 'gsd-core/bin/x.cjs', ... ] — files to create in repo
|
||||
const repoRoot = fs.mkdtempSync(path.join(tmpRoot, 'repo-'));
|
||||
const workflowsDir = path.join(repoRoot, 'gsd-core', 'workflows');
|
||||
fs.mkdirSync(workflowsDir, { recursive: true });
|
||||
for (const [name, body] of Object.entries(workflows || {})) {
|
||||
fs.writeFileSync(path.join(workflowsDir, name), body);
|
||||
}
|
||||
for (const rel of files || []) {
|
||||
const full = path.join(repoRoot, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, '');
|
||||
}
|
||||
return { repoRoot, workflowsDir };
|
||||
}
|
||||
|
||||
before(() => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2995-')); });
|
||||
after(() => { cleanup(tmpRoot); });
|
||||
|
||||
describe('Bug #2995: post-install script-paths audit (#2995)', () => {
|
||||
test('AUDIT_FINDING enum exposes the documented codes', () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(AUDIT_FINDING).sort(),
|
||||
['MISSING_FROM_REPO', 'NOT_INSTALLED'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('returns { ok: true, findings: [] } when workflow refs an existing, installed-path script', () => {
|
||||
const { repoRoot, workflowsDir } = fixtureRepo({
|
||||
workflows: {
|
||||
'good.md': 'node "${GSD_HOME}/gsd-core/bin/foo.cjs" --json\n',
|
||||
},
|
||||
files: ['gsd-core/bin/foo.cjs'],
|
||||
});
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir,
|
||||
repoRoot,
|
||||
installedPrefixes: ['gsd-core', 'commands', 'agents', 'hooks'],
|
||||
});
|
||||
assert.deepEqual(r, { ok: true, findings: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #2995: detection paths', () => {
|
||||
const { auditWorkflowScriptPaths, AUDIT_FINDING } = require(require('node:path').join(__dirname, '..', 'scripts', 'audit-workflow-script-paths.cjs'));
|
||||
|
||||
test('reports MISSING_FROM_REPO when the referenced file does not exist in the repo', () => {
|
||||
const { repoRoot, workflowsDir } = fixtureRepo({
|
||||
workflows: {
|
||||
'foo.md': 'node "${GSD_HOME}/gsd-core/bin/typo.cjs" --json\n',
|
||||
},
|
||||
files: [],
|
||||
});
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir,
|
||||
repoRoot,
|
||||
installedPrefixes: ['gsd-core'],
|
||||
});
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.findings.length, 1);
|
||||
assert.deepEqual(r.findings[0], {
|
||||
workflow: 'foo.md',
|
||||
path: 'gsd-core/bin/typo.cjs',
|
||||
kind: AUDIT_FINDING.MISSING_FROM_REPO,
|
||||
});
|
||||
});
|
||||
|
||||
test('reports NOT_INSTALLED when first path segment is outside installedPrefixes (the #2994 case)', () => {
|
||||
const { repoRoot, workflowsDir } = fixtureRepo({
|
||||
workflows: {
|
||||
'foo.md': 'node "${GSD_HOME}/scripts/verify-reapply-patches.cjs"\n',
|
||||
},
|
||||
files: ['scripts/verify-reapply-patches.cjs'], // file exists, but `scripts/` not in installed prefixes
|
||||
});
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir,
|
||||
repoRoot,
|
||||
installedPrefixes: ['gsd-core', 'commands', 'agents', 'hooks'],
|
||||
});
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.findings.length, 1);
|
||||
assert.deepEqual(r.findings[0], {
|
||||
workflow: 'foo.md',
|
||||
path: 'scripts/verify-reapply-patches.cjs',
|
||||
kind: AUDIT_FINDING.NOT_INSTALLED,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles ${GSD_HOME:-$HOME/.claude}/... default-fallback syntax', () => {
|
||||
const { repoRoot, workflowsDir } = fixtureRepo({
|
||||
workflows: {
|
||||
'a.md': 'node "${GSD_HOME:-$HOME/.claude}/gsd-core/bin/x.cjs"\n',
|
||||
},
|
||||
files: ['gsd-core/bin/x.cjs'],
|
||||
});
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir,
|
||||
repoRoot,
|
||||
installedPrefixes: ['gsd-core'],
|
||||
});
|
||||
assert.deepEqual(r, { ok: true, findings: [] });
|
||||
});
|
||||
|
||||
test('reports both findings when one workflow has multiple problems', () => {
|
||||
const { repoRoot, workflowsDir } = fixtureRepo({
|
||||
workflows: {
|
||||
'multi.md': [
|
||||
'node "${GSD_HOME}/scripts/a.cjs"',
|
||||
'node "${GSD_HOME}/gsd-core/bin/b.cjs"',
|
||||
'node "${GSD_HOME}/gsd-core/bin/missing.cjs"',
|
||||
].join('\n') + '\n',
|
||||
},
|
||||
files: ['scripts/a.cjs', 'gsd-core/bin/b.cjs'],
|
||||
});
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir,
|
||||
repoRoot,
|
||||
installedPrefixes: ['gsd-core'],
|
||||
});
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.findings.length, 2);
|
||||
const kinds = r.findings.map((f) => f.kind).sort();
|
||||
assert.deepEqual(kinds, [AUDIT_FINDING.MISSING_FROM_REPO, AUDIT_FINDING.NOT_INSTALLED]);
|
||||
});
|
||||
|
||||
test('extracts no findings from a workflow without GSD_HOME script refs', () => {
|
||||
const { repoRoot, workflowsDir } = fixtureRepo({
|
||||
workflows: {
|
||||
'plain.md': '# A workflow\n\nSome prose, no script refs.\n',
|
||||
},
|
||||
});
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir,
|
||||
repoRoot,
|
||||
installedPrefixes: ['gsd-core'],
|
||||
});
|
||||
assert.deepEqual(r, { ok: true, findings: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #2995: real workflow audit', () => {
|
||||
const { auditWorkflowScriptPaths, AUDIT_FINDING } = require(require('node:path').join(__dirname, '..', 'scripts', 'audit-workflow-script-paths.cjs'));
|
||||
|
||||
// The set of top-level directories the installer (bin/install.js) actually
|
||||
// copies into ${configDir}/. Touching this set requires updating both
|
||||
// bin/install.js AND this constant — the parity is intentional.
|
||||
const INSTALLED_PREFIXES = [
|
||||
'gsd-core', // workflows, references, bin/lib, templates
|
||||
'commands', // commands/gsd/*.md (Claude Code local + Gemini global)
|
||||
'skills', // skills/gsd-*/SKILL.md (Claude Code 2.1.88+ global, Codex, etc.)
|
||||
'agents', // agents/gsd-*.md
|
||||
'hooks', // hooks/gsd-*.{sh,js}
|
||||
];
|
||||
|
||||
// Known existing gaps tracked in their own issues. Removing an entry should
|
||||
// land in the same PR that fixes the underlying issue; CI surfaces any NEW
|
||||
// gap as a hard failure.
|
||||
// (#2994 entry removed: this PR moves verify-reapply-patches.cjs to
|
||||
// gsd-core/bin/ which IS an installed prefix, closing the gap.)
|
||||
const KNOWN_GAPS = new Set();
|
||||
|
||||
test('no NEW workflow refs fail to resolve at the deployed path (KNOWN_GAPS allow-listed)', () => {
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir: require('node:path').join(ROOT, 'gsd-core', 'workflows'),
|
||||
repoRoot: ROOT,
|
||||
installedPrefixes: INSTALLED_PREFIXES,
|
||||
});
|
||||
const newGaps = r.findings.filter(
|
||||
(f) => !KNOWN_GAPS.has(`${f.workflow}|${f.path}|${f.kind}`),
|
||||
);
|
||||
if (newGaps.length > 0) {
|
||||
const summary = newGaps.map(
|
||||
(f) => ` ${f.workflow}: ${f.path} (${f.kind})`,
|
||||
).join('\n');
|
||||
assert.fail(
|
||||
`New workflow ref does not resolve at the deployed path:\n${summary}\n\n` +
|
||||
`Either move the script under one of [${INSTALLED_PREFIXES.join(', ')}], ` +
|
||||
`update bin/install.js to copy the new top-level directory, or ` +
|
||||
`(if intentionally tracked) add an entry to KNOWN_GAPS with the issue reference.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// #2996 CR: a reference that is both outside an installed prefix AND
|
||||
// missing from the repo must emit BOTH findings in one run. Previously
|
||||
// the code short-circuited on NOT_INSTALLED, hiding MISSING_FROM_REPO
|
||||
// until the developer fixed the prefix and re-ran CI.
|
||||
test('a reference that is both not-installed AND missing-from-repo emits both findings (no short-circuit)', () => {
|
||||
const { repoRoot, workflowsDir } = fixtureRepo({
|
||||
workflows: {
|
||||
'foo.md': '```bash\nnode "${GSD_HOME}/scripts/missing.cjs"\n```\n',
|
||||
},
|
||||
// Note: scripts/missing.cjs intentionally NOT created in the repo.
|
||||
});
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir,
|
||||
repoRoot,
|
||||
installedPrefixes: ['gsd-core', 'agents', 'hooks', 'commands'],
|
||||
});
|
||||
assert.equal(r.ok, false);
|
||||
const kinds = r.findings.filter((f) => f.path === 'scripts/missing.cjs').map((f) => f.kind).sort();
|
||||
assert.deepEqual(
|
||||
kinds,
|
||||
[AUDIT_FINDING.MISSING_FROM_REPO, AUDIT_FINDING.NOT_INSTALLED].sort(),
|
||||
'expected both NOT_INSTALLED and MISSING_FROM_REPO findings for the same ref',
|
||||
);
|
||||
});
|
||||
|
||||
test('KNOWN_GAPS entries still match real findings — fixed gaps must be removed from the allow-list', () => {
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir: require('node:path').join(ROOT, 'gsd-core', 'workflows'),
|
||||
repoRoot: ROOT,
|
||||
installedPrefixes: INSTALLED_PREFIXES,
|
||||
});
|
||||
const realKeys = new Set(r.findings.map((f) => `${f.workflow}|${f.path}|${f.kind}`));
|
||||
const stale = [...KNOWN_GAPS].filter((k) => !realKeys.has(k));
|
||||
assert.deepEqual(
|
||||
stale,
|
||||
[],
|
||||
`KNOWN_GAPS contains entries not present in audit findings — remove these: ${stale.join(', ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #3019.
|
||||
*
|
||||
* `gsd-sdk query <subcommand> --help` returned the top-level SDK USAGE
|
||||
* instead of contextual help for the subcommand. The query argv parser
|
||||
* harvested --help as a global flag and main() short-circuited dispatch
|
||||
* before the registry handler / gsd-tools.cjs fallback could render
|
||||
* useful help.
|
||||
*
|
||||
* Two-layer fix:
|
||||
* 1. sdk/src/cli.ts — leave --help in queryArgv so it travels to the
|
||||
* handler/fallback. Only honor the global help flag when there is
|
||||
* no subcommand to dispatch to.
|
||||
* 2. gsd-core/bin/gsd-tools.cjs — render the top-level usage on
|
||||
* --help instead of erroring. Anti-hallucination invariant from
|
||||
* #1818 is preserved (the destructive command never executes).
|
||||
*
|
||||
* Tests the integration: invoke gsd-tools.cjs the same way the SDK
|
||||
* dispatcher does and assert structured-IR (success flag + usage shape)
|
||||
* rather than raw substring matches.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { runGsdTools, isUsageOutput } = require('./helpers.cjs');
|
||||
|
||||
// #3026 CR (Major outside-diff): the SDK fallback wraps gsd-tools.cjs.
|
||||
// When gsd-tools emits plain-text help (exit 0), the SDK previously
|
||||
// JSON.parsed stdout and threw "Unexpected token 'U'". Verify the fix
|
||||
// by invoking the built SDK end-to-end and asserting:
|
||||
// - exit 0
|
||||
// - stdout contains the gsd-tools usage
|
||||
// - stderr does NOT contain a JSON parse error
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const SDK_CLI = path.join(__dirname, '..', 'sdk', 'dist', 'cli.js');
|
||||
const fs = require('node:fs');
|
||||
|
||||
describe('bug #3026 (CR Major outside-diff): SDK forwards plain-text help from gsd-tools fallback', () => {
|
||||
test('gsd-sdk query phase --help (fallback path) returns usage, not a JSON parse error', (t) => {
|
||||
if (!fs.existsSync(SDK_CLI)) {
|
||||
// CR feedback (#3026): a bare `return` here silent-passes the test
|
||||
// when sdk/dist/cli.js is absent (CI checkouts that haven't run
|
||||
// `npm run build`), giving no signal that the integration check
|
||||
// was skipped. Use t.skip() so the omission is visible in the
|
||||
// test report. The unit-level fix is covered by vitest on
|
||||
// sdk/src/cli.ts; this integration test only runs when the
|
||||
// built SDK is on disk.
|
||||
t.skip('sdk/dist/cli.js not built — run `npm run build` in sdk/ to enable this integration test');
|
||||
return;
|
||||
}
|
||||
// `query phase --help` (no further subcommand) is NOT in the native
|
||||
// registry, so it routes through the gsd-tools.cjs fallback. That is
|
||||
// the path that JSON.parsed the help text and threw before this fix.
|
||||
const result = spawnSync(process.execPath, [SDK_CLI, 'query', 'phase', '--help'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
// The fallback gsd-tools.cjs emits exit 0 with usage on stdout.
|
||||
assert.strictEqual(result.status, 0,
|
||||
`must exit 0 — got ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
// Negative: must NOT see the JSON parse error that was the regression.
|
||||
assert.ok(!/Unexpected token|not valid JSON/i.test(result.stderr),
|
||||
`must NOT JSON.parse the help text (stderr): ${result.stderr}`);
|
||||
// Positive: the usage should reach the user via stdout.
|
||||
assert.ok(/Usage:\s*gsd-tools/.test(result.stdout) && /Commands:/.test(result.stdout),
|
||||
`usage must reach stdout: ${result.stdout}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #3019: gsd-tools renders usage on --help instead of erroring', () => {
|
||||
test('bare gsd-tools (no args) renders usage', () => {
|
||||
const result = runGsdTools([]);
|
||||
// No args path: error() helper emits to stderr and exits non-zero,
|
||||
// but the message body is the usage.
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.ok(/Usage:\s*gsd-tools/.test(result.error));
|
||||
assert.ok(/Commands:/.test(result.error));
|
||||
});
|
||||
|
||||
test('gsd-tools --help renders usage on stdout, exits 0', () => {
|
||||
const result = runGsdTools(['--help']);
|
||||
assert.strictEqual(result.success, true, '--help should not be an error');
|
||||
assert.ok(isUsageOutput(result.output), `expected usage on stdout, got: ${result.output}`);
|
||||
});
|
||||
|
||||
test('gsd-tools -h renders usage on stdout, exits 0', () => {
|
||||
const result = runGsdTools(['-h']);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output));
|
||||
});
|
||||
|
||||
test('gsd-tools <subcommand> --help renders usage (does not run subcommand)', () => {
|
||||
// The classic #3019 surface: the user types a subcommand expecting
|
||||
// contextual help. We render the top-level usage — strictly better
|
||||
// than the previous unhelpful "Unknown flag --help" error.
|
||||
const result = runGsdTools(['phase', 'add', '--help']);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output));
|
||||
});
|
||||
|
||||
test('usage hint mentions how to discover argument requirements', () => {
|
||||
// The usage now points users at the discovery method that actually works
|
||||
// (run without args → error message names required arguments). Asserting
|
||||
// on the parsed shape of the usage rather than substring-matching prose:
|
||||
const result = runGsdTools(['--help']);
|
||||
assert.strictEqual(result.success, true);
|
||||
// Structural check: split into sections.
|
||||
const lines = result.output.split('\n');
|
||||
const hasUsageLine = lines.some((l) => l.startsWith('Usage:'));
|
||||
const hasCommandsLine = lines.some((l) => l.startsWith('Commands:'));
|
||||
const hasDiscoveryHint = lines.some((l) => /argument requirements|without args|invoke the command/i.test(l));
|
||||
assert.ok(hasUsageLine, 'first section: Usage');
|
||||
assert.ok(hasCommandsLine, 'second section: Commands');
|
||||
assert.ok(hasDiscoveryHint, 'third section: how to discover per-command args');
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const DRIFT_LINT = path.join(ROOT, 'scripts', 'lint-shell-command-projection-drift.cjs');
|
||||
|
||||
function runLint(targetFile) {
|
||||
return spawnSync(process.execPath, [DRIFT_LINT, targetFile], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
// (The buildWindowsShimTriple parity test was removed with the gsd-sdk shim,
|
||||
// #191. The serialized-command drift guard below is retained and unaffected.)
|
||||
|
||||
describe('bug #3442: shim/wrapper serialized-command drift guard', () => {
|
||||
test('drift guard passes for current install.js', () => {
|
||||
const result = runLint(path.join(ROOT, 'bin', 'install.js'));
|
||||
assert.equal(result.status, 0, `expected lint pass, got:\n${result.stderr || result.stdout}`);
|
||||
});
|
||||
|
||||
test('drift guard fails when install-owned inline shim text builder is present', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3442-'));
|
||||
try {
|
||||
const fixture = path.join(tmp, 'install-inline-builder.js');
|
||||
fs.writeFileSync(
|
||||
fixture,
|
||||
[
|
||||
'function badBuilder() {',
|
||||
" return '@ECHO OFF\\r\\n@SETLOCAL\\r\\n@node \"C:/shim.js\" %*\\r\\n';",
|
||||
'}',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
const result = runLint(fixture);
|
||||
assert.notEqual(result.status, 0, 'inline shim renderer should be rejected by the drift guard');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('drift guard does not block safe subprocess execution patterns', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3442-'));
|
||||
try {
|
||||
const fixture = path.join(tmp, 'install-subprocess-safe.js');
|
||||
fs.writeFileSync(
|
||||
fixture,
|
||||
[
|
||||
"const cp = require('node:child_process');",
|
||||
"cp.spawnSync('cmd.exe', ['/c', 'echo ok']);",
|
||||
"cp.execFileSync('bash', ['-lc', 'printf %s \"$PATH\"']);",
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
const result = runLint(fixture);
|
||||
assert.equal(result.status, 0, `spawnSync/execFileSync should remain allowed:\n${result.stderr || result.stdout}`);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow `.md` files are the runtime contract executed by Claude Code as
|
||||
// embedded bash. This test extracts the actual `check_incomplete_work` bash
|
||||
// block from resume-project.md and exercises it against a planted directory
|
||||
// layout — that's a behavioral integration test of the workflow contract,
|
||||
// not regex-on-source.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'resume-project.md');
|
||||
|
||||
// Extract the first ```bash``` code block inside the
|
||||
// `<step name="check_incomplete_work">` element. That's the snippet the
|
||||
// runtime actually executes; it's what we want to validate.
|
||||
function extractCheckBlock() {
|
||||
const md = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const stepStart = md.indexOf('<step name="check_incomplete_work">');
|
||||
assert.ok(stepStart >= 0, 'resume-project.md must contain a check_incomplete_work step');
|
||||
const stepEnd = md.indexOf('</step>', stepStart);
|
||||
assert.ok(
|
||||
stepEnd >= 0,
|
||||
'check_incomplete_work step must have a closing </step> tag',
|
||||
);
|
||||
const stepBody = md.slice(stepStart, stepEnd);
|
||||
const fenceMatch = stepBody.match(/```(?:bash|sh)\r?\n([\s\S]*?)\r?\n```/);
|
||||
assert.ok(fenceMatch, 'check_incomplete_work step must embed a ```bash code block');
|
||||
return fenceMatch[1];
|
||||
}
|
||||
|
||||
function runSnippet(cwd, snippet) {
|
||||
// has_interrupted_agent is a downstream-orchestrator variable; default it
|
||||
// to "false" so the embedded `if` branch is a no-op during this test.
|
||||
return spawnSync('bash', ['-c', snippet], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, has_interrupted_agent: 'false', interrupted_agent_id: '' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('bug #3446: resume-project detects non-phase and legacy continue-here handoffs', () => {
|
||||
let tmpDir;
|
||||
let snippet;
|
||||
|
||||
before(() => {
|
||||
snippet = extractCheckBlock();
|
||||
tmpDir = createTempDir('gsd-bug-3446-');
|
||||
|
||||
// Plant the three discovery surfaces that bug #3446 was originally
|
||||
// filed to cover.
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', '.continue-here.md'),
|
||||
'---\ncontext: default\n---\nroot-of-.planning handoff\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'sketches', 'SKETCH-001'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'sketches', 'SKETCH-001', '.continue-here.md'),
|
||||
'---\ncontext: sketch\n---\nsketch handoff\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.continue-here.md'),
|
||||
'---\ncontext: legacy\n---\nlegacy repo-root handoff\n',
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('check_incomplete_work surfaces .planning/.continue-here.md (depth 1 under .planning)', () => {
|
||||
const result = runSnippet(tmpDir, snippet);
|
||||
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/\.planning\/\.continue-here\.md/,
|
||||
`expected .planning/.continue-here.md in stdout; got: ${JSON.stringify(result.stdout)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('check_incomplete_work surfaces .planning/sketches/SKETCH-001/.continue-here.md (depth 3 under .planning)', () => {
|
||||
const result = runSnippet(tmpDir, snippet);
|
||||
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/\.planning\/sketches\/SKETCH-001\/\.continue-here\.md/,
|
||||
`expected sketch handoff in stdout; got: ${JSON.stringify(result.stdout)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('check_incomplete_work surfaces legacy repo-root .continue-here.md', () => {
|
||||
const result = runSnippet(tmpDir, snippet);
|
||||
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/(^|\n)\.\/\.continue-here\.md(\n|$)/,
|
||||
`expected legacy ./.continue-here.md in stdout; got: ${JSON.stringify(result.stdout)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Bug #3491 — new-project workflow creates nested .git in subdirectory when
|
||||
// parent already has git repo.
|
||||
//
|
||||
// The workflow's `has_git` boolean was derived from `pathExists(cwd, '.git')`
|
||||
// — a shallow check that only sees a `.git` entry directly in the current
|
||||
// directory. Subdirectories of an existing git worktree therefore reported
|
||||
// `has_git: false`, causing the workflow's `git init` step to create a nested
|
||||
// `.git` inside the outer repo's worktree. Subsequent gsd-sdk commits then
|
||||
// targeted the nested repo instead of the outer one, silently dropping all
|
||||
// planning artefacts from the outer repo's history.
|
||||
//
|
||||
// This test asserts the corrected semantics, mirroring `git rev-parse
|
||||
// --is-inside-work-tree`:
|
||||
//
|
||||
// - `has_git: true` is reported whenever the cwd is inside a git worktree,
|
||||
// even when no `.git` entry is in cwd itself.
|
||||
// - The init payload surfaces `git_worktree_root` and `in_nested_subdir` so
|
||||
// the workflow can warn the user and skip `git init`.
|
||||
// - The workflow markdown's `git init` step is gated on
|
||||
// `in_nested_subdir: false`, never unconditional under `has_git: false`.
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execSync } = require('node:child_process');
|
||||
|
||||
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const WORKFLOW_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'new-project.md',
|
||||
);
|
||||
|
||||
// ─── Helper: create outer git repo with a nested workstream subdir ─────────
|
||||
|
||||
// On Windows the runtime emits forward slashes (git's convention) while
|
||||
// path.join produces backslashes — normalize both sides via the shared
|
||||
// toPosixPath helper before any equality comparison.
|
||||
const { toPosixPath: normalizePath } = require('./helpers.cjs');
|
||||
|
||||
function createOuterRepoWithSubdir(prefix = 'bug-3491-') {
|
||||
const outer = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
// macOS /tmp -> /private/tmp; on Windows the runner's %TEMP% is the 8.3
|
||||
// short-name (RUNNER~1) and the runtime resolves to the long form.
|
||||
// realpathSync.native handles both; then normalize separators for compare.
|
||||
const outerReal = fs.realpathSync.native(outer);
|
||||
execSync('git init', { cwd: outerReal, stdio: 'pipe' });
|
||||
execSync('git config user.email "test@test.com"', { cwd: outerReal, stdio: 'pipe' });
|
||||
execSync('git config user.name "Test"', { cwd: outerReal, stdio: 'pipe' });
|
||||
execSync('git config commit.gpgsign false', { cwd: outerReal, stdio: 'pipe' });
|
||||
fs.writeFileSync(path.join(outerReal, 'README.md'), '# outer\n');
|
||||
execSync('git add -A', { cwd: outerReal, stdio: 'pipe' });
|
||||
execSync('git commit -m "initial"', { cwd: outerReal, stdio: 'pipe' });
|
||||
|
||||
const subdir = path.join(outerReal, 'workstreams', 'my-project');
|
||||
fs.mkdirSync(subdir, { recursive: true });
|
||||
return { outer: outerReal, subdir };
|
||||
}
|
||||
|
||||
// ─── Behavioural tests against the live `init new-project` handler ─────────
|
||||
|
||||
test('bug-3491: init new-project reports has_git: true inside parent git worktree', () => {
|
||||
const { outer, subdir } = createOuterRepoWithSubdir();
|
||||
try {
|
||||
const result = runGsdTools('init new-project', subdir);
|
||||
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
||||
|
||||
const payload = JSON.parse(result.output);
|
||||
|
||||
// Core fix: shallow `.git in cwd` check was wrong — we are inside the
|
||||
// outer worktree, so the workflow MUST see has_git: true.
|
||||
assert.strictEqual(
|
||||
payload.has_git,
|
||||
true,
|
||||
'expected has_git=true when cwd is inside an existing git worktree (parent .git)',
|
||||
);
|
||||
|
||||
// The workflow needs the worktree root and a nesting flag to decide
|
||||
// whether to skip `git init` and emit a friendly warning.
|
||||
assert.strictEqual(
|
||||
normalizePath(payload.git_worktree_root),
|
||||
normalizePath(outer),
|
||||
`expected git_worktree_root to be the outer repo (${outer}), got: ${payload.git_worktree_root}`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
payload.in_nested_subdir,
|
||||
true,
|
||||
'expected in_nested_subdir=true when cwd is a subdirectory of the worktree root',
|
||||
);
|
||||
} finally {
|
||||
cleanup(outer);
|
||||
}
|
||||
});
|
||||
|
||||
test('bug-3491: init new-project reports has_git: true at worktree root with in_nested_subdir: false', () => {
|
||||
const { outer } = createOuterRepoWithSubdir();
|
||||
try {
|
||||
const result = runGsdTools('init new-project', outer);
|
||||
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
||||
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.strictEqual(payload.has_git, true, 'has_git must be true at the worktree root');
|
||||
assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer));
|
||||
assert.strictEqual(
|
||||
payload.in_nested_subdir,
|
||||
false,
|
||||
'at the worktree root, in_nested_subdir must be false',
|
||||
);
|
||||
} finally {
|
||||
cleanup(outer);
|
||||
}
|
||||
});
|
||||
|
||||
test('bug-3491: init new-project reports has_git: false outside any git worktree', () => {
|
||||
const tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3491-bare-')));
|
||||
try {
|
||||
const result = runGsdTools('init new-project', tmp);
|
||||
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.strictEqual(payload.has_git, false);
|
||||
assert.strictEqual(payload.in_nested_subdir, false);
|
||||
assert.strictEqual(payload.git_worktree_root, null);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('bug-3491: init ingest-docs mirrors the same has_git semantics', () => {
|
||||
// ingest-docs.md has the same shallow check and the same nested-init risk.
|
||||
const { outer, subdir } = createOuterRepoWithSubdir('bug-3491-ingest-');
|
||||
try {
|
||||
const result = runGsdTools('init ingest-docs', subdir);
|
||||
assert.ok(result.success, `init ingest-docs failed: ${result.error}`);
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.strictEqual(
|
||||
payload.has_git,
|
||||
true,
|
||||
'init ingest-docs must also detect parent worktree (#3491 related path)',
|
||||
);
|
||||
assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer));
|
||||
assert.strictEqual(payload.in_nested_subdir, true);
|
||||
} finally {
|
||||
cleanup(outer);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Workflow-text test: the deployed `new-project.md` must gate `git init` ─
|
||||
|
||||
test('bug-3491: new-project.md gates `git init` on in_nested_subdir, not just has_git', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
|
||||
// The pre-fix workflow had the literal sequence:
|
||||
//
|
||||
// **If `has_git` is false:** Initialize git:
|
||||
// ```bash
|
||||
// git init
|
||||
// ```
|
||||
//
|
||||
// …which fires for any subdirectory of an existing repo. The fix must
|
||||
// either gate the init on `in_nested_subdir`/worktree-root semantics or
|
||||
// drop the unconditional `git init` block entirely.
|
||||
const unconditionalInitPattern =
|
||||
/\*\*If `has_git` is false:\*\* Initialize git:\s*\r?\n+```bash\s*\r?\ngit init\s*\r?\n```/;
|
||||
assert.ok(
|
||||
!unconditionalInitPattern.test(content),
|
||||
'new-project.md must not run `git init` unconditionally on has_git=false (#3491). ' +
|
||||
'Gate it on `in_nested_subdir === false` so the workflow refuses to create ' +
|
||||
'a nested .git inside an existing worktree.',
|
||||
);
|
||||
|
||||
// The fixed workflow MUST mention the new field so reviewers can see the
|
||||
// gating exists. (Workflow markdown IS the deployed product — testing it
|
||||
// as text is the only end-to-end signal we have.)
|
||||
assert.ok(
|
||||
/in_nested_subdir/.test(content),
|
||||
'new-project.md must reference `in_nested_subdir` after the #3491 fix',
|
||||
);
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* Regression tests for #3509 — CLI breaks when repo path contains spaces
|
||||
*
|
||||
* Root cause: test code embedded space-containing paths into runGsdTools()
|
||||
* string args; the helper's whitespace tokenizer truncated paths at the first
|
||||
* space. All calls that carry dynamic paths must use the array form of
|
||||
* runGsdTools() so execFileSync receives the full path as a single argv slot.
|
||||
*
|
||||
* These tests create a tmpdir whose prefix intentionally contains a space so
|
||||
* they remain red on a broken codebase regardless of the host machine's
|
||||
* tmpdir location.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// Create a tmpdir whose name always contains a space — this is the invariant
|
||||
// that was violated on /Volumes/Mini Me/... machines.
|
||||
function createSpacedTmpDir(prefix = 'path with spaces-') {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
// ─── dispatcher --cwd= with space in path ────────────────────────────────────
|
||||
|
||||
describe('bug-3509: --cwd= survives spaces in path', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createSpacedTmpDir();
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
'# Project State\n\n## Current Position\n\nPhase: 1 of 1 (Test)\n'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('--cwd= array form passes full path with spaces to dispatcher', () => {
|
||||
// Array form: path is a single argv slot, never split on whitespace
|
||||
const result = runGsdTools(['--cwd=' + tmpDir, 'state', 'load'], process.cwd());
|
||||
assert.ok(result.success, `--cwd= with spaced path should succeed, got: ${result.error}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── frontmatter-cli file path with spaces ───────────────────────────────────
|
||||
|
||||
describe('bug-3509: frontmatter get/set/merge/validate survive spaces in file path', () => {
|
||||
let tmpDir;
|
||||
let tmpFile;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createSpacedTmpDir();
|
||||
tmpFile = path.join(tmpDir, 'test.md');
|
||||
fs.writeFileSync(tmpFile, '---\nphase: 01\nplan: 01\ntype: execute\n---\nbody');
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('frontmatter get returns parsed fields when file path contains spaces', () => {
|
||||
const result = runGsdTools(['frontmatter', 'get', tmpFile]);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.strictEqual(parsed.phase, '01', 'phase field should be "01"');
|
||||
});
|
||||
|
||||
test('frontmatter set works when file path contains spaces', () => {
|
||||
const setResult = runGsdTools(['frontmatter', 'set', tmpFile, '--field', 'phase', '--value', '02']);
|
||||
assert.ok(setResult.success, `set failed: ${setResult.error}`);
|
||||
// Verify behaviorally — round-trip via frontmatter get rather than reading the file
|
||||
// and grepping (which trips lint-no-source-grep even on tmp files).
|
||||
const getResult = runGsdTools(['frontmatter', 'get', tmpFile]);
|
||||
assert.ok(getResult.success, `get failed: ${getResult.error}`);
|
||||
const parsed = JSON.parse(getResult.output);
|
||||
assert.strictEqual(parsed.phase, '02', 'field should be updated to "02"');
|
||||
});
|
||||
|
||||
test('frontmatter validate works when file path contains spaces', () => {
|
||||
// Plan frontmatter schema — file path contains a space; must reach validation, not fail on path
|
||||
const result = runGsdTools(['frontmatter', 'validate', tmpFile, '--schema', 'plan']);
|
||||
// Should succeed (exit 0) and return structured JSON with valid/missing, not a path-split error
|
||||
assert.ok(result.success, `Command should exit 0, got: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.ok('valid' in out, 'should return structured JSON with "valid" field');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── verify-path-exists with absolute path containing spaces ─────────────────
|
||||
|
||||
describe('bug-3509: verify-path-exists survives absolute paths with spaces', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createSpacedTmpDir();
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('absolute path with spaces resolves correctly via array form', () => {
|
||||
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, 'file should be found');
|
||||
assert.strictEqual(output.type, 'file');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── profile-pipeline --path with spaces ─────────────────────────────────────
|
||||
|
||||
describe('bug-3509: scan-sessions --path survives spaces in path', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createSpacedTmpDir();
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('scan-sessions --path with spaces returns empty array, not path-split error', () => {
|
||||
const sessionsDir = path.join(tmpDir, 'projects');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const result = runGsdTools(['scan-sessions', '--path', sessionsDir, '--raw'], tmpDir);
|
||||
assert.ok(result.success, `Failed: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.ok(Array.isArray(out), 'should return an array');
|
||||
assert.strictEqual(out.length, 0, 'should be empty for empty sessions dir');
|
||||
});
|
||||
});
|
||||
@@ -1,386 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: validates runtime CLI stdout/stderr warning behavior, not source grep
|
||||
|
||||
/**
|
||||
* Regression tests for #3523 — CJS loadConfig must not emit a false
|
||||
* "unknown config key(s)" warning for `branching_strategy` when that key
|
||||
* is written at the top level of .planning/config.json.
|
||||
*
|
||||
* Root cause: KNOWN_TOP_LEVEL in core.cjs was built from VALID_CONFIG_KEYS
|
||||
* via k.split('.')[0], which turns 'git.branching_strategy' → 'git', not
|
||||
* 'branching_strategy'. So a config with the legacy top-level shape tripped
|
||||
* the unknown-key warning even though core.cjs:485 actively reads the value.
|
||||
*
|
||||
* Fix (option 3 — self-healing): mirror the multiRepo → planning.sub_repos
|
||||
* precedent: graft branching_strategy into fileData.git.branching_strategy
|
||||
* and delete the top-level key, then persist. The KNOWN_TOP_LEVEL list also
|
||||
* gains 'branching_strategy' as a deprecated-still-accepted key so the warning
|
||||
* never fires even on the first read before the write-back occurs.
|
||||
*
|
||||
* Double-emission is also reduced: the warning site is guarded by a
|
||||
* module-level Set so repeated loadConfig calls during one CLI invocation
|
||||
* don't echo the same line twice.
|
||||
*
|
||||
* CJS↔SDK contract: the SDK mergeDefaults() already handles the legacy
|
||||
* top-level key (PR #3116). This file adds a fixture-level parity check
|
||||
* that proves both paths produce the same branching_strategy value.
|
||||
*
|
||||
* Test strategy: we use `resolve-model` as the minimal CJS entry point that
|
||||
* calls loadConfig internally, then assert on stderr emptiness (typed-IR
|
||||
* "no warning" pattern from #2687).
|
||||
*/
|
||||
|
||||
const { describe, test, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Run gsd-tools and return { stdout, stderr, status }.
|
||||
* Always captures stderr even when exit code is 0.
|
||||
*/
|
||||
function runWithStderr(args, cwd, env = {}) {
|
||||
const result = spawnSync(process.execPath, [TOOLS_PATH, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, ...TEST_ENV_BASE, ...env },
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
status: result.status,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Test 1: no warning for legacy top-level branching_strategy ──────────────
|
||||
|
||||
describe('bug-3523 — no warning for legacy top-level branching_strategy', () => {
|
||||
let tmpDir;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) cleanup(tmpDir);
|
||||
tmpDir = null;
|
||||
});
|
||||
|
||||
test('loadConfig emits no stderr when config.json has top-level branching_strategy', () => {
|
||||
tmpDir = createTempProject('gsd-3523-warn-');
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
branching_strategy: 'phase',
|
||||
git: { base_branch: 'main' },
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// resolve-model calls loadConfig internally, triggering KNOWN_TOP_LEVEL check.
|
||||
const result = runWithStderr(['resolve-model', 'planner'], tmpDir);
|
||||
|
||||
assert.equal(
|
||||
result.stderr.trim(),
|
||||
'',
|
||||
`loadConfig must not warn about top-level branching_strategy (#3523) — got: ${result.stderr}`
|
||||
);
|
||||
});
|
||||
|
||||
test('branching_strategy value is still surfaced after loadConfig on legacy shape', () => {
|
||||
tmpDir = createTempProject('gsd-3523-value-');
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
branching_strategy: 'milestone',
|
||||
git: { base_branch: 'main' },
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Trigger loadConfig (which runs the migration and writes git.branching_strategy
|
||||
// back to disk), then read it with config-get to verify the value is preserved.
|
||||
const triggerResult = runWithStderr(['resolve-model', 'planner'], tmpDir);
|
||||
assert.equal(
|
||||
triggerResult.stderr.trim(),
|
||||
'',
|
||||
`No warning should fire on legacy shape (#3523) — got: ${triggerResult.stderr}`
|
||||
);
|
||||
|
||||
// After migration write-back, config-get should find git.branching_strategy.
|
||||
const result = runWithStderr(['config-get', 'git.branching_strategy'], tmpDir);
|
||||
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`config-get command must succeed — exit status ${result.status}, stderr: ${result.stderr}`
|
||||
);
|
||||
assert.equal(
|
||||
result.stderr.trim(),
|
||||
'',
|
||||
`No error should fire when reading migrated branching_strategy (#3523) — got: ${result.stderr}`
|
||||
);
|
||||
assert.ok(
|
||||
result.stdout.includes('milestone'),
|
||||
`Expected git.branching_strategy to be 'milestone' but got: ${result.stdout}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 2: no duplicated warning (double-emission) ─────────────────────────
|
||||
|
||||
describe('bug-3523 — double-emission reduced to single-emission', () => {
|
||||
let tmpDir;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) cleanup(tmpDir);
|
||||
tmpDir = null;
|
||||
});
|
||||
|
||||
test('unknown-key warning appears at most once per process invocation', () => {
|
||||
// Use a key that IS genuinely unknown (not branching_strategy, which is now
|
||||
// fixed) to verify the deduplication guard works for other keys too.
|
||||
// We verify that the count of warning lines for a single unknown key is
|
||||
// exactly once — not zero and not two — even if loadConfig is invoked twice internally.
|
||||
tmpDir = createTempProject('gsd-3523-dedup-');
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
// intentionally_unknown_key_for_dedup_test: a key that can never be valid
|
||||
__gsd3523_dedup_sentinel__: true,
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const result = runWithStderr(['resolve-model', 'planner'], tmpDir);
|
||||
|
||||
// Count how many times the sentinel key appears in warnings
|
||||
const warningLines = result.stderr
|
||||
.split('\n')
|
||||
.filter(l => l.includes('__gsd3523_dedup_sentinel__'));
|
||||
|
||||
assert.equal(
|
||||
warningLines.length,
|
||||
1,
|
||||
`Unknown-key warning must appear exactly once per process invocation — ` +
|
||||
`appeared ${warningLines.length} times. stderr:\n${result.stderr}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 3: on-disk migration (option 3 write-back) ─────────────────────────
|
||||
|
||||
describe('bug-3523 — option 3 on-disk migration of branching_strategy', () => {
|
||||
let tmpDir;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) cleanup(tmpDir);
|
||||
tmpDir = null;
|
||||
});
|
||||
|
||||
test('after loadConfig, on-disk config.json has branching_strategy under git.*', () => {
|
||||
tmpDir = createTempProject('gsd-3523-writeback-');
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
branching_strategy: 'phase',
|
||||
git: { base_branch: 'main' },
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Trigger loadConfig by running a command.
|
||||
runWithStderr(['resolve-model', 'planner'], tmpDir);
|
||||
|
||||
// On-disk file should now have git.branching_strategy and no top-level branching_strategy.
|
||||
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
assert.equal(
|
||||
onDisk.git?.branching_strategy,
|
||||
'phase',
|
||||
'Expected on-disk config.json to have git.branching_strategy = "phase" after migration'
|
||||
);
|
||||
assert.equal(
|
||||
onDisk.branching_strategy,
|
||||
undefined,
|
||||
'Expected on-disk config.json to have no top-level branching_strategy after migration'
|
||||
);
|
||||
});
|
||||
|
||||
test('migration does not clobber existing git.branching_strategy', () => {
|
||||
// If git.branching_strategy is already set, the top-level value should
|
||||
// not overwrite it (nested wins, matching SDK mergeDefaults precedence).
|
||||
tmpDir = createTempProject('gsd-3523-no-clobber-');
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
branching_strategy: 'phase', // legacy top-level
|
||||
git: {
|
||||
base_branch: 'main',
|
||||
branching_strategy: 'milestone', // canonical nested — must win
|
||||
},
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Trigger loadConfig.
|
||||
runWithStderr(['resolve-model', 'planner'], tmpDir);
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
assert.equal(
|
||||
onDisk.git?.branching_strategy,
|
||||
'milestone',
|
||||
'canonical git.branching_strategy must not be overwritten by legacy top-level key'
|
||||
);
|
||||
// top-level key should be removed since it was redundant
|
||||
assert.equal(
|
||||
onDisk.branching_strategy,
|
||||
undefined,
|
||||
'top-level branching_strategy should be removed even when git.branching_strategy already set'
|
||||
);
|
||||
});
|
||||
|
||||
test('workstream load also self-heals legacy root branching_strategy', () => {
|
||||
tmpDir = createTempProject('gsd-3523-workstream-root-');
|
||||
const rootConfigPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
const workstreamDir = path.join(tmpDir, '.planning', 'workstreams', 'alpha');
|
||||
fs.mkdirSync(workstreamDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
rootConfigPath,
|
||||
JSON.stringify({
|
||||
branching_strategy: 'phase',
|
||||
git: { base_branch: 'main' },
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(workstreamDir, 'config.json'),
|
||||
JSON.stringify({ workflow: { tdd: true } }, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const triggerResult = runWithStderr(['resolve-model', 'planner'], tmpDir, {
|
||||
GSD_WORKSTREAM: 'alpha',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
triggerResult.status,
|
||||
0,
|
||||
`workstream load command must succeed — exit status ${triggerResult.status}, stderr: ${triggerResult.stderr}`
|
||||
);
|
||||
assert.equal(
|
||||
triggerResult.stderr.trim(),
|
||||
'',
|
||||
`No warning should fire while migrating root config for a workstream — got: ${triggerResult.stderr}`
|
||||
);
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(rootConfigPath, 'utf-8'));
|
||||
assert.equal(
|
||||
onDisk.git?.branching_strategy,
|
||||
'phase',
|
||||
'Expected root config.json to persist git.branching_strategy after workstream load'
|
||||
);
|
||||
assert.equal(
|
||||
onDisk.branching_strategy,
|
||||
undefined,
|
||||
'Expected root config.json to remove top-level branching_strategy after workstream load'
|
||||
);
|
||||
|
||||
const rootResult = runWithStderr(['config-get', 'git.branching_strategy'], tmpDir);
|
||||
assert.equal(
|
||||
rootResult.status,
|
||||
0,
|
||||
`root config-get command must succeed after workstream migration — exit status ${rootResult.status}, stderr: ${rootResult.stderr}`
|
||||
);
|
||||
assert.ok(
|
||||
rootResult.stdout.includes('phase'),
|
||||
`Expected migrated root git.branching_strategy to be 'phase' but got: ${rootResult.stdout}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 4: CJS↔SDK contract parity ────────────────────────────────────────
|
||||
|
||||
describe('bug-3523 — CJS↔SDK contract: both agree on legacy branching_strategy fixture', () => {
|
||||
/**
|
||||
* This is a light-touch contract test: we invoke the CJS path via CLI and
|
||||
* compare the branching_strategy value it returns against what the SDK's
|
||||
* mergeDefaults would compute for the same fixture.
|
||||
*
|
||||
* We can't import SDK TypeScript here, so we assert on the CJS output and
|
||||
* use a snapshot of expected SDK behavior derived from the mergeDefaults
|
||||
* source (sdk/src/config.ts:192-218):
|
||||
* mergeDefaults({ branching_strategy: 'phase', git: { base_branch: 'main' } })
|
||||
* → git.branching_strategy = 'phase'
|
||||
*/
|
||||
let tmpDir;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) cleanup(tmpDir);
|
||||
tmpDir = null;
|
||||
});
|
||||
|
||||
test('CJS loadConfig surfaces branching_strategy matching SDK mergeDefaults behavior', () => {
|
||||
tmpDir = createTempProject('gsd-3523-parity-');
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
// The fixture that the SDK's mergeDefaults handles correctly (PR #3116).
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
branching_strategy: 'phase',
|
||||
git: { base_branch: 'main' },
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// SDK mergeDefaults produces: git.branching_strategy = 'phase'
|
||||
// CJS loadConfig must produce the same. Trigger loadConfig first (migration
|
||||
// writes git.branching_strategy to disk), then verify with config-get.
|
||||
const triggerResult = runWithStderr(['resolve-model', 'planner'], tmpDir);
|
||||
assert.equal(
|
||||
triggerResult.stderr.trim(),
|
||||
'',
|
||||
`No warning must fire on a standard legacy fixture — got: ${triggerResult.stderr}`
|
||||
);
|
||||
|
||||
// After the migration write-back, config-get must find git.branching_strategy = 'phase',
|
||||
// matching what the SDK's mergeDefaults would compute.
|
||||
const result = runWithStderr(['config-get', 'git.branching_strategy'], tmpDir);
|
||||
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`config-get command must succeed — exit status ${result.status}, stderr: ${result.stderr}`
|
||||
);
|
||||
assert.equal(
|
||||
result.stderr.trim(),
|
||||
'',
|
||||
`No error when reading post-migration git.branching_strategy — got: ${result.stderr}`
|
||||
);
|
||||
assert.ok(
|
||||
result.stdout.includes('phase'),
|
||||
`CJS must agree with SDK: git.branching_strategy = 'phase' for legacy fixture. ` +
|
||||
`Got: ${result.stdout}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Bug #3542 — Worktree stash storage is shared across agent worktrees;
|
||||
// `git stash pop` from an executor agent contaminates its isolation.
|
||||
//
|
||||
// Git stores stashes at `refs/stash` (plus the stash reflog) inside the
|
||||
// PARENT `.git/` directory. Every linked worktree shares that ref, so a
|
||||
// `git stash push` in any worktree (or in the main checkout) is visible —
|
||||
// and poppable — from every other worktree. From inside a worktree,
|
||||
// `git stash list` shows the shared list with no indication that an entry
|
||||
// originated elsewhere.
|
||||
//
|
||||
// Incident: an executor agent ran `git stash` (printed "No local changes
|
||||
// to save" — nothing pushed), then `git stash pop`, which yanked a stash
|
||||
// from a prior worktree-agent session. Result: 21 files in UU/UD state,
|
||||
// 16 phantom untracked files, ~12 minutes of recovery work. This breaks
|
||||
// the `isolation="worktree"` invariant documented in the executor agent.
|
||||
//
|
||||
// Two test cases:
|
||||
//
|
||||
// A. The agent prompt content asserts the `git stash` family is
|
||||
// prohibited and documents an alternative. The prompt content IS
|
||||
// the runtime contract for the agent — source-text-is-the-product
|
||||
// (per CONTEXT.md `RULESET.TESTS.no-source-grep.exemption`).
|
||||
//
|
||||
// B. A behavioural test that pins the git invariant the prohibition
|
||||
// defends against: a stash pushed in the main checkout is visible in
|
||||
// a linked worktree's `git stash list`, proving stash storage is
|
||||
// shared and cannot be relied on for worktree-scoped isolation.
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const EXECUTOR_PATH = path.join(__dirname, '..', 'agents', 'gsd-executor.md');
|
||||
|
||||
// ─── Test A — prompt content asserts the prohibition ───────────────────────
|
||||
|
||||
test('bug-3542: gsd-executor.md prohibits `git stash` family inside worktrees', () => {
|
||||
const content = fs.readFileSync(EXECUTOR_PATH, 'utf-8');
|
||||
|
||||
// The prohibition must call out `git stash` explicitly. Just listing
|
||||
// "stash" isn't enough — the existing post-wave-hook helper script
|
||||
// legitimately mentions stash, so we look for the specific forbidden
|
||||
// commands the agent must never run on its own.
|
||||
assert.match(
|
||||
content,
|
||||
/`git stash`/,
|
||||
'gsd-executor.md must explicitly forbid `git stash` (bare push) — see #3542',
|
||||
);
|
||||
assert.match(
|
||||
content,
|
||||
/`git stash pop`/,
|
||||
'gsd-executor.md must explicitly forbid `git stash pop` — the load-bearing footgun (#3542)',
|
||||
);
|
||||
assert.match(
|
||||
content,
|
||||
/`git stash apply`/,
|
||||
'gsd-executor.md must explicitly forbid `git stash apply` — same shared-stack hazard as pop (#3542)',
|
||||
);
|
||||
assert.match(
|
||||
content,
|
||||
/`git stash drop`/,
|
||||
'gsd-executor.md must explicitly forbid `git stash drop` — mutates the shared stack (#3542)',
|
||||
);
|
||||
|
||||
// The prohibition must explain WHY (shared storage across worktrees) so
|
||||
// the agent understands the failure mode rather than treating it as an
|
||||
// arbitrary rule.
|
||||
assert.match(
|
||||
content,
|
||||
/shared|share[d]?\s+(across|between)/i,
|
||||
'gsd-executor.md must document that stash storage is shared across worktrees (#3542)',
|
||||
);
|
||||
|
||||
// The prohibition must document at least one alternative the agent CAN
|
||||
// use to inspect or move work between refs without touching `refs/stash`.
|
||||
// The triage brief proposes commit-to-throwaway-branch OR read-only
|
||||
// `git show <ref>:<path>` / `git diff <ref> -- <path>`.
|
||||
const hasThrowawayBranch = /throwaway[- ]branch|temp(?:orary)?[- ]?branch|scratch[- ]branch/i.test(
|
||||
content,
|
||||
);
|
||||
const hasGitShow = /`git show /i.test(content);
|
||||
const hasGitDiffRef = /`git diff [^`]*\$?\{?ref\}?|`git diff [A-Z]+:/i.test(content);
|
||||
assert.ok(
|
||||
hasThrowawayBranch || hasGitShow || hasGitDiffRef,
|
||||
'gsd-executor.md must document an alternative to `git stash` ' +
|
||||
'(commit-to-throwaway-branch, or read-only `git show <ref>:<path>` / ' +
|
||||
'`git diff <ref> -- <path>`) so the agent has a sanctioned escape path (#3542)',
|
||||
);
|
||||
|
||||
// The issue number must appear so future readers can trace the rule to
|
||||
// its incident.
|
||||
assert.match(
|
||||
content,
|
||||
/#3542/,
|
||||
'gsd-executor.md must reference issue #3542 next to the stash prohibition for traceability',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Test B — behavioural pin of the git invariant ─────────────────────────
|
||||
|
||||
test('bug-3542: stash pushed in main checkout is visible inside a linked worktree', () => {
|
||||
const tmpRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3542-stash-')));
|
||||
const mainRepo = path.join(tmpRoot, 'main');
|
||||
const linkedWorktree = path.join(tmpRoot, 'wt');
|
||||
|
||||
try {
|
||||
// Set up a normal repo with one commit.
|
||||
fs.mkdirSync(mainRepo);
|
||||
const gitOpts = { cwd: mainRepo, stdio: 'pipe' };
|
||||
execSync('git init -q', gitOpts);
|
||||
execSync('git config user.email "test@test.com"', gitOpts);
|
||||
execSync('git config user.name "Test"', gitOpts);
|
||||
execSync('git config commit.gpgsign false', gitOpts);
|
||||
fs.writeFileSync(path.join(mainRepo, 'a.txt'), 'initial\n');
|
||||
execSync('git add a.txt', gitOpts);
|
||||
execSync('git commit -q -m initial', gitOpts);
|
||||
|
||||
// Create a linked worktree on a separate branch — this is what the
|
||||
// executor agent runs inside.
|
||||
execSync(`git worktree add -q "${linkedWorktree}" -b wt-branch`, gitOpts);
|
||||
|
||||
// Push a stash from the MAIN checkout (simulating a prior session).
|
||||
fs.writeFileSync(path.join(mainRepo, 'a.txt'), 'wip in main\n');
|
||||
execSync('git stash push -q -u -m "from-main-checkout"', gitOpts);
|
||||
|
||||
// Sanity check: the stash exists in the main checkout's view.
|
||||
const mainList = execSync('git stash list', { cwd: mainRepo }).toString();
|
||||
assert.match(
|
||||
mainList,
|
||||
/from-main-checkout/,
|
||||
'pre-condition: main checkout must see its own stash entry',
|
||||
);
|
||||
|
||||
// The load-bearing assertion: the linked worktree sees the same
|
||||
// stash entry, even though it was pushed from a different working
|
||||
// tree. This is the invariant that makes `git stash pop` inside an
|
||||
// executor agent's worktree an isolation violation.
|
||||
const worktreeList = execSync('git stash list', {
|
||||
cwd: linkedWorktree,
|
||||
}).toString();
|
||||
assert.match(
|
||||
worktreeList,
|
||||
/from-main-checkout/,
|
||||
'bug #3542 invariant: stash entries pushed from any worktree (or the ' +
|
||||
'main checkout) are visible in every linked worktree, because ' +
|
||||
'`refs/stash` lives in the shared parent .git directory. If this ' +
|
||||
'assertion ever stops holding (e.g. git introduces per-worktree ' +
|
||||
'stash storage in a future release), the executor agent prohibition ' +
|
||||
'in agents/gsd-executor.md can be relaxed.',
|
||||
);
|
||||
|
||||
// Stronger pin: a `git stash pop` inside the worktree must actually
|
||||
// pop the stash pushed from main — proving cross-worktree mutation,
|
||||
// not just visibility. We pop into a clean working tree on a
|
||||
// different branch, so any applied content is the contamination.
|
||||
execSync('git stash pop -q', { cwd: linkedWorktree, stdio: 'pipe' });
|
||||
// On Windows autocrlf=true, git rewrites stashed content with CRLF on
|
||||
// checkout. Strip \r before content compare — the test pins git's
|
||||
// shared-stash behavior, not line endings.
|
||||
const popped = fs.readFileSync(path.join(linkedWorktree, 'a.txt'), 'utf-8').replace(/\r\n/g, '\n');
|
||||
assert.strictEqual(
|
||||
popped,
|
||||
'wip in main\n',
|
||||
'bug #3542 invariant: `git stash pop` inside a linked worktree applies ' +
|
||||
'a stash pushed in the main checkout — proving the shared-stack ' +
|
||||
'contamination the executor prohibition exists to prevent.',
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmpRoot);
|
||||
}
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression test for #3588 — production dependency tree must not carry
|
||||
* high or moderate npm-audit advisories.
|
||||
*
|
||||
* Strategy: run `npm audit --omit=dev --json` against both the root
|
||||
* workspace and the embedded SDK package and assert that the metadata
|
||||
* vulnerability counts are zero across info/low/moderate/high/critical.
|
||||
*
|
||||
* The test is intentionally strict — any advisory of any severity (other
|
||||
* than 'low' if the maintainer accepts it; that branch is left explicit
|
||||
* here) blocks CI. If a future advisory lands without an upstream patch,
|
||||
* either bump the patched transitive (preferred), or annotate the
|
||||
* acceptance below with a justification AND a link to the upstream tracker.
|
||||
*
|
||||
* Skips automatically when `node_modules/` is absent (a fresh checkout
|
||||
* before `npm install`) so the test does not falsely report on developer
|
||||
* machines mid-setup.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SDK = path.join(ROOT, 'sdk');
|
||||
const AUDIT_TIMEOUT_MS = 180_000;
|
||||
const TEST_TIMEOUT_MS = AUDIT_TIMEOUT_MS + 30_000;
|
||||
|
||||
function auditProductionVulns(cwd) {
|
||||
if (!fs.existsSync(path.join(cwd, 'package.json'))) {
|
||||
return null; // signal "skip" to caller
|
||||
}
|
||||
if (!fs.existsSync(path.join(cwd, 'node_modules'))) {
|
||||
return null; // signal "skip" to caller
|
||||
}
|
||||
const isWindows = process.platform === 'win32';
|
||||
const npmCandidates = isWindows ? ['npm.cmd', 'npm'] : ['npm'];
|
||||
const args = ['audit', '--omit=dev', '--json'];
|
||||
let out;
|
||||
let lastErr = null;
|
||||
for (const npmCmd of npmCandidates) {
|
||||
try {
|
||||
out = execFileSync(
|
||||
npmCmd,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: AUDIT_TIMEOUT_MS,
|
||||
shell: isWindows,
|
||||
}
|
||||
);
|
||||
lastErr = null;
|
||||
break;
|
||||
} catch (e) {
|
||||
// `npm audit` exits non-zero when advisories are present; the JSON is
|
||||
// still on stdout in that case. Recover and let the assertion classify.
|
||||
if (e && typeof e.stdout !== 'undefined' && e.stdout !== undefined && e.stdout !== null) {
|
||||
out = Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout);
|
||||
lastErr = null;
|
||||
break;
|
||||
}
|
||||
lastErr = e;
|
||||
}
|
||||
}
|
||||
if (lastErr) throw lastErr;
|
||||
const parsed = JSON.parse(out);
|
||||
// `null` is reserved for the "node_modules missing → skip" signal above.
|
||||
// Any other unexpected JSON shape is a real failure of the audit harness
|
||||
// (npm changed its output format, audit aborted before metadata, etc.) —
|
||||
// throw so the test fails loudly instead of skipping silently.
|
||||
if (parsed && parsed.metadata && parsed.metadata.vulnerabilities) {
|
||||
return parsed.metadata.vulnerabilities;
|
||||
}
|
||||
throw new Error(`Unexpected npm audit JSON shape in ${cwd}: missing metadata.vulnerabilities`);
|
||||
}
|
||||
|
||||
describe('#3588: npm audit --omit=dev reports zero advisories', () => {
|
||||
test('root workspace production tree has no advisories', { timeout: TEST_TIMEOUT_MS }, (t) => {
|
||||
const vulns = auditProductionVulns(ROOT);
|
||||
if (vulns === null) {
|
||||
t.skip('auditable npm package not present or node_modules/ missing');
|
||||
return;
|
||||
}
|
||||
assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`);
|
||||
assert.strictEqual(vulns.high, 0, `expected 0 high; got ${vulns.high}`);
|
||||
assert.strictEqual(vulns.moderate, 0, `expected 0 moderate; got ${vulns.moderate}`);
|
||||
// Low advisories are not explicitly forbidden by the #3588 acceptance
|
||||
// criterion but the issue listed only high/moderate as actual findings —
|
||||
// tighten if any future low advisory is introduced.
|
||||
assert.strictEqual(vulns.low, 0, `expected 0 low; got ${vulns.low}`);
|
||||
});
|
||||
|
||||
test('sdk/ production tree has no advisories', { timeout: TEST_TIMEOUT_MS }, (t) => {
|
||||
const vulns = auditProductionVulns(SDK);
|
||||
if (vulns === null) {
|
||||
t.skip('sdk/ is not an auditable npm package or sdk/node_modules/ is missing');
|
||||
return;
|
||||
}
|
||||
assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`);
|
||||
assert.strictEqual(vulns.high, 0, `expected 0 high; got ${vulns.high}`);
|
||||
assert.strictEqual(vulns.moderate, 0, `expected 0 moderate; got ${vulns.moderate}`);
|
||||
assert.strictEqual(vulns.low, 0, `expected 0 low; got ${vulns.low}`);
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* Bug #3668: workflow resolver snippets must run from installed user projects.
|
||||
*
|
||||
* A user project normally does not contain gsd-core/bin/gsd-tools.cjs.
|
||||
* The snippets should still prefer RUNTIME_DIR for local/dev installs, then
|
||||
* fall back to the installed gsd-tools binary on PATH.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'next.md');
|
||||
|
||||
/**
|
||||
* Extract the canonical runtime resolver snippet from next.md.
|
||||
*
|
||||
* Supports two forms:
|
||||
* - One-line form (canonical): the entire launcher is a single line starting with
|
||||
* `_GSD_SHIM_NAME="gsd-tools.cjs";` — return that line directly.
|
||||
* - Multi-line form (legacy): starts with a `# Runtime launcher:` comment or a
|
||||
* `_GSD_SHIM_NAME=` line followed by separate GSD_TOOLS= and if/elif/else/fi
|
||||
* lines — scan to the closing `fi`.
|
||||
*/
|
||||
function extractResolverSnippet() {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
// Find the canonical preamble — prefer _GSD_SHIM_NAME= line (handles both forms)
|
||||
let start = lines.findIndex((line) => /^_GSD_SHIM_NAME=/.test(line.trim()));
|
||||
if (start === -1) {
|
||||
// Fallback: canonical preamble comment (multi-line legacy form)
|
||||
start = lines.findIndex((line) =>
|
||||
/^\s*#\s*Runtime launcher:.*prefer local gsd-tools\.cjs.*installed gsd-tools on PATH/.test(line)
|
||||
);
|
||||
}
|
||||
if (start === -1) {
|
||||
// Last fallback: GSD_TOOLS= with RUNTIME_DIR
|
||||
start = lines.findIndex((line) => line.includes('GSD_TOOLS="${RUNTIME_DIR:-'));
|
||||
}
|
||||
assert.notEqual(
|
||||
start,
|
||||
-1,
|
||||
'next.md must contain the canonical runtime preamble ' +
|
||||
'(_GSD_SHIM_NAME= line, # Runtime launcher: comment, or GSD_TOOLS= line with RUNTIME_DIR)'
|
||||
);
|
||||
|
||||
// One-line form: the entire launcher (including `if` and `fi`) is on a single line.
|
||||
// Detect by checking whether the start line contains a semicolon-separated `if` and `fi`.
|
||||
const startLine = lines[start].trim();
|
||||
if (/^_GSD_SHIM_NAME=.*;\s*if\s+\[.*\bfi$/.test(startLine)) {
|
||||
// Single-line canonical launcher — return it as-is
|
||||
return startLine;
|
||||
}
|
||||
|
||||
// Multi-line form: scan forward from start to the closing `fi`, tracking if-depth
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const t = lines[i].trim();
|
||||
if (/^if\s+/.test(t)) depth++;
|
||||
if (/^fi(\s|$)/.test(t)) {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.notEqual(end, -1, 'runtime preamble must end with a closing `fi`');
|
||||
|
||||
return lines.slice(start, end + 1).join('\n');
|
||||
}
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-runtime-resolution-'));
|
||||
}
|
||||
|
||||
function runResolver({ cwd, runtimeDir, pathDir }) {
|
||||
const script = [
|
||||
'set -e',
|
||||
extractResolverSnippet(),
|
||||
'printf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"',
|
||||
'gsd_run query state.json',
|
||||
].join('\n');
|
||||
|
||||
return execFileSync('bash', ['-c', script], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${pathDir}${path.delimiter}${process.env.PATH || ''}`,
|
||||
RUNTIME_DIR: runtimeDir || '',
|
||||
},
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function writeExecutable(file, content) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, content, { mode: 0o755 });
|
||||
}
|
||||
|
||||
describe('bug-3668: workflow SDK resolver supports installed user projects', () => {
|
||||
test('falls back to installed gsd-tools when project-local runtime copy is absent', () => {
|
||||
// Bug #3668: when a user project has no local gsd-core/bin/gsd-tools.cjs,
|
||||
// the elif branch must resolve to the gsd-tools binary on PATH.
|
||||
// RUNTIME_DIR points to a dir that has no gsd-tools.cjs.
|
||||
const tmp = makeTempDir();
|
||||
const project = path.join(tmp, 'user-project');
|
||||
const runtimeNoLocal = path.join(tmp, 'runtime-no-local');
|
||||
const pathBin = path.join(tmp, 'bin');
|
||||
fs.mkdirSync(project, { recursive: true });
|
||||
fs.mkdirSync(runtimeNoLocal, { recursive: true });
|
||||
|
||||
// Place gsd-tools stub on PATH (installed binary)
|
||||
writeExecutable(
|
||||
path.join(pathBin, 'gsd-tools'),
|
||||
'#!/bin/sh\nprintf "installed:%s %s\\n" "$1" "$2"\n',
|
||||
);
|
||||
|
||||
// NO gsd-core/bin/gsd-tools.cjs in runtimeNoLocal
|
||||
const output = runResolver({ cwd: project, runtimeDir: runtimeNoLocal, pathDir: pathBin });
|
||||
|
||||
// GSD_TOOLS must have been reassigned to the PATH binary (not the missing .cjs)
|
||||
assert.match(output, /GSD_TOOLS=.+gsd-tools(?:\s|$)/m);
|
||||
// The PATH stub must have been invoked
|
||||
assert.match(output, /installed:query state\.json/);
|
||||
});
|
||||
|
||||
test('preserves RUNTIME_DIR local gsd-tools.cjs preference over PATH fallback', () => {
|
||||
const tmp = makeTempDir();
|
||||
const project = path.join(tmp, 'user-project');
|
||||
const runtime = path.join(tmp, 'runtime');
|
||||
const pathBin = path.join(tmp, 'bin');
|
||||
fs.mkdirSync(project, { recursive: true });
|
||||
writeExecutable(path.join(pathBin, 'gsd-tools'), '#!/bin/sh\nprintf "path-installed:%s %s\\n" "$1" "$2"\n');
|
||||
writeExecutable(
|
||||
path.join(runtime, 'gsd-core', 'bin', 'gsd-tools.cjs'),
|
||||
'#!/usr/bin/env node\nconsole.log(`runtime:${process.argv[2]} ${process.argv[3]}`);\n',
|
||||
);
|
||||
|
||||
const output = runResolver({ cwd: project, runtimeDir: runtime, pathDir: pathBin });
|
||||
|
||||
// Normalize separators so the assertion works on Windows (Git bash emits POSIX paths)
|
||||
const norm = output.replace(/\\/g, '/');
|
||||
// The resolved bin is the RUNTIME_DIR local runtime (suffix /gsd-core/bin/gsd-tools.cjs)
|
||||
// Use .+ instead of \S* to handle paths with spaces (e.g. /Volumes/Mini Me/...)
|
||||
assert.match(norm, /GSD_TOOLS=.+\/gsd-core\/bin\/gsd-tools\.cjs(?:\s|$)/m);
|
||||
assert.match(output, /runtime:query state\.json/);
|
||||
assert.doesNotMatch(output, /path-installed:query state\.json/);
|
||||
});
|
||||
});
|
||||
@@ -1,244 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Tests A1/A2/B inspect agent / installed `.md` bodies whose deployed text IS
|
||||
// the runtime contract. Tests C exercises the install.js exported pure helper
|
||||
// `shouldNormalizeHyphenNamespaceInAgentBody` directly — purely behavioral.
|
||||
|
||||
/**
|
||||
* Regression for #3677 — installed agent bodies leak `/gsd:<cmd>` colon refs
|
||||
* for Claude / Qwen / Hermes (unroutable since #2808).
|
||||
*
|
||||
* Root cause: `bin/install.js` agent install loop (around line 8350-8447)
|
||||
* reads each agent .md, runs runtime-specific transforms via
|
||||
* `convertClaudeAgentToXAgent()`, then writes the result. For:
|
||||
* - Self-converting runtimes (Copilot/Codex/Cursor/Windsurf/Augment/Trae/
|
||||
* Codebuddy/Cline/Antigravity/Opencode/Kilo): their converters handle
|
||||
* namespace themselves.
|
||||
* - Gemini: intentionally uses colon namespace.
|
||||
* - Claude-default / Qwen / Hermes: register hyphen-form `name:` (#2808)
|
||||
* but copy bodies verbatim (Qwen/Hermes do branding-only swaps; Claude
|
||||
* does no namespace work). The retired `/gsd:<cmd>` colon refs leak.
|
||||
*
|
||||
* Sibling fixes #3583 (SKILL.md, via #3629) and #3584 (runtime emissions, via
|
||||
* #3606) covered the other two surfaces. This is the agent-body surface.
|
||||
*
|
||||
* Fix surface:
|
||||
* 1. `bin/install.js` exports a pure predicate
|
||||
* `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` plus a helper
|
||||
* `normalizeAgentBodyForRuntime(content, runtime, cmdNames)` that
|
||||
* conditionally applies `transformContentToHyphen` from
|
||||
* scripts/fix-slash-commands.cjs.
|
||||
* 2. The agent install loop calls the helper after all runtime-specific
|
||||
* conversions but before writeFileSync.
|
||||
* 3. This regression test guards both the predicate and the integration.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
// Single `..` traversal matches the existing tests/helpers.cjs convention
|
||||
// (TOOLS_PATH at tests/helpers.cjs:21). Avoids `..` chains per CLAUDE.md and
|
||||
// works in the docker mirror at /work/tests (which has no `.git` to anchor on).
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const install = require(path.join(REPO_ROOT, 'bin', 'install.js'));
|
||||
const { transformContentToHyphen } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs'));
|
||||
|
||||
// Snapshot of all runtime IDs in the layout table at the time of this fix.
|
||||
// Keep these two sets covering: any runtime listed in
|
||||
// runtime-artifact-layout.cjs MUST appear in exactly one bucket.
|
||||
const HYPHEN_NAME_AGENT_RUNTIMES = ['claude', 'qwen', 'hermes'];
|
||||
const SELF_CONVERTING_OR_COLON_RUNTIMES = [
|
||||
'gemini', // intentionally colon-namespaced
|
||||
'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment',
|
||||
'trae', 'codebuddy', 'cline',
|
||||
'opencode', 'kilo',
|
||||
];
|
||||
|
||||
describe('bug #3677 — agent body colon-namespace leak (Claude / Qwen / Hermes)', () => {
|
||||
|
||||
describe('A — install.js exports the pure predicate + helper', () => {
|
||||
test('A1: shouldNormalizeHyphenNamespaceInAgentBody is an exported function', () => {
|
||||
assert.strictEqual(
|
||||
typeof install.shouldNormalizeHyphenNamespaceInAgentBody,
|
||||
'function',
|
||||
'bin/install.js must export shouldNormalizeHyphenNamespaceInAgentBody as the runtime predicate (regression seam for #3677)',
|
||||
);
|
||||
});
|
||||
|
||||
test('A2: normalizeAgentBodyForRuntime is an exported function', () => {
|
||||
assert.strictEqual(
|
||||
typeof install.normalizeAgentBodyForRuntime,
|
||||
'function',
|
||||
'bin/install.js must export normalizeAgentBodyForRuntime as the wired helper called by the agent install loop',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('B — predicate returns true for hyphen-`name:` runtimes and false otherwise', () => {
|
||||
const { shouldNormalizeHyphenNamespaceInAgentBody } = install;
|
||||
|
||||
for (const runtime of HYPHEN_NAME_AGENT_RUNTIMES) {
|
||||
test(`B+ '${runtime}': normalize hyphen namespace (true)`, () => {
|
||||
assert.strictEqual(
|
||||
shouldNormalizeHyphenNamespaceInAgentBody(runtime),
|
||||
true,
|
||||
`${runtime} registers hyphen-form 'name:' (#2808) and copies agent bodies verbatim — must normalize`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (const runtime of SELF_CONVERTING_OR_COLON_RUNTIMES) {
|
||||
test(`B- '${runtime}': skip normalization (false)`, () => {
|
||||
assert.strictEqual(
|
||||
shouldNormalizeHyphenNamespaceInAgentBody(runtime),
|
||||
false,
|
||||
`${runtime} either self-converts via convertClaudeAgentToXAgent or intentionally uses colon — must NOT re-rewrite`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('B?: unknown runtime defaults to false (conservative)', () => {
|
||||
assert.strictEqual(
|
||||
shouldNormalizeHyphenNamespaceInAgentBody('bogus-runtime-id'),
|
||||
false,
|
||||
'unknown runtimes must not be normalized — better to leak than to mangle',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('C — normalizeAgentBodyForRuntime applies transformContentToHyphen iff predicate is true', () => {
|
||||
const { normalizeAgentBodyForRuntime } = install;
|
||||
// Sample agent body with colon refs that #2808 retired.
|
||||
const inputBody = [
|
||||
'# Agent prose',
|
||||
'',
|
||||
'Run `/gsd:execute-phase 1 --tdd` to execute the phase.',
|
||||
'Then `/gsd:verify-work 1` to verify.',
|
||||
'Reference unchanged: `gsd-sdk query commit` (this is a CLI binary, not a slash command).',
|
||||
].join('\n');
|
||||
// Only known commands from commands/gsd/*.md should be rewritten; gsd-sdk
|
||||
// (a binary) must stay untouched.
|
||||
const cmdNames = ['execute-phase', 'verify-work', 'plan-phase'];
|
||||
|
||||
test('C1: claude — rewrites both colon refs to hyphen', () => {
|
||||
const out = normalizeAgentBodyForRuntime(inputBody, 'claude', cmdNames);
|
||||
assert.ok(out.includes('/gsd-execute-phase'), 'execute-phase must be rewritten to hyphen form');
|
||||
assert.ok(out.includes('/gsd-verify-work'), 'verify-work must be rewritten to hyphen form');
|
||||
assert.ok(!out.includes('/gsd:execute-phase'), 'colon form for execute-phase must be gone');
|
||||
assert.ok(!out.includes('/gsd:verify-work'), 'colon form for verify-work must be gone');
|
||||
assert.ok(out.includes('gsd-sdk query commit'), 'gsd-sdk (CLI binary) must not be touched');
|
||||
});
|
||||
|
||||
test('C2: qwen — same transform applies', () => {
|
||||
const out = normalizeAgentBodyForRuntime(inputBody, 'qwen', cmdNames);
|
||||
assert.ok(out.includes('/gsd-execute-phase'));
|
||||
assert.ok(!out.includes('/gsd:execute-phase'));
|
||||
});
|
||||
|
||||
test('C3: hermes — same transform applies', () => {
|
||||
const out = normalizeAgentBodyForRuntime(inputBody, 'hermes', cmdNames);
|
||||
assert.ok(out.includes('/gsd-execute-phase'));
|
||||
assert.ok(!out.includes('/gsd:execute-phase'));
|
||||
});
|
||||
|
||||
test('C4: gemini — colon refs preserved (intentional namespace)', () => {
|
||||
const out = normalizeAgentBodyForRuntime(inputBody, 'gemini', cmdNames);
|
||||
assert.ok(out.includes('/gsd:execute-phase'), 'Gemini intentionally uses colon namespace; do not rewrite');
|
||||
assert.ok(!out.includes('/gsd-execute-phase'), 'Gemini agents must NOT have hyphen form');
|
||||
});
|
||||
|
||||
test('C5: self-converting runtime (copilot) — body returned unchanged at this layer', () => {
|
||||
const out = normalizeAgentBodyForRuntime(inputBody, 'copilot', cmdNames);
|
||||
// Copilot has its own convertClaudeAgentToCopilotAgent that handles
|
||||
// namespace — the normalize layer is a no-op for it.
|
||||
assert.strictEqual(out, inputBody);
|
||||
});
|
||||
});
|
||||
|
||||
describe('D — sanity check: the underlying transform actually works against real cmd names', () => {
|
||||
test('D1: transformContentToHyphen rewrites /gsd:<cmd> to /gsd-<cmd> for known cmds only', () => {
|
||||
const out = transformContentToHyphen(
|
||||
'A /gsd:execute-phase B /gsd:unknown-cmd C /gsd-sdk D',
|
||||
['execute-phase'],
|
||||
);
|
||||
assert.ok(out.includes('/gsd-execute-phase'), 'known cmd rewritten');
|
||||
assert.ok(out.includes('/gsd:unknown-cmd'), 'unknown cmd preserved (longest-first matcher only rewrites registered names)');
|
||||
assert.ok(out.includes('/gsd-sdk'), 'gsd-sdk (binary, not slash command) preserved');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E — Behavioral coverage ported from PR #3681 (johnzilla / John Turner).
|
||||
//
|
||||
// #3681 proposed the same allow-list fix independently and was closed by its
|
||||
// author in favor of this PR. Its test file contributed two coverage angles
|
||||
// worth keeping: real-source efficacy against every `agents/gsd-*.md` (the
|
||||
// shape of bug that pure-function tests miss) and idempotence-via-fixpoint
|
||||
// (guards against double-rewrite on reinstall). Credit: johnzilla.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('E — real-source efficacy + idempotence (ported from #3681, credit: johnzilla)', () => {
|
||||
const fs = require('node:fs');
|
||||
const { readCmdNames } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs'));
|
||||
const cmdNames = readCmdNames();
|
||||
|
||||
// Roster regex matches any registered command in `gsd:<cmd>` form with a
|
||||
// negative lookbehind (so `mygsd:foo` is ignored) and a non-word lookahead
|
||||
// (so `plan-phase-extra` is not a false match for `plan-phase`).
|
||||
const roster = () => new RegExp(
|
||||
`(?<![a-zA-Z0-9_-])gsd:(${[...cmdNames].sort((a, b) => b.length - a.length).join('|')})(?=[^a-zA-Z0-9_-]|$)`,
|
||||
);
|
||||
|
||||
test('E0: command roster is populated and contains the symptom commands', () => {
|
||||
assert.ok(cmdNames.length > 0, 'command roster must be populated');
|
||||
assert.ok(cmdNames.includes('execute-phase'));
|
||||
assert.ok(cmdNames.includes('plan-phase'));
|
||||
});
|
||||
|
||||
test('E1: every agents/gsd-*.md transforms clean — no roster colon refs survive', () => {
|
||||
const agentsDir = path.join(REPO_ROOT, 'agents');
|
||||
const offenders = [];
|
||||
// Not the shared listAgentFiles() helper: this needs full `.md` filenames
|
||||
// (not stripped basenames) to readFileSync + transform each agent body.
|
||||
for (const f of fs.readdirSync(agentsDir)) {
|
||||
if (!f.startsWith('gsd-') || !f.endsWith('.md')) continue;
|
||||
const src = fs.readFileSync(path.join(agentsDir, f), 'utf-8');
|
||||
const out = transformContentToHyphen(src, cmdNames);
|
||||
if (roster().test(out)) offenders.push(f);
|
||||
}
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
`agents still carry roster colon refs after transform: ${offenders.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('E2: idempotent — transform of already-hyphenated input is a no-op', () => {
|
||||
const input = 'use /gsd-plan-phase next, then /gsd-execute-phase';
|
||||
assert.strictEqual(
|
||||
transformContentToHyphen(input, cmdNames),
|
||||
input,
|
||||
'reinstalls re-run the transform; double application must not mangle the body',
|
||||
);
|
||||
});
|
||||
|
||||
test('E3: word boundary — /gsd:plan-phase-extra is not a roster match', () => {
|
||||
assert.strictEqual(
|
||||
transformContentToHyphen('/gsd:plan-phase-extra', cmdNames),
|
||||
'/gsd:plan-phase-extra',
|
||||
);
|
||||
});
|
||||
|
||||
test('E4: rewrites bare `gsd:<cmd>` shorthand (no leading slash)', () => {
|
||||
const out = transformContentToHyphen(
|
||||
'Spawned by the gsd:execute-phase orchestrator.',
|
||||
cmdNames,
|
||||
);
|
||||
assert.strictEqual(out, 'Spawned by the gsd-execute-phase orchestrator.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,251 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Three of the assertions in this file (A1, A2, C) inspect agent / workflow
|
||||
// `.md` bodies. Those files ARE the runtime contract that GSD loads into agent
|
||||
// prompts at run time, so source-text inspection is exactly what the
|
||||
// `source-text-is-the-product` exception covers.
|
||||
//
|
||||
// The remaining assertions (B1, B2, B3) are behavioral — they invoke
|
||||
// `gsd-tools commit` against a temp project and assert on its structured
|
||||
// JSON return envelope plus the git index state. No raw-text matching on
|
||||
// rendered output.
|
||||
|
||||
/**
|
||||
* Regression for #3678 — gsd-executor force-commits .planning/ files when
|
||||
* commit_docs is false.
|
||||
*
|
||||
* Root cause: the executor agent prompt (agents/gsd-executor.md) tells the
|
||||
* agent to call `gsd-sdk query commit "docs(...)" --files .planning/...`
|
||||
* in the per-plan final_commit block, but the prompt says nothing about
|
||||
* what to do when the SDK returns `{committed: false, skipped: true,
|
||||
* reason: 'skipped_commit_docs_false'}`. With no explicit instruction, the
|
||||
* agent improvises raw `git add` / `git commit` against `.planning/` paths
|
||||
* (and uses `-f` to bypass gitignore), which is exactly the leakage the
|
||||
* reporter observed.
|
||||
*
|
||||
* Fix surface:
|
||||
* 1. Agent prompt: explicit handling text in the final_commit section.
|
||||
* 2. SDK envelope: add `skipped: true` field so agents see "skipped" as a
|
||||
* first-class success signal, not "committed is missing, must improvise."
|
||||
* 3. Structural guard: ban `git add -f` / `git add --force` from agent and
|
||||
* workflow bodies entirely (no GSD-managed surface should force-stage
|
||||
* gitignored content).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { createTempGitProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
// Repo root resolution. This test file lives in `<repo>/tests/`. Use a single
|
||||
// parent reference (the established repo-wide pattern, e.g. tests/helpers.cjs
|
||||
// `path.resolve(__dirname, '..', 'gsd-core', ...)`). A `.git`-anchored
|
||||
// walker is not portable because the docker test mirror at `/work` strips the
|
||||
// `.git/` directory before running tests.
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const EXECUTOR_AGENT = path.join(REPO_ROOT, 'agents', 'gsd-executor.md');
|
||||
|
||||
// Frozen reason enum mirrors the SDK source — keep in sync with
|
||||
// `cmdCommit` in gsd-core/bin/lib/commands.cjs.
|
||||
const COMMIT_REASON = Object.freeze({
|
||||
SKIPPED_COMMIT_DOCS_FALSE: 'skipped_commit_docs_false',
|
||||
SKIPPED_GITIGNORED: 'skipped_gitignored',
|
||||
});
|
||||
|
||||
function git(args, cwd) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
}
|
||||
|
||||
describe('bug #3678 — executor must respect commit_docs:false', () => {
|
||||
|
||||
describe('A — agent prompt teaches the agent how to handle commit_docs:false', () => {
|
||||
test('A1: agent body explicitly references the SDK skipped envelope', () => {
|
||||
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
|
||||
// The prompt must contain at least one literal mention of the skipped
|
||||
// reason code OR the `committed: false` envelope so the agent knows
|
||||
// that skipping is an intentional control flow, not a failure to work
|
||||
// around.
|
||||
const mentionsSkipReason = body.includes(COMMIT_REASON.SKIPPED_COMMIT_DOCS_FALSE);
|
||||
const mentionsCommittedFalse = /committed:\s*false/i.test(body);
|
||||
const mentionsSkippedTrue = /skipped:\s*true/i.test(body);
|
||||
assert.ok(
|
||||
mentionsSkipReason || mentionsCommittedFalse || mentionsSkippedTrue,
|
||||
'agents/gsd-executor.md must teach the agent how to recognize the '
|
||||
+ 'skipped envelope from `gsd-sdk query commit` (one of: '
|
||||
+ `'${COMMIT_REASON.SKIPPED_COMMIT_DOCS_FALSE}', 'committed: false', `
|
||||
+ "'skipped: true').",
|
||||
);
|
||||
});
|
||||
|
||||
test('A2: agent body explicitly forbids raw git fallback when SDK skips', () => {
|
||||
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
|
||||
// Look for an explicit instruction tying the SDK-skipped signal to the
|
||||
// forbidden-fallback rule. Accept any of three shapes the doc writer
|
||||
// might use: "do not", "must not", or "never" + a verb that names the
|
||||
// forbidden action.
|
||||
const forbidsFallbackText = /(do not|must not|never)\s+(fall back|fallback|use .*git add|run .*git commit|force[- ]?add)/i;
|
||||
assert.ok(
|
||||
forbidsFallbackText.test(body),
|
||||
'agents/gsd-executor.md must contain an explicit "do not fall back to '
|
||||
+ 'raw git" instruction tied to the commit_docs:false / skipped envelope. '
|
||||
+ 'Without it, the agent improvises raw `git add` / `git add -f` to '
|
||||
+ 'fulfill its "complete plan" goal.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('B — SDK behavior: commit_docs:false leaves repo state untouched', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempGitProject();
|
||||
// .planning/ already exists from createTempGitProject's setup.
|
||||
// Set commit_docs to false on the config.
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
let config = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
config.commit_docs = false;
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
// Make a token edit to .planning/STATE.md so there IS something the SDK
|
||||
// could in principle stage (or that an improvising agent could leak).
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
if (!fs.existsSync(statePath)) {
|
||||
fs.writeFileSync(statePath, '---\nproject: test\n---\n# State\n');
|
||||
}
|
||||
fs.appendFileSync(statePath, '\n<!-- token edit for #3678 repro -->\n');
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('B1: commit returns committed:false with skipped envelope', () => {
|
||||
const result = runGsdTools(
|
||||
'commit "docs(test): noop" --files .planning/STATE.md',
|
||||
tmpDir,
|
||||
);
|
||||
assert.ok(result.success, `gsd-tools commit should exit 0 even when skipped: ${result.error || ''}`);
|
||||
const envelope = JSON.parse(result.output);
|
||||
assert.strictEqual(envelope.committed, false, 'committed must be false when commit_docs is false');
|
||||
assert.strictEqual(
|
||||
envelope.skipped,
|
||||
true,
|
||||
'envelope must carry skipped:true so agents see skip as a first-class signal (envelope contract for #3678)',
|
||||
);
|
||||
assert.strictEqual(
|
||||
envelope.reason,
|
||||
COMMIT_REASON.SKIPPED_COMMIT_DOCS_FALSE,
|
||||
'reason must be the canonical skipped_commit_docs_false code (frozen enum)',
|
||||
);
|
||||
});
|
||||
|
||||
test('B2: commit_docs:false leaves the git index empty (no .planning/ staged)', () => {
|
||||
runGsdTools(
|
||||
'commit "docs(test): noop" --files .planning/STATE.md',
|
||||
tmpDir,
|
||||
);
|
||||
const stagedAll = git(['diff', '--cached', '--name-only'], tmpDir);
|
||||
const stagedPlanning = stagedAll
|
||||
.split(/\r?\n/)
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.startsWith('.planning/'));
|
||||
assert.deepStrictEqual(
|
||||
stagedPlanning,
|
||||
[],
|
||||
'no .planning/ files should be staged when commit_docs is false',
|
||||
);
|
||||
});
|
||||
|
||||
test('B3: commit_docs:false produces no new commits', () => {
|
||||
const headBefore = git(['rev-parse', 'HEAD'], tmpDir).trim();
|
||||
runGsdTools(
|
||||
'commit "docs(test): noop" --files .planning/STATE.md',
|
||||
tmpDir,
|
||||
);
|
||||
const headAfter = git(['rev-parse', 'HEAD'], tmpDir).trim();
|
||||
assert.strictEqual(
|
||||
headAfter,
|
||||
headBefore,
|
||||
'HEAD must not advance when commit_docs is false',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('checklist carve-out preserved for intentional skip', () => {
|
||||
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
|
||||
const checklistLine = body
|
||||
.split(/\r?\n/)
|
||||
.find(line => /Final metadata commit made/.test(line));
|
||||
assert.ok(
|
||||
checklistLine,
|
||||
'agents/gsd-executor.md must contain a "Final metadata commit made" checklist line',
|
||||
);
|
||||
assert.ok(
|
||||
checklistLine.includes('Final metadata commit'),
|
||||
'checklist line must reference "Final metadata commit"',
|
||||
);
|
||||
assert.ok(
|
||||
checklistLine.includes('skipped_commit_docs_false'),
|
||||
'checklist line must carve out the intentional-skip case by referencing '
|
||||
+ '"skipped_commit_docs_false" — prevents executor from treating an '
|
||||
+ 'unchecked mandatory box as a raw-git TODO (regression guard for #3679)',
|
||||
);
|
||||
});
|
||||
|
||||
describe('C — structural ban on raw force-add in GSD-managed bodies', () => {
|
||||
function scanForForceAdd(rootDir) {
|
||||
const offenders = [];
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { walk(full); continue; }
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const body = fs.readFileSync(full, 'utf-8');
|
||||
const lines = body.split(/\r?\n/);
|
||||
const danger = lines.filter((line) => {
|
||||
if (!/git\s+add\s+(-f|--force)\b/.test(line)) return false;
|
||||
// Allow prohibition / warning sentences and code-fence prose that
|
||||
// frames `git add -f` AS the bug (so an audit comment doesn't
|
||||
// create a false positive).
|
||||
if (/(do not|don'?t|must not|never|forbidden|prohibited)/i.test(line)) return false;
|
||||
if (/(bug|wrong|incorrect|antipattern|anti-pattern|forces?\s+gitignored|leak)/i.test(line)) return false;
|
||||
return true;
|
||||
});
|
||||
if (danger.length > 0) {
|
||||
offenders.push({
|
||||
file: full.replace(REPO_ROOT + '/', ''),
|
||||
lines: danger.map(l => l.trim().slice(0, 120)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(rootDir);
|
||||
return offenders;
|
||||
}
|
||||
|
||||
test('C1: no agent body contains `git add -f` / `git add --force`', () => {
|
||||
const offenders = scanForForceAdd(path.join(REPO_ROOT, 'agents'));
|
||||
assert.deepStrictEqual(
|
||||
offenders,
|
||||
[],
|
||||
'no agent body may use `git add -f` / `git add --force` outside a '
|
||||
+ 'prohibition sentence — agents must never force-stage gitignored '
|
||||
+ 'content (regression guard for #3678).',
|
||||
);
|
||||
});
|
||||
|
||||
test('C2: no workflow body contains `git add -f` / `git add --force`', () => {
|
||||
const offenders = scanForForceAdd(path.join(REPO_ROOT, 'gsd-core', 'workflows'));
|
||||
assert.deepStrictEqual(
|
||||
offenders,
|
||||
[],
|
||||
'no workflow body may use `git add -f` / `git add --force` outside a '
|
||||
+ 'prohibition sentence (regression guard for #3678).',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,236 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Command `.md` files — their staged text IS the runtime contract loaded by
|
||||
// Claude Code. Asserting that staged bodies lack `/gsd:<cmd>` colon refs is
|
||||
// a behavioral test of the install transform, not source-grep theater.
|
||||
|
||||
/**
|
||||
* Regression for #3683 — installed command bodies leak `/gsd:<cmd>` colon refs
|
||||
* for Claude Code local installs.
|
||||
*
|
||||
* Root cause: `bin/install.js` command install path (`copyWithPathReplacement`,
|
||||
* around line 8296 in the `else` branch) copies each command `.md` body without
|
||||
* applying the hyphen-namespace normalizer that the agent install loop gained in
|
||||
* PR #3677. Static prose in `commands/gsd/*.md` (e.g. plan-phase.md referencing
|
||||
* `/gsd:execute-phase`) therefore reaches the model verbatim, causing the model
|
||||
* to echo the retired colon form at workflow boundaries.
|
||||
*
|
||||
* Fix surface:
|
||||
* Call `normalizeAgentBodyForRuntime` (or an equivalent helper) in the command
|
||||
* staging path after all other rewrites but before writeFileSync, mirroring
|
||||
* the agent install loop fix from #3677.
|
||||
*
|
||||
* This test guards the behavioral integration: run a real local claude install
|
||||
* into a temp dir, then assert that no staged command body contains a
|
||||
* `/gsd:<known-cmd>` colon ref.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js');
|
||||
|
||||
const install = require(INSTALL_PATH);
|
||||
const { readCmdNames } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs'));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run `node install.js --claude --local --no-sdk` in tmpDir.
|
||||
* GSD_TEST_MODE must be cleared so the install() main block executes.
|
||||
*/
|
||||
function runClaudeLocalInstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_TEST_MODE;
|
||||
execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the roster regex that matches `/gsd:<known-cmd>` or `gsd:<known-cmd>`
|
||||
* (with appropriate word boundaries). Mirrors the pattern used in bug-3677.
|
||||
*/
|
||||
function buildRosterRegex(cmdNames) {
|
||||
const sorted = [...cmdNames].sort((a, b) => b.length - a.length);
|
||||
return new RegExp(
|
||||
`(?<![a-zA-Z0-9_-])gsd:(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite A — export surface: normalizeAgentBodyForRuntime must be exported
|
||||
// (same seam used for command bodies)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('bug #3683 — command body colon-namespace leak (Claude local install)', () => {
|
||||
|
||||
describe('A — install.js exports the normalizer seam', () => {
|
||||
test('A1: normalizeAgentBodyForRuntime is exported (reused for command bodies)', () => {
|
||||
assert.strictEqual(
|
||||
typeof install.normalizeAgentBodyForRuntime,
|
||||
'function',
|
||||
'bin/install.js must export normalizeAgentBodyForRuntime — the seam used for both agent and command body normalization',
|
||||
);
|
||||
});
|
||||
|
||||
test('A2: shouldNormalizeHyphenNamespaceInAgentBody is exported and true for claude', () => {
|
||||
assert.strictEqual(
|
||||
typeof install.shouldNormalizeHyphenNamespaceInAgentBody,
|
||||
'function',
|
||||
);
|
||||
assert.strictEqual(
|
||||
install.shouldNormalizeHyphenNamespaceInAgentBody('claude'),
|
||||
true,
|
||||
'claude must normalize hyphen namespace — it is in the allow-list from #2808',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B — pure-function coverage: normalizer rewrites command body colon refs
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('B — normalizeAgentBodyForRuntime rewrites colon refs in command-body prose', () => {
|
||||
const { normalizeAgentBodyForRuntime } = install;
|
||||
const cmdNames = readCmdNames();
|
||||
|
||||
test('B0: command roster is populated and includes symptom commands', () => {
|
||||
assert.ok(cmdNames.length > 0, 'readCmdNames() must return a non-empty list');
|
||||
assert.ok(cmdNames.includes('execute-phase'), 'roster must include execute-phase');
|
||||
assert.ok(cmdNames.includes('plan-phase'), 'roster must include plan-phase');
|
||||
});
|
||||
|
||||
test('B1: claude — rewrites /gsd:<cmd> colon refs in command-body prose to hyphen form', () => {
|
||||
const input = [
|
||||
'## After planning',
|
||||
'',
|
||||
'Run `/gsd:execute-phase 1 --tdd` to begin execution.',
|
||||
'Then use `/gsd:verify-work 1` when done.',
|
||||
].join('\n');
|
||||
const out = normalizeAgentBodyForRuntime(input, 'claude', cmdNames);
|
||||
assert.ok(out.includes('/gsd-execute-phase'), 'execute-phase must be rewritten to hyphen form');
|
||||
assert.ok(out.includes('/gsd-verify-work'), 'verify-work must be rewritten to hyphen form');
|
||||
assert.ok(!out.includes('/gsd:execute-phase'), 'colon form for execute-phase must be absent');
|
||||
assert.ok(!out.includes('/gsd:verify-work'), 'colon form for verify-work must be absent');
|
||||
});
|
||||
|
||||
test('B2: gemini — colon refs preserved (Gemini intentionally uses colon namespace)', () => {
|
||||
const input = 'Run `/gsd:execute-phase 1` to begin.';
|
||||
const out = normalizeAgentBodyForRuntime(input, 'gemini', cmdNames);
|
||||
assert.ok(out.includes('/gsd:execute-phase'), 'Gemini must keep colon form');
|
||||
assert.ok(!out.includes('/gsd-execute-phase'), 'Gemini must not have hyphen form injected');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E — Integration: real local claude install produces clean command bodies
|
||||
// ---------------------------------------------------------------------------
|
||||
// E — integration: flat gsd-*.md layout + clean bodies (#1367 fix)
|
||||
//
|
||||
// Prior to #1367: commands wrote to commands/gsd/<cmd>.md (bare names in a
|
||||
// subdir), causing Claude Code to namespace them as /gsd:<cmd> (colon form).
|
||||
// After #1367: commands write flat gsd-<cmd>.md at commands/ level so Claude
|
||||
// Code registers them as /gsd-<cmd> (hyphen form, matching all framework refs).
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('E — integration: staged gsd-*.md flat commands contain no colon-namespace refs', () => {
|
||||
let tmpDir;
|
||||
const cmdNames = readCmdNames();
|
||||
const rosterRegex = buildRosterRegex(cmdNames);
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3683-'));
|
||||
runClaudeLocalInstall(tmpDir);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('E0: staged commands/ directory has flat gsd-*.md files after install (#1367)', () => {
|
||||
// After #1367 fix: commands land at .claude/commands/gsd-<cmd>.md (flat,
|
||||
// hyphen-prefixed). The old .claude/commands/gsd/<cmd>.md subdirectory
|
||||
// layout must NOT be created.
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(
|
||||
fs.existsSync(commandsDir),
|
||||
`commands/ must be created by local claude install at ${commandsDir}`,
|
||||
);
|
||||
const flatFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
assert.ok(
|
||||
flatFiles.length > 0,
|
||||
`commands/ must contain flat gsd-*.md files (e.g. gsd-help.md). ` +
|
||||
`Found none — install may still be using the old commands/gsd/<cmd>.md subdirectory layout.`,
|
||||
);
|
||||
// The old subdirectory must NOT exist (it caused /gsd:<cmd> colon namespace)
|
||||
const oldSubdir = path.join(commandsDir, 'gsd');
|
||||
assert.ok(
|
||||
!fs.existsSync(oldSubdir),
|
||||
`commands/gsd/ subdir must NOT exist after install (it causes /gsd:<cmd> colon namespace in Claude Code). ` +
|
||||
`#1367 fix: use flat gsd-<cmd>.md at commands/ level instead.`,
|
||||
);
|
||||
});
|
||||
|
||||
test('E1: no staged command body contains /gsd:<known-cmd> colon refs', () => {
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(fs.existsSync(commandsDir), 'commands/ must exist for this check to be meaningful');
|
||||
|
||||
const offenders = [];
|
||||
|
||||
for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
if (!entry.name.startsWith('gsd-')) continue;
|
||||
const fullPath = path.join(commandsDir, entry.name);
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
if (rosterRegex.test(content)) {
|
||||
offenders.push(path.relative(tmpDir, fullPath));
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
`Staged command bodies still contain roster colon refs (e.g. /gsd:execute-phase). ` +
|
||||
`Install must normalize these to /gsd-<cmd> for claude runtime. Offenders: ${offenders.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('E2: idempotent — re-running install does not double-mangle already-hyphenated refs', () => {
|
||||
// Run install a second time; if the normalizer double-applies it would
|
||||
// produce garbled output like /gsd--execute-phase. Verify the commands
|
||||
// still pass the same cleanliness check after a second install.
|
||||
runClaudeLocalInstall(tmpDir);
|
||||
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
const doubleRewriteRegex = /\/gsd--[a-z]/;
|
||||
const garbled = [];
|
||||
|
||||
for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
if (!entry.name.startsWith('gsd-')) continue;
|
||||
const content = fs.readFileSync(path.join(commandsDir, entry.name), 'utf-8');
|
||||
if (doubleRewriteRegex.test(content)) {
|
||||
garbled.push(entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
garbled,
|
||||
[],
|
||||
`Re-install produced double-hyphen artifacts (/gsd--cmd) — normalizer is not idempotent. Garbled files: ${garbled.join(', ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,455 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow and reference `.md` files are deployed verbatim as part of the
|
||||
// gsd-core skill payload — their staged text IS the runtime contract
|
||||
// loaded by Claude Code. Asserting that staged bodies lack `/gsd:<cmd>`
|
||||
// colon refs is a behavioral test of the install transform, not
|
||||
// source-grep theater.
|
||||
|
||||
/**
|
||||
* Regression for #3683 — installed workflow/reference bodies leak `/gsd:<cmd>`
|
||||
* colon refs for Claude Code local installs.
|
||||
*
|
||||
* Root cause: `copyWithPathReplacement` in `bin/install.js` guarded the
|
||||
* `normalizeAgentBodyForRuntime` call behind `if (isCommand)`, so the
|
||||
* `gsd-core/` directory (workflows, references — all `isCommand=false`)
|
||||
* was copied without applying the hyphen-namespace normalizer. Static prose
|
||||
* in `gsd-core/workflows/*.md` and `gsd-core/references/*.md`
|
||||
* (e.g. discuss-phase.md referencing `/gsd:plan-phase`) therefore reached
|
||||
* the model verbatim, causing it to echo the retired colon form.
|
||||
*
|
||||
* Fix surface:
|
||||
* Remove the `if (isCommand)` guard so `normalizeAgentBodyForRuntime` is
|
||||
* called unconditionally in `copyWithPathReplacement`. The function
|
||||
* self-gates on `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` and
|
||||
* is a no-op for colon-canonical runtimes (Gemini, Codex, etc.).
|
||||
*
|
||||
* User repro path: `/gsd-discuss-phase` output ends with `/gsd:nextcommand`
|
||||
* because discuss-phase.md (7 colon refs) is not normalized at install time.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js');
|
||||
|
||||
require(INSTALL_PATH);
|
||||
const { readCmdNames } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs'));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run `node install.js --claude --local --no-sdk` in tmpDir.
|
||||
* GSD_TEST_MODE must be cleared so the install() main block executes.
|
||||
*/
|
||||
function runClaudeLocalInstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_TEST_MODE;
|
||||
execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `node install.js --gemini --local --no-sdk` in tmpDir.
|
||||
* GSD_TEST_MODE must be cleared so the install() main block executes.
|
||||
*/
|
||||
function runGeminiLocalInstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_TEST_MODE;
|
||||
execFileSync(process.execPath, [INSTALL_PATH, '--gemini', '--local', '--no-sdk'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the roster regex that matches `gsd:<known-cmd>` references.
|
||||
* Mirrors the pattern used by the Cycle 1 command test.
|
||||
*/
|
||||
function buildRosterRegex(cmdNames) {
|
||||
const sorted = [...cmdNames].sort((a, b) => b.length - a.length);
|
||||
return new RegExp(
|
||||
`(?<![a-zA-Z0-9_-])gsd:(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a directory recursively and collect .md files whose body matches regex.
|
||||
*/
|
||||
function collectOffenders(dir, regex) {
|
||||
const offenders = [];
|
||||
const walk = (d) => {
|
||||
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
const fullPath = path.join(d, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath);
|
||||
} else if (entry.name.endsWith('.md')) {
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
if (regex.test(content)) {
|
||||
offenders.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
return offenders;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite — integration: staged gsd-core/workflows/ and references/ must
|
||||
// have no colon-namespace refs for claude, and must preserve them for gemini.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('bug #3683 — workflow/reference colon-namespace leak (Claude local install)', () => {
|
||||
|
||||
// Shared Claude local install used by W and R suites.
|
||||
// Consolidating to a single install halves disk I/O for this file and
|
||||
// reduces concurrent load on CI runners — preventing timing interference
|
||||
// with concurrently-running tests (e.g. the TOCTOU barrier tests in
|
||||
// locking-bugs-1909-1916-1925-1927.test.cjs).
|
||||
let claudeTmpDir;
|
||||
const cmdNames = readCmdNames();
|
||||
const rosterRegex = buildRosterRegex(cmdNames);
|
||||
|
||||
// Shared claude local install — used by W (workflow/reference clean-slate) and
|
||||
// R (routing-block positive assertion) sub-suites. G suite runs its own separate
|
||||
// gemini install and does not depend on claudeTmpDir.
|
||||
before(() => {
|
||||
claudeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3683-claude-'));
|
||||
runClaudeLocalInstall(claudeTmpDir);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(claudeTmpDir);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// W — real local claude install: workflow + reference bodies are clean
|
||||
// -------------------------------------------------------------------------
|
||||
describe('W — integration: staged workflows and references contain no colon-namespace refs', () => {
|
||||
|
||||
test('W0: staged gsd-core/workflows/ directory exists after install', () => {
|
||||
const workflowsDir = path.join(claudeTmpDir, '.claude', 'gsd-core', 'workflows');
|
||||
assert.ok(
|
||||
fs.existsSync(workflowsDir),
|
||||
`gsd-core/workflows/ must be created by local claude install at ${workflowsDir}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('W1: staged gsd-core/references/ directory exists after install', () => {
|
||||
const refsDir = path.join(claudeTmpDir, '.claude', 'gsd-core', 'references');
|
||||
assert.ok(
|
||||
fs.existsSync(refsDir),
|
||||
`gsd-core/references/ must be created by local claude install at ${refsDir}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('W2: focused repro — staged discuss-phase.md has zero /gsd: colon refs', () => {
|
||||
// User-reported repro: /gsd-discuss-phase output ends with /gsd:nextcommand
|
||||
// because discuss-phase.md ships 7 colon refs that were not normalized.
|
||||
const stagedFile = path.join(
|
||||
claudeTmpDir, '.claude', 'gsd-core', 'workflows', 'discuss-phase.md',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(stagedFile),
|
||||
`discuss-phase.md must exist in staged gsd-core/workflows/`,
|
||||
);
|
||||
const content = fs.readFileSync(stagedFile, 'utf-8');
|
||||
const colonMatches = content.match(/gsd:[a-z][a-z0-9-]*/g) || [];
|
||||
// Filter to known-command refs only
|
||||
const knownColonRefs = colonMatches.filter(m => {
|
||||
const cmd = m.slice(4); // strip 'gsd:'
|
||||
return cmdNames.includes(cmd);
|
||||
});
|
||||
assert.deepEqual(
|
||||
knownColonRefs,
|
||||
[],
|
||||
`discuss-phase.md still contains colon-namespace refs that install must normalize: ${knownColonRefs.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('W3: no staged workflow body contains /gsd:<known-cmd> colon refs', () => {
|
||||
const workflowsDir = path.join(claudeTmpDir, '.claude', 'gsd-core', 'workflows');
|
||||
assert.ok(fs.existsSync(workflowsDir), 'workflows/ must exist for this check to be meaningful');
|
||||
|
||||
const offenders = collectOffenders(workflowsDir, rosterRegex);
|
||||
const relOffenders = offenders.map(f => path.relative(claudeTmpDir, f));
|
||||
|
||||
assert.deepEqual(
|
||||
relOffenders,
|
||||
[],
|
||||
`Staged workflow bodies still contain roster colon refs (e.g. /gsd:plan-phase). ` +
|
||||
`Install must normalize these to /gsd-<cmd> for claude runtime. Offenders: ${relOffenders.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('W4: no staged reference body contains /gsd:<known-cmd> colon refs', () => {
|
||||
const refsDir = path.join(claudeTmpDir, '.claude', 'gsd-core', 'references');
|
||||
assert.ok(fs.existsSync(refsDir), 'references/ must exist for this check to be meaningful');
|
||||
|
||||
const offenders = collectOffenders(refsDir, rosterRegex);
|
||||
const relOffenders = offenders.map(f => path.relative(claudeTmpDir, f));
|
||||
|
||||
assert.deepEqual(
|
||||
relOffenders,
|
||||
[],
|
||||
`Staged reference bodies still contain roster colon refs. ` +
|
||||
`Install must normalize these to /gsd-<cmd> for claude runtime. Offenders: ${relOffenders.join(', ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// R — #3646 routing-block positive assertion: ▶-prefixed lines use hyphen
|
||||
//
|
||||
// User repro: workflow output ends with "▶ /gsd:validate-phase {N}" (colon
|
||||
// form) which does not resolve in Claude Code — the installed skill is
|
||||
// /gsd-validate-phase (hyphen). Workflows emit routing blocks verbatim, so
|
||||
// the colon form reaches the model and is echoed to the user unchanged.
|
||||
//
|
||||
// This suite checks the POSITIVE invariant: lines starting with ▶ that
|
||||
// reference a GSD slash command must use /gsd-<cmd> (hyphen) in the staged
|
||||
// output. This is a stricter assertion than W3 (which only checks absence
|
||||
// of colon globally) because it confirms the routing-position strings were
|
||||
// NOT omitted — they must be present AND use the correct form.
|
||||
//
|
||||
// Source files with known ▶-prefixed routing-block colon refs (#3646):
|
||||
// - gsd-core/workflows/validate-phase.md:151 ▶ Next: /gsd:audit-milestone
|
||||
// - gsd-core/workflows/validate-phase.md:158 ▶ Retry: /gsd:validate-phase
|
||||
// - gsd-core/workflows/secure-phase.md:140 ▶ Fix mitigations: /gsd:secure-phase
|
||||
// - gsd-core/workflows/secure-phase.md:158 ▶ /gsd:validate-phase
|
||||
// - gsd-core/workflows/secure-phase.md:159 ▶ /gsd:verify-work
|
||||
// -------------------------------------------------------------------------
|
||||
describe('R — #3646 routing-block: ▶-prefixed lines use hyphen form in staged claude install', () => {
|
||||
// Uses the shared claudeTmpDir from the parent describe block — no separate install needed.
|
||||
|
||||
/**
|
||||
* Collect all lines starting with the ▶ routing marker from a file.
|
||||
* Returns an array of { lineNo, text } objects.
|
||||
*/
|
||||
function collectRoutingLines(filePath) {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
return fs.readFileSync(filePath, 'utf-8')
|
||||
.split(/\r?\n/)
|
||||
.map((text, i) => ({ lineNo: i + 1, text }))
|
||||
.filter(({ text }) => text.startsWith('▶'));
|
||||
}
|
||||
|
||||
test('R1: staged validate-phase.md routing block uses /gsd-<cmd> hyphen form', () => {
|
||||
const stagedFile = path.join(
|
||||
claudeTmpDir, '.claude', 'gsd-core', 'workflows', 'validate-phase.md',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(stagedFile),
|
||||
`validate-phase.md must exist in staged gsd-core/workflows/`,
|
||||
);
|
||||
const routingLines = collectRoutingLines(stagedFile);
|
||||
// Exactly two known routing lines (▶ Next / ▶ Retry).
|
||||
const gsdRoutingLines = routingLines.filter(({ text }) => /\/gsd[-:]/.test(text));
|
||||
assert.strictEqual(
|
||||
gsdRoutingLines.length,
|
||||
2,
|
||||
`validate-phase.md must have exactly 2 ▶-routing lines referencing a /gsd- command — ` +
|
||||
`found ${gsdRoutingLines.length}: ${JSON.stringify(gsdRoutingLines)}`,
|
||||
);
|
||||
// Positive: every routing line that references gsd must use the hyphen form.
|
||||
for (const { lineNo, text } of gsdRoutingLines) {
|
||||
assert.ok(
|
||||
/\/gsd-[a-z]/.test(text),
|
||||
`validate-phase.md line ${lineNo}: ▶-routing line must use /gsd-<cmd> hyphen form, got: ${JSON.stringify(text)}`,
|
||||
);
|
||||
// Negative: must not contain the colon form.
|
||||
assert.ok(
|
||||
!/\/gsd:[a-z]/.test(text),
|
||||
`validate-phase.md line ${lineNo}: ▶-routing line must not contain /gsd:<cmd> colon form, got: ${JSON.stringify(text)}`,
|
||||
);
|
||||
// Token-level: extract real command tokens (/gsd-<cmd> starting with a
|
||||
// lowercase letter) and assert none contain an embedded colon.
|
||||
// Skips documentation placeholder tokens like /gsd-[command].
|
||||
const rawTokens = text.match(/\/gsd[^\s]*/g) || [];
|
||||
for (const token of rawTokens) {
|
||||
assert.ok(
|
||||
!token.includes(':'),
|
||||
`validate-phase.md line ${lineNo}: /gsd token "${token}" must not contain a colon — embedded colon detected (e.g. /gsd-validate:phase), got: ${JSON.stringify(text)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('R2: staged secure-phase.md routing block uses /gsd-<cmd> hyphen form', () => {
|
||||
const stagedFile = path.join(
|
||||
claudeTmpDir, '.claude', 'gsd-core', 'workflows', 'secure-phase.md',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(stagedFile),
|
||||
`secure-phase.md must exist in staged gsd-core/workflows/`,
|
||||
);
|
||||
const routingLines = collectRoutingLines(stagedFile);
|
||||
// Exactly three known routing lines (fix-mitigations, validate-phase, verify-work).
|
||||
const gsdRoutingLines = routingLines.filter(({ text }) => /\/gsd[-:]/.test(text));
|
||||
assert.strictEqual(
|
||||
gsdRoutingLines.length,
|
||||
3,
|
||||
`secure-phase.md must have exactly 3 ▶-routing lines referencing a /gsd- command — ` +
|
||||
`found ${gsdRoutingLines.length}: ${JSON.stringify(gsdRoutingLines)}`,
|
||||
);
|
||||
for (const { lineNo, text } of gsdRoutingLines) {
|
||||
assert.ok(
|
||||
/\/gsd-[a-z]/.test(text),
|
||||
`secure-phase.md line ${lineNo}: ▶-routing line must use /gsd-<cmd> hyphen form, got: ${JSON.stringify(text)}`,
|
||||
);
|
||||
assert.ok(
|
||||
!/\/gsd:[a-z]/.test(text),
|
||||
`secure-phase.md line ${lineNo}: ▶-routing line must not contain /gsd:<cmd> colon form, got: ${JSON.stringify(text)}`,
|
||||
);
|
||||
// Token-level: extract all /gsd... tokens and assert none contain an
|
||||
// embedded colon (catches /gsd-validate:phase etc).
|
||||
// Skips documentation placeholder tokens like /gsd-[command].
|
||||
const rawTokens = text.match(/\/gsd[^\s]*/g) || [];
|
||||
for (const token of rawTokens) {
|
||||
assert.ok(
|
||||
!token.includes(':'),
|
||||
`secure-phase.md line ${lineNo}: /gsd token "${token}" must not contain a colon — embedded colon detected (e.g. /gsd-validate:phase), got: ${JSON.stringify(text)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('R3: all staged workflow routing blocks use hyphen form (cross-file sweep)', () => {
|
||||
// R3 unique value vs W3:
|
||||
// W3 catches overt /gsd:<cmd> at file level (any line).
|
||||
// R3 adds:
|
||||
// (a) ▶-line-scoped assertion (catches drift specifically in routing-block context)
|
||||
// (b) embedded-colon token check (e.g. /gsd-validate:phase partial-conversion artifacts)
|
||||
// not detectable by W3's file-level regex
|
||||
// Sweeps both workflows/ and references/ so routing blocks in reference files
|
||||
// are covered alongside workflow files.
|
||||
const gsdDir = path.join(claudeTmpDir, '.claude', 'gsd-core');
|
||||
const workflowsDir = path.join(gsdDir, 'workflows');
|
||||
assert.ok(fs.existsSync(workflowsDir), 'workflows/ must exist for R3 to be meaningful');
|
||||
|
||||
const colonOffenders = [];
|
||||
const embeddedColonOffenders = [];
|
||||
const walk = (d) => {
|
||||
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
const fullPath = path.join(d, entry.name);
|
||||
if (entry.isDirectory()) { walk(fullPath); continue; }
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
const lines = fs.readFileSync(fullPath, 'utf-8').split(/\r?\n/);
|
||||
const rel = path.relative(claudeTmpDir, fullPath);
|
||||
lines.forEach((text, i) => {
|
||||
if (!text.startsWith('▶')) return;
|
||||
// Negative: must not contain overt /gsd:<cmd> colon form.
|
||||
if (/\/gsd:[a-z]/.test(text)) {
|
||||
colonOffenders.push(`${rel}:${i + 1}: ${text.trim()}`);
|
||||
}
|
||||
// Token-level: check each /gsd... token for an embedded colon.
|
||||
// Catches cases like /gsd-validate:phase where normalizer half-converted.
|
||||
// Documentation placeholder tokens like /gsd-[command] are skipped
|
||||
// because their tokens will not contain a colon.
|
||||
const tokens = text.match(/\/gsd[^\s]*/g) || [];
|
||||
for (const token of tokens) {
|
||||
if (token.includes(':')) {
|
||||
embeddedColonOffenders.push(`${rel}:${i + 1}: token "${token}" in "${text.trim()}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
// Walk both workflows/ and references/ — routing blocks can appear in either.
|
||||
walk(workflowsDir);
|
||||
const refsDir = path.join(gsdDir, 'references');
|
||||
if (fs.existsSync(refsDir)) walk(refsDir);
|
||||
|
||||
assert.deepEqual(
|
||||
colonOffenders,
|
||||
[],
|
||||
`Staged workflows contain ▶-routing lines with /gsd:<cmd> colon form — ` +
|
||||
`these must resolve to /gsd-<cmd> for Claude Code skills-based install. ` +
|
||||
`Offenders:\n ${colonOffenders.join('\n ')}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
embeddedColonOffenders,
|
||||
[],
|
||||
`Staged workflows contain ▶-routing lines with /gsd tokens that have an embedded ` +
|
||||
`colon (e.g. /gsd-validate:phase) — normalizer may have partially converted a token. ` +
|
||||
`Offenders:\n ${embeddedColonOffenders.join('\n ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// G — negative: gemini install must PRESERVE colon form (no-op normalizer)
|
||||
// -------------------------------------------------------------------------
|
||||
describe('G — negative: staged gemini workflows preserve colon-namespace refs', () => {
|
||||
let tmpDir;
|
||||
const cmdNames = readCmdNames();
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3683-gem-'));
|
||||
runGeminiLocalInstall(tmpDir);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('G0: staged gemini gsd-core/workflows/ directory exists after install', () => {
|
||||
const workflowsDir = path.join(tmpDir, '.gemini', 'gsd-core', 'workflows');
|
||||
assert.ok(
|
||||
fs.existsSync(workflowsDir),
|
||||
`gemini gsd-core/workflows/ must be created at ${workflowsDir}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('G1: gemini discuss-phase.md preserves colon form (normalizer is a no-op for gemini)', () => {
|
||||
// Gemini registers /gsd:<cmd> as its canonical form — normalization
|
||||
// must NOT fire for this runtime. Verify colon refs survive unchanged.
|
||||
const stagedFile = path.join(
|
||||
tmpDir, '.gemini', 'gsd-core', 'workflows', 'discuss-phase.md',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(stagedFile),
|
||||
`gemini discuss-phase.md must exist in staged gsd-core/workflows/`,
|
||||
);
|
||||
const content = fs.readFileSync(stagedFile, 'utf-8');
|
||||
// The source has 7 colon refs; at least one must be present in gemini output.
|
||||
const colonMatches = content.match(/gsd:[a-z][a-z0-9-]*/g) || [];
|
||||
const knownColonRefs = colonMatches.filter(m => cmdNames.includes(m.slice(4)));
|
||||
assert.ok(
|
||||
knownColonRefs.length > 0,
|
||||
`gemini staged discuss-phase.md must preserve /gsd:<cmd> colon refs — ` +
|
||||
`they are Gemini's canonical command namespace and must not be rewritten to hyphen form`,
|
||||
);
|
||||
});
|
||||
|
||||
test('G2: gemini workflows are not over-normalized (no /gsd-- double-hyphen artifacts)', () => {
|
||||
const workflowsDir = path.join(tmpDir, '.gemini', 'gsd-core', 'workflows');
|
||||
if (!fs.existsSync(workflowsDir)) return; // guard — G0 already asserts existence
|
||||
const doubleHyphenRegex = /\/gsd--[a-z]/;
|
||||
const garbled = collectOffenders(workflowsDir, doubleHyphenRegex);
|
||||
const relGarbled = garbled.map(f => path.relative(tmpDir, f));
|
||||
assert.deepEqual(
|
||||
relGarbled,
|
||||
[],
|
||||
`Gemini staged workflows contain /gsd-- double-hyphen artifacts — normalizer ran when it should not have. Garbled: ${relGarbled.join(', ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow `.md` files are the runtime contract executed by Claude Code as
|
||||
// embedded bash. Asserting on the staged text of resume-project.md and on the
|
||||
// behavior of the embedded snippet under real shells is a behavioral test of
|
||||
// the workflow itself, not source-grep theater.
|
||||
|
||||
/**
|
||||
* Regression for #3689 — /gsd-resume-work silently drops
|
||||
* `.planning/.continue-here*.md` checkpoints under zsh's default NOMATCH.
|
||||
*
|
||||
* Root cause: the `check_incomplete_work` step in
|
||||
* `gsd-core/workflows/resume-project.md` used a chained `ls` with six
|
||||
* bare-glob arguments. Under zsh's default `NOMATCH` setopt the first
|
||||
* non-matching glob aborts the entire command during word-expansion — every
|
||||
* pattern after that point is never evaluated, including the one that holds
|
||||
* valid pause checkpoints (`.planning/.continue-here*.md`). `2>/dev/null ||
|
||||
* true` only suppresses ls's own stderr / exit code; it has no effect on the
|
||||
* shell's pre-exec abort.
|
||||
*
|
||||
* Fix: replace the chained `ls` with two `find` calls. `find` does not use
|
||||
* shell glob expansion, and `find <missing-dir> -maxdepth N -name PATTERN
|
||||
* -print 2>/dev/null` tolerates absent directories on both bash and zsh.
|
||||
*
|
||||
* This test covers:
|
||||
* 1. zsh under `-o nomatch`: checkpoint at `.planning/.continue-here-*.md`
|
||||
* is listed even when `.planning/spikes`, `.planning/sketches`,
|
||||
* `.planning/deliberations` are absent (the common new-project layout).
|
||||
* 2. bash default: same behavior.
|
||||
* 3. zsh `-o nomatch` with no `.continue-here` files anywhere: exits 0,
|
||||
* no output, no error.
|
||||
* 4. Text invariant: resume-project.md no longer carries the brittle
|
||||
* chained-ls pattern.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const WORKFLOW_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'resume-project.md');
|
||||
|
||||
// The exact snippet the workflow now embeds. Keep in sync with
|
||||
// resume-project.md `check_incomplete_work` step.
|
||||
const FIND_SNIPPET = [
|
||||
"find .planning -maxdepth 3 -name '.continue-here*.md' -print 2>/dev/null || true",
|
||||
"find . -maxdepth 1 -name '.continue-here*.md' -print 2>/dev/null || true",
|
||||
].join('\n');
|
||||
|
||||
function hasShell(name) {
|
||||
const result = spawnSync('which', [name], { encoding: 'utf8' });
|
||||
return result.status === 0 && result.stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
describe('bug #3689 — resume-project.md continue-here scan under zsh NOMATCH', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = createTempDir('gsd-bug-3689-');
|
||||
// Reproduce the common new-project layout: a `.planning/` with a
|
||||
// suffixed continue-here file and *no* spike / sketch / deliberation
|
||||
// subdirectories.
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', '.continue-here-AT-1234.md'),
|
||||
'---\ncontext: default\n---\nhandoff body\n',
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('zsh -o nomatch lists the .planning/.continue-here-* checkpoint', { skip: !hasShell('zsh') }, () => {
|
||||
const result = spawnSync('zsh', ['-o', 'nomatch', '-c', FIND_SNIPPET], {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, `zsh exited ${result.status}; stderr=${result.stderr}`);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/\.planning\/\.continue-here-AT-1234\.md/,
|
||||
`expected checkpoint in stdout, got: ${JSON.stringify(result.stdout)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('bash default lists the .planning/.continue-here-* checkpoint', { skip: !hasShell('bash') }, () => {
|
||||
const result = spawnSync('bash', ['-c', FIND_SNIPPET], {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, `bash exited ${result.status}; stderr=${result.stderr}`);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/\.planning\/\.continue-here-AT-1234\.md/,
|
||||
`expected checkpoint in stdout, got: ${JSON.stringify(result.stdout)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #3689 — empty workspace exits cleanly', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = createTempDir('gsd-bug-3689-');
|
||||
// No .planning/ at all, no .continue-here files. Pure greenfield.
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('zsh -o nomatch with no checkpoints exits 0, empty output', { skip: !hasShell('zsh') }, () => {
|
||||
const result = spawnSync('zsh', ['-o', 'nomatch', '-c', FIND_SNIPPET], {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, `zsh exited ${result.status}; stderr=${result.stderr}`);
|
||||
assert.equal(result.stdout.trim(), '', `expected no stdout, got: ${JSON.stringify(result.stdout)}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #3689 — workflow text invariant', () => {
|
||||
test('resume-project.md no longer chains bare globs through ls', () => {
|
||||
const body = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
assert.doesNotMatch(
|
||||
body,
|
||||
/ls\s+\.planning\/spikes\/\*\/\.continue-here/,
|
||||
'resume-project.md still contains the chained `ls .planning/spikes/*/.continue-here*.md` pattern that aborts under zsh NOMATCH; the find-based scan should replace it.',
|
||||
);
|
||||
assert.match(
|
||||
body,
|
||||
/find \.planning -maxdepth 3 -name '\.continue-here\*\.md'/,
|
||||
'resume-project.md must use the find-based scan introduced by the #3689 fix.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,212 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression test for bug #444: gsd_run resolver must probe
|
||||
* <repo-root>/.claude/gsd-core/bin/gsd-tools.cjs (the project-local
|
||||
* `--claude --local` install location) BEFORE checking $HOME/.claude and PATH.
|
||||
*
|
||||
* Asserts:
|
||||
* (A) The canonical snippet file contains the repo-local .claude/ check.
|
||||
* (B) Behavioral: when RUNTIME_DIR/gsd-core/bin/ misses, but a stub
|
||||
* exists ONLY at <repo-root>/.claude/gsd-core/bin/gsd-tools.cjs,
|
||||
* gsd_run resolves to that stub (no PATH stub, no HOME stub).
|
||||
* (C) Precedence: repo-local .claude/ wins over $HOME/.claude/ when both exist.
|
||||
*/
|
||||
|
||||
// allow-test-rule: structural/behavioral regression for the repo-local .claude/ install
|
||||
// arm in the gsd_run launcher snippet -- asserts literal substring presence and exercises
|
||||
// the bash resolution path via execFileSync; there is no typed IR for "snippet contains arm X".
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
|
||||
|
||||
// The probe string that must appear in the snippet for the new repo-local check.
|
||||
// The snippet uses _GSD_RUNTIME_ROOT as the intermediate variable.
|
||||
const LOCAL_CLAUDE_PROBE = '_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/';
|
||||
|
||||
/**
|
||||
* Build a PATH that strips gsd-tools but keeps node and system binaries.
|
||||
* Accepts additional bin dirs to prepend.
|
||||
*
|
||||
* We cannot simply remove the whole directory that contains gsd-tools because
|
||||
* that directory may also contain node (e.g. /opt/homebrew/bin on macOS).
|
||||
* Instead, we keep the system PATH as-is and rely on the test's RUNTIME_DIR
|
||||
* having no gsd-core/bin/ sub-path, so the resolver's first two checks
|
||||
* (RUNTIME_DIR/gsd-core/bin/ and RUNTIME_DIR/.claude/gsd-core/bin/)
|
||||
* are the only ones exercised before we hit our stub.
|
||||
*
|
||||
* The extra extraBefore dirs (e.g. noToolsBin) sit first but have no gsd-tools
|
||||
* binary, so command -v gsd-tools still falls back to PATH lookup. However,
|
||||
* the snippet's elif arm that uses `command -v gsd-tools` will find the real
|
||||
* installed one unless we mask it. To mask it without losing node, we create
|
||||
* a noToolsBin dir that shadows gsd-tools with a sentinel that must NOT be
|
||||
* called — and we only call makeIsolatedPath for tests where the .claude stub
|
||||
* must win before PATH is consulted (i.e. the elif PATH arm is never reached).
|
||||
*
|
||||
* For B: stub is at RUNTIME_DIR/.claude/... so resolver picks it at elif-1 (before command -v).
|
||||
* For C: same — local .claude/ is checked before command -v and before $HOME/.claude.
|
||||
*/
|
||||
function makeIsolatedPath(extraBefore = []) {
|
||||
// Keep full system PATH so node remains accessible.
|
||||
// Tests B and C exercise only the RUNTIME_DIR/.claude arm which fires
|
||||
// before command -v gsd-tools — so the real gsd-tools on PATH is never reached.
|
||||
const systemPaths = (process.env.PATH || '/usr/bin:/bin').split(path.delimiter);
|
||||
return [...extraBefore, ...systemPaths].join(path.delimiter);
|
||||
}
|
||||
|
||||
describe('bug-444: resolver finds repo-local .claude install', () => {
|
||||
// --- (A) Snippet contains the repo-local .claude arm ----------------------
|
||||
test('(A) snippet file contains the repo-local .claude/ check arm before $HOME/.claude/', () => {
|
||||
const content = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
|
||||
// Must contain the repo-local .claude/ check (via _GSD_RUNTIME_ROOT variable)
|
||||
const localClaudeIdx = content.indexOf(LOCAL_CLAUDE_PROBE);
|
||||
assert.ok(
|
||||
localClaudeIdx !== -1,
|
||||
`_runtime-launcher.snippet.sh must contain the repo-local .claude check ` +
|
||||
`('${LOCAL_CLAUDE_PROBE}'). ` +
|
||||
`Found snippet content:\n${content.trim()}`,
|
||||
);
|
||||
|
||||
// Must still contain the $HOME/.claude fallback arm
|
||||
const homeClaudeIdx = content.indexOf('$HOME/.claude/gsd-core/bin/');
|
||||
assert.ok(
|
||||
homeClaudeIdx !== -1,
|
||||
`Snippet must still contain the $HOME/.claude fallback arm.`,
|
||||
);
|
||||
|
||||
// Repo-local check must appear BEFORE $HOME/.claude check (local overrides global)
|
||||
assert.ok(
|
||||
localClaudeIdx < homeClaudeIdx,
|
||||
`Repo-local .claude/ check (idx ${localClaudeIdx}) must appear BEFORE ` +
|
||||
`$HOME/.claude/ check (idx ${homeClaudeIdx}) in the snippet (local overrides global).`,
|
||||
);
|
||||
});
|
||||
|
||||
// --- (B) Behavioral: repo-local .claude stub resolved when only location ---
|
||||
test('(B) gsd_run resolves repo-local .claude/gsd-core/bin/ stub when no other locations present', () => {
|
||||
// Create a fake repo root with a stub ONLY at .claude/gsd-core/bin/gsd-tools.cjs
|
||||
// NO stub at gsd-core/bin/, NOT on PATH, NOT in $HOME/.claude
|
||||
const fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-444-root-'));
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-444-home-'));
|
||||
const noToolsBin = path.join(fakeRoot, 'nobin');
|
||||
fs.mkdirSync(noToolsBin, { recursive: true });
|
||||
|
||||
try {
|
||||
// Create the stub at the repo-local .claude path ONLY
|
||||
const localClaudeBinDir = path.join(fakeRoot, '.claude', 'gsd-core', 'bin');
|
||||
fs.mkdirSync(localClaudeBinDir, { recursive: true });
|
||||
const stubPath = path.join(localClaudeBinDir, 'gsd-tools.cjs');
|
||||
fs.writeFileSync(
|
||||
stubPath,
|
||||
'#!/usr/bin/env node\nconsole.log("LOCAL_CLAUDE_STUB:" + process.argv.slice(2).join(","));\n',
|
||||
);
|
||||
fs.chmodSync(stubPath, 0o755);
|
||||
|
||||
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
// Set RUNTIME_DIR to fakeRoot so the resolver uses it as the repo root.
|
||||
const scriptContent =
|
||||
`unset GSD_TOOLS\n` +
|
||||
`export RUNTIME_DIR=${JSON.stringify(fakeRoot)}\n` +
|
||||
`export HOME=${JSON.stringify(fakeHome)}\n` +
|
||||
snippet +
|
||||
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
|
||||
`gsd_run ping test\n`;
|
||||
|
||||
const scriptPath = path.join(fakeRoot, 'test-local-claude.sh');
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
// Keep node in PATH (needed to run the .cjs stub); remove gsd-tools
|
||||
const isolatedPath = makeIsolatedPath([noToolsBin]);
|
||||
|
||||
const stdout = execFileSync('bash', [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome },
|
||||
});
|
||||
|
||||
// Must have resolved to the local .claude stub
|
||||
const normStdout = stdout.replace(/\\/g, '/');
|
||||
assert.ok(
|
||||
normStdout.includes('.claude/gsd-core/bin/gsd-tools.cjs'),
|
||||
`Expected GSD_TOOLS to resolve to .claude/gsd-core/bin/gsd-tools.cjs, got:\n${stdout.trim()}`,
|
||||
);
|
||||
// The stub must have been invoked with the correct arguments
|
||||
assert.ok(
|
||||
stdout.includes('LOCAL_CLAUDE_STUB:ping,test'),
|
||||
`Expected stub output "LOCAL_CLAUDE_STUB:ping,test" but got:\n${stdout.trim()}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(fakeRoot);
|
||||
cleanup(fakeHome);
|
||||
}
|
||||
});
|
||||
|
||||
// --- (C) Precedence: repo-local .claude/ wins over $HOME/.claude/ ----------
|
||||
test('(C) repo-local .claude/ install wins over $HOME/.claude/ when both exist', () => {
|
||||
const fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-444-prec-root-'));
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-444-prec-home-'));
|
||||
const noToolsBin = path.join(fakeRoot, 'nobin');
|
||||
fs.mkdirSync(noToolsBin, { recursive: true });
|
||||
|
||||
try {
|
||||
// Stub at repo-local .claude/ path (should be picked)
|
||||
const localClaudeBinDir = path.join(fakeRoot, '.claude', 'gsd-core', 'bin');
|
||||
fs.mkdirSync(localClaudeBinDir, { recursive: true });
|
||||
const localStubPath = path.join(localClaudeBinDir, 'gsd-tools.cjs');
|
||||
fs.writeFileSync(
|
||||
localStubPath,
|
||||
'#!/usr/bin/env node\nconsole.log("LOCAL_WINS:" + process.argv.slice(2).join(","));\n',
|
||||
);
|
||||
fs.chmodSync(localStubPath, 0o755);
|
||||
|
||||
// Stub at $HOME/.claude/ path (must NOT be picked)
|
||||
const homeClaudeBinDir = path.join(fakeHome, '.claude', 'gsd-core', 'bin');
|
||||
fs.mkdirSync(homeClaudeBinDir, { recursive: true });
|
||||
const homeStubPath = path.join(homeClaudeBinDir, 'gsd-tools.cjs');
|
||||
fs.writeFileSync(
|
||||
homeStubPath,
|
||||
'#!/usr/bin/env node\nconsole.log("HOME_WINS:" + process.argv.slice(2).join(","));\n',
|
||||
);
|
||||
fs.chmodSync(homeStubPath, 0o755);
|
||||
|
||||
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
const scriptContent =
|
||||
`unset GSD_TOOLS\n` +
|
||||
`export RUNTIME_DIR=${JSON.stringify(fakeRoot)}\n` +
|
||||
`export HOME=${JSON.stringify(fakeHome)}\n` +
|
||||
snippet +
|
||||
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
|
||||
`gsd_run check\n`;
|
||||
|
||||
const scriptPath = path.join(fakeRoot, 'test-precedence.sh');
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
const isolatedPath = makeIsolatedPath([noToolsBin]);
|
||||
|
||||
const stdout = execFileSync('bash', [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome },
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
stdout.includes('LOCAL_WINS:check'),
|
||||
`Expected repo-local .claude stub to be invoked ("LOCAL_WINS:check") ` +
|
||||
`but got:\n${stdout.trim()}`,
|
||||
);
|
||||
assert.ok(
|
||||
!stdout.includes('HOME_WINS'),
|
||||
`Expected $HOME/.claude stub NOT to be invoked, but got:\n${stdout.trim()}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(fakeRoot);
|
||||
cleanup(fakeHome);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// codebase-drift-gate.md is the shipped orchestration step contract. Bug #619:
|
||||
// the initial drift check ran the bare PATH binary `gsd-tools verify codebase-drift`.
|
||||
// On a shim-only install (gsd-tools.cjs present, `gsd-tools` not on PATH) that exits
|
||||
// 127, `2>/dev/null` hides it, and the `|| echo` fallback marks the gate skipped —
|
||||
// so post-execution drift detection silently never runs. The fix resolves gsd-tools
|
||||
// through the runtime shim launcher (gsd_run), defining the canonical preamble once in
|
||||
// this always-run block so the file stays compliant with the single-preamble parity
|
||||
// invariant (the conditional auto-remap block reuses the launcher via shared shell scope).
|
||||
//
|
||||
// This file locks the source contract AND behaviorally proves the shim resolves: it runs
|
||||
// the exact shipped drift-check block against a shim-only topology and asserts the shim
|
||||
// actually executes, where the old bare-binary form would have skipped.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const GATE_MD = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase', 'steps', 'codebase-drift-gate.md',
|
||||
);
|
||||
const SNIPPET_FILE = path.join(__dirname, '..', 'gsd-core', 'workflows', '_runtime-launcher.snippet.sh');
|
||||
|
||||
function readGate() {
|
||||
return fs.readFileSync(GATE_MD, 'utf8');
|
||||
}
|
||||
|
||||
// Extract the Nth (0-based) ```bash fenced block body from the file.
|
||||
function bashBlock(content, n) {
|
||||
const blocks = [];
|
||||
const re = /```bash\r?\n([\s\S]*?)```/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) blocks.push(m[1]);
|
||||
assert.ok(blocks.length > n, `expected at least ${n + 1} bash blocks, found ${blocks.length}`);
|
||||
return blocks[n];
|
||||
}
|
||||
|
||||
describe('bug #619 — codebase-drift-gate resolves gsd-tools via the runtime shim, not the bare PATH binary', () => {
|
||||
test('codebase-drift-gate.md is readable', () => {
|
||||
assert.ok(readGate().length > 0, 'codebase-drift-gate.md must not be empty');
|
||||
});
|
||||
|
||||
// ── Source contract (the .md is the product) ──────────────────────────────
|
||||
|
||||
test('the drift check resolves gsd-tools via the shim launcher (gsd_run), not the bare binary (#619)', () => {
|
||||
const content = readGate();
|
||||
assert.match(
|
||||
content,
|
||||
/DRIFT=\$\(gsd_run verify codebase-drift 2>\/dev\/null \|\| echo '\{"skipped":true,"reason":"sdk-failed"\}'\)/,
|
||||
'drift check must call `gsd_run verify codebase-drift` with the non-blocking skip fallback',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
content,
|
||||
/\bgsd-tools verify codebase-drift\b/,
|
||||
'the bare `gsd-tools verify codebase-drift` PATH-binary call (the #619 bug) must be gone',
|
||||
);
|
||||
});
|
||||
|
||||
test('non-blocking contract preserved: the skip JSON fallback is intact (#619)', () => {
|
||||
const content = readGate();
|
||||
assert.match(
|
||||
content,
|
||||
/\|\| echo '\{"skipped":true,"reason":"sdk-failed"\}'/,
|
||||
'an internal drift-command failure must still fall through to the skip JSON',
|
||||
);
|
||||
});
|
||||
|
||||
test('exactly one canonical launcher preamble, in the drift-check block, before any launcher call (#619)', () => {
|
||||
const content = readGate();
|
||||
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8').replace(/\r?\n$/, '');
|
||||
|
||||
// Count canonical preamble occurrences across the whole file (parity: exactly one).
|
||||
let count = 0;
|
||||
let pos = 0;
|
||||
for (;;) {
|
||||
const idx = content.indexOf(snippet, pos);
|
||||
if (idx === -1) break;
|
||||
count++;
|
||||
pos = idx + snippet.length;
|
||||
}
|
||||
assert.equal(count, 1, `expected exactly one canonical preamble; found ${count}`);
|
||||
|
||||
// The preamble must live in the first (drift-check) bash block, before the DRIFT call.
|
||||
const block0 = bashBlock(content, 0);
|
||||
assert.ok(block0.includes(snippet), 'the canonical preamble must be in the drift-check block');
|
||||
assert.ok(
|
||||
block0.indexOf(snippet) < block0.indexOf('gsd_run verify codebase-drift'),
|
||||
'the preamble must precede the gsd_run drift call in the same block',
|
||||
);
|
||||
|
||||
// The auto-remap block reuses gsd_run but must NOT carry its own preamble.
|
||||
const content2 = content.slice(content.indexOf('AGENT_SKILLS_MAPPER'));
|
||||
assert.ok(!content2.includes(snippet), 'the auto-remap block must not re-declare the preamble (single-preamble parity)');
|
||||
});
|
||||
|
||||
// ── Behavioral proof: the shim resolves on a shim-only topology ───────────
|
||||
|
||||
test('shipped drift-check block runs the shim (gsd-tools.cjs), not skip, on a shim-only install (#619)', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-619-'));
|
||||
try {
|
||||
// Shim-only topology: gsd-tools.cjs present under RUNTIME_DIR; no `gsd-tools` on PATH.
|
||||
const binDir = path.join(tmp, 'gsd-core', 'bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(binDir, 'gsd-tools.cjs'),
|
||||
'if (process.argv[2] === "verify" && process.argv[3] === "codebase-drift") {\n' +
|
||||
' process.stdout.write(JSON.stringify({ action_required: false, sentinel: "SHIM_RAN" }));\n' +
|
||||
'}\n',
|
||||
);
|
||||
|
||||
const block = bashBlock(readGate(), 0) + '\nprintf "%s" "$DRIFT"\n';
|
||||
const out = execFileSync('bash', ['-c', block], {
|
||||
env: { ...process.env, RUNTIME_DIR: tmp },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.match(out, /SHIM_RAN/, 'the drift check must execute the resolved shim, proving gsd_run resolution');
|
||||
assert.doesNotMatch(out, /sdk-failed/, 'the gate must NOT silently skip when the shim is present (#619)');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('red-proof: the old bare `gsd-tools` form would skip when gsd-tools is not on PATH', () => {
|
||||
// Documents the #619 bug: the pre-fix bare-binary call, with no `gsd-tools` on PATH,
|
||||
// hits the 127 → `|| echo` skip path even though the shim (gsd-tools.cjs) exists.
|
||||
const oldForm =
|
||||
'DRIFT=$(gsd-tools verify codebase-drift 2>/dev/null || echo \'{"skipped":true,"reason":"sdk-failed"}\'); printf "%s" "$DRIFT"';
|
||||
const out = execFileSync('bash', ['-c', 'export PATH=/nonexistent-empty-path; ' + oldForm], {
|
||||
env: { ...process.env },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.match(out, /sdk-failed/, 'sanity: the bare-binary form skips without gsd-tools on PATH — the bug the fix removes');
|
||||
});
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// This test extracts the deployed Step 3 shell block from commands/gsd/graphify.md
|
||||
// and executes it to prove that a skipped graph.html (due to the graphify HTML viz
|
||||
// node limit) does not abort the chain (#622). The deployed markdown text IS the
|
||||
// product surface — the block the runtime executes — so asserting on its execution
|
||||
// behavior requires reading the source text.
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression test for bug #622.
|
||||
*
|
||||
* The `/gsd-graphify build` Step 3 shell chain in commands/gsd/graphify.md
|
||||
* aborted when `graph.html` was intentionally skipped (graph exceeds the HTML
|
||||
* viz node limit, default 5000). The unconditional `cp graphify-out/graph.html`
|
||||
* failed with "cannot stat", and the `&&` chain aborted before the
|
||||
* GRAPH_REPORT.md copy, snapshot, and status steps ran.
|
||||
*
|
||||
* Fix: guard the graph.html copy with
|
||||
* `{ [ -f graphify-out/graph.html ] && cp … || true; }`
|
||||
* so the chain continues when the file is absent.
|
||||
*/
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// Path to the command doc (relative to repo root)
|
||||
const GRAPHIFY_MD = path.join(__dirname, '..', 'commands', 'gsd', 'graphify.md');
|
||||
|
||||
/**
|
||||
* Extract the Step 3 fenced bash block from graphify.md.
|
||||
* The block starts with the line `graphify update .` and ends at the next
|
||||
* closing ``` fence.
|
||||
*
|
||||
* Returns the bash source text (without the fence lines themselves).
|
||||
*/
|
||||
function extractStep3Block() {
|
||||
const content = fs.readFileSync(GRAPHIFY_MD, 'utf-8');
|
||||
// Capture the full body of the ```bash fence that CONTAINS `graphify update .`
|
||||
// (including any leading preamble line), without crossing into other fences.
|
||||
const match = content.match(/```bash\r?\n((?:(?!```)[\s\S])*?graphify update \.(?:(?!```)[\s\S])*?)\r?\n```/);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
// ─── shared sandbox dirs ──────────────────────────────────────────────────────
|
||||
|
||||
let sandbox;
|
||||
let fakeBin;
|
||||
let fakeHome;
|
||||
|
||||
before(() => {
|
||||
sandbox = createTempDir('gsd-622-sandbox-');
|
||||
fakeBin = createTempDir('gsd-622-fakebin-');
|
||||
fakeHome = createTempDir('gsd-622-fakehome-');
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(sandbox);
|
||||
cleanup(fakeBin);
|
||||
cleanup(fakeHome);
|
||||
});
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write a minimal fake `graphify` executable into fakeBin.
|
||||
* It just exits 0 so the `graphify update .` step succeeds.
|
||||
*/
|
||||
function writeFakeGraphify() {
|
||||
const exe = path.join(fakeBin, 'graphify');
|
||||
fs.writeFileSync(exe, ['#!/bin/sh', 'exit 0'].join('\n'), { mode: 0o755 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a minimal gsd-tools.cjs stub into fakeHome that exits 0 for any
|
||||
* invocation (covers the `graphify build snapshot` and `graphify status` steps).
|
||||
*/
|
||||
function writeFakeGsdTools() {
|
||||
const binDir = path.join(fakeHome, '.claude', 'gsd-core', 'bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(binDir, 'gsd-tools.cjs'),
|
||||
['#!/usr/bin/env node', 'process.exit(0);'].join('\n'),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the sandbox with the minimal directory structure and output files
|
||||
* that a real `graphify update .` would produce. `includeHtml` controls
|
||||
* whether graphify-out/graph.html is created (simulating the node-limit skip
|
||||
* when false).
|
||||
*/
|
||||
function populateSandbox(includeHtml) {
|
||||
// graphify-out/ — simulates graphify CLI output directory
|
||||
const outDir = path.join(sandbox, 'graphify-out');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, 'graph.json'), '{}');
|
||||
fs.writeFileSync(path.join(outDir, 'GRAPH_REPORT.md'), '# report');
|
||||
if (includeHtml) {
|
||||
fs.writeFileSync(path.join(outDir, 'graph.html'), '<html/>');
|
||||
}
|
||||
|
||||
// .planning/graphs/ — destination directory
|
||||
const graphsDir = path.join(sandbox, '.planning', 'graphs');
|
||||
fs.mkdirSync(graphsDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the extracted Step 3 block in the sandbox.
|
||||
*/
|
||||
function runBlock(block) {
|
||||
return spawnSync('bash', ['-c', block], {
|
||||
cwd: sandbox,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: fakeBin + ':' + process.env.PATH,
|
||||
HOME: fakeHome,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #622: graph.html absence must not abort the Step 3 shell chain', () => {
|
||||
let block;
|
||||
|
||||
before(() => {
|
||||
block = extractStep3Block();
|
||||
});
|
||||
|
||||
test('Step 3 bash block is present in graphify.md (sanity gate)', () => {
|
||||
assert.ok(block !== null, 'Step 3 bash block starting with "graphify update ." was not found in commands/gsd/graphify.md');
|
||||
assert.ok(block.length > 0, 'Extracted bash block must not be empty');
|
||||
});
|
||||
|
||||
test('graph.html absent: chain exits 0 and all other artifacts are copied (#622 regression)', (t) => {
|
||||
// Use t.after for per-test cleanup so sandbox is fresh for each test
|
||||
t.after(() => {
|
||||
// Remove and recreate sandbox so the next test starts with an empty dir
|
||||
cleanup(sandbox);
|
||||
fs.mkdirSync(sandbox, { recursive: true });
|
||||
});
|
||||
|
||||
writeFakeGraphify();
|
||||
writeFakeGsdTools();
|
||||
populateSandbox(false); // no graph.html — simulates node-limit skip
|
||||
|
||||
const result = runBlock(block);
|
||||
|
||||
// Chain must not abort
|
||||
assert.equal(result.status, 0, [
|
||||
'Expected exit 0 but got ' + result.status,
|
||||
'stderr: ' + result.stderr,
|
||||
'stdout: ' + result.stdout,
|
||||
].join('\n'));
|
||||
|
||||
// graph.json was copied (step before the guarded line)
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.json')),
|
||||
'.planning/graphs/graph.json must be copied even when graph.html is absent',
|
||||
);
|
||||
|
||||
// GRAPH_REPORT.md was copied (step AFTER the guarded line — key regression assertion)
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'GRAPH_REPORT.md')),
|
||||
'.planning/graphs/GRAPH_REPORT.md must be copied (the chain must not abort at graph.html)',
|
||||
);
|
||||
|
||||
// graph.html must NOT exist in the destination (correctly skipped)
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.html')),
|
||||
'.planning/graphs/graph.html must NOT be created when source is absent',
|
||||
);
|
||||
});
|
||||
|
||||
test('graph.html present: chain exits 0 and graph.html is copied (happy path)', (t) => {
|
||||
t.after(() => {
|
||||
cleanup(sandbox);
|
||||
fs.mkdirSync(sandbox, { recursive: true });
|
||||
});
|
||||
|
||||
writeFakeGraphify();
|
||||
writeFakeGsdTools();
|
||||
populateSandbox(true); // include graph.html
|
||||
|
||||
const result = runBlock(block);
|
||||
|
||||
assert.equal(result.status, 0, [
|
||||
'Expected exit 0 but got ' + result.status,
|
||||
'stderr: ' + result.stderr,
|
||||
'stdout: ' + result.stdout,
|
||||
].join('\n'));
|
||||
|
||||
// graph.html must exist in the destination (normal copy)
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.html')),
|
||||
'.planning/graphs/graph.html must be copied when the source file is present',
|
||||
);
|
||||
|
||||
// Other artifacts also copied
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.json')),
|
||||
'.planning/graphs/graph.json must be copied',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'GRAPH_REPORT.md')),
|
||||
'.planning/graphs/GRAPH_REPORT.md must be copied',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// execute-phase.md is the shipped orchestration contract for wave execution and
|
||||
// cleanup. Bug #630: the two wave-cleanup guards resolved PRIMARY_WT from
|
||||
// `git worktree list --porcelain`'s first entry — always the main checkout —
|
||||
// so an orchestrator running from a non-primary (per-phase lane) worktree was
|
||||
// cd'd off its own lane and tripped the #3174 branch-drift assertion at cleanup,
|
||||
// refusing merge-back. The fix persists the dispatch-time orchestrator root in
|
||||
// WAVE_WORKTREE_MANIFEST and pins cleanup to that, falling back to first-entry
|
||||
// only for pre-#630 manifests.
|
||||
//
|
||||
// This file locks the source contract (the .md is the product) AND behaviorally
|
||||
// proves the pivot by running the shipped manifest-reader one-liner against a
|
||||
// real non-primary-worktree git topology.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const EXECUTE_PHASE_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
|
||||
function readMd() {
|
||||
return fs.readFileSync(EXECUTE_PHASE_MD, 'utf8');
|
||||
}
|
||||
|
||||
// Pull the exact `node -e '...'` manifest-reader script shipped in the cleanup
|
||||
// guard, so the behavioral test exercises the real shipped code, not a copy.
|
||||
function extractManifestReaderScript() {
|
||||
const content = readMd();
|
||||
// Anchor on `PRIMARY_WT=$(MANIFEST=...` so we grab the cleanup READER, not the
|
||||
// dispatch-time writer one-liner (which shares the `MANIFEST="..." node -e` prefix).
|
||||
const m = content.match(/PRIMARY_WT=\$\(MANIFEST="\$WAVE_WORKTREE_MANIFEST" node -e '([^']*)'\)/);
|
||||
assert.ok(m, 'expected a `PRIMARY_WT=$(MANIFEST="$WAVE_WORKTREE_MANIFEST" node -e \'...\')` reader in execute-phase.md');
|
||||
return m[1];
|
||||
}
|
||||
|
||||
function git(cwd, args) {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
// Canonicalize a path the way the OS does. On Windows, os.tmpdir() can yield an 8.3
|
||||
// short name (RUNNER~1) while `git worktree list` reports the long form (runneradmin);
|
||||
// realpathSync.native reconciles both to the true canonical path so comparisons are stable.
|
||||
function canon(p) {
|
||||
return fs.realpathSync.native(p);
|
||||
}
|
||||
|
||||
describe('bug #630 — wave-cleanup pins to the orchestrator root, not git-worktree-list first entry', () => {
|
||||
test('execute-phase.md is readable', () => {
|
||||
assert.ok(readMd().length > 0, 'execute-phase.md must not be empty');
|
||||
});
|
||||
|
||||
// ── Source contract (the .md is the product) ──────────────────────────────
|
||||
|
||||
test('dispatch persists the orchestrator root into the manifest (#630)', () => {
|
||||
const content = readMd();
|
||||
assert.match(
|
||||
content,
|
||||
/ORCH_ROOT=\$\(git rev-parse --show-toplevel\)/,
|
||||
'manifest init must capture the dispatch-time orchestrator root via show-toplevel',
|
||||
);
|
||||
assert.match(
|
||||
content,
|
||||
/orchestrator_root:\s*process\.env\.ORCH_ROOT/,
|
||||
'manifest init must write orchestrator_root into WAVE_WORKTREE_MANIFEST',
|
||||
);
|
||||
});
|
||||
|
||||
test('both cleanup guards resolve PRIMARY_WT from the manifest orchestrator_root (#630)', () => {
|
||||
const content = readMd();
|
||||
const readers = content.match(
|
||||
/PRIMARY_WT=\$\(MANIFEST="\$WAVE_WORKTREE_MANIFEST" node -e '[^']*orchestrator_root[^']*'\)/g,
|
||||
);
|
||||
assert.ok(
|
||||
readers && readers.length >= 2,
|
||||
`both wave-cleanup guards (templated + cleanup-tail) must read orchestrator_root from the manifest; found ${readers ? readers.length : 0}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('first-entry resolution survives only as a guarded fallback, never the sole resolver (#630)', () => {
|
||||
const content = readMd();
|
||||
// Every remaining first-entry resolution must be preceded by the `[ -n "$PRIMARY_WT" ] ||`
|
||||
// guard, i.e. it only runs when the manifest lookup produced nothing.
|
||||
const firstEntryLines = content.match(/^.*git worktree list --porcelain \| awk '\/\^worktree \/.*$/gm) || [];
|
||||
for (const line of firstEntryLines) {
|
||||
assert.match(
|
||||
line,
|
||||
/\[ -n "\$PRIMARY_WT" \] \|\|/,
|
||||
`first-entry resolution must be a guarded fallback, not the primary resolver: ${line.trim()}`,
|
||||
);
|
||||
}
|
||||
assert.ok(firstEntryLines.length >= 2, 'expected the fallback in both cleanup guards');
|
||||
});
|
||||
|
||||
// ── Behavioral proof of the pivot ─────────────────────────────────────────
|
||||
|
||||
test('shipped manifest reader resolves to the lane worktree, while first-entry resolves to main (#630)', () => {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-630-'));
|
||||
try {
|
||||
const mainDir = path.join(tmpRoot, 'main');
|
||||
fs.mkdirSync(mainDir);
|
||||
git(mainDir, ['-c', 'init.defaultBranch=main', 'init', '-q']);
|
||||
git(mainDir, ['config', 'user.email', 'test@example.com']);
|
||||
git(mainDir, ['config', 'user.name', 'Test']);
|
||||
fs.writeFileSync(path.join(mainDir, 'f.txt'), 'x\n');
|
||||
git(mainDir, ['add', '.']);
|
||||
git(mainDir, ['commit', '-q', '-m', 'init']);
|
||||
|
||||
// Non-primary worktree on a per-phase lane branch.
|
||||
const laneDir = path.join(tmpRoot, 'lane');
|
||||
git(mainDir, ['worktree', 'add', '-q', '-b', 'feat/lane', laneDir]);
|
||||
|
||||
const realMain = canon(mainDir);
|
||||
const realLane = canon(laneDir);
|
||||
|
||||
// Manifest as written at dispatch: orchestrator_root is the lane (the orchestrator runs there).
|
||||
const manifest = path.join(tmpRoot, 'wave.json');
|
||||
fs.writeFileSync(manifest, JSON.stringify({ orchestrator_root: realLane, worktrees: [] }) + '\n');
|
||||
|
||||
// Run the EXACT shipped reader one-liner.
|
||||
const script = extractManifestReaderScript();
|
||||
const resolved = execFileSync('node', ['-e', script], {
|
||||
cwd: laneDir,
|
||||
env: { ...process.env, MANIFEST: manifest },
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
|
||||
// The buggy first-entry resolution (run from the lane) yields the MAIN checkout.
|
||||
const firstEntry = canon(
|
||||
git(laneDir, ['worktree', 'list', '--porcelain'])
|
||||
.split('\n')
|
||||
.find(l => l.startsWith('worktree '))
|
||||
.slice('worktree '.length),
|
||||
);
|
||||
|
||||
assert.equal(canon(resolved), realLane, 'manifest reader must resolve to the orchestrator lane worktree');
|
||||
assert.equal(firstEntry, realMain, 'sanity: first-entry resolution points at the main checkout (the #630 bug target)');
|
||||
assert.notEqual(canon(resolved), firstEntry, 'the fix must diverge from the old first-entry behavior for a lane orchestrator');
|
||||
|
||||
// The #3174 branch assertion now passes (pinned to lane → branch matches EXPECTED_BRANCH);
|
||||
// pinning to first-entry (main) would have failed it.
|
||||
const expectedBranch = 'feat/lane';
|
||||
assert.equal(git(resolved, ['rev-parse', '--abbrev-ref', 'HEAD']), expectedBranch, 'lane pin satisfies the #3174 branch check');
|
||||
assert.notEqual(git(firstEntry, ['rev-parse', '--abbrev-ref', 'HEAD']), expectedBranch, 'first-entry pin would have tripped the #3174 branch check');
|
||||
} finally {
|
||||
cleanup(tmpRoot);
|
||||
}
|
||||
});
|
||||
|
||||
test('manifest reader falls through (empty output) when orchestrator_root is absent — fallback engages (#630)', () => {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-630-fb-'));
|
||||
try {
|
||||
const manifest = path.join(tmpRoot, 'legacy.json');
|
||||
// Pre-#630 manifest shape: no orchestrator_root.
|
||||
fs.writeFileSync(manifest, JSON.stringify({ worktrees: [] }) + '\n');
|
||||
const script = extractManifestReaderScript();
|
||||
const out = execFileSync('node', ['-e', script], {
|
||||
env: { ...process.env, MANIFEST: manifest },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(out, '', 'reader must emit nothing for a manifest without orchestrator_root so the first-entry fallback engages');
|
||||
} finally {
|
||||
cleanup(tmpRoot);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
// Regression test for issue #641:
|
||||
// `--files-from` with a bare suite token (e.g. "unit") crashes with
|
||||
// "requested test file(s) not found: unit" instead of expanding the token
|
||||
// to the matching suite's files.
|
||||
//
|
||||
// The bug: selectExplicitFiles() checked `available.has('unit')` against the
|
||||
// set of *.test.cjs filenames. 'unit' is not a filename, so it landed in
|
||||
// `missing` and caused exit 2. The fix teaches selectExplicitFiles() to
|
||||
// delegate bare SUITES members to selectFiles() before the path-existence
|
||||
// check.
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const HARNESS = path.join(__dirname, '..', 'scripts', 'run-tests.cjs');
|
||||
|
||||
const PASS_BODY = `'use strict';
|
||||
const { test } = require('node:test');
|
||||
test('noop', () => {});
|
||||
`;
|
||||
|
||||
function seed(dir, names) {
|
||||
for (const name of names) {
|
||||
fs.writeFileSync(path.join(dir, name), PASS_BODY, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function runHarness(testDir, args = [], extraEnv = {}) {
|
||||
const env = { ...process.env, GSD_TEST_DIR: testDir, ...extraEnv };
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
return spawnSync(process.execPath, [HARNESS, ...args], {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
describe('bug #641 — --files-from with bare suite token', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-641-suite-token-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('--files-from with bare "unit" token expands to unit suite, does not exit 2', () => {
|
||||
// Seed a mix: one unit file, one security file.
|
||||
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
|
||||
const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
|
||||
fs.writeFileSync(listPath, 'unit\n', 'utf8');
|
||||
|
||||
const r = runHarness(tmpDir, ['--files-from', listPath]);
|
||||
|
||||
// Must NOT exit 2 with the "not found" error.
|
||||
assert.notStrictEqual(
|
||||
r.status,
|
||||
2,
|
||||
`Expected exit 0 or 1, got 2.\nstderr: ${r.stderr}\nstdout: ${r.stdout}`,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
r.stderr,
|
||||
/requested test file\(s\) not found: unit/,
|
||||
`Must not emit "not found: unit".\nstderr: ${r.stderr}`,
|
||||
);
|
||||
// The unit suite file (a.test.cjs) must appear in the run.
|
||||
assert.ok(
|
||||
r.stderr.includes('a.test.cjs'),
|
||||
`Expected a.test.cjs (unit suite) to be selected.\nstderr: ${r.stderr}`,
|
||||
);
|
||||
// The security suite file must NOT be included (unit token = unit only).
|
||||
assert.ok(
|
||||
!r.stderr.includes('b.security.test.cjs'),
|
||||
`Expected b.security.test.cjs (security suite) to be excluded.\nstderr: ${r.stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('--files-from with bare "unit" token exits 0 (tests run successfully)', () => {
|
||||
seed(tmpDir, ['a.test.cjs']);
|
||||
const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
|
||||
fs.writeFileSync(listPath, 'unit\n', 'utf8');
|
||||
|
||||
const r = runHarness(tmpDir, ['--files-from', listPath]);
|
||||
|
||||
assert.strictEqual(
|
||||
r.status,
|
||||
0,
|
||||
`Expected exit 0.\nstderr: ${r.stderr}\nstdout: ${r.stdout}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('--files with bare "unit" token also resolves correctly', () => {
|
||||
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
|
||||
const r = runHarness(tmpDir, ['--files', 'unit']);
|
||||
|
||||
assert.notStrictEqual(
|
||||
r.status,
|
||||
2,
|
||||
`Expected exit 0, got 2.\nstderr: ${r.stderr}`,
|
||||
);
|
||||
assert.doesNotMatch(r.stderr, /requested test file\(s\) not found: unit/);
|
||||
assert.ok(r.stderr.includes('a.test.cjs'), `a.test.cjs must be selected.\nstderr: ${r.stderr}`);
|
||||
assert.ok(!r.stderr.includes('b.security.test.cjs'), `security file must not be selected.\nstderr: ${r.stderr}`);
|
||||
});
|
||||
|
||||
test('mixed: suite token "unit" alongside an explicit file resolves both', () => {
|
||||
seed(tmpDir, ['a.test.cjs', 'b.test.cjs', 'c.security.test.cjs']);
|
||||
const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
|
||||
// 'unit' expands to [a.test.cjs, b.test.cjs]; b.test.cjs is explicit too.
|
||||
fs.writeFileSync(listPath, 'unit\nb.test.cjs\n', 'utf8');
|
||||
|
||||
const r = runHarness(tmpDir, ['--files-from', listPath]);
|
||||
|
||||
assert.strictEqual(r.status, 0, `stderr: ${r.stderr}`);
|
||||
// Both unit files present; security not.
|
||||
assert.ok(r.stderr.includes('a.test.cjs'), `a.test.cjs must be selected.\nstderr: ${r.stderr}`);
|
||||
assert.ok(r.stderr.includes('b.test.cjs'), `b.test.cjs must be selected.\nstderr: ${r.stderr}`);
|
||||
assert.ok(!r.stderr.includes('c.security.test.cjs'), `c.security.test.cjs must be excluded.\nstderr: ${r.stderr}`);
|
||||
});
|
||||
|
||||
test('#408 fallback: ci-test-scope "unit" sentinel does not crash run-tests', () => {
|
||||
// This test simulates the end-to-end #408 fallback path:
|
||||
// ci-test-scope produces "unit" (the fallback sentinel for "code changed
|
||||
// but no rule matched any test"), ci-prepare-test-scope writes it verbatim,
|
||||
// and run-tests must resolve it rather than crash.
|
||||
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
|
||||
// Simulate what ci-prepare-test-scope writes: "unit\n"
|
||||
const listPath = path.join(tmpDir, '.ci-selected-tests.txt');
|
||||
fs.writeFileSync(listPath, 'unit\n', 'utf8');
|
||||
|
||||
const r = runHarness(tmpDir, ['--files-from', listPath]);
|
||||
|
||||
assert.strictEqual(
|
||||
r.status,
|
||||
0,
|
||||
`#408 fallback: expected exit 0 but got ${r.status}.\nstderr: ${r.stderr}`,
|
||||
);
|
||||
assert.doesNotMatch(r.stderr, /not found: unit/);
|
||||
assert.ok(r.stderr.includes('a.test.cjs'), `unit test must run.\nstderr: ${r.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression test for issue #1329:
|
||||
// ci-prepare-test-scope's empty-detection FALLBACK hardcoded an explicit file
|
||||
// list that included tests/core.test.cjs — a file deleted in #1291. Every
|
||||
// scoped lane (scope=targeted|windows) that hit the fallback wrote the stale
|
||||
// path into .ci-selected-tests.txt and crashed run-tests with
|
||||
// "requested test file(s) not found: core.test.cjs". The fix: existence-filter
|
||||
// the fallback at write time, fall back to the 'unit' suite sentinel when
|
||||
// nothing survives, and guard the FALLBACK constant against disk reality.
|
||||
describe('bug #1329 — ci-prepare-test-scope fallback never emits a deleted file', () => {
|
||||
const { FALLBACK, FALLBACK_SENTINEL, SUITE_SENTINELS, resolveSelection } =
|
||||
require('../scripts/ci-prepare-test-scope.cjs');
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
|
||||
// Generative parity guard (DEFECT.GENERATIVE-FIX): the FALLBACK constant and
|
||||
// the test files on disk are two surfaces that must stay in sync. This fails
|
||||
// the instant a refactor deletes a file still named in FALLBACK — which is
|
||||
// precisely what #1291 did and CI did not catch.
|
||||
test('every FALLBACK entry resolves on disk or is a known suite sentinel', () => {
|
||||
for (const entry of FALLBACK) {
|
||||
const isSentinel = SUITE_SENTINELS.includes(entry);
|
||||
const exists = fs.existsSync(path.join(REPO_ROOT, entry));
|
||||
assert.ok(
|
||||
isSentinel || exists,
|
||||
`FALLBACK entry "${entry}" is neither an existing test file nor a suite sentinel — stale reference will crash scoped CI lanes (see #1329).`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let tmpDir;
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-1329-fallback-');
|
||||
fs.mkdirSync(path.join(tmpDir, 'tests'), { recursive: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('empty detection drops a non-existent fallback entry instead of emitting it', () => {
|
||||
// Create all but the last FALLBACK file under a controlled root, simulating
|
||||
// a since-deleted test (the #1329 mechanism), independent of which files
|
||||
// FALLBACK happens to name today.
|
||||
const present = FALLBACK.slice(0, -1);
|
||||
const absent = FALLBACK[FALLBACK.length - 1];
|
||||
for (const f of present) {
|
||||
fs.writeFileSync(path.join(tmpDir, f), PASS_BODY, 'utf8');
|
||||
}
|
||||
|
||||
const lines = resolveSelection({ scope: 'targeted', targeted: '', windows: '', root: tmpDir });
|
||||
|
||||
assert.ok(!lines.includes(absent), `absent file "${absent}" must be filtered out, got: ${lines.join(', ')}`);
|
||||
for (const f of present) {
|
||||
assert.ok(lines.includes(f), `present file "${f}" must survive, got: ${lines.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('empty detection with no surviving fallback files falls back to the unit sentinel', () => {
|
||||
// tmpDir/tests exists but contains none of the FALLBACK files.
|
||||
const lines = resolveSelection({ scope: 'windows', targeted: '', windows: '', root: tmpDir });
|
||||
assert.deepStrictEqual(lines, [FALLBACK_SENTINEL]);
|
||||
});
|
||||
|
||||
test('detected list passes through verbatim — files and suite sentinels preserved, not existence-filtered', () => {
|
||||
// The detected list is already filtered by affected-tests-lib and may carry
|
||||
// a suite sentinel; ci-prepare-test-scope must not touch it.
|
||||
const lines = resolveSelection({
|
||||
scope: 'targeted',
|
||||
targeted: 'tests/does-not-exist.test.cjs unit',
|
||||
windows: '',
|
||||
root: tmpDir,
|
||||
});
|
||||
assert.deepStrictEqual(lines, ['tests/does-not-exist.test.cjs', 'unit']);
|
||||
});
|
||||
|
||||
test('end-to-end: the real script writes a fallback list whose every entry resolves', () => {
|
||||
// Run the real script (subprocess) with empty detection inside an isolated
|
||||
// root that holds the FALLBACK files, then verify every line it wrote into
|
||||
// .ci-selected-tests.txt resolves — the exact scoped-lane path that crashed
|
||||
// in #1329. Hermetic: the temp root is removed by afterEach's cleanup().
|
||||
for (const f of FALLBACK) {
|
||||
fs.writeFileSync(path.join(tmpDir, f), PASS_BODY, 'utf8');
|
||||
}
|
||||
const prep = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(REPO_ROOT, 'scripts', 'ci-prepare-test-scope.cjs')],
|
||||
{ cwd: tmpDir, env: { ...process.env, TEST_SCOPE: 'targeted', TARGETED_TESTS: '', WINDOWS_TESTS: '' }, encoding: 'utf8' },
|
||||
);
|
||||
assert.strictEqual(prep.status, 0, `prepare step failed: ${prep.stderr}`);
|
||||
|
||||
const selected = fs.readFileSync(path.join(tmpDir, '.ci-selected-tests.txt'), 'utf8');
|
||||
for (const line of selected.split(/\r?\n/).filter(Boolean)) {
|
||||
const isSentinel = SUITE_SENTINELS.includes(line);
|
||||
assert.ok(
|
||||
isSentinel || fs.existsSync(path.join(tmpDir, line)),
|
||||
`selected entry "${line}" does not resolve — would crash run-tests (#1329)`,
|
||||
);
|
||||
}
|
||||
assert.doesNotMatch(selected, /core\.test\.cjs/, 'deleted core.test.cjs must never be selected');
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// These spawn/exec sites cannot be behaviourally tested for windowsHide
|
||||
// off-Windows; the source text is the runtime contract (issue #685). Without
|
||||
// windowsHide:true a detached or shell:true child allocates a visible console
|
||||
// window on Windows (the "gsd-core" flash).
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const read = (p) => fs.readFileSync(path.join(root, p), 'utf-8');
|
||||
|
||||
// Slice the exact body of one spawn site so the assertion binds that site,
|
||||
// not merely "windowsHide appears somewhere in the file".
|
||||
function regionBetween(src, startAnchor, endAnchor) {
|
||||
const i = src.indexOf(startAnchor);
|
||||
assert.notEqual(i, -1, `start anchor not found: ${startAnchor}`);
|
||||
const j = src.indexOf(endAnchor, i);
|
||||
assert.notEqual(j, -1, `end anchor not found after start: ${endAnchor}`);
|
||||
return src.slice(i, j);
|
||||
}
|
||||
|
||||
describe('bug #685: Windows spawns must set windowsHide:true (no console-window flash)', () => {
|
||||
test('gsd-context-monitor record-session spawn sets windowsHide', () => {
|
||||
const region = regionBetween(read('hooks/gsd-context-monitor.js'), "'record-session'", '.unref()');
|
||||
assert.match(region, /windowsHide:\s*true/, 'record-session spawn must set windowsHide: true');
|
||||
});
|
||||
|
||||
const cts = () => read('src/shell-command-projection.cts');
|
||||
const helpers = [
|
||||
['execGit', 'export function execGit', "_spawnResult(result, 'git')"],
|
||||
['execNpm', 'export function execNpm', "_spawnResult(result, 'npm')"],
|
||||
['execTool', 'export function execTool', '_spawnResult(result, program)'],
|
||||
];
|
||||
for (const [name, start, end] of helpers) {
|
||||
test(`shell-command-projection ${name} spawnSync sets windowsHide`, () => {
|
||||
const region = regionBetween(cts(), start, end);
|
||||
assert.match(region, /windowsHide:\s*true/, `${name} spawnSync must set windowsHide: true`);
|
||||
});
|
||||
}
|
||||
|
||||
test('gsd-worktree-path-guard SPAWNOPT sets windowsHide', () => {
|
||||
const region = regionBetween(read('hooks/gsd-worktree-path-guard.js'), 'const SPAWNOPT', '};');
|
||||
assert.match(region, /windowsHide:\s*true/, 'gsd-worktree-path-guard SPAWNOPT must set windowsHide: true');
|
||||
});
|
||||
|
||||
test('gsd-workflow-guard currentBranch spawnSync sets windowsHide', () => {
|
||||
const region = regionBetween(read('hooks/gsd-workflow-guard.js'), "spawnSync('git', ['branch'", '});');
|
||||
assert.match(region, /windowsHide:\s*true/, 'gsd-workflow-guard git-branch spawn must set windowsHide: true');
|
||||
});
|
||||
|
||||
test('check-command-router recentCommitMessages execFileSync sets windowsHide', () => {
|
||||
const region = regionBetween(read('src/check-command-router.cts'), "execFileSync('git', ['log'", '});');
|
||||
assert.match(region, /windowsHide:\s*true/, 'check-command-router git-log execFileSync must set windowsHide: true');
|
||||
});
|
||||
|
||||
test('roadmap-upgrade execSync git calls all set windowsHide', () => {
|
||||
const src = read('src/roadmap-upgrade.cts');
|
||||
const calls = src.match(/execSync\([^)]*\)/g) || [];
|
||||
// #1542 made rollback git-independent (surgical fs restore), so the only
|
||||
// remaining git execSync is the `git status --porcelain` precondition. The
|
||||
// durable guard is that EVERY git execSync still present sets windowsHide.
|
||||
assert.ok(calls.length >= 1, 'expected at least the roadmap-upgrade git status execSync call to be present');
|
||||
const missing = calls.filter((c) => !/windowsHide:\s*true/.test(c));
|
||||
assert.deepEqual(missing, [], `execSync without windowsHide:\n${missing.join('\n')}`);
|
||||
});
|
||||
|
||||
test('gsd-check-update spawn retains windowsHide (precedent guard)', () => {
|
||||
assert.match(read('hooks/gsd-check-update.js'), /windowsHide:\s*true/,
|
||||
'gsd-check-update.js must keep windowsHide: true');
|
||||
});
|
||||
|
||||
// Durable invariant: ANY external-binary process spawn in the runtime source
|
||||
// (hooks + src) must set windowsHide — catches future additions, not just the
|
||||
// sites known today. Handles the `{ ...CONST }` spread indirection.
|
||||
test('completeness: no external-binary spawn in runtime source omits windowsHide', () => {
|
||||
const listDir = (dir, re) =>
|
||||
fs.readdirSync(path.join(root, dir)).filter((f) => re.test(f)).map((f) => `${dir}/${f}`);
|
||||
const files = [...listDir('hooks', /\.js$/), ...listDir('src', /\.cts$/)];
|
||||
const callRe = /(?:execSync|execFileSync|spawnSync|spawn)\s*\(\s*(?:`|'|")?(?:git|npm|gh)\b|spawn\s*\(\s*process\.execPath/g;
|
||||
const offenders = [];
|
||||
for (const rel of files) {
|
||||
const src = read(rel);
|
||||
let m;
|
||||
while ((m = callRe.exec(src)) !== null) {
|
||||
const win = src.slice(m.index, m.index + 400);
|
||||
let ok = /windowsHide:\s*true/.test(win);
|
||||
if (!ok) {
|
||||
const spread = win.match(/\{\s*\.\.\.(\w+)/); // e.g. { ...SPAWNOPT, cwd }
|
||||
if (spread) {
|
||||
ok = new RegExp(`(?:const|let|var)\\s+${spread[1]}\\s*=\\s*\\{[^}]*windowsHide:\\s*true`).test(src);
|
||||
}
|
||||
}
|
||||
if (!ok) offenders.push(`${rel}: ...${src.slice(m.index, m.index + 48).replace(/\s+/g, ' ')}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, [], `external-binary spawns missing windowsHide:\n${offenders.join('\n')}`);
|
||||
});
|
||||
});
|
||||
@@ -1,433 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression test for bug #891: gsd_run launcher must probe non-Claude
|
||||
* runtime homes before emitting the hard error.
|
||||
*
|
||||
* The last-resort $HOME/.claude/gsd-core branch is Claude Code-specific.
|
||||
* Every non-Claude runtime (Hermes, Cursor, Codex, Copilot, Windsurf, …)
|
||||
* installs gsd-core into a *different* directory that the shim never tried,
|
||||
* causing a false-positive fatal ERROR on all non-Claude runtimes when
|
||||
* RUNTIME_DIR is not set and gsd-tools is not on PATH.
|
||||
*
|
||||
* Asserts:
|
||||
* (A) Snippet contains all expected non-Claude runtime home probes (structural).
|
||||
* (B) HERMES_HOME behavioral: when RUNTIME_DIR misses and gsd-tools is NOT on
|
||||
* PATH, a stub at ${HERMES_HOME}/gsd-core/bin/gsd-tools.cjs is invoked.
|
||||
* (C) Default Hermes path behavioral: stub at $HOME/.hermes/gsd-core/bin/
|
||||
* gsd-tools.cjs is invoked when HERMES_HOME is not set.
|
||||
* (D) Resolution order: non-Claude homes are probed BEFORE the hard error,
|
||||
* and AFTER the $HOME/.claude branch.
|
||||
* (E) Propagation: all workflow .md files using gsd_run contain each probe
|
||||
* (sync-runtime-launcher.cjs was re-run after editing the snippet).
|
||||
*/
|
||||
|
||||
// allow-test-rule: structural/behavioral regression for non-Claude runtime-home
|
||||
// fallback arms in the gsd_run launcher snippet -- asserts literal substring
|
||||
// presence for each runtime-home probe and exercises the bash resolution paths
|
||||
// via execFileSync; there is no typed IR for "snippet contains arm X".
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
|
||||
|
||||
// Every non-Claude runtime home probe the snippet must contain.
|
||||
// Key: runtime name (for diagnostics). Value: the substring that must appear
|
||||
// in the snippet (the env-var-with-default expansion that probes that runtime's
|
||||
// gsd-core install location). Mirrors src/runtime-homes.cts getGlobalConfigDir().
|
||||
const EXPECTED_RUNTIME_PROBES = {
|
||||
hermes: '.hermes}/gsd-core/bin/',
|
||||
cursor: '.cursor}/gsd-core/bin/',
|
||||
codex: '.codex}/gsd-core/bin/',
|
||||
gemini: '.gemini}/gsd-core/bin/',
|
||||
copilot: '.copilot}/gsd-core/bin/',
|
||||
windsurf: '.codeium/windsurf}/gsd-core/bin/',
|
||||
augment: '.augment}/gsd-core/bin/',
|
||||
trae: '.trae}/gsd-core/bin/',
|
||||
qwen: '.qwen}/gsd-core/bin/',
|
||||
codebuddy: '.codebuddy}/gsd-core/bin/',
|
||||
cline: '.cline}/gsd-core/bin/',
|
||||
grok: '.agents}/gsd-core/bin/',
|
||||
antigravity: '.gemini/antigravity}/gsd-core/bin/',
|
||||
opencode: 'opencode}/gsd-core/bin/',
|
||||
kilo: 'kilo}/gsd-core/bin/',
|
||||
};
|
||||
|
||||
/**
|
||||
* Collect all workflow .md files recursively.
|
||||
*/
|
||||
function collectWorkflowFiles() {
|
||||
const results = [];
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(WORKFLOWS_DIR);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all bash/sh/shell fenced blocks from markdown content.
|
||||
*/
|
||||
function extractShellBlocks(content) {
|
||||
const allLines = content.split('\n');
|
||||
const blocks = [];
|
||||
let inBlock = false;
|
||||
let blockLang = null;
|
||||
let blockLines = [];
|
||||
let blockIndent = '';
|
||||
let closingPattern = null;
|
||||
|
||||
for (let i = 0; i < allLines.length; i++) {
|
||||
const line = allLines[i];
|
||||
if (!inBlock) {
|
||||
const fenceOpen = line.match(/^(\s*)```(\w+)?\s*$/);
|
||||
if (fenceOpen) {
|
||||
inBlock = true;
|
||||
blockIndent = fenceOpen[1];
|
||||
blockLang = (fenceOpen[2] || '').toLowerCase();
|
||||
blockLines = [];
|
||||
closingPattern = new RegExp('^' + blockIndent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '```\\s*$');
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (closingPattern.test(line)) {
|
||||
if (['bash', 'sh', 'shell', 'zsh', ''].includes(blockLang)) {
|
||||
blocks.push({ lines: blockLines });
|
||||
}
|
||||
inBlock = false;
|
||||
blockLang = null;
|
||||
blockLines = [];
|
||||
blockIndent = '';
|
||||
closingPattern = null;
|
||||
continue;
|
||||
}
|
||||
blockLines.push(line);
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a PATH with no gsd-tools binary so the PATH fallback branch is skipped,
|
||||
* while guaranteeing that a bare `node` lookup still resolves regardless of whether
|
||||
* the real node binary co-locates with a global gsd-tools shim (e.g. fnm/nvm/Homebrew).
|
||||
*
|
||||
* Strategy (POSIX only): create a temp dir containing only a `node` symlink →
|
||||
* process.execPath, prepend it to the gsd-tools-filtered PATH. The filtered
|
||||
* PATH excludes any directory that contains an executable `gsd-tools`.
|
||||
*
|
||||
* On Windows the co-location bug does not apply (gsd-tools resolves via .cmd/.ps1,
|
||||
* not the bare binary probed here), and symlinks may require elevated privileges,
|
||||
* so we skip the symlink step entirely on that platform.
|
||||
*
|
||||
* The caller is responsible for cleaning up `result.nodeBinDir` when non-null
|
||||
* (pass it to `cleanup()` in a `t.after` or `finally` block).
|
||||
*
|
||||
* @returns {{ isolatedPath: string, nodeBinDir: string|null }}
|
||||
*/
|
||||
function buildIsolatedPath() {
|
||||
const filteredPath = (process.env.PATH || '/usr/bin:/bin')
|
||||
.split(path.delimiter)
|
||||
.filter((p) => {
|
||||
try { fs.accessSync(path.join(p, 'gsd-tools'), fs.constants.X_OK); return false; }
|
||||
catch { return true; }
|
||||
})
|
||||
.join(path.delimiter);
|
||||
|
||||
// Windows: no symlink (see JSDoc above); callers must handle nodeBinDir === null.
|
||||
if (process.platform === 'win32') {
|
||||
return { isolatedPath: filteredPath, nodeBinDir: null };
|
||||
}
|
||||
|
||||
const nodeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-node-'));
|
||||
try {
|
||||
fs.symlinkSync(process.execPath, path.join(nodeBinDir, 'node'));
|
||||
} catch (err) {
|
||||
cleanup(nodeBinDir);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return { isolatedPath: nodeBinDir + path.delimiter + filteredPath, nodeBinDir };
|
||||
}
|
||||
|
||||
describe('bug-891: non-Claude runtime home fallback arms', () => {
|
||||
|
||||
// ── (A) Structural: snippet contains all expected non-Claude probes ───────
|
||||
test('(A) snippet contains all non-Claude runtime home probes', () => {
|
||||
const snippetContent = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
|
||||
const missing = [];
|
||||
for (const [runtime, probe] of Object.entries(EXPECTED_RUNTIME_PROBES)) {
|
||||
if (!snippetContent.includes(probe)) {
|
||||
missing.push(`${runtime}: expected snippet to contain "${probe}"`);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(
|
||||
missing,
|
||||
[],
|
||||
`_runtime-launcher.snippet.sh is missing fallback probes for non-Claude runtimes:\n` +
|
||||
missing.join('\n') +
|
||||
`\n\nAdd elif arms for each runtime home (e.g. "\${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/...")` +
|
||||
` before the hard-error else. Current snippet:\n${snippetContent.trim()}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── (A2) Structural: probes appear AFTER .claude arm but BEFORE hard error ─
|
||||
test('(A2) non-Claude probes appear after .claude/gsd-core arm and before hard error', () => {
|
||||
const snippetContent = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
const claudePos = snippetContent.indexOf('.claude/gsd-core/bin/');
|
||||
const errorPos = snippetContent.indexOf('exit 1');
|
||||
|
||||
assert.ok(claudePos !== -1, 'Snippet must still contain .claude/gsd-core/bin/ arm (regression guard)');
|
||||
assert.ok(errorPos !== -1, 'Snippet must contain exit 1 (hard-error guard)');
|
||||
|
||||
for (const [runtime, probe] of Object.entries(EXPECTED_RUNTIME_PROBES)) {
|
||||
const probePos = snippetContent.indexOf(probe);
|
||||
assert.ok(
|
||||
probePos !== -1,
|
||||
`Snippet must contain probe for ${runtime} ("${probe}")`,
|
||||
);
|
||||
assert.ok(
|
||||
probePos < errorPos,
|
||||
`${runtime} probe must appear before "exit 1" in snippet (found at ${probePos}, exit 1 at ${errorPos})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── (B0) Regression: buildIsolatedPath keeps node resolvable when node and ──
|
||||
// gsd-tools co-locate in the same PATH directory. ─
|
||||
//
|
||||
// Machine-independence guarantee: PATH is set to ONLY two controlled dirs —
|
||||
// fakeBinDir (holds both fake gsd-tools AND a node symlink) plus a fresh
|
||||
// empty dir (no executables at all). The real system PATH is NOT appended.
|
||||
//
|
||||
// Old logic: filters out fakeBinDir → only the empty dir remains → node
|
||||
// UNresolvable → assertion (ii) FAILS (true-red on any machine).
|
||||
// New logic: prepends its own nodeBinDir → node resolvable despite fakeBinDir
|
||||
// being filtered → both assertions pass.
|
||||
test(
|
||||
'(B0) buildIsolatedPath: node is resolvable and gsd-tools is not when they share a PATH dir',
|
||||
{ skip: process.platform === 'win32' ? 'POSIX-only co-location scenario' : false },
|
||||
(t) => {
|
||||
// Build a fake bin dir that contains BOTH a gsd-tools executable and a node
|
||||
// symlink, simulating a dev setup (fnm/nvm/Homebrew) where both land in the
|
||||
// same bin directory.
|
||||
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-colocated-'));
|
||||
// A second fresh empty dir — contains neither gsd-tools nor node.
|
||||
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-empty-'));
|
||||
t.after(() => cleanup(fakeBinDir));
|
||||
t.after(() => cleanup(emptyDir));
|
||||
|
||||
// Fake gsd-tools shim (executable file)
|
||||
const fakeGsdTools = path.join(fakeBinDir, 'gsd-tools');
|
||||
fs.writeFileSync(fakeGsdTools, '#!/bin/sh\necho fake-gsd-tools\n');
|
||||
fs.chmodSync(fakeGsdTools, 0o755);
|
||||
|
||||
// node symlink pointing at the real interpreter (co-located with gsd-tools)
|
||||
fs.symlinkSync(process.execPath, path.join(fakeBinDir, 'node'));
|
||||
|
||||
// Set PATH to ONLY the two controlled dirs (no real system dirs).
|
||||
// This makes the test machine-independent: on any machine, the only place
|
||||
// node *could* come from before the fix is fakeBinDir — which gets filtered.
|
||||
const origPath = process.env.PATH;
|
||||
process.env.PATH = fakeBinDir + path.delimiter + emptyDir;
|
||||
let result;
|
||||
try {
|
||||
result = buildIsolatedPath();
|
||||
} finally {
|
||||
process.env.PATH = origPath;
|
||||
}
|
||||
t.after(() => cleanup(result.nodeBinDir));
|
||||
|
||||
const returnedDirs = result.isolatedPath.split(path.delimiter);
|
||||
|
||||
// (i) gsd-tools must NOT be resolvable on the returned PATH
|
||||
const gsdToolsResolvable = returnedDirs.some((dir) => {
|
||||
try { fs.accessSync(path.join(dir, 'gsd-tools'), fs.constants.X_OK); return true; }
|
||||
catch { return false; }
|
||||
});
|
||||
assert.equal(
|
||||
gsdToolsResolvable,
|
||||
false,
|
||||
'gsd-tools must not be resolvable on the isolated PATH (home-fallback would be bypassed)',
|
||||
);
|
||||
|
||||
// (ii) node must BE resolvable on the returned PATH (the new nodeBinDir makes it so)
|
||||
const nodeResolvable = returnedDirs.some((dir) => {
|
||||
try { fs.accessSync(path.join(dir, 'node'), fs.constants.X_OK); return true; }
|
||||
catch { return false; }
|
||||
});
|
||||
assert.equal(
|
||||
nodeResolvable,
|
||||
true,
|
||||
'node must be resolvable on the isolated PATH (launcher runs: node "$GSD_TOOLS" "$@")',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── (B) Behavioral: HERMES_HOME stub is resolved ──────────────────────────
|
||||
test('(B) gsd_run resolves ${HERMES_HOME}/gsd-core/bin/ stub when set and local+PATH both miss', () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-home-b-'));
|
||||
const fakeHermesHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-hermes-'));
|
||||
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-rt-'));
|
||||
const { isolatedPath, nodeBinDir } = buildIsolatedPath();
|
||||
try {
|
||||
const hermesBinDir = path.join(fakeHermesHome, 'gsd-core', 'bin');
|
||||
fs.mkdirSync(hermesBinDir, { recursive: true });
|
||||
|
||||
const stubPath = path.join(hermesBinDir, 'gsd-tools.cjs');
|
||||
fs.writeFileSync(
|
||||
stubPath,
|
||||
'#!/usr/bin/env node\nconsole.log("HERMES_HOME_STUB:" + process.argv.slice(2).join(","));\n',
|
||||
);
|
||||
fs.chmodSync(stubPath, 0o755);
|
||||
|
||||
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
// Export HOME to an isolated temp dir (no .claude install there) so the
|
||||
// $HOME/.claude arm is skipped and we fall through to the HERMES_HOME arm.
|
||||
const scriptContent =
|
||||
`unset GSD_TOOLS\n` +
|
||||
`export HOME=${JSON.stringify(fakeHome)}\n` +
|
||||
`export RUNTIME_DIR=${JSON.stringify(fakeRuntime)}\n` +
|
||||
`export HERMES_HOME=${JSON.stringify(fakeHermesHome)}\n` +
|
||||
snippet +
|
||||
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
|
||||
`gsd_run ping test\n`;
|
||||
|
||||
const scriptPath = path.join(fakeRuntime, 'test-hermes-home.sh');
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
const stdout = execFileSync('bash', [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome, HERMES_HOME: fakeHermesHome },
|
||||
});
|
||||
|
||||
const normStdout = stdout.replace(/\\/g, '/');
|
||||
assert.ok(
|
||||
normStdout.includes('gsd-core/bin/'),
|
||||
`Expected GSD_TOOLS to resolve into hermes gsd-core/bin/, got:\n${stdout.trim()}`,
|
||||
);
|
||||
assert.ok(
|
||||
stdout.includes('HERMES_HOME_STUB:ping,test'),
|
||||
`Expected stub output "HERMES_HOME_STUB:ping,test", got:\n${stdout.trim()}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(fakeHome);
|
||||
cleanup(fakeHermesHome);
|
||||
cleanup(fakeRuntime);
|
||||
if (nodeBinDir) cleanup(nodeBinDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── (C) Behavioral: default .hermes path used when HERMES_HOME not set ────
|
||||
test('(C) gsd_run resolves $HOME/.hermes/gsd-core/bin/ stub when HERMES_HOME is unset', () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-home-'));
|
||||
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-891-rt2-'));
|
||||
const { isolatedPath, nodeBinDir } = buildIsolatedPath();
|
||||
try {
|
||||
const hermesBinDir = path.join(fakeHome, '.hermes', 'gsd-core', 'bin');
|
||||
fs.mkdirSync(hermesBinDir, { recursive: true });
|
||||
|
||||
const stubPath = path.join(hermesBinDir, 'gsd-tools.cjs');
|
||||
fs.writeFileSync(
|
||||
stubPath,
|
||||
'#!/usr/bin/env node\nconsole.log("HERMES_DEFAULT_STUB:" + process.argv.slice(2).join(","));\n',
|
||||
);
|
||||
fs.chmodSync(stubPath, 0o755);
|
||||
|
||||
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
const scriptContent =
|
||||
`unset GSD_TOOLS HERMES_HOME\n` +
|
||||
`export RUNTIME_DIR=${JSON.stringify(fakeRuntime)}\n` +
|
||||
`export HOME=${JSON.stringify(fakeHome)}\n` +
|
||||
snippet +
|
||||
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
|
||||
`gsd_run status\n`;
|
||||
|
||||
const scriptPath = path.join(fakeRuntime, 'test-hermes-default.sh');
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
const stdout = execFileSync('bash', [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: isolatedPath, HOME: fakeHome },
|
||||
});
|
||||
|
||||
const normStdout = stdout.replace(/\\/g, '/');
|
||||
assert.ok(
|
||||
normStdout.includes('.hermes/gsd-core/bin/'),
|
||||
`Expected GSD_TOOLS to resolve into .hermes/gsd-core/bin/, got:\n${stdout.trim()}`,
|
||||
);
|
||||
assert.ok(
|
||||
stdout.includes('HERMES_DEFAULT_STUB:status'),
|
||||
`Expected stub output "HERMES_DEFAULT_STUB:status", got:\n${stdout.trim()}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(fakeHome);
|
||||
cleanup(fakeRuntime);
|
||||
if (nodeBinDir) cleanup(nodeBinDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── (D) Resolution order: claude < hermes < hard-error ───────────────────
|
||||
test('(D) resolution order: .claude probe comes before hermes probe, hermes before hard error', () => {
|
||||
const snippetContent = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
const claudePos = snippetContent.indexOf('.claude/gsd-core/bin/');
|
||||
const hermesPos = snippetContent.indexOf('.hermes}/gsd-core/bin/');
|
||||
const errorPos = snippetContent.indexOf('exit 1');
|
||||
|
||||
assert.ok(claudePos !== -1, 'Snippet must contain .claude/gsd-core/bin/ arm');
|
||||
assert.ok(hermesPos !== -1, 'Snippet must contain .hermes}/gsd-core/bin/ arm');
|
||||
assert.ok(errorPos !== -1, 'Snippet must contain exit 1 hard-error');
|
||||
|
||||
assert.ok(
|
||||
claudePos < hermesPos,
|
||||
`Expected .claude probe (at ${claudePos}) before .hermes probe (at ${hermesPos})`,
|
||||
);
|
||||
assert.ok(
|
||||
hermesPos < errorPos,
|
||||
`Expected .hermes probe (at ${hermesPos}) before exit 1 (at ${errorPos})`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── (E) Propagation: workflow .md files using gsd_run contain hermes probe ─
|
||||
test('(E) all workflow .md files using gsd_run contain the hermes runtime home probe', () => {
|
||||
const HERMES_PROBE = '.hermes}/gsd-core/bin/';
|
||||
const files = collectWorkflowFiles();
|
||||
assert.ok(files.length > 0, 'expected at least one workflow .md file');
|
||||
|
||||
const missing = [];
|
||||
for (const f of files) {
|
||||
const content = fs.readFileSync(f, 'utf8');
|
||||
const blocks = extractShellBlocks(content);
|
||||
const allBlockLines = blocks.flatMap((b) => b.lines);
|
||||
const fileHasGsdRun = allBlockLines.some((l) => /\bgsd_run\b/.test(l));
|
||||
if (!fileHasGsdRun) continue;
|
||||
const allContent = allBlockLines.join('\n');
|
||||
if (!allContent.includes(HERMES_PROBE)) {
|
||||
missing.push(path.relative(WORKFLOWS_DIR, f));
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(
|
||||
missing,
|
||||
[],
|
||||
`These workflow files use gsd_run but are missing the hermes runtime home probe ("${HERMES_PROBE}"). ` +
|
||||
`Run \`node scripts/sync-runtime-launcher.cjs\` to propagate:\n` +
|
||||
missing.join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,205 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #925
|
||||
*
|
||||
* hooks/gsd-context-monitor.js hardcodes `hookEventName: "PostToolUse"` (or
|
||||
* "AfterTool" for Gemini) regardless of which hook event invoked it. Since
|
||||
* PR #821 the same script is also registered under Stop, SubagentStop, and
|
||||
* PreCompact in hooks/hooks.json. Claude Code rejects output whose
|
||||
* hookSpecificOutput.hookEventName doesn't echo the triggering event:
|
||||
*
|
||||
* "expected Stop but got PostToolUse"
|
||||
*
|
||||
* Fix: derive hookEventName from the parsed stdin payload's `hook_event_name`
|
||||
* field (already available in the data object), falling back to the
|
||||
* Gemini / non-Gemini heuristic for runtimes that don't send it.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const MONITOR_PATH = path.join(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
|
||||
|
||||
/**
|
||||
* Write a bridge metrics file and invoke the context monitor with the given
|
||||
* payload fields. Returns the parsed stdout object (or null if the hook
|
||||
* produced no output).
|
||||
*
|
||||
* remainingPct must be <= 35 to cross the WARNING threshold so the hook
|
||||
* actually emits output.
|
||||
*/
|
||||
function runMonitor({ hookEventName, sessionId, remainingPct = 30, usedPct = 70, env = {} }) {
|
||||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||||
fs.writeFileSync(bridgePath, JSON.stringify({
|
||||
session_id: sessionId,
|
||||
remaining_percentage: remainingPct,
|
||||
used_pct: usedPct,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
}));
|
||||
|
||||
const payload = { session_id: sessionId, cwd: os.tmpdir() };
|
||||
if (hookEventName !== undefined) {
|
||||
payload.hook_event_name = hookEventName;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, [MONITOR_PATH], {
|
||||
input: JSON.stringify(payload),
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
} catch (e) {
|
||||
stdout = e.stdout || '';
|
||||
} finally {
|
||||
try { fs.unlinkSync(bridgePath); } catch { /* noop */ }
|
||||
try {
|
||||
fs.unlinkSync(path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`));
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
if (!stdout) return null;
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
function makeSessionId(suffix) {
|
||||
return `test-925-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
// ─── hookEventName echoing ────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #925: context monitor echoes the invoking hook event name', () => {
|
||||
test('hookEventName is "Stop" when payload contains hook_event_name: "Stop"', () => {
|
||||
const out = runMonitor({ hookEventName: 'Stop', sessionId: makeSessionId('stop') });
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold (remaining=30)');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'Stop',
|
||||
`Expected hookEventName "Stop" but got "${out.hookSpecificOutput?.hookEventName}". ` +
|
||||
'The hook must echo the hook_event_name from stdin, not hardcode "PostToolUse".'
|
||||
);
|
||||
});
|
||||
|
||||
test('hookEventName is "SubagentStop" when payload contains hook_event_name: "SubagentStop"', () => {
|
||||
const out = runMonitor({ hookEventName: 'SubagentStop', sessionId: makeSessionId('subagent-stop') });
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'SubagentStop',
|
||||
`Expected hookEventName "SubagentStop" but got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
});
|
||||
|
||||
test('hookEventName is "PreCompact" when payload contains hook_event_name: "PreCompact"', () => {
|
||||
const out = runMonitor({ hookEventName: 'PreCompact', sessionId: makeSessionId('precompact') });
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'PreCompact',
|
||||
`Expected hookEventName "PreCompact" but got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
});
|
||||
|
||||
test('hookEventName is "PostToolUse" when payload contains hook_event_name: "PostToolUse"', () => {
|
||||
const out = runMonitor({ hookEventName: 'PostToolUse', sessionId: makeSessionId('posttools') });
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'PostToolUse',
|
||||
`Expected hookEventName "PostToolUse" but got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fallback behaviour (no hook_event_name in payload) ──────────────────────
|
||||
|
||||
describe('bug #925: context monitor falls back to heuristic when hook_event_name absent', () => {
|
||||
test('falls back to "PostToolUse" when hook_event_name is absent (non-Gemini)', () => {
|
||||
const env = { ...process.env };
|
||||
delete env.GEMINI_API_KEY;
|
||||
const out = runMonitor({
|
||||
hookEventName: undefined,
|
||||
sessionId: makeSessionId('fallback-non-gemini'),
|
||||
env: { GEMINI_API_KEY: '' }, // ensure unset
|
||||
});
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'PostToolUse',
|
||||
`Expected fallback "PostToolUse" for non-Gemini but got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to "AfterTool" when hook_event_name is absent and GEMINI_API_KEY is set', () => {
|
||||
const out = runMonitor({
|
||||
hookEventName: undefined,
|
||||
sessionId: makeSessionId('fallback-gemini'),
|
||||
env: { GEMINI_API_KEY: 'fake-key-for-test' },
|
||||
});
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'AfterTool',
|
||||
`Expected fallback "AfterTool" for Gemini but got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to "PostToolUse" when hook_event_name is an empty string (non-Gemini)', () => {
|
||||
const out = runMonitor({
|
||||
hookEventName: '',
|
||||
sessionId: makeSessionId('fallback-empty'),
|
||||
env: { GEMINI_API_KEY: '' },
|
||||
});
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'PostToolUse',
|
||||
`Expected fallback "PostToolUse" for empty hook_event_name but got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to "PostToolUse" when hook_event_name is whitespace-only (non-Gemini)', () => {
|
||||
// trim() makes " " → "" which is falsy, so the || fallback fires
|
||||
const out = runMonitor({
|
||||
hookEventName: ' ',
|
||||
sessionId: makeSessionId('fallback-whitespace'),
|
||||
env: { GEMINI_API_KEY: '' },
|
||||
});
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'PostToolUse',
|
||||
`Expected fallback "PostToolUse" for whitespace-only hook_event_name but got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Critical threshold also echoes the event name ───────────────────────────
|
||||
|
||||
describe('bug #925: critical threshold warning also uses correct hookEventName', () => {
|
||||
test('CRITICAL warning emitted under Stop also echoes "Stop"', () => {
|
||||
const out = runMonitor({
|
||||
hookEventName: 'Stop',
|
||||
sessionId: makeSessionId('critical-stop'),
|
||||
remainingPct: 20,
|
||||
usedPct: 80,
|
||||
});
|
||||
assert.ok(out, 'hook must emit output at critical threshold (remaining=20)');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'Stop',
|
||||
`Expected hookEventName "Stop" at critical threshold, got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
assert.match(
|
||||
out.hookSpecificOutput?.additionalContext || '',
|
||||
/CONTEXT CRITICAL/,
|
||||
'Output should be a CRITICAL warning at remaining=20'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,247 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #941
|
||||
*
|
||||
* `managed-hooks-registry.cjs` is shipped alongside gsd-check-update-worker.js
|
||||
* in hooks/dist/ (it is listed in HOOKS_TO_COPY in scripts/build-hooks.js).
|
||||
* However, the manifest-writing loop in bin/install.js gated on
|
||||
* file.startsWith('gsd-') && (file.endsWith('.js') || file.endsWith('.sh'))
|
||||
* — which `managed-hooks-registry.cjs` fails on both predicates (wrong prefix,
|
||||
* .cjs extension). The result: after every install, `detect-custom-files`
|
||||
* found the installed file in the hooks/ dir but had no manifest entry for it
|
||||
* and reported a perpetual false-positive "Found 1 custom file(s)" warning on
|
||||
* every `/gsd-update`.
|
||||
*
|
||||
* Fix: drive the manifest hooks loop from HOOKS_TO_COPY (the canonical build
|
||||
* set), so the manifest set is structurally identical to what was installed.
|
||||
*
|
||||
* Closes: #941
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
|
||||
const { HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before any install test ────────────────
|
||||
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup helper, swallows ENOENT
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the installer targeting a temp directory as the claude global config dir.
|
||||
* Returns the path to configDir.
|
||||
*/
|
||||
function runInstaller(configDir) {
|
||||
// Clear GSD_TEST_MODE so the installer's main() block actually runs.
|
||||
// The test file sets GSD_TEST_MODE=1 (top of file) to suppress in-process
|
||||
// import side effects, but when install.js is spawned as a subprocess it
|
||||
// must not skip the main() gate or the install is a no-op.
|
||||
const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir };
|
||||
delete env.GSD_TEST_MODE;
|
||||
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
});
|
||||
return configDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run detect-custom-files and return parsed JSON output.
|
||||
*/
|
||||
function detectCustomFiles(configDir) {
|
||||
const result = execFileSync(process.execPath, [TOOLS_PATH, 'detect-custom-files', '--config-dir', configDir], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, GSD_SESSION_KEY: '' },
|
||||
});
|
||||
return JSON.parse(result.trim());
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #941 — managed-hooks-registry.cjs recorded in file manifest', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-bug-941-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('managed-hooks-registry.cjs appears in gsd-file-manifest.json after install', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
|
||||
assert.ok(
|
||||
fs.existsSync(manifestPath),
|
||||
`${MANIFEST_NAME} must exist after install (not found at ${manifestPath})`,
|
||||
);
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
assert.ok(
|
||||
typeof manifest.files === 'object' && manifest.files !== null,
|
||||
'manifest must have a files map',
|
||||
);
|
||||
|
||||
// The key must use forward slashes (cross-platform manifest format)
|
||||
const key = 'hooks/managed-hooks-registry.cjs';
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(manifest.files, key),
|
||||
[
|
||||
`manifest.files must contain '${key}' — managed-hooks-registry.cjs is`,
|
||||
'shipped to users but was not recorded in the manifest, causing',
|
||||
`detect-custom-files to flag it as a perpetual false-positive custom file.`,
|
||||
`Actual manifest hook keys: ${Object.keys(manifest.files).filter(k => k.startsWith('hooks/')).join(', ')}`,
|
||||
].join(' '),
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-file-manifest.json covers the full HOOKS_TO_COPY set (forward-proof)', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
|
||||
assert.ok(fs.existsSync(manifestPath), `${MANIFEST_NAME} must exist after install`);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
const hooksDir = path.join(tmpDir, 'hooks');
|
||||
|
||||
// Every hook in HOOKS_TO_COPY that was actually installed must have a
|
||||
// manifest entry. This assertion is forward-proof: adding any new hook to
|
||||
// HOOKS_TO_COPY without updating the manifest loop will fail this test.
|
||||
for (const hook of HOOKS_TO_COPY) {
|
||||
const installed = path.join(hooksDir, hook);
|
||||
if (!fs.existsSync(installed)) {
|
||||
// Skip hooks that weren't installed (e.g. .sh hooks on non-unix skip
|
||||
// chmod but still install — only skip if truly absent).
|
||||
continue;
|
||||
}
|
||||
const key = `hooks/${hook}`;
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(manifest.files, key),
|
||||
[
|
||||
`manifest.files must contain '${key}'.`,
|
||||
`HOOKS_TO_COPY lists '${hook}' and it was installed, but the manifest`,
|
||||
`loop in writeManifest() did not record it.`,
|
||||
`Actual manifest hook keys: ${Object.keys(manifest.files).filter(k => k.startsWith('hooks/')).join(', ')}`,
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('detect-custom-files reports zero custom files after a clean install (no false positives)', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
let detected;
|
||||
try {
|
||||
detected = detectCustomFiles(tmpDir);
|
||||
} catch (err) {
|
||||
assert.fail(
|
||||
`detect-custom-files failed: ${err.message}\nstderr: ${err.stderr || '(none)'}`,
|
||||
);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
detected.manifest_found,
|
||||
'detect-custom-files must find the manifest after install',
|
||||
);
|
||||
|
||||
const hookCustomFiles = (detected.custom_files || []).filter(f => f.startsWith('hooks/'));
|
||||
assert.strictEqual(
|
||||
hookCustomFiles.length,
|
||||
0,
|
||||
[
|
||||
`detect-custom-files must report 0 custom hook files after a clean install, but got ${hookCustomFiles.length}:`,
|
||||
JSON.stringify(hookCustomFiles, null, 2),
|
||||
'This is the perpetual false-positive bug #941 — hooks in HOOKS_TO_COPY that',
|
||||
'were not recorded in the manifest appear as custom files.',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('manifest hook keys use forward slashes (cross-platform compatibility)', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, MANIFEST_NAME), 'utf-8'));
|
||||
const hookKeys = Object.keys(manifest.files).filter(k => k.startsWith('hooks/'));
|
||||
|
||||
assert.ok(hookKeys.length > 0, 'manifest must contain at least one hooks/ entry');
|
||||
|
||||
for (const key of hookKeys) {
|
||||
assert.ok(
|
||||
!key.includes('\\'),
|
||||
`manifest key '${key}' must use forward slashes, not backslashes`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('manifest hash for managed-hooks-registry.cjs matches the installed file contents', () => {
|
||||
// Strengthened assertion: proves the manifest not only records the right KEY
|
||||
// but stores a hash that matches the ACTUAL installed file bytes. A future
|
||||
// refactor that records the key from the wrong path/content would fail here
|
||||
// even if the key is present.
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
|
||||
const key = 'hooks/managed-hooks-registry.cjs';
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(manifest.files, key),
|
||||
`manifest.files must contain '${key}' before hash comparison`,
|
||||
);
|
||||
|
||||
// Recompute the hash the same way the installer's fileHash() does:
|
||||
// sha256 of the raw file bytes as a hex string.
|
||||
const installedPath = path.join(tmpDir, 'hooks', 'managed-hooks-registry.cjs');
|
||||
assert.ok(
|
||||
fs.existsSync(installedPath),
|
||||
`installed file must exist at ${installedPath}`,
|
||||
);
|
||||
const actualHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(installedPath))
|
||||
.digest('hex');
|
||||
|
||||
assert.strictEqual(
|
||||
manifest.files[key],
|
||||
actualHash,
|
||||
[
|
||||
`manifest hash for '${key}' does not match the installed file's actual contents.`,
|
||||
`This means writeManifest() hashed the wrong path or wrong content.`,
|
||||
`Expected (from installed file): ${actualHash}`,
|
||||
`Got (from manifest): ${manifest.files[key]}`,
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,496 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression tests for bug #969 — test-infra flake hardening.
|
||||
*
|
||||
* Two root causes addressed:
|
||||
*
|
||||
* A. SIGNATURE A: "X is not a function"
|
||||
* ensureBuiltArtifacts() previously short-circuited on a single sentinel
|
||||
* (semver-compare.cjs). If any other migrated .cjs was stale or absent,
|
||||
* it would be silently loaded in that broken state. This test proves the
|
||||
* unconditional-build fix: deleting a non-sentinel artifact and invoking
|
||||
* ensureBuiltArtifacts() regenerates it even when the sentinel is present.
|
||||
*
|
||||
* B. SIGNATURE B: misleading assertion failures from killed subprocesses
|
||||
* runGsdTools() previously had no timeout, so an OOM/SIGKILL'd subprocess
|
||||
* returned { success: false } and looked like a product error. This test
|
||||
* proves the kill-discrimination fix: a killed/timed-out invocation now
|
||||
* throws a labeled resource-starvation error, while a clean non-zero exit
|
||||
* still returns { success: false, exitCode: N }.
|
||||
*
|
||||
* C. SIGNATURE C: "Failed to install hooks: directory is empty" in scoped CI
|
||||
* hooks/dist is gitignored and NOT built by `prepare` (build:lib only), so
|
||||
* the scoped test lane starts with it absent. The first install test's
|
||||
* before() hook triggers build-hooks.js, which creates DIST_DIR empty then
|
||||
* fills it file-by-file — a window where a concurrently-spawned install
|
||||
* reader sees zero hooks and hard-fails. ensureBuiltHooks() builds hooks/dist
|
||||
* ONCE upfront (same chokepoint as ensureBuiltArtifacts) so the empty window
|
||||
* never exists during concurrent test execution. These tests prove it
|
||||
* rebuilds when dist is absent/empty/incomplete and no-ops when complete.
|
||||
*
|
||||
* RULESET.TESTS.regression-must-fail-first: each test section documents what
|
||||
* the old behavior would have been (fail-before) and asserts the new behavior
|
||||
* (pass-after), using only behavioral invocations — no source-grep.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { ensureBuiltArtifacts, ensureBuiltHooks } = require('../scripts/run-tests.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Part A — ensureBuiltArtifacts: unconditional rebuild
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #969 A — ensureBuiltArtifacts rebuilds stale artifacts', () => {
|
||||
/**
|
||||
* Helper: create a self-contained temp TypeScript project with two source files
|
||||
* (sentinelmod.cts and targetmod.cts) and a tsconfig that emits to <tmp>/out.
|
||||
* Returns { tmp, overrides, sentinelOut, targetOut, tsBuildInfoPath }.
|
||||
*
|
||||
* HERMETIC: all destructive tests use this helper. They NEVER touch the real
|
||||
* gsd-core/bin/lib/*.cjs or the real tsbuildinfo. (Regression from #996 fixed here.)
|
||||
*/
|
||||
function makeTempProject() {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-bug969-'));
|
||||
const srcDir = path.join(tmp, 'src');
|
||||
const outDir = path.join(tmp, 'out');
|
||||
const tsBuildInfoPath = path.join(outDir, '.tsbuildinfo');
|
||||
const tsconfigPath = path.join(tmp, 'tsconfig.build.json');
|
||||
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(srcDir, 'sentinelmod.cts'), 'export const sentinelValue = 1;\n');
|
||||
fs.writeFileSync(path.join(srcDir, 'targetmod.cts'), 'export const targetValue = 2;\n');
|
||||
|
||||
fs.writeFileSync(tsconfigPath, JSON.stringify({
|
||||
compilerOptions: {
|
||||
rootDir: 'src',
|
||||
outDir: 'out',
|
||||
module: 'commonjs',
|
||||
target: 'es2022',
|
||||
esModuleInterop: true,
|
||||
noEmitOnError: true,
|
||||
incremental: true,
|
||||
tsBuildInfoFile: 'out/.tsbuildinfo',
|
||||
},
|
||||
include: ['src/**/*.cts'],
|
||||
}, null, 2));
|
||||
|
||||
const overrides = { root: tmp, srcDir, outDir, tsBuildInfoPath, tsconfigPath };
|
||||
const sentinelOut = path.join(outDir, 'sentinelmod.cjs');
|
||||
const targetOut = path.join(outDir, 'targetmod.cjs');
|
||||
return { tmp, overrides, sentinelOut, targetOut, tsBuildInfoPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* FAIL-BEFORE (origin/next behavior):
|
||||
* The old code contained `if (existsSync(sentinel)) return;`. When the
|
||||
* sentinel (semver-compare.cjs) was present, the function returned early
|
||||
* without touching any other .cjs. This test confirms the new code always
|
||||
* invokes tsc — it would have returned immediately on origin/next.
|
||||
*
|
||||
* Specifically: on origin/next, after deleting a non-sentinel artifact +
|
||||
* its tsbuildinfo and calling ensureBuiltArtifacts() with sentinel present,
|
||||
* the artifact would remain absent. On the fix, tsc runs unconditionally
|
||||
* and recreates it.
|
||||
*
|
||||
* PASS-AFTER (fix):
|
||||
* The sentinel guard is removed. ensureBuiltArtifacts() always invokes tsc.
|
||||
* With no tsbuildinfo present (clean state), tsc performs a full emit and
|
||||
* recreates all .cjs outputs including the deleted non-sentinel artifact.
|
||||
*
|
||||
* HERMETIC: this test operates on a self-contained temp project. It NEVER
|
||||
* touches gsd-core/bin/lib/core.cjs or the real tsbuildinfo. (Fixed from #996.)
|
||||
*/
|
||||
test('rebuilds a non-sentinel artifact (with no tsbuildinfo) even when sentinel exists', () => {
|
||||
const { tmp, overrides, sentinelOut, targetOut, tsBuildInfoPath } = makeTempProject();
|
||||
try {
|
||||
// Initial build — both outputs must appear.
|
||||
ensureBuiltArtifacts(overrides);
|
||||
assert.ok(fs.existsSync(sentinelOut), 'initial build: sentinelmod.cjs must exist');
|
||||
assert.ok(fs.existsSync(targetOut), 'initial build: targetmod.cjs must exist');
|
||||
|
||||
// Simulate: fresh CI checkout — target artifact missing, no tsbuildinfo.
|
||||
fs.unlinkSync(targetOut);
|
||||
if (fs.existsSync(tsBuildInfoPath)) fs.unlinkSync(tsBuildInfoPath);
|
||||
|
||||
assert.ok(!fs.existsSync(targetOut), 'pre-condition: targetmod.cjs must be absent');
|
||||
assert.ok(fs.existsSync(sentinelOut), 'pre-condition: sentinelmod.cjs must still be present');
|
||||
|
||||
// Under the OLD code this returned immediately (sentinel present → return).
|
||||
// Under the NEW code this calls tsc unconditionally → full emit → recreated.
|
||||
ensureBuiltArtifacts(overrides);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(targetOut),
|
||||
'ensureBuiltArtifacts must recreate targetmod.cjs even when sentinelmod.cjs ' +
|
||||
'exists (sentinel-short-circuit was removed in fix #969)'
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PASS-AFTER: the unconditional build emits the expected output (sentinelmod.cjs).
|
||||
* Uses the temp project helper so this test is fully hermetic — it never touches
|
||||
* the real gsd-core/bin/lib tree.
|
||||
*/
|
||||
test('sentinel (semver-compare.cjs) still exists after unconditional build', () => {
|
||||
const { tmp, overrides, sentinelOut } = makeTempProject();
|
||||
try {
|
||||
ensureBuiltArtifacts(overrides);
|
||||
assert.ok(fs.existsSync(sentinelOut), 'sentinel output (sentinelmod.cjs) must exist after ensureBuiltArtifacts');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PERSISTENT-MIRROR CASE — the residual hole found by adversarial review.
|
||||
*
|
||||
* FAIL-BEFORE (incremental: true — the old behavior on this branch):
|
||||
* With "incremental": true in tsconfig.build.json, tsc reads the .tsbuildinfo
|
||||
* on disk. If sources are unchanged since the last build, tsc skips re-emitting
|
||||
* any outputs — including outputs that were deleted or overwritten by an rsync
|
||||
* from a different branch. This is the persistent-docker-mirror scenario:
|
||||
* 1. A prior branch rsync'd a stale core.cjs into bin/lib/
|
||||
* 2. A stale tsbuildinfo is present (from that same branch)
|
||||
* 3. ensureBuiltArtifacts() calls tsc (incremental)
|
||||
* 4. tsc sees "sources unchanged vs tsbuildinfo" → no-ops → stale .cjs served
|
||||
* With "incremental": true this test would FAIL because targetmod.cjs remains absent.
|
||||
*
|
||||
* PASS-AFTER (step-3 unlink+clean-reemit logic):
|
||||
* When a missing/zero-bytes output is detected after the incremental pass,
|
||||
* ensureBuiltArtifacts() unlinks the tsbuildinfo and runs tsc a second time
|
||||
* (clean re-emit). The stale/missing output is always regenerated.
|
||||
*
|
||||
* HERMETIC: this test operates on a self-contained temp project. It NEVER
|
||||
* touches gsd-core/bin/lib/core.cjs or the real tsbuildinfo. (Fixed from #996.)
|
||||
*/
|
||||
test('PERSISTENT-MIRROR: rebuilds stale output even when tsbuildinfo is present (non-incremental is authoritative)', () => {
|
||||
const { tmp, overrides, targetOut, tsBuildInfoPath } = makeTempProject();
|
||||
const STALE_TSBUILDINFO = JSON.stringify({
|
||||
program: { fileNames: [], options: { incremental: true } },
|
||||
version: '5.0.0',
|
||||
_gsd_test_marker: 'stale-persistent-mirror',
|
||||
});
|
||||
|
||||
try {
|
||||
// Initial build to populate outputs.
|
||||
ensureBuiltArtifacts(overrides);
|
||||
assert.ok(fs.existsSync(targetOut), 'initial build: targetmod.cjs must exist');
|
||||
|
||||
// Inject a stale tsbuildinfo (mirrors: old branch rsync'd state onto workspace).
|
||||
fs.writeFileSync(tsBuildInfoPath, STALE_TSBUILDINFO);
|
||||
// Delete the output .cjs (mirrors: stale/missing output on the persistent mirror).
|
||||
fs.unlinkSync(targetOut);
|
||||
|
||||
assert.ok(!fs.existsSync(targetOut), 'pre-condition: targetmod.cjs must be absent');
|
||||
assert.ok(fs.existsSync(tsBuildInfoPath), 'pre-condition: tsbuildinfo must be present');
|
||||
|
||||
// FAIL-BEFORE (incremental: true, no step-3): tsc would read the stale
|
||||
// tsbuildinfo, see "sources unchanged", and skip re-emitting targetmod.cjs
|
||||
// → it would remain absent.
|
||||
//
|
||||
// PASS-AFTER (step-3 unlink+clean-reemit): missing output detected after
|
||||
// incremental pass → tsbuildinfo unlinked → tsc runs again → targetmod.cjs
|
||||
// is regenerated unconditionally.
|
||||
ensureBuiltArtifacts(overrides);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(targetOut),
|
||||
'ensureBuiltArtifacts must regenerate targetmod.cjs even when a stale ' +
|
||||
'tsbuildinfo is present on disk (persistent-mirror scenario — ' +
|
||||
'incremental:true alone would have no-op\'d here)'
|
||||
);
|
||||
|
||||
// Verify the regenerated file is valid JS.
|
||||
const regenerated = fs.readFileSync(targetOut, 'utf-8');
|
||||
assert.ok(regenerated.length > 0, 'regenerated targetmod.cjs must be non-empty');
|
||||
assert.ok(
|
||||
regenerated.includes('exports.') || regenerated.includes('"use strict"'),
|
||||
'regenerated targetmod.cjs must look like a valid CommonJS module'
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Part B — runGsdTools: timeout + kill-signal discrimination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #969 B — runGsdTools kill-signal discrimination', () => {
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
/**
|
||||
* Shared helper that mirrors the production runGsdTools implementation
|
||||
* (from tests/helpers.cjs) but accepts an explicit timeout so we can
|
||||
* trigger the kill path in tests without waiting 60 seconds.
|
||||
*
|
||||
* IMPORTANT: this helper is intentionally self-contained so that the test
|
||||
* proves the CONTRACT of the implementation, not just calls the real
|
||||
* runGsdTools (which would need a real 60s+ hang to trigger in tests).
|
||||
* We test the identical logic paths using a tiny timeout.
|
||||
*/
|
||||
function runGsdToolsWithTimeout(args, cwd, env, timeoutMs) {
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
};
|
||||
try {
|
||||
let result;
|
||||
const childEnv = { ...process.env, ...TEST_ENV_BASE, ...(env || {}) };
|
||||
const argv = Array.isArray(args)
|
||||
? args
|
||||
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
|
||||
.map(t => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
|
||||
result = execFileSync(process.execPath, [TOOLS_PATH, ...argv], {
|
||||
cwd: cwd || process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: childEnv,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
return { success: true, output: result.trim(), exitCode: 0 };
|
||||
} catch (err) {
|
||||
// Production kill-discrimination logic (verbatim from helpers.cjs fix).
|
||||
if (err.killed || err.signal != null || err.code === 'ETIMEDOUT') {
|
||||
throw new Error(
|
||||
`[runGsdTools: resource-starvation / subprocess-kill] ` +
|
||||
`gsd-tools was killed before completion ` +
|
||||
`(signal=${err.signal}, code=${err.code}, killed=${err.killed}). ` +
|
||||
`This indicates host OOM or scheduler contention, not a product bug. ` +
|
||||
`stdout=${err.stdout?.toString().trim() || ''} ` +
|
||||
`stderr=${err.stderr?.toString().trim() || ''}`
|
||||
);
|
||||
}
|
||||
const stderrRaw = err.stderr?.toString().trim() || '';
|
||||
const error = stderrRaw || `${err.message} [stderr: (empty) exit:${err.status ?? 1}]`;
|
||||
return {
|
||||
success: false,
|
||||
output: err.stdout?.toString().trim() || '',
|
||||
error,
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FAIL-BEFORE (origin/next behavior):
|
||||
* Without a timeout, an OOM-killed subprocess threw with err.killed=true
|
||||
* but the catch block fell through to `return { success: false, ... }`.
|
||||
* The test consumer saw a normal {success:false} result and tried to parse
|
||||
* gsd-tools output from it, causing a confusing downstream assertion fail.
|
||||
*
|
||||
* PASS-AFTER (fix):
|
||||
* The kill-discrimination guard rethrows immediately with a labeled error
|
||||
* message containing "resource-starvation / subprocess-kill". The test
|
||||
* asserts on that throw rather than getting a silent {success:false}.
|
||||
*
|
||||
* Mechanism: we use a tiny timeout (1ms) to guarantee a timeout-kill on a
|
||||
* real gsd-tools invocation (even `--help` takes >1ms to start node).
|
||||
*/
|
||||
test('throws a resource-starvation error when subprocess is killed/times out', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-'));
|
||||
try {
|
||||
// 1ms timeout guarantees ETIMEDOUT / killed before gsd-tools can respond.
|
||||
assert.throws(
|
||||
() => runGsdToolsWithTimeout(['--help'], tmpDir, {}, 1),
|
||||
(err) => {
|
||||
assert.ok(
|
||||
err.message.includes('resource-starvation / subprocess-kill'),
|
||||
`Expected labeled resource-starvation error, got: ${err.message}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that a normal fast command still returns { success: true } and does
|
||||
* NOT throw — i.e., the timeout addition does not break the happy path.
|
||||
*/
|
||||
test('returns { success: true } for a normal fast command with generous timeout', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-'));
|
||||
try {
|
||||
// 30s timeout; gsd-tools --help completes in well under 1s.
|
||||
const result = runGsdToolsWithTimeout(['--help'], tmpDir, {}, 30000);
|
||||
assert.ok(result.success === true, `Expected success:true, got ${JSON.stringify(result)}`);
|
||||
assert.ok(typeof result.output === 'string', 'output must be a string');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that a clean non-zero exit (a real gsd-tools application error, not
|
||||
* a kill) still returns { success: false } WITHOUT throwing. This preserves
|
||||
* existing test behavior that asserts on error shape.
|
||||
*
|
||||
* We trigger a clean non-zero by invoking a command that is known to fail
|
||||
* cleanly (no project directory set up).
|
||||
*/
|
||||
test('returns { success: false } for a clean non-zero exit (no throw)', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-'));
|
||||
try {
|
||||
// 'phase list' on a directory with no .planning/ produces a clean error exit.
|
||||
const result = runGsdToolsWithTimeout(['phase', 'list'], tmpDir, {}, 30000);
|
||||
assert.ok(result.success === false, `Expected success:false for clean error, got ${JSON.stringify(result)}`);
|
||||
assert.ok(result.exitCode !== 0, 'exitCode must be non-zero');
|
||||
// Must NOT have thrown — the clean-error path returns normally.
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Part C — ensureBuiltHooks: build hooks/dist once, closing the scoped-CI
|
||||
// first-build empty-dir race.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #969 C — ensureBuiltHooks populates hooks/dist before concurrent tests', () => {
|
||||
/**
|
||||
* Helper: a hermetic temp dist dir + a runBuild spy. The spy records how many
|
||||
* times a build was requested and, when invoked, writes the given hook files
|
||||
* (simulating build-hooks.js populating DIST_DIR) so idempotency is testable.
|
||||
*
|
||||
* HERMETIC: never touches the real hooks/dist. Uses dependency-injected
|
||||
* overrides (distDir, hookNames, runBuild) — no fs monkeypatching, so the test
|
||||
* is deterministic and root/OS-independent.
|
||||
*/
|
||||
function makeHooksFixture(hookNames) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-hooks-'));
|
||||
const distDir = path.join(tmp, 'hooks', 'dist');
|
||||
let buildCalls = 0;
|
||||
const runBuild = () => {
|
||||
buildCalls += 1;
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
for (const h of hookNames) {
|
||||
fs.writeFileSync(path.join(distDir, h), `// ${h}\nmodule.exports = {};\n`);
|
||||
}
|
||||
};
|
||||
const overrides = () => ({ distDir, hookNames, runBuild });
|
||||
return { tmp, distDir, hookNames, overrides, calls: () => buildCalls };
|
||||
}
|
||||
|
||||
const HOOKS = ['a-hook.js', 'b-hook.js', 'c-hook.sh'];
|
||||
|
||||
/**
|
||||
* FAIL-BEFORE (origin/next): ensureBuiltHooks did not exist, so the export was
|
||||
* undefined and there was no upfront hooks build — the first concurrent
|
||||
* install test raced build-hooks.js's empty-then-fill window. The import at the
|
||||
* top of this file (ensureBuiltHooks) is itself the fail-first anchor: on
|
||||
* origin/next it is undefined and every test below throws "not a function".
|
||||
*
|
||||
* PASS-AFTER: ensureBuiltHooks() builds when hooks/dist is entirely absent.
|
||||
*/
|
||||
test('builds when hooks/dist is absent (fresh checkout / scoped CI)', () => {
|
||||
const fx = makeHooksFixture(HOOKS);
|
||||
try {
|
||||
assert.equal(typeof ensureBuiltHooks, 'function',
|
||||
'ensureBuiltHooks must be exported (absent on origin/next — the fail-first anchor)');
|
||||
assert.ok(!fs.existsSync(fx.distDir), 'pre-condition: hooks/dist must be absent');
|
||||
ensureBuiltHooks(fx.overrides());
|
||||
assert.equal(fx.calls(), 1, 'a build must be triggered when dist is absent');
|
||||
for (const h of HOOKS) {
|
||||
assert.ok(fs.existsSync(path.join(fx.distDir, h)), `${h} must exist after build`);
|
||||
}
|
||||
} finally {
|
||||
cleanup(fx.tmp);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* BOUNDARY: dist exists but is empty (0 of N hooks) — the exact transient state
|
||||
* build-hooks.js exposes between mkdir(DIST_DIR) and the first file rename.
|
||||
*/
|
||||
test('builds when hooks/dist exists but is empty (0 of N)', () => {
|
||||
const fx = makeHooksFixture(HOOKS);
|
||||
try {
|
||||
fs.mkdirSync(fx.distDir, { recursive: true }); // empty dir — the race window
|
||||
ensureBuiltHooks(fx.overrides());
|
||||
assert.equal(fx.calls(), 1, 'an empty dist must trigger a build');
|
||||
} finally {
|
||||
cleanup(fx.tmp);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* BOUNDARY: dist has all-but-one hook (N-1 of N) — a partially-filled dir mid
|
||||
* first-build. Must still be treated as incomplete and rebuilt.
|
||||
*/
|
||||
test('builds when hooks/dist is partial (N-1 of N)', () => {
|
||||
const fx = makeHooksFixture(HOOKS);
|
||||
try {
|
||||
fs.mkdirSync(fx.distDir, { recursive: true });
|
||||
for (const h of HOOKS.slice(0, HOOKS.length - 1)) {
|
||||
fs.writeFileSync(path.join(fx.distDir, h), 'x');
|
||||
}
|
||||
ensureBuiltHooks(fx.overrides());
|
||||
assert.equal(fx.calls(), 1, 'a partial dist (missing one hook) must trigger a build');
|
||||
} finally {
|
||||
cleanup(fx.tmp);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* BOUNDARY: a zero-byte hook (N of N present, but one is 0 bytes) — a truncated
|
||||
* mid-write file. statSync().size === 0 must count as incomplete → rebuild.
|
||||
*/
|
||||
test('builds when a hook file is present but zero-byte', () => {
|
||||
const fx = makeHooksFixture(HOOKS);
|
||||
try {
|
||||
fs.mkdirSync(fx.distDir, { recursive: true });
|
||||
HOOKS.forEach((h, i) => {
|
||||
fs.writeFileSync(path.join(fx.distDir, h), i === 0 ? '' : 'ok'); // first is 0 bytes
|
||||
});
|
||||
ensureBuiltHooks(fx.overrides());
|
||||
assert.equal(fx.calls(), 1, 'a zero-byte hook must be treated as incomplete → rebuild');
|
||||
} finally {
|
||||
cleanup(fx.tmp);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* BOUNDARY + idempotency: dist is complete (all N present, non-empty). No build
|
||||
* must fire — this keeps nested run-tests spawns and repeat invocations cheap
|
||||
* and avoids a redundant concurrent build against an already-populated dist.
|
||||
*/
|
||||
test('no-op when hooks/dist is complete (N of N non-empty)', () => {
|
||||
const fx = makeHooksFixture(HOOKS);
|
||||
try {
|
||||
fs.mkdirSync(fx.distDir, { recursive: true });
|
||||
for (const h of HOOKS) fs.writeFileSync(path.join(fx.distDir, h), 'ok');
|
||||
ensureBuiltHooks(fx.overrides());
|
||||
assert.equal(fx.calls(), 0, 'a complete dist must NOT trigger a build (idempotent no-op)');
|
||||
} finally {
|
||||
cleanup(fx.tmp);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Integration guard: the REAL default hook set (from build-hooks.js) is what
|
||||
* ensureBuiltHooks checks when no override is given. Prove the real export is a
|
||||
* non-empty list so the completeness predicate can never vacuously pass.
|
||||
*/
|
||||
test('default hook set (build-hooks.js HOOKS_TO_COPY) is a non-empty list', () => {
|
||||
const { HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
|
||||
assert.ok(Array.isArray(HOOKS_TO_COPY) && HOOKS_TO_COPY.length > 0,
|
||||
'HOOKS_TO_COPY must be a non-empty array or the completeness check is vacuous');
|
||||
});
|
||||
});
|
||||
@@ -6428,3 +6428,468 @@ describe('enh-1055 descriptor-drive: finishPermissionWriter passthrough', () =>
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/enh-1592-plan-drift-precheck.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:enh-1592-plan-drift-precheck (consolidation epic #1969 B6 #1975)", () => {
|
||||
'use strict';
|
||||
// allow-test-rule: source-text-is-the-product see #1592
|
||||
// The plan-phase.md host-dispatch assertions below read the workflow .md file — its text IS the
|
||||
// deployed contract the runtime loads (CONTRIBUTING.md exemption category). The registry assertions
|
||||
// are behavioral: they build the registry from the REAL capabilities/drift declaration via the
|
||||
// generator, so they fail if the plan:pre gate is ever removed or mutated.
|
||||
|
||||
/**
|
||||
* Enhancement (#1592): plan-time codebase-map freshness pre-check.
|
||||
*
|
||||
* The `drift` capability gains a non-blocking `plan:pre` codebase-drift gate so a stale codebase map is
|
||||
* flagged BEFORE planning, instead of being discovered mid-execution by the existing
|
||||
* `execute:wave:post` codebase-drift gate. Warn-only at `plan:pre` (no mapper-agent spawn): the
|
||||
* capability's `drift_action: auto-remap` stays at `execute:wave:post`, so plan time never pays
|
||||
* speculative mapper-agent cost.
|
||||
*
|
||||
* Per maintainer review on #1592 (mod 1a), the plan:pre gate is gated on a DEDICATED
|
||||
* `workflow.plan_drift_precheck` toggle (default true) rather than reusing `workflow.schema_drift_gate`,
|
||||
* so autonomous/CI runs can silence the plan-time advisory without disabling the execute-time gates.
|
||||
* The gate declaration conforms to ADR-857 (`plan:pre` is an enumerated, additive-only loop point).
|
||||
*
|
||||
* Issue: #1592 (open-gsd/gsd-core).
|
||||
*/
|
||||
|
||||
const { describe, test, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { loadAndValidate, buildRegistry } = require('../scripts/gen-capability-registry.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const DRIFT_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'capabilities', 'drift', 'capability.json'), 'utf8'),
|
||||
);
|
||||
const PLAN_PHASE = fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Track every temp dir created so the suite can remove them on teardown — leaked
|
||||
// mkdtemp dirs have been a flake source here before (per #1592 review).
|
||||
const tempCapDirs = [];
|
||||
|
||||
function makeTempCapDir(capabilities) {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'enh-1592-'));
|
||||
tempCapDirs.push(tmpDir);
|
||||
for (const [id, cap] of Object.entries(capabilities)) {
|
||||
const subDir = path.join(tmpDir, id);
|
||||
fs.mkdirSync(subDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(subDir, 'capability.json'), JSON.stringify(cap), 'utf8');
|
||||
}
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
after(() => {
|
||||
for (const dir of tempCapDirs) {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
function planPreDriftGate() {
|
||||
const capDir = makeTempCapDir({ drift: DRIFT_CAP });
|
||||
const { capMap, errors } = loadAndValidate(new Set(), capDir);
|
||||
assert.deepEqual(errors, [], 'drift capability should validate cleanly: ' + JSON.stringify(errors));
|
||||
const registry = buildRegistry(capMap);
|
||||
const planPreGates = registry.byLoopPoint['plan:pre'].gates;
|
||||
assert.ok(Array.isArray(planPreGates), 'plan:pre.gates should be an array');
|
||||
return planPreGates.find(
|
||||
(g) => g.capId === 'drift' && g.check && g.check.query === 'verify.codebase-drift',
|
||||
);
|
||||
}
|
||||
|
||||
describe('#1592 — drift plan:pre codebase-drift gate (registry, behavioral)', () => {
|
||||
test('the real drift capability registers a non-blocking plan:pre codebase-drift gate', () => {
|
||||
const driftGate = planPreDriftGate();
|
||||
assert.ok(driftGate, 'plan:pre.gates must contain the drift codebase-drift gate');
|
||||
assert.strictEqual(driftGate.blocking, false, 'plan-time drift gate must be NON-blocking');
|
||||
assert.strictEqual(driftGate.onError, 'skip', 'must fail-soft (skip) — never halt planning');
|
||||
});
|
||||
|
||||
test('the plan:pre gate is gated on the dedicated plan_drift_precheck toggle (mod 1a)', () => {
|
||||
const driftGate = planPreDriftGate();
|
||||
assert.strictEqual(
|
||||
driftGate.when,
|
||||
'workflow.plan_drift_precheck',
|
||||
'plan:pre drift gate must use the dedicated toggle so CI/autonomous runs can silence it ' +
|
||||
'without disabling the execute-time gates',
|
||||
);
|
||||
});
|
||||
|
||||
test('the execute:wave:post codebase-drift gate is preserved and keeps its OWN toggle (no regression)', () => {
|
||||
const capDir = makeTempCapDir({ drift: DRIFT_CAP });
|
||||
const { capMap } = loadAndValidate(new Set(), capDir);
|
||||
const registry = buildRegistry(capMap);
|
||||
|
||||
const execGates = registry.byLoopPoint['execute:wave:post'].gates;
|
||||
const stillThere = execGates.find(
|
||||
(g) => g.capId === 'drift' && g.check && g.check.query === 'verify.codebase-drift',
|
||||
);
|
||||
assert.ok(stillThere, 'execute:wave:post codebase-drift gate must remain after adding the plan:pre gate');
|
||||
assert.strictEqual(stillThere.blocking, false, 'execute codebase-drift gate stays non-blocking');
|
||||
assert.strictEqual(
|
||||
stillThere.when,
|
||||
'workflow.schema_drift_gate',
|
||||
'the execute-time gate keeps schema_drift_gate — the plan-time toggle is separable from it',
|
||||
);
|
||||
});
|
||||
|
||||
test('plan_drift_precheck is a separate toggle from schema_drift_gate (silencing is independent)', () => {
|
||||
const planWhen = planPreDriftGate().when;
|
||||
assert.notStrictEqual(
|
||||
planWhen,
|
||||
'workflow.schema_drift_gate',
|
||||
'silencing the plan-time advisory must not require disabling the execute-time gates',
|
||||
);
|
||||
});
|
||||
|
||||
test('plan_drift_precheck is declared as a boolean defaulting to true', () => {
|
||||
const cfg = DRIFT_CAP.config['workflow.plan_drift_precheck'];
|
||||
assert.ok(cfg, 'workflow.plan_drift_precheck must be declared in the drift capability config');
|
||||
assert.strictEqual(cfg.type, 'boolean', 'plan_drift_precheck must be a boolean');
|
||||
assert.strictEqual(cfg.default, true, 'plan_drift_precheck must default to true (on by default)');
|
||||
});
|
||||
|
||||
test('exactly one new config key is introduced (the dedicated plan_drift_precheck toggle)', () => {
|
||||
const keys = Object.keys(DRIFT_CAP.config).sort();
|
||||
assert.deepStrictEqual(
|
||||
keys,
|
||||
[
|
||||
'workflow.drift_action',
|
||||
'workflow.drift_threshold',
|
||||
'workflow.plan_drift_precheck',
|
||||
'workflow.schema_drift_gate',
|
||||
],
|
||||
'the plan:pre gate adds exactly the dedicated plan_drift_precheck toggle — no other new keys',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#1592 — plan-phase host dispatches the drift plan:pre gate before planning', () => {
|
||||
const SECTION = PLAN_PHASE.slice(
|
||||
PLAN_PHASE.indexOf('5.65. Codebase Map Freshness Pre-Check'),
|
||||
PLAN_PHASE.indexOf('## 6. Check Existing Plans'),
|
||||
);
|
||||
|
||||
test('§5.65 invokes the verify codebase-drift check', () => {
|
||||
assert.match(PLAN_PHASE, /5\.65\. Codebase Map Freshness Pre-Check/, 'plan-phase must declare §5.65');
|
||||
assert.match(PLAN_PHASE, /gsd_run verify codebase-drift/, '§5.65 must invoke `verify codebase-drift`');
|
||||
});
|
||||
|
||||
test('the drift pre-check runs BEFORE the planner spawn (load-bearing ordering)', () => {
|
||||
const preCheckIdx = PLAN_PHASE.indexOf('5.65. Codebase Map Freshness Pre-Check');
|
||||
const plannerIdx = PLAN_PHASE.indexOf('## 8. Spawn gsd-planner Agent');
|
||||
assert.ok(preCheckIdx > 0, '§5.65 must exist');
|
||||
assert.ok(plannerIdx > 0, '§8 planner spawn must exist');
|
||||
assert.ok(
|
||||
preCheckIdx < plannerIdx,
|
||||
'the drift map-freshness pre-check must run before the planner is spawned — the whole point of #1592',
|
||||
);
|
||||
});
|
||||
|
||||
test('§5.65 is documented as non-blocking and warn-only (no spawn)', () => {
|
||||
assert.match(SECTION, /non-blocking/i, '§5.65 must state the gate is non-blocking');
|
||||
assert.match(SECTION, /never blocks, never spawns/i, '§5.65 must state it never spawns the mapper at plan time');
|
||||
});
|
||||
|
||||
test('§5.65 gates on the dedicated plan_drift_precheck toggle (mod 1a)', () => {
|
||||
assert.match(
|
||||
SECTION,
|
||||
/workflow\.plan_drift_precheck/,
|
||||
'§5.65 must dispatch on the dedicated plan_drift_precheck toggle, not schema_drift_gate',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/fix-1464-docs-manifest-validation.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:fix-1464-docs-manifest-validation (consolidation epic #1969 B6 #1975)", () => {
|
||||
// allow-test-rule: source-text-is-the-product see #1464
|
||||
// Tutorial docs are the product surface users follow. Reading JSON code blocks
|
||||
// from them and validating through validateCapability is behavioral, not
|
||||
// source-grep — it proves the manifests work, not just that they "mention" a term.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { validateCapability } = require('../scripts/gen-capability-registry.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
// ─── Extractor ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Required top-level fields that distinguish a complete capability manifest
|
||||
// from a partial output snippet (list-entry, install-result, etc.).
|
||||
// Partial output snippets have id+role but lack steps/contributions/gates/config.
|
||||
const MANIFEST_REQUIRED_KEYS = new Set([
|
||||
'id', 'role', 'title', 'description', 'tier',
|
||||
'requires', 'runtimeCompat', 'skills', 'agents',
|
||||
'config', 'steps', 'contributions', 'gates',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract JSON code blocks from markdown that are complete capability manifests.
|
||||
* A complete manifest has ALL keys in MANIFEST_REQUIRED_KEYS.
|
||||
* Partial output snippets (list-entries, install-results) have only id+role and are skipped.
|
||||
*/
|
||||
function extractManifests(mdContent) {
|
||||
const manifests = [];
|
||||
const fenceRe = /```json\s*\r?\n([\s\S]*?)```/g;
|
||||
let match;
|
||||
while ((match = fenceRe.exec(mdContent)) !== null) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(match[1]);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const keys = new Set(Object.keys(parsed));
|
||||
if ([...MANIFEST_REQUIRED_KEYS].every((k) => keys.has(k))) {
|
||||
manifests.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return manifests;
|
||||
}
|
||||
|
||||
// ─── Suite 1: tutorial manifests validate ────────────────────────────────────
|
||||
|
||||
describe('docs tutorial manifests pass validateCapability (#1464 regression)', () => {
|
||||
test('build-your-first-capability.md: every manifest passes', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(ROOT, 'docs', 'tutorials', 'build-your-first-capability.md'),
|
||||
'utf8',
|
||||
);
|
||||
const manifests = extractManifests(content);
|
||||
assert.ok(
|
||||
manifests.length > 0,
|
||||
'expected at least one capability manifest in build tutorial',
|
||||
);
|
||||
for (const cap of manifests) {
|
||||
const errors = validateCapability(cap, cap.id);
|
||||
assert.deepStrictEqual(
|
||||
errors,
|
||||
[],
|
||||
`build tutorial manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('install-your-first-capability.md: every manifest passes', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(ROOT, 'docs', 'tutorials', 'install-your-first-capability.md'),
|
||||
'utf8',
|
||||
);
|
||||
const manifests = extractManifests(content);
|
||||
assert.ok(
|
||||
manifests.length > 0,
|
||||
'expected at least one capability manifest in install tutorial',
|
||||
);
|
||||
for (const cap of manifests) {
|
||||
const errors = validateCapability(cap, cap.id);
|
||||
assert.deepStrictEqual(
|
||||
errors,
|
||||
[],
|
||||
`install tutorial manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('capability-manifest.md reference example passes', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(ROOT, 'docs', 'reference', 'capability-manifest.md'),
|
||||
'utf8',
|
||||
);
|
||||
const manifests = extractManifests(content);
|
||||
assert.ok(
|
||||
manifests.length > 0,
|
||||
'expected at least one capability manifest in reference doc',
|
||||
);
|
||||
for (const cap of manifests) {
|
||||
const errors = validateCapability(cap, cap.id);
|
||||
assert.deepStrictEqual(
|
||||
errors,
|
||||
[],
|
||||
`reference manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: adversarial — #1464 failure modes caught ───────────────────────
|
||||
//
|
||||
// These are the EXACT failure shapes from issue #1464.
|
||||
// They must fail validateCapability — proving this test would have caught the bug.
|
||||
|
||||
describe('validateCapability catches original #1464 bug shapes', () => {
|
||||
// #1464 high-1: step missing ref → validateStep rejects it
|
||||
test('step without ref fails (the original broken tutorial step)', () => {
|
||||
const cap = {
|
||||
id: 'hello-note',
|
||||
role: 'feature',
|
||||
version: '0.1.0',
|
||||
title: 'Hello Note',
|
||||
description: 'Test fixture for #1464 regression.',
|
||||
tier: 'standard',
|
||||
requires: [],
|
||||
engines: { gsd: '>=1.6.0' },
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [],
|
||||
agents: [],
|
||||
config: {},
|
||||
steps: [
|
||||
{
|
||||
// Missing ref — this was the #1464 high-1 bug in the original tutorial
|
||||
point: 'plan:pre',
|
||||
produces: ['HELLO.md'],
|
||||
consumes: [],
|
||||
onError: 'skip',
|
||||
},
|
||||
],
|
||||
contributions: [],
|
||||
gates: [],
|
||||
};
|
||||
const errors = validateCapability(cap, 'hello-note');
|
||||
assert.ok(errors.length > 0, 'expected validation errors for step without ref');
|
||||
assert.ok(
|
||||
errors.some((e) => /ref/.test(e)),
|
||||
`expected an error mentioning "ref"; got: ${errors.join('; ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
// #1464 shape: id must match folder name (folderId contract)
|
||||
test('id not matching folderId fails', () => {
|
||||
const cap = {
|
||||
id: 'hello-note',
|
||||
role: 'feature',
|
||||
version: '0.1.0',
|
||||
title: 'Hello Note',
|
||||
description: 'Test fixture for id/folderId mismatch.',
|
||||
tier: 'standard',
|
||||
requires: [],
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [],
|
||||
agents: [],
|
||||
config: {},
|
||||
steps: [],
|
||||
contributions: [],
|
||||
gates: [],
|
||||
};
|
||||
const errors = validateCapability(cap, 'wrong-folder');
|
||||
assert.ok(errors.length > 0, 'expected id/folderId mismatch to fail validation');
|
||||
assert.ok(
|
||||
errors.some((e) => /folder/.test(e) || /equal/.test(e) || /id/.test(e)),
|
||||
`expected error about id/folderId mismatch; got: ${errors.join('; ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Corrected shape: contribution with fragment + into (the PR #1495 fix)
|
||||
test('contribution with fragment.path + into passes (the PR #1495 fix shape)', () => {
|
||||
const cap = {
|
||||
id: 'hello-note',
|
||||
role: 'feature',
|
||||
version: '0.1.0',
|
||||
title: 'Hello Note',
|
||||
description: 'Injects a greeting note at plan:pre and produces HELLO.md.',
|
||||
tier: 'standard',
|
||||
requires: [],
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [],
|
||||
agents: [],
|
||||
config: {},
|
||||
steps: [],
|
||||
contributions: [
|
||||
{
|
||||
point: 'plan:pre',
|
||||
into: 'planner',
|
||||
fragment: { path: 'fragments/plan-pre.md' },
|
||||
produces: ['HELLO.md'],
|
||||
consumes: [],
|
||||
onError: 'skip',
|
||||
},
|
||||
],
|
||||
gates: [],
|
||||
};
|
||||
const errors = validateCapability(cap, 'hello-note');
|
||||
assert.deepStrictEqual(
|
||||
errors,
|
||||
[],
|
||||
`corrected contribution manifest has unexpected errors: ${errors.join('; ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 3: extractManifests helper ────────────────────────────────────────
|
||||
|
||||
describe('extractManifests helper unit tests', () => {
|
||||
test('returns empty array for plain text with no JSON fences', () => {
|
||||
assert.deepStrictEqual(extractManifests('No code blocks here.'), []);
|
||||
});
|
||||
|
||||
test('skips JSON blocks without all required manifest keys', () => {
|
||||
// Partial list-entry block — only has id, role, version but not steps/contributions/etc.
|
||||
const md = '```json\n{"id":"x","role":"feature","version":"1.0.0"}\n```';
|
||||
assert.deepStrictEqual(extractManifests(md), []);
|
||||
});
|
||||
|
||||
function makeCompleteManifest(overrides) {
|
||||
return {
|
||||
id: 'test-cap', role: 'feature', title: 'T', description: 'D',
|
||||
tier: 'standard', requires: [], runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [], agents: [], config: {}, steps: [], contributions: [], gates: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('extracts a complete manifest (all required keys present)', () => {
|
||||
const cap = makeCompleteManifest({ id: 'x' });
|
||||
const md = '```json\n' + JSON.stringify(cap, null, 2) + '\n```';
|
||||
const result = extractManifests(md);
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.strictEqual(result[0].id, 'x');
|
||||
});
|
||||
|
||||
test('skips malformed JSON blocks silently', () => {
|
||||
const complete = makeCompleteManifest({ id: 'y' });
|
||||
const md = '```json\n{bad json here\n```\n```json\n' + JSON.stringify(complete) + '\n```';
|
||||
const result = extractManifests(md);
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.strictEqual(result[0].id, 'y');
|
||||
});
|
||||
|
||||
test('extracts multiple complete manifests from one doc', () => {
|
||||
const a = makeCompleteManifest({ id: 'cap-a' });
|
||||
const b = makeCompleteManifest({ id: 'cap-b' });
|
||||
const md = [
|
||||
'```json\n' + JSON.stringify(a) + '\n```',
|
||||
'```json\n' + JSON.stringify(b) + '\n```',
|
||||
].join('\n');
|
||||
const result = extractManifests(md);
|
||||
assert.strictEqual(result.length, 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -161,7 +161,13 @@ describe('ci-test-scope.cjs', () => {
|
||||
`expected product_changed=true for bin/gsd, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.full_matrix, true);
|
||||
assert.ok(result.targeted_tests.includes('tests/install.test.cjs'));
|
||||
assert.ok(result.targeted_tests.includes('tests/release-tarball-smoke.install.test.cjs'));
|
||||
// release-tarball-smoke.install.test.cjs is intentionally EXCLUDED from the
|
||||
// scoped/targeted lane (SCOPED_LANE_EXCLUDE in ci-test-scope.cjs): it is a
|
||||
// 3–6 min npm-pack + global-install integration test that runs via its own
|
||||
// install-smoke.yml workflow, not here — running it in the scoped lane too is
|
||||
// redundant and overran the per-chunk Windows timeout (epic #1969).
|
||||
assert.ok(!result.targeted_tests.includes('tests/release-tarball-smoke.install.test.cjs'),
|
||||
`release-tarball-smoke.install.test.cjs must not be in the scoped targeted lane; got: ${JSON.stringify(result.targeted_tests)}`);
|
||||
});
|
||||
|
||||
test('missing required CLI values fail with usage', () => {
|
||||
@@ -285,12 +291,12 @@ describe('ci-test-scope superset invariant (#494, narrowed)', () => {
|
||||
// so OS-specific breakage in the changed test (the #482 class) is still
|
||||
// exercised pre-merge. Ubuntu 22/24 coverage comes via targeted_tests.
|
||||
test('A1: a changed test file joins the windows scoped lane without full_matrix', () => {
|
||||
const result = scopeFor(['tests/bug-1974-context-exhaustion-record.test.cjs']);
|
||||
const result = scopeFor(['tests/perf-317-context-monitor-fs.test.cjs']);
|
||||
assert.strictEqual(result.full_matrix, false,
|
||||
`expected full_matrix=false for a tests/**-only change, got: ${JSON.stringify(result)}`);
|
||||
assert.ok(result.targeted_tests.includes('tests/bug-1974-context-exhaustion-record.test.cjs'),
|
||||
assert.ok(result.targeted_tests.includes('tests/perf-317-context-monitor-fs.test.cjs'),
|
||||
`expected the changed test in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`);
|
||||
assert.ok(result.windows_tests.includes('tests/bug-1974-context-exhaustion-record.test.cjs'),
|
||||
assert.ok(result.windows_tests.includes('tests/perf-317-context-monitor-fs.test.cjs'),
|
||||
`expected the changed test in windows_tests, got: ${JSON.stringify(result.windows_tests)}`);
|
||||
});
|
||||
|
||||
@@ -629,3 +635,262 @@ describe('code_changed=false implies clean output invariant', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-641-files-from-suite-token.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-641-files-from-suite-token (consolidation epic #1969 B6 #1975)", () => {
|
||||
// Regression test for issue #641:
|
||||
// `--files-from` with a bare suite token (e.g. "unit") crashes with
|
||||
// "requested test file(s) not found: unit" instead of expanding the token
|
||||
// to the matching suite's files.
|
||||
//
|
||||
// The bug: selectExplicitFiles() checked `available.has('unit')` against the
|
||||
// set of *.test.cjs filenames. 'unit' is not a filename, so it landed in
|
||||
// `missing` and caused exit 2. The fix teaches selectExplicitFiles() to
|
||||
// delegate bare SUITES members to selectFiles() before the path-existence
|
||||
// check.
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const HARNESS = path.join(__dirname, '..', 'scripts', 'run-tests.cjs');
|
||||
|
||||
const PASS_BODY = `'use strict';
|
||||
const { test } = require('node:test');
|
||||
test('noop', () => {});
|
||||
`;
|
||||
|
||||
function seed(dir, names) {
|
||||
for (const name of names) {
|
||||
fs.writeFileSync(path.join(dir, name), PASS_BODY, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function runHarness(testDir, args = [], extraEnv = {}) {
|
||||
const env = { ...process.env, GSD_TEST_DIR: testDir, ...extraEnv };
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
return spawnSync(process.execPath, [HARNESS, ...args], {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
describe('bug #641 — --files-from with bare suite token', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-641-suite-token-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('--files-from with bare "unit" token expands to unit suite, does not exit 2', () => {
|
||||
// Seed a mix: one unit file, one security file.
|
||||
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
|
||||
const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
|
||||
fs.writeFileSync(listPath, 'unit\n', 'utf8');
|
||||
|
||||
const r = runHarness(tmpDir, ['--files-from', listPath]);
|
||||
|
||||
// Must NOT exit 2 with the "not found" error.
|
||||
assert.notStrictEqual(
|
||||
r.status,
|
||||
2,
|
||||
`Expected exit 0 or 1, got 2.\nstderr: ${r.stderr}\nstdout: ${r.stdout}`,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
r.stderr,
|
||||
/requested test file\(s\) not found: unit/,
|
||||
`Must not emit "not found: unit".\nstderr: ${r.stderr}`,
|
||||
);
|
||||
// The unit suite file (a.test.cjs) must appear in the run.
|
||||
assert.ok(
|
||||
r.stderr.includes('a.test.cjs'),
|
||||
`Expected a.test.cjs (unit suite) to be selected.\nstderr: ${r.stderr}`,
|
||||
);
|
||||
// The security suite file must NOT be included (unit token = unit only).
|
||||
assert.ok(
|
||||
!r.stderr.includes('b.security.test.cjs'),
|
||||
`Expected b.security.test.cjs (security suite) to be excluded.\nstderr: ${r.stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('--files-from with bare "unit" token exits 0 (tests run successfully)', () => {
|
||||
seed(tmpDir, ['a.test.cjs']);
|
||||
const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
|
||||
fs.writeFileSync(listPath, 'unit\n', 'utf8');
|
||||
|
||||
const r = runHarness(tmpDir, ['--files-from', listPath]);
|
||||
|
||||
assert.strictEqual(
|
||||
r.status,
|
||||
0,
|
||||
`Expected exit 0.\nstderr: ${r.stderr}\nstdout: ${r.stdout}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('--files with bare "unit" token also resolves correctly', () => {
|
||||
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
|
||||
const r = runHarness(tmpDir, ['--files', 'unit']);
|
||||
|
||||
assert.notStrictEqual(
|
||||
r.status,
|
||||
2,
|
||||
`Expected exit 0, got 2.\nstderr: ${r.stderr}`,
|
||||
);
|
||||
assert.doesNotMatch(r.stderr, /requested test file\(s\) not found: unit/);
|
||||
assert.ok(r.stderr.includes('a.test.cjs'), `a.test.cjs must be selected.\nstderr: ${r.stderr}`);
|
||||
assert.ok(!r.stderr.includes('b.security.test.cjs'), `security file must not be selected.\nstderr: ${r.stderr}`);
|
||||
});
|
||||
|
||||
test('mixed: suite token "unit" alongside an explicit file resolves both', () => {
|
||||
seed(tmpDir, ['a.test.cjs', 'b.test.cjs', 'c.security.test.cjs']);
|
||||
const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
|
||||
// 'unit' expands to [a.test.cjs, b.test.cjs]; b.test.cjs is explicit too.
|
||||
fs.writeFileSync(listPath, 'unit\nb.test.cjs\n', 'utf8');
|
||||
|
||||
const r = runHarness(tmpDir, ['--files-from', listPath]);
|
||||
|
||||
assert.strictEqual(r.status, 0, `stderr: ${r.stderr}`);
|
||||
// Both unit files present; security not.
|
||||
assert.ok(r.stderr.includes('a.test.cjs'), `a.test.cjs must be selected.\nstderr: ${r.stderr}`);
|
||||
assert.ok(r.stderr.includes('b.test.cjs'), `b.test.cjs must be selected.\nstderr: ${r.stderr}`);
|
||||
assert.ok(!r.stderr.includes('c.security.test.cjs'), `c.security.test.cjs must be excluded.\nstderr: ${r.stderr}`);
|
||||
});
|
||||
|
||||
test('#408 fallback: ci-test-scope "unit" sentinel does not crash run-tests', () => {
|
||||
// This test simulates the end-to-end #408 fallback path:
|
||||
// ci-test-scope produces "unit" (the fallback sentinel for "code changed
|
||||
// but no rule matched any test"), ci-prepare-test-scope writes it verbatim,
|
||||
// and run-tests must resolve it rather than crash.
|
||||
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
|
||||
// Simulate what ci-prepare-test-scope writes: "unit\n"
|
||||
const listPath = path.join(tmpDir, '.ci-selected-tests.txt');
|
||||
fs.writeFileSync(listPath, 'unit\n', 'utf8');
|
||||
|
||||
const r = runHarness(tmpDir, ['--files-from', listPath]);
|
||||
|
||||
assert.strictEqual(
|
||||
r.status,
|
||||
0,
|
||||
`#408 fallback: expected exit 0 but got ${r.status}.\nstderr: ${r.stderr}`,
|
||||
);
|
||||
assert.doesNotMatch(r.stderr, /not found: unit/);
|
||||
assert.ok(r.stderr.includes('a.test.cjs'), `unit test must run.\nstderr: ${r.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression test for issue #1329:
|
||||
// ci-prepare-test-scope's empty-detection FALLBACK hardcoded an explicit file
|
||||
// list that included tests/core.test.cjs — a file deleted in #1291. Every
|
||||
// scoped lane (scope=targeted|windows) that hit the fallback wrote the stale
|
||||
// path into .ci-selected-tests.txt and crashed run-tests with
|
||||
// "requested test file(s) not found: core.test.cjs". The fix: existence-filter
|
||||
// the fallback at write time, fall back to the 'unit' suite sentinel when
|
||||
// nothing survives, and guard the FALLBACK constant against disk reality.
|
||||
describe('bug #1329 — ci-prepare-test-scope fallback never emits a deleted file', () => {
|
||||
const { FALLBACK, FALLBACK_SENTINEL, SUITE_SENTINELS, resolveSelection } =
|
||||
require('../scripts/ci-prepare-test-scope.cjs');
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
|
||||
// Generative parity guard (DEFECT.GENERATIVE-FIX): the FALLBACK constant and
|
||||
// the test files on disk are two surfaces that must stay in sync. This fails
|
||||
// the instant a refactor deletes a file still named in FALLBACK — which is
|
||||
// precisely what #1291 did and CI did not catch.
|
||||
test('every FALLBACK entry resolves on disk or is a known suite sentinel', () => {
|
||||
for (const entry of FALLBACK) {
|
||||
const isSentinel = SUITE_SENTINELS.includes(entry);
|
||||
const exists = fs.existsSync(path.join(REPO_ROOT, entry));
|
||||
assert.ok(
|
||||
isSentinel || exists,
|
||||
`FALLBACK entry "${entry}" is neither an existing test file nor a suite sentinel — stale reference will crash scoped CI lanes (see #1329).`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let tmpDir;
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-1329-fallback-');
|
||||
fs.mkdirSync(path.join(tmpDir, 'tests'), { recursive: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('empty detection drops a non-existent fallback entry instead of emitting it', () => {
|
||||
// Create all but the last FALLBACK file under a controlled root, simulating
|
||||
// a since-deleted test (the #1329 mechanism), independent of which files
|
||||
// FALLBACK happens to name today.
|
||||
const present = FALLBACK.slice(0, -1);
|
||||
const absent = FALLBACK[FALLBACK.length - 1];
|
||||
for (const f of present) {
|
||||
fs.writeFileSync(path.join(tmpDir, f), PASS_BODY, 'utf8');
|
||||
}
|
||||
|
||||
const lines = resolveSelection({ scope: 'targeted', targeted: '', windows: '', root: tmpDir });
|
||||
|
||||
assert.ok(!lines.includes(absent), `absent file "${absent}" must be filtered out, got: ${lines.join(', ')}`);
|
||||
for (const f of present) {
|
||||
assert.ok(lines.includes(f), `present file "${f}" must survive, got: ${lines.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('empty detection with no surviving fallback files falls back to the unit sentinel', () => {
|
||||
// tmpDir/tests exists but contains none of the FALLBACK files.
|
||||
const lines = resolveSelection({ scope: 'windows', targeted: '', windows: '', root: tmpDir });
|
||||
assert.deepStrictEqual(lines, [FALLBACK_SENTINEL]);
|
||||
});
|
||||
|
||||
test('detected list passes through verbatim — files and suite sentinels preserved, not existence-filtered', () => {
|
||||
// The detected list is already filtered by affected-tests-lib and may carry
|
||||
// a suite sentinel; ci-prepare-test-scope must not touch it.
|
||||
const lines = resolveSelection({
|
||||
scope: 'targeted',
|
||||
targeted: 'tests/does-not-exist.test.cjs unit',
|
||||
windows: '',
|
||||
root: tmpDir,
|
||||
});
|
||||
assert.deepStrictEqual(lines, ['tests/does-not-exist.test.cjs', 'unit']);
|
||||
});
|
||||
|
||||
test('end-to-end: the real script writes a fallback list whose every entry resolves', () => {
|
||||
// Run the real script (subprocess) with empty detection inside an isolated
|
||||
// root that holds the FALLBACK files, then verify every line it wrote into
|
||||
// .ci-selected-tests.txt resolves — the exact scoped-lane path that crashed
|
||||
// in #1329. Hermetic: the temp root is removed by afterEach's cleanup().
|
||||
for (const f of FALLBACK) {
|
||||
fs.writeFileSync(path.join(tmpDir, f), PASS_BODY, 'utf8');
|
||||
}
|
||||
const prep = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(REPO_ROOT, 'scripts', 'ci-prepare-test-scope.cjs')],
|
||||
{ cwd: tmpDir, env: { ...process.env, TEST_SCOPE: 'targeted', TARGETED_TESTS: '', WINDOWS_TESTS: '' }, encoding: 'utf8' },
|
||||
);
|
||||
assert.strictEqual(prep.status, 0, `prepare step failed: ${prep.stderr}`);
|
||||
|
||||
const selected = fs.readFileSync(path.join(tmpDir, '.ci-selected-tests.txt'), 'utf8');
|
||||
for (const line of selected.split(/\r?\n/).filter(Boolean)) {
|
||||
const isSentinel = SUITE_SENTINELS.includes(line);
|
||||
assert.ok(
|
||||
isSentinel || fs.existsSync(path.join(tmpDir, line)),
|
||||
`selected entry "${line}" does not resolve — would crash run-tests (#1329)`,
|
||||
);
|
||||
}
|
||||
assert.doesNotMatch(selected, /core\.test\.cjs/, 'deleted core.test.cjs must never be selected');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -183,3 +183,263 @@ describe('plan-phase commit_docs support (#2399)', () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3678-executor-commit-docs-respect.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3678-executor-commit-docs-respect (consolidation epic #1969 B6 #1975)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #3678)
|
||||
// Three of the assertions in this file (A1, A2, C) inspect agent / workflow
|
||||
// `.md` bodies. Those files ARE the runtime contract that GSD loads into agent
|
||||
// prompts at run time, so source-text inspection is exactly what the
|
||||
// `source-text-is-the-product` exception covers.
|
||||
//
|
||||
// The remaining assertions (B1, B2, B3) are behavioral — they invoke
|
||||
// `gsd-tools commit` against a temp project and assert on its structured
|
||||
// JSON return envelope plus the git index state. No raw-text matching on
|
||||
// rendered output.
|
||||
|
||||
/**
|
||||
* Regression for #3678 — gsd-executor force-commits .planning/ files when
|
||||
* commit_docs is false.
|
||||
*
|
||||
* Root cause: the executor agent prompt (agents/gsd-executor.md) tells the
|
||||
* agent to call `gsd-sdk query commit "docs(...)" --files .planning/...`
|
||||
* in the per-plan final_commit block, but the prompt says nothing about
|
||||
* what to do when the SDK returns `{committed: false, skipped: true,
|
||||
* reason: 'skipped_commit_docs_false'}`. With no explicit instruction, the
|
||||
* agent improvises raw `git add` / `git commit` against `.planning/` paths
|
||||
* (and uses `-f` to bypass gitignore), which is exactly the leakage the
|
||||
* reporter observed.
|
||||
*
|
||||
* Fix surface:
|
||||
* 1. Agent prompt: explicit handling text in the final_commit section.
|
||||
* 2. SDK envelope: add `skipped: true` field so agents see "skipped" as a
|
||||
* first-class success signal, not "committed is missing, must improvise."
|
||||
* 3. Structural guard: ban `git add -f` / `git add --force` from agent and
|
||||
* workflow bodies entirely (no GSD-managed surface should force-stage
|
||||
* gitignored content).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { createTempGitProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
// Repo root resolution. This test file lives in `<repo>/tests/`. Use a single
|
||||
// parent reference (the established repo-wide pattern, e.g. tests/helpers.cjs
|
||||
// `path.resolve(__dirname, '..', 'gsd-core', ...)`). A `.git`-anchored
|
||||
// walker is not portable because the docker test mirror at `/work` strips the
|
||||
// `.git/` directory before running tests.
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const EXECUTOR_AGENT = path.join(REPO_ROOT, 'agents', 'gsd-executor.md');
|
||||
|
||||
// Frozen reason enum mirrors the SDK source — keep in sync with
|
||||
// `cmdCommit` in gsd-core/bin/lib/commands.cjs.
|
||||
const COMMIT_REASON = Object.freeze({
|
||||
SKIPPED_COMMIT_DOCS_FALSE: 'skipped_commit_docs_false',
|
||||
SKIPPED_GITIGNORED: 'skipped_gitignored',
|
||||
});
|
||||
|
||||
function git(args, cwd) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
}
|
||||
|
||||
describe('bug #3678 — executor must respect commit_docs:false', () => {
|
||||
|
||||
describe('A — agent prompt teaches the agent how to handle commit_docs:false', () => {
|
||||
test('A1: agent body explicitly references the SDK skipped envelope', () => {
|
||||
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
|
||||
// The prompt must contain at least one literal mention of the skipped
|
||||
// reason code OR the `committed: false` envelope so the agent knows
|
||||
// that skipping is an intentional control flow, not a failure to work
|
||||
// around.
|
||||
const mentionsSkipReason = body.includes(COMMIT_REASON.SKIPPED_COMMIT_DOCS_FALSE);
|
||||
const mentionsCommittedFalse = /committed:\s*false/i.test(body);
|
||||
const mentionsSkippedTrue = /skipped:\s*true/i.test(body);
|
||||
assert.ok(
|
||||
mentionsSkipReason || mentionsCommittedFalse || mentionsSkippedTrue,
|
||||
'agents/gsd-executor.md must teach the agent how to recognize the '
|
||||
+ 'skipped envelope from `gsd-sdk query commit` (one of: '
|
||||
+ `'${COMMIT_REASON.SKIPPED_COMMIT_DOCS_FALSE}', 'committed: false', `
|
||||
+ "'skipped: true').",
|
||||
);
|
||||
});
|
||||
|
||||
test('A2: agent body explicitly forbids raw git fallback when SDK skips', () => {
|
||||
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
|
||||
// Look for an explicit instruction tying the SDK-skipped signal to the
|
||||
// forbidden-fallback rule. Accept any of three shapes the doc writer
|
||||
// might use: "do not", "must not", or "never" + a verb that names the
|
||||
// forbidden action.
|
||||
const forbidsFallbackText = /(do not|must not|never)\s+(fall back|fallback|use .*git add|run .*git commit|force[- ]?add)/i;
|
||||
assert.ok(
|
||||
forbidsFallbackText.test(body),
|
||||
'agents/gsd-executor.md must contain an explicit "do not fall back to '
|
||||
+ 'raw git" instruction tied to the commit_docs:false / skipped envelope. '
|
||||
+ 'Without it, the agent improvises raw `git add` / `git add -f` to '
|
||||
+ 'fulfill its "complete plan" goal.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('B — SDK behavior: commit_docs:false leaves repo state untouched', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempGitProject();
|
||||
// .planning/ already exists from createTempGitProject's setup.
|
||||
// Set commit_docs to false on the config.
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
let config = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
config.commit_docs = false;
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
// Make a token edit to .planning/STATE.md so there IS something the SDK
|
||||
// could in principle stage (or that an improvising agent could leak).
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
if (!fs.existsSync(statePath)) {
|
||||
fs.writeFileSync(statePath, '---\nproject: test\n---\n# State\n');
|
||||
}
|
||||
fs.appendFileSync(statePath, '\n<!-- token edit for #3678 repro -->\n');
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('B1: commit returns committed:false with skipped envelope', () => {
|
||||
const result = runGsdTools(
|
||||
'commit "docs(test): noop" --files .planning/STATE.md',
|
||||
tmpDir,
|
||||
);
|
||||
assert.ok(result.success, `gsd-tools commit should exit 0 even when skipped: ${result.error || ''}`);
|
||||
const envelope = JSON.parse(result.output);
|
||||
assert.strictEqual(envelope.committed, false, 'committed must be false when commit_docs is false');
|
||||
assert.strictEqual(
|
||||
envelope.skipped,
|
||||
true,
|
||||
'envelope must carry skipped:true so agents see skip as a first-class signal (envelope contract for #3678)',
|
||||
);
|
||||
assert.strictEqual(
|
||||
envelope.reason,
|
||||
COMMIT_REASON.SKIPPED_COMMIT_DOCS_FALSE,
|
||||
'reason must be the canonical skipped_commit_docs_false code (frozen enum)',
|
||||
);
|
||||
});
|
||||
|
||||
test('B2: commit_docs:false leaves the git index empty (no .planning/ staged)', () => {
|
||||
runGsdTools(
|
||||
'commit "docs(test): noop" --files .planning/STATE.md',
|
||||
tmpDir,
|
||||
);
|
||||
const stagedAll = git(['diff', '--cached', '--name-only'], tmpDir);
|
||||
const stagedPlanning = stagedAll
|
||||
.split(/\r?\n/)
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.startsWith('.planning/'));
|
||||
assert.deepStrictEqual(
|
||||
stagedPlanning,
|
||||
[],
|
||||
'no .planning/ files should be staged when commit_docs is false',
|
||||
);
|
||||
});
|
||||
|
||||
test('B3: commit_docs:false produces no new commits', () => {
|
||||
const headBefore = git(['rev-parse', 'HEAD'], tmpDir).trim();
|
||||
runGsdTools(
|
||||
'commit "docs(test): noop" --files .planning/STATE.md',
|
||||
tmpDir,
|
||||
);
|
||||
const headAfter = git(['rev-parse', 'HEAD'], tmpDir).trim();
|
||||
assert.strictEqual(
|
||||
headAfter,
|
||||
headBefore,
|
||||
'HEAD must not advance when commit_docs is false',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('checklist carve-out preserved for intentional skip', () => {
|
||||
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
|
||||
const checklistLine = body
|
||||
.split(/\r?\n/)
|
||||
.find(line => /Final metadata commit made/.test(line));
|
||||
assert.ok(
|
||||
checklistLine,
|
||||
'agents/gsd-executor.md must contain a "Final metadata commit made" checklist line',
|
||||
);
|
||||
assert.ok(
|
||||
checklistLine.includes('Final metadata commit'),
|
||||
'checklist line must reference "Final metadata commit"',
|
||||
);
|
||||
assert.ok(
|
||||
checklistLine.includes('skipped_commit_docs_false'),
|
||||
'checklist line must carve out the intentional-skip case by referencing '
|
||||
+ '"skipped_commit_docs_false" — prevents executor from treating an '
|
||||
+ 'unchecked mandatory box as a raw-git TODO (regression guard for #3679)',
|
||||
);
|
||||
});
|
||||
|
||||
describe('C — structural ban on raw force-add in GSD-managed bodies', () => {
|
||||
function scanForForceAdd(rootDir) {
|
||||
const offenders = [];
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { walk(full); continue; }
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const body = fs.readFileSync(full, 'utf-8');
|
||||
const lines = body.split(/\r?\n/);
|
||||
const danger = lines.filter((line) => {
|
||||
if (!/git\s+add\s+(-f|--force)\b/.test(line)) return false;
|
||||
// Allow prohibition / warning sentences and code-fence prose that
|
||||
// frames `git add -f` AS the bug (so an audit comment doesn't
|
||||
// create a false positive).
|
||||
if (/(do not|don'?t|must not|never|forbidden|prohibited)/i.test(line)) return false;
|
||||
if (/(bug|wrong|incorrect|antipattern|anti-pattern|forces?\s+gitignored|leak)/i.test(line)) return false;
|
||||
return true;
|
||||
});
|
||||
if (danger.length > 0) {
|
||||
offenders.push({
|
||||
file: full.replace(REPO_ROOT + '/', ''),
|
||||
lines: danger.map(l => l.trim().slice(0, 120)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(rootDir);
|
||||
return offenders;
|
||||
}
|
||||
|
||||
test('C1: no agent body contains `git add -f` / `git add --force`', () => {
|
||||
const offenders = scanForForceAdd(path.join(REPO_ROOT, 'agents'));
|
||||
assert.deepStrictEqual(
|
||||
offenders,
|
||||
[],
|
||||
'no agent body may use `git add -f` / `git add --force` outside a '
|
||||
+ 'prohibition sentence — agents must never force-stage gitignored '
|
||||
+ 'content (regression guard for #3678).',
|
||||
);
|
||||
});
|
||||
|
||||
test('C2: no workflow body contains `git add -f` / `git add --force`', () => {
|
||||
const offenders = scanForForceAdd(path.join(REPO_ROOT, 'gsd-core', 'workflows'));
|
||||
assert.deepStrictEqual(
|
||||
offenders,
|
||||
[],
|
||||
'no workflow body may use `git add -f` / `git add --force` outside a '
|
||||
+ 'prohibition sentence (regression guard for #3678).',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -376,3 +376,463 @@ describe('bug-2943: config-get returns schema default for context_window', () =>
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/feat-3593-cli-negative-config.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:feat-3593-cli-negative-config (consolidation epic #1969 B6 #1975)", () => {
|
||||
/**
|
||||
* CLI negative matrix for the `config` command family (#3593).
|
||||
*
|
||||
* Exercises the 12 adversarial input categories enumerated in
|
||||
* CONTRIBUTING.md §"QA Matrix Requirements / CLI and command routing"
|
||||
* against `config-get` and `config-set`. The harness in
|
||||
* `tests/helpers/cli-negative.cjs` shapes spawnSync output into a typed
|
||||
* IR so every assertion runs on `result.reason`, `result.status`, and
|
||||
* `result.hasStackTrace` — never on stderr/stdout prose.
|
||||
*
|
||||
* Each test gets its own temp project (no shared state) so concurrent
|
||||
* runs can't observe each other's filesystem mutations. Hostile values
|
||||
* (shell metacharacters, null bytes, unicode, very long strings) reach
|
||||
* the CLI as single argv elements via spawnSync — never composed into
|
||||
* a shell string — so the test framework itself can't be the source of
|
||||
* a false positive on shell-injection assertions.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { runCli } = require('./helpers/cli-negative.cjs');
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
/**
|
||||
* Universal invariants every adversarial case must satisfy when the
|
||||
* CLI is invoked with --json-errors. Bundling these in a helper keeps
|
||||
* each test focused on the case-specific reason assertion.
|
||||
*/
|
||||
function assertSafeFailure(result, msg = '') {
|
||||
assert.notEqual(result.status, 0, `${msg} :: expected non-zero exit`);
|
||||
assert.equal(result.signal, null, `${msg} :: must exit cleanly, not via signal`);
|
||||
assert.equal(result.hasStackTrace, false, `${msg} :: stderr must not leak a V8 stack frame`);
|
||||
assert.equal(result.ok, false, `${msg} :: JSON payload ok must be false`);
|
||||
assert.equal(typeof result.reason, 'string', `${msg} :: reason must be a string`);
|
||||
assert.notEqual(result.reason, '', `${msg} :: reason must not be empty`);
|
||||
// The harness's JSON-shape detection runs on the trimmed stderr; if we
|
||||
// got here with reason set, the payload was a valid object — that already
|
||||
// implies no rogue prose was mixed in. Re-asserting the trimmed form would
|
||||
// be redundant.
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the file inventory of a directory so a later assertion can
|
||||
* prove the failing CLI invocation did NOT create or modify any file.
|
||||
*/
|
||||
function snapshotInventory(dir) {
|
||||
const entries = [];
|
||||
function walk(rel) {
|
||||
const abs = path.join(dir, rel);
|
||||
let stat;
|
||||
try { stat = fs.lstatSync(abs); } catch { return; }
|
||||
if (stat.isDirectory()) {
|
||||
for (const name of fs.readdirSync(abs).sort()) walk(path.join(rel, name));
|
||||
} else {
|
||||
entries.push(`${rel}\t${stat.size}\t${stat.mtimeMs}`);
|
||||
}
|
||||
}
|
||||
walk('.');
|
||||
return entries.join('\n');
|
||||
}
|
||||
|
||||
// ─── 1. Missing required arg ────────────────────────────────────────────────
|
||||
|
||||
test('config-get with no key fails with a typed reason and no stack trace', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-1-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const before = snapshotInventory(projectDir);
|
||||
const result = runCli(['config-get'], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'config-get missing key');
|
||||
assert.equal(snapshotInventory(projectDir), before, 'failing read must not mutate FS');
|
||||
});
|
||||
|
||||
test('config-set with no key fails with a typed reason', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-2-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['config-set'], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'config-set missing key');
|
||||
});
|
||||
|
||||
test('config-set with key but no value fails with a typed reason', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-3-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['config-set', 'model_profile'], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'config-set missing value');
|
||||
});
|
||||
|
||||
// ─── 2/3. Empty / whitespace arg ────────────────────────────────────────────
|
||||
|
||||
test('config-get with empty-string key fails safely', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-4-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const before = snapshotInventory(projectDir);
|
||||
const result = runCli(['config-get', ''], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'config-get empty key');
|
||||
assert.equal(snapshotInventory(projectDir), before, 'failing read must not mutate FS');
|
||||
});
|
||||
|
||||
test('config-get with whitespace-only key fails safely', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-5-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['config-get', ' \t '], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'config-get whitespace key');
|
||||
});
|
||||
|
||||
test('config-set with empty key string fails safely', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-6-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['config-set', '', 'value'], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'config-set empty key');
|
||||
});
|
||||
|
||||
// ─── 4. Duplicate flags ─────────────────────────────────────────────────────
|
||||
|
||||
test('--cwd specified twice does not silently use the wrong one', (t) => {
|
||||
// Make two real but distinct dirs so the test can't accidentally pass
|
||||
// because one of the paths is invalid.
|
||||
const a = createTempProject('cli-neg-config-7a-');
|
||||
const b = createTempProject('cli-neg-config-7b-');
|
||||
t.after(() => { cleanup(a); cleanup(b); });
|
||||
// No --json-errors here on purpose: --cwd is parsed before json mode is
|
||||
// applied, so we exercise both code paths by running once each.
|
||||
const result = runCli(['--cwd', a, '--cwd', b, 'config-get', 'model_profile'], { cwd: process.cwd() });
|
||||
// Either: (a) the CLI rejects duplicate --cwd with a typed reason; OR
|
||||
// (b) it commits to one of the values deterministically. The safety
|
||||
// bar is "no stack trace, no half-state mutation in EITHER dir".
|
||||
assert.equal(result.hasStackTrace, false, 'duplicate --cwd must not crash with a stack trace');
|
||||
// Neither tmp dir should have a written config since model_profile is
|
||||
// a read, not a write, and it didn't exist beforehand. Prove the read
|
||||
// didn't accidentally trigger a write side effect.
|
||||
assert.equal(fs.existsSync(path.join(a, '.planning', 'config.json')), false);
|
||||
assert.equal(fs.existsSync(path.join(b, '.planning', 'config.json')), false);
|
||||
});
|
||||
|
||||
// ─── 5. Conflicting flags ───────────────────────────────────────────────────
|
||||
|
||||
test('--json-errors with --no-such-flag does not crash with a stack trace', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-8-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['--no-such-flag', 'config-get', 'model_profile'], { cwd: projectDir });
|
||||
assert.equal(result.hasStackTrace, false, 'unknown global flag must not crash with a stack trace');
|
||||
assert.notEqual(result.status, 0, 'unknown global flag must fail');
|
||||
});
|
||||
|
||||
// ─── 6. Malformed assignment / unknown subcommand ──────────────────────────
|
||||
|
||||
test('config-FAKE subcommand fails with a typed reason', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-9-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['config-FAKE'], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'unknown config-* command');
|
||||
});
|
||||
|
||||
// ─── 7. Unknown subcommands at each command depth ───────────────────────────
|
||||
|
||||
test('config family — bare top-level "config" without a subcommand fails safely', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-10-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['config'], { cwd: projectDir });
|
||||
// Either "missing subcommand" usage or genuine no-op behavior — what we
|
||||
// pin is "no stack trace, no FS mutation".
|
||||
assert.equal(result.hasStackTrace, false);
|
||||
});
|
||||
|
||||
// ─── 8. Values that look like flags ─────────────────────────────────────────
|
||||
|
||||
test('config-set value that starts with -- is treated as a value, not a flag', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-11-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
// First create a config.json so the set has a target file.
|
||||
runCli(['config-ensure-section'], { cwd: projectDir });
|
||||
const result = runCli(['config-set', 'project_code', '--weird'], { cwd: projectDir });
|
||||
// Acceptable outcomes:
|
||||
// (a) CLI accepts --weird as the value (and persists it),
|
||||
// (b) CLI rejects it as a usage error.
|
||||
// Either way: no stack trace, no half-written corrupt config.
|
||||
assert.equal(result.hasStackTrace, false, 'value-looking-like-a-flag must not crash');
|
||||
const configPath = path.join(projectDir, '.planning', 'config.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
// If a config exists, it must still be valid JSON — no half-write corruption.
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
assert.doesNotThrow(() => JSON.parse(raw), 'config.json must remain parseable after a failed set');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 9. Invalid JSON / corrupt config file ──────────────────────────────────
|
||||
|
||||
test('config-get against a corrupt config.json fails with a parse-failed reason', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-12-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const configPath = path.join(projectDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, '{ this is not json'); // deliberate corruption
|
||||
const originalCorrupt = fs.readFileSync(configPath, 'utf-8');
|
||||
const result = runCli(['config-get', 'model_profile'], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'corrupt config.json');
|
||||
// The corrupt file must remain untouched — the CLI must not "helpfully"
|
||||
// overwrite an unparseable config in the failure path.
|
||||
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalCorrupt, 'corrupt file must be preserved as-is');
|
||||
// Specific reason: CONFIG_PARSE_FAILED (or equivalent) — pin this so a
|
||||
// regression where parse failure leaks as "unknown" is caught.
|
||||
assert.match(
|
||||
result.reason,
|
||||
/^(config_parse_failed|config_no_file|config_invalid_key|usage)$/,
|
||||
`parse-failure reason must be from the typed ERROR_REASON enum (got: ${result.reason})`,
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 10. Very long arg ──────────────────────────────────────────────────────
|
||||
|
||||
test('config-get with a very long key (50KB) fails safely without hanging', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-13-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const longKey = 'x'.repeat(50000);
|
||||
const result = runCli(['config-get', longKey], { cwd: projectDir, timeoutMs: 8000 });
|
||||
assert.equal(result.signal, null, 'long input must not trigger the harness timeout');
|
||||
assert.equal(result.hasStackTrace, false, 'long input must not crash');
|
||||
assert.notEqual(result.status, 0, 'unknown 50KB key must fail');
|
||||
});
|
||||
|
||||
// ─── 11. Unicode / non-ASCII ────────────────────────────────────────────────
|
||||
|
||||
test('config-get with a Unicode key fails safely', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-14-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['config-get', 'workflow.🔥_mode'], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'unicode key');
|
||||
});
|
||||
|
||||
test('config-set with an emoji value persists or rejects without corrupting JSON', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-15-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
runCli(['config-ensure-section'], { cwd: projectDir });
|
||||
const result = runCli(['config-set', 'project_code', '🔥👾'], { cwd: projectDir });
|
||||
assert.equal(result.hasStackTrace, false);
|
||||
// If it accepted, the JSON must round-trip cleanly.
|
||||
const configPath = path.join(projectDir, '.planning', 'config.json');
|
||||
if (result.status === 0 && fs.existsSync(configPath)) {
|
||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
assert.equal(typeof parsed, 'object', 'config.json must be a valid object');
|
||||
if (parsed.project_code != null) {
|
||||
assert.equal(typeof parsed.project_code, 'string', 'project_code must remain a string');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 12. Shell metacharacters (the security-critical case) ──────────────────
|
||||
|
||||
const SHELL_PAYLOADS = [
|
||||
// Each one would, if shell-interpreted, create a sentinel file
|
||||
// adjacent to the project tree. Argv-based invocation must treat them
|
||||
// as opaque text.
|
||||
'$(touch ${PROJECT}/INJ-dollar-paren)',
|
||||
'`touch ${PROJECT}/INJ-backtick`',
|
||||
'; touch ${PROJECT}/INJ-semicolon;',
|
||||
'&& touch ${PROJECT}/INJ-and',
|
||||
'|| touch ${PROJECT}/INJ-or',
|
||||
'| tee ${PROJECT}/INJ-pipe',
|
||||
'> ${PROJECT}/INJ-redirect',
|
||||
// Quote-balanced payloads — these have historically broken naive
|
||||
// shell-string composition even when the rest of the code uses argv.
|
||||
'"; touch ${PROJECT}/INJ-quote;"',
|
||||
'\'; touch ${PROJECT}/INJ-quote;\'',
|
||||
];
|
||||
|
||||
for (const payload of SHELL_PAYLOADS) {
|
||||
test(`config-get with shell-metachar key (${payload.slice(0, 25)}…) does NOT execute the payload`, (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-shell-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const resolvedPayload = payload.replace(/\$\{PROJECT\}/g, projectDir);
|
||||
const result = runCli(['config-get', resolvedPayload], { cwd: projectDir });
|
||||
// No shell interpretation: none of the INJ-* sentinel files must
|
||||
// exist after the run. Walk the project dir and assert.
|
||||
const entries = fs.readdirSync(projectDir);
|
||||
const sentinels = entries.filter((n) => n.startsWith('INJ-'));
|
||||
assert.deepEqual(sentinels, [], `shell payload must NOT create sentinel files (found: ${sentinels.join(', ')})`);
|
||||
// The CLI may exit 0 (legitimate — the metacharacter-laden key
|
||||
// simply doesn't exist in config) or non-zero (typed reason). Both
|
||||
// are acceptable as long as no payload was executed.
|
||||
assert.equal(result.hasStackTrace, false);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Cross-cutting: --cwd points at a non-existent path ────────────────────
|
||||
|
||||
test('--cwd pointing at a non-existent path fails with a typed usage reason', (_t) => {
|
||||
const nonExistent = path.join(require('os').tmpdir(), 'cli-neg-no-such-dir-' + Date.now() + '-' + Math.random());
|
||||
assert.equal(fs.existsSync(nonExistent), false, 'pre-check: path must not exist');
|
||||
const result = runCli(['--cwd', nonExistent, 'config-get', 'model_profile'], { cwd: process.cwd() });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(result.hasStackTrace, false);
|
||||
// gsd-tools validates --cwd up-front and emits ERROR_REASON.USAGE.
|
||||
assert.equal(result.reason, 'usage', `expected reason=usage for invalid --cwd, got: ${result.reason}`);
|
||||
});
|
||||
|
||||
test('--cwd with an empty value fails with a typed usage reason', () => {
|
||||
const result = runCli(['--cwd', '', 'config-get', 'model_profile'], { cwd: process.cwd() });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(result.hasStackTrace, false);
|
||||
assert.equal(result.reason, 'usage');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/feat-3593-cli-negative-harness.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:feat-3593-cli-negative-harness (consolidation epic #1969 B6 #1975)", () => {
|
||||
/**
|
||||
* Meta-test for the CLI negative-matrix harness (#3593).
|
||||
*
|
||||
* The harness in `tests/helpers/cli-negative.cjs` shapes spawnSync
|
||||
* results into a typed IR that adversarial-input tests consume. This
|
||||
* file pins the IR contract by exercising the harness against
|
||||
* deliberate scenarios — not as a placeholder for the real matrix tests
|
||||
* (those live in sibling feat-3593-* files) but to surface harness
|
||||
* regressions before they cascade through every matrix test.
|
||||
*
|
||||
* Tests deliberately avoid prose-matching: they assert on numeric exit
|
||||
* codes, boolean flags, and reason codes pulled from the parsed JSON
|
||||
* payload.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { runCli, parseSpawnResult } = require('./helpers/cli-negative.cjs');
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
test('runCli rejects non-array argv with TypeError', () => {
|
||||
assert.throws(
|
||||
() => runCli('config-get', { cwd: '/tmp' }),
|
||||
(err) => err instanceof TypeError && /argv/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
test('runCli rejects missing cwd with TypeError', () => {
|
||||
assert.throws(
|
||||
() => runCli(['config-get'], {}),
|
||||
(err) => err instanceof TypeError && /cwd/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
test('runCli surfaces typed reason from a known failure path', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-harness-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
// Unknown command — gsd-tools emits ERROR_REASON.SDK_UNKNOWN_COMMAND or
|
||||
// USAGE depending on dispatch depth. Either is a real reason string;
|
||||
// the contract we pin here is just "the IR carries a reason from the
|
||||
// ERROR_REASON enum, never null".
|
||||
const result = runCli(['this-command-does-not-exist'], { cwd: projectDir });
|
||||
assert.notEqual(result.status, 0, 'unknown command must exit non-zero');
|
||||
assert.equal(result.ok, false, 'JSON payload must report ok=false');
|
||||
assert.equal(typeof result.reason, 'string', 'reason must be a string from ERROR_REASON');
|
||||
assert.notEqual(result.reason, null);
|
||||
assert.notEqual(result.reason, '');
|
||||
assert.equal(result.hasStackTrace, false, 'a typed failure must NOT print a V8 stack trace');
|
||||
});
|
||||
|
||||
test('parseSpawnResult detects stack-trace leakage in stderr', () => {
|
||||
const fakeSpawn = {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: 'Error: boom\n at Object.<anonymous> (/some/file.js:10:5)\n at Module._compile\n',
|
||||
error: null,
|
||||
};
|
||||
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: false });
|
||||
assert.equal(ir.hasStackTrace, true, 'stack frames in stderr must be flagged');
|
||||
assert.equal(ir.reason, null, 'non-JSON stderr leaves reason null');
|
||||
});
|
||||
|
||||
test('parseSpawnResult does NOT match the literal word "at" in prose', () => {
|
||||
// Guard against a regex regression that would catch sentences like
|
||||
// "command failed at startup" as stack frames.
|
||||
const fakeSpawn = {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: 'Error: command failed at startup\nbecause no project was found.\n',
|
||||
error: null,
|
||||
};
|
||||
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: false });
|
||||
assert.equal(ir.hasStackTrace, false, 'prose containing the word "at" is not a stack frame');
|
||||
});
|
||||
|
||||
test('parseSpawnResult extracts ok/reason/message from a json-errors payload', () => {
|
||||
const payload = { ok: false, reason: 'config_invalid_key', message: 'no such key: foo' };
|
||||
const fakeSpawn = {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: JSON.stringify(payload) + '\n',
|
||||
error: null,
|
||||
};
|
||||
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: true });
|
||||
assert.equal(ir.ok, false);
|
||||
assert.equal(ir.reason, 'config_invalid_key');
|
||||
assert.equal(ir.message, 'no such key: foo');
|
||||
assert.equal(ir.hasStackTrace, false);
|
||||
});
|
||||
|
||||
test('parseSpawnResult ignores malformed JSON in stderr without throwing', () => {
|
||||
const fakeSpawn = {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '{ ok: false, reason }', // missing quotes — invalid JSON
|
||||
error: null,
|
||||
};
|
||||
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: true });
|
||||
assert.equal(ir.ok, null, 'malformed JSON must NOT promote partial data into ok');
|
||||
assert.equal(ir.reason, null);
|
||||
assert.equal(ir.message, null);
|
||||
});
|
||||
|
||||
test('parseSpawnResult ignores JSON arrays and primitives, only accepts objects', () => {
|
||||
const cases = [
|
||||
'["ok", false]', // array
|
||||
'"just a string"', // primitive
|
||||
'null', // null literal
|
||||
'42', // number
|
||||
];
|
||||
for (const stderr of cases) {
|
||||
const ir = parseSpawnResult(
|
||||
{ status: 1, signal: null, stdout: '', stderr, error: null },
|
||||
{ jsonErrorsRequested: true },
|
||||
);
|
||||
assert.equal(ir.ok, null, `non-object JSON (${stderr}) must not set ok`);
|
||||
assert.equal(ir.reason, null);
|
||||
}
|
||||
});
|
||||
|
||||
test('runCli treats jsonErrors=false as an explicit human-formatter path', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-harness-text-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['this-command-does-not-exist'], { cwd: projectDir, jsonErrors: false });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(result.jsonErrorsRequested, false);
|
||||
// Reason fields stay null in human-mode because stderr is prose, not JSON.
|
||||
assert.equal(result.ok, null);
|
||||
assert.equal(result.reason, null);
|
||||
// But the prose still must not include a V8 stack trace.
|
||||
assert.equal(result.hasStackTrace, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -671,3 +671,398 @@ describe('bug #2638 — sub_repos canonical location', () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3523-cjs-loadconfig-branching-strategy-warning (consolidation epic #1969 B6 #1975)", () => {
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: validates runtime CLI stdout/stderr warning behavior, not source grep (see #3523)
|
||||
|
||||
/**
|
||||
* Regression tests for #3523 — CJS loadConfig must not emit a false
|
||||
* "unknown config key(s)" warning for `branching_strategy` when that key
|
||||
* is written at the top level of .planning/config.json.
|
||||
*
|
||||
* Root cause: KNOWN_TOP_LEVEL in core.cjs was built from VALID_CONFIG_KEYS
|
||||
* via k.split('.')[0], which turns 'git.branching_strategy' → 'git', not
|
||||
* 'branching_strategy'. So a config with the legacy top-level shape tripped
|
||||
* the unknown-key warning even though core.cjs:485 actively reads the value.
|
||||
*
|
||||
* Fix (option 3 — self-healing): mirror the multiRepo → planning.sub_repos
|
||||
* precedent: graft branching_strategy into fileData.git.branching_strategy
|
||||
* and delete the top-level key, then persist. The KNOWN_TOP_LEVEL list also
|
||||
* gains 'branching_strategy' as a deprecated-still-accepted key so the warning
|
||||
* never fires even on the first read before the write-back occurs.
|
||||
*
|
||||
* Double-emission is also reduced: the warning site is guarded by a
|
||||
* module-level Set so repeated loadConfig calls during one CLI invocation
|
||||
* don't echo the same line twice.
|
||||
*
|
||||
* CJS↔SDK contract: the SDK mergeDefaults() already handles the legacy
|
||||
* top-level key (PR #3116). This file adds a fixture-level parity check
|
||||
* that proves both paths produce the same branching_strategy value.
|
||||
*
|
||||
* Test strategy: we use `resolve-model` as the minimal CJS entry point that
|
||||
* calls loadConfig internally, then assert on stderr emptiness (typed-IR
|
||||
* "no warning" pattern from #2687).
|
||||
*/
|
||||
|
||||
const { describe, test, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Run gsd-tools and return { stdout, stderr, status }.
|
||||
* Always captures stderr even when exit code is 0.
|
||||
*/
|
||||
function runWithStderr(args, cwd, env = {}) {
|
||||
const result = spawnSync(process.execPath, [TOOLS_PATH, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, ...TEST_ENV_BASE, ...env },
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
status: result.status,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Test 1: no warning for legacy top-level branching_strategy ──────────────
|
||||
|
||||
describe('bug-3523 — no warning for legacy top-level branching_strategy', () => {
|
||||
let tmpDir;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) cleanup(tmpDir);
|
||||
tmpDir = null;
|
||||
});
|
||||
|
||||
test('loadConfig emits no stderr when config.json has top-level branching_strategy', () => {
|
||||
tmpDir = createTempProject('gsd-3523-warn-');
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
branching_strategy: 'phase',
|
||||
git: { base_branch: 'main' },
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// resolve-model calls loadConfig internally, triggering KNOWN_TOP_LEVEL check.
|
||||
const result = runWithStderr(['resolve-model', 'planner'], tmpDir);
|
||||
|
||||
assert.equal(
|
||||
result.stderr.trim(),
|
||||
'',
|
||||
`loadConfig must not warn about top-level branching_strategy (#3523) — got: ${result.stderr}`
|
||||
);
|
||||
});
|
||||
|
||||
test('branching_strategy value is still surfaced after loadConfig on legacy shape', () => {
|
||||
tmpDir = createTempProject('gsd-3523-value-');
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
branching_strategy: 'milestone',
|
||||
git: { base_branch: 'main' },
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Trigger loadConfig (which runs the migration and writes git.branching_strategy
|
||||
// back to disk), then read it with config-get to verify the value is preserved.
|
||||
const triggerResult = runWithStderr(['resolve-model', 'planner'], tmpDir);
|
||||
assert.equal(
|
||||
triggerResult.stderr.trim(),
|
||||
'',
|
||||
`No warning should fire on legacy shape (#3523) — got: ${triggerResult.stderr}`
|
||||
);
|
||||
|
||||
// After migration write-back, config-get should find git.branching_strategy.
|
||||
const result = runWithStderr(['config-get', 'git.branching_strategy'], tmpDir);
|
||||
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`config-get command must succeed — exit status ${result.status}, stderr: ${result.stderr}`
|
||||
);
|
||||
assert.equal(
|
||||
result.stderr.trim(),
|
||||
'',
|
||||
`No error should fire when reading migrated branching_strategy (#3523) — got: ${result.stderr}`
|
||||
);
|
||||
assert.ok(
|
||||
result.stdout.includes('milestone'),
|
||||
`Expected git.branching_strategy to be 'milestone' but got: ${result.stdout}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 2: no duplicated warning (double-emission) ─────────────────────────
|
||||
|
||||
describe('bug-3523 — double-emission reduced to single-emission', () => {
|
||||
let tmpDir;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) cleanup(tmpDir);
|
||||
tmpDir = null;
|
||||
});
|
||||
|
||||
test('unknown-key warning appears at most once per process invocation', () => {
|
||||
// Use a key that IS genuinely unknown (not branching_strategy, which is now
|
||||
// fixed) to verify the deduplication guard works for other keys too.
|
||||
// We verify that the count of warning lines for a single unknown key is
|
||||
// exactly once — not zero and not two — even if loadConfig is invoked twice internally.
|
||||
tmpDir = createTempProject('gsd-3523-dedup-');
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
// intentionally_unknown_key_for_dedup_test: a key that can never be valid
|
||||
__gsd3523_dedup_sentinel__: true,
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const result = runWithStderr(['resolve-model', 'planner'], tmpDir);
|
||||
|
||||
// Count how many times the sentinel key appears in warnings
|
||||
const warningLines = result.stderr
|
||||
.split('\n')
|
||||
.filter(l => l.includes('__gsd3523_dedup_sentinel__'));
|
||||
|
||||
assert.equal(
|
||||
warningLines.length,
|
||||
1,
|
||||
`Unknown-key warning must appear exactly once per process invocation — ` +
|
||||
`appeared ${warningLines.length} times. stderr:\n${result.stderr}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 3: on-disk migration (option 3 write-back) ─────────────────────────
|
||||
|
||||
describe('bug-3523 — option 3 on-disk migration of branching_strategy', () => {
|
||||
let tmpDir;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) cleanup(tmpDir);
|
||||
tmpDir = null;
|
||||
});
|
||||
|
||||
test('after loadConfig, on-disk config.json has branching_strategy under git.*', () => {
|
||||
tmpDir = createTempProject('gsd-3523-writeback-');
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
branching_strategy: 'phase',
|
||||
git: { base_branch: 'main' },
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Trigger loadConfig by running a command.
|
||||
runWithStderr(['resolve-model', 'planner'], tmpDir);
|
||||
|
||||
// On-disk file should now have git.branching_strategy and no top-level branching_strategy.
|
||||
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
assert.equal(
|
||||
onDisk.git?.branching_strategy,
|
||||
'phase',
|
||||
'Expected on-disk config.json to have git.branching_strategy = "phase" after migration'
|
||||
);
|
||||
assert.equal(
|
||||
onDisk.branching_strategy,
|
||||
undefined,
|
||||
'Expected on-disk config.json to have no top-level branching_strategy after migration'
|
||||
);
|
||||
});
|
||||
|
||||
test('migration does not clobber existing git.branching_strategy', () => {
|
||||
// If git.branching_strategy is already set, the top-level value should
|
||||
// not overwrite it (nested wins, matching SDK mergeDefaults precedence).
|
||||
tmpDir = createTempProject('gsd-3523-no-clobber-');
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
branching_strategy: 'phase', // legacy top-level
|
||||
git: {
|
||||
base_branch: 'main',
|
||||
branching_strategy: 'milestone', // canonical nested — must win
|
||||
},
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Trigger loadConfig.
|
||||
runWithStderr(['resolve-model', 'planner'], tmpDir);
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
assert.equal(
|
||||
onDisk.git?.branching_strategy,
|
||||
'milestone',
|
||||
'canonical git.branching_strategy must not be overwritten by legacy top-level key'
|
||||
);
|
||||
// top-level key should be removed since it was redundant
|
||||
assert.equal(
|
||||
onDisk.branching_strategy,
|
||||
undefined,
|
||||
'top-level branching_strategy should be removed even when git.branching_strategy already set'
|
||||
);
|
||||
});
|
||||
|
||||
test('workstream load also self-heals legacy root branching_strategy', () => {
|
||||
tmpDir = createTempProject('gsd-3523-workstream-root-');
|
||||
const rootConfigPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
const workstreamDir = path.join(tmpDir, '.planning', 'workstreams', 'alpha');
|
||||
fs.mkdirSync(workstreamDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
rootConfigPath,
|
||||
JSON.stringify({
|
||||
branching_strategy: 'phase',
|
||||
git: { base_branch: 'main' },
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(workstreamDir, 'config.json'),
|
||||
JSON.stringify({ workflow: { tdd: true } }, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const triggerResult = runWithStderr(['resolve-model', 'planner'], tmpDir, {
|
||||
GSD_WORKSTREAM: 'alpha',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
triggerResult.status,
|
||||
0,
|
||||
`workstream load command must succeed — exit status ${triggerResult.status}, stderr: ${triggerResult.stderr}`
|
||||
);
|
||||
assert.equal(
|
||||
triggerResult.stderr.trim(),
|
||||
'',
|
||||
`No warning should fire while migrating root config for a workstream — got: ${triggerResult.stderr}`
|
||||
);
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(rootConfigPath, 'utf-8'));
|
||||
assert.equal(
|
||||
onDisk.git?.branching_strategy,
|
||||
'phase',
|
||||
'Expected root config.json to persist git.branching_strategy after workstream load'
|
||||
);
|
||||
assert.equal(
|
||||
onDisk.branching_strategy,
|
||||
undefined,
|
||||
'Expected root config.json to remove top-level branching_strategy after workstream load'
|
||||
);
|
||||
|
||||
const rootResult = runWithStderr(['config-get', 'git.branching_strategy'], tmpDir);
|
||||
assert.equal(
|
||||
rootResult.status,
|
||||
0,
|
||||
`root config-get command must succeed after workstream migration — exit status ${rootResult.status}, stderr: ${rootResult.stderr}`
|
||||
);
|
||||
assert.ok(
|
||||
rootResult.stdout.includes('phase'),
|
||||
`Expected migrated root git.branching_strategy to be 'phase' but got: ${rootResult.stdout}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 4: CJS↔SDK contract parity ────────────────────────────────────────
|
||||
|
||||
describe('bug-3523 — CJS↔SDK contract: both agree on legacy branching_strategy fixture', () => {
|
||||
/**
|
||||
* This is a light-touch contract test: we invoke the CJS path via CLI and
|
||||
* compare the branching_strategy value it returns against what the SDK's
|
||||
* mergeDefaults would compute for the same fixture.
|
||||
*
|
||||
* We can't import SDK TypeScript here, so we assert on the CJS output and
|
||||
* use a snapshot of expected SDK behavior derived from the mergeDefaults
|
||||
* source (sdk/src/config.ts:192-218):
|
||||
* mergeDefaults({ branching_strategy: 'phase', git: { base_branch: 'main' } })
|
||||
* → git.branching_strategy = 'phase'
|
||||
*/
|
||||
let tmpDir;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) cleanup(tmpDir);
|
||||
tmpDir = null;
|
||||
});
|
||||
|
||||
test('CJS loadConfig surfaces branching_strategy matching SDK mergeDefaults behavior', () => {
|
||||
tmpDir = createTempProject('gsd-3523-parity-');
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
// The fixture that the SDK's mergeDefaults handles correctly (PR #3116).
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
branching_strategy: 'phase',
|
||||
git: { base_branch: 'main' },
|
||||
}, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// SDK mergeDefaults produces: git.branching_strategy = 'phase'
|
||||
// CJS loadConfig must produce the same. Trigger loadConfig first (migration
|
||||
// writes git.branching_strategy to disk), then verify with config-get.
|
||||
const triggerResult = runWithStderr(['resolve-model', 'planner'], tmpDir);
|
||||
assert.equal(
|
||||
triggerResult.stderr.trim(),
|
||||
'',
|
||||
`No warning must fire on a standard legacy fixture — got: ${triggerResult.stderr}`
|
||||
);
|
||||
|
||||
// After the migration write-back, config-get must find git.branching_strategy = 'phase',
|
||||
// matching what the SDK's mergeDefaults would compute.
|
||||
const result = runWithStderr(['config-get', 'git.branching_strategy'], tmpDir);
|
||||
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`config-get command must succeed — exit status ${result.status}, stderr: ${result.stderr}`
|
||||
);
|
||||
assert.equal(
|
||||
result.stderr.trim(),
|
||||
'',
|
||||
`No error when reading post-migration git.branching_strategy — got: ${result.stderr}`
|
||||
);
|
||||
assert.ok(
|
||||
result.stdout.includes('phase'),
|
||||
`CJS must agree with SDK: git.branching_strategy = 'phase' for legacy fixture. ` +
|
||||
`Got: ${result.stdout}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -296,3 +296,133 @@ requirements-completed: [TEST-01]
|
||||
assert.deepStrictEqual(parsed.requirements_completed, ['TEST-01'], 'requirements_completed should contain TEST-01');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3019-help-passthrough.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3019-help-passthrough (consolidation epic #1969 B6 #1975)", () => {
|
||||
/**
|
||||
* Regression test for bug #3019.
|
||||
*
|
||||
* `gsd-sdk query <subcommand> --help` returned the top-level SDK USAGE
|
||||
* instead of contextual help for the subcommand. The query argv parser
|
||||
* harvested --help as a global flag and main() short-circuited dispatch
|
||||
* before the registry handler / gsd-tools.cjs fallback could render
|
||||
* useful help.
|
||||
*
|
||||
* Two-layer fix:
|
||||
* 1. sdk/src/cli.ts — leave --help in queryArgv so it travels to the
|
||||
* handler/fallback. Only honor the global help flag when there is
|
||||
* no subcommand to dispatch to.
|
||||
* 2. gsd-core/bin/gsd-tools.cjs — render the top-level usage on
|
||||
* --help instead of erroring. Anti-hallucination invariant from
|
||||
* #1818 is preserved (the destructive command never executes).
|
||||
*
|
||||
* Tests the integration: invoke gsd-tools.cjs the same way the SDK
|
||||
* dispatcher does and assert structured-IR (success flag + usage shape)
|
||||
* rather than raw substring matches.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { runGsdTools, isUsageOutput } = require('./helpers.cjs');
|
||||
|
||||
// #3026 CR (Major outside-diff): the SDK fallback wraps gsd-tools.cjs.
|
||||
// When gsd-tools emits plain-text help (exit 0), the SDK previously
|
||||
// JSON.parsed stdout and threw "Unexpected token 'U'". Verify the fix
|
||||
// by invoking the built SDK end-to-end and asserting:
|
||||
// - exit 0
|
||||
// - stdout contains the gsd-tools usage
|
||||
// - stderr does NOT contain a JSON parse error
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const SDK_CLI = path.join(__dirname, '..', 'sdk', 'dist', 'cli.js');
|
||||
const fs = require('node:fs');
|
||||
|
||||
describe('bug #3026 (CR Major outside-diff): SDK forwards plain-text help from gsd-tools fallback', () => {
|
||||
test('gsd-sdk query phase --help (fallback path) returns usage, not a JSON parse error', (t) => {
|
||||
if (!fs.existsSync(SDK_CLI)) {
|
||||
// CR feedback (#3026): a bare `return` here silent-passes the test
|
||||
// when sdk/dist/cli.js is absent (CI checkouts that haven't run
|
||||
// `npm run build`), giving no signal that the integration check
|
||||
// was skipped. Use t.skip() so the omission is visible in the
|
||||
// test report. The unit-level fix is covered by vitest on
|
||||
// sdk/src/cli.ts; this integration test only runs when the
|
||||
// built SDK is on disk.
|
||||
t.skip('sdk/dist/cli.js not built — run `npm run build` in sdk/ to enable this integration test');
|
||||
return;
|
||||
}
|
||||
// `query phase --help` (no further subcommand) is NOT in the native
|
||||
// registry, so it routes through the gsd-tools.cjs fallback. That is
|
||||
// the path that JSON.parsed the help text and threw before this fix.
|
||||
const result = spawnSync(process.execPath, [SDK_CLI, 'query', 'phase', '--help'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
// The fallback gsd-tools.cjs emits exit 0 with usage on stdout.
|
||||
assert.strictEqual(result.status, 0,
|
||||
`must exit 0 — got ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
// Negative: must NOT see the JSON parse error that was the regression.
|
||||
assert.ok(!/Unexpected token|not valid JSON/i.test(result.stderr),
|
||||
`must NOT JSON.parse the help text (stderr): ${result.stderr}`);
|
||||
// Positive: the usage should reach the user via stdout.
|
||||
assert.ok(/Usage:\s*gsd-tools/.test(result.stdout) && /Commands:/.test(result.stdout),
|
||||
`usage must reach stdout: ${result.stdout}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #3019: gsd-tools renders usage on --help instead of erroring', () => {
|
||||
test('bare gsd-tools (no args) renders usage', () => {
|
||||
const result = runGsdTools([]);
|
||||
// No args path: error() helper emits to stderr and exits non-zero,
|
||||
// but the message body is the usage.
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.ok(/Usage:\s*gsd-tools/.test(result.error));
|
||||
assert.ok(/Commands:/.test(result.error));
|
||||
});
|
||||
|
||||
test('gsd-tools --help renders usage on stdout, exits 0', () => {
|
||||
const result = runGsdTools(['--help']);
|
||||
assert.strictEqual(result.success, true, '--help should not be an error');
|
||||
assert.ok(isUsageOutput(result.output), `expected usage on stdout, got: ${result.output}`);
|
||||
});
|
||||
|
||||
test('gsd-tools -h renders usage on stdout, exits 0', () => {
|
||||
const result = runGsdTools(['-h']);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output));
|
||||
});
|
||||
|
||||
test('gsd-tools <subcommand> --help renders usage (does not run subcommand)', () => {
|
||||
// The classic #3019 surface: the user types a subcommand expecting
|
||||
// contextual help. We render the top-level usage — strictly better
|
||||
// than the previous unhelpful "Unknown flag --help" error.
|
||||
const result = runGsdTools(['phase', 'add', '--help']);
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(isUsageOutput(result.output));
|
||||
});
|
||||
|
||||
test('usage hint mentions how to discover argument requirements', () => {
|
||||
// The usage now points users at the discovery method that actually works
|
||||
// (run without args → error message names required arguments). Asserting
|
||||
// on the parsed shape of the usage rather than substring-matching prose:
|
||||
const result = runGsdTools(['--help']);
|
||||
assert.strictEqual(result.success, true);
|
||||
// Structural check: split into sections.
|
||||
const lines = result.output.split('\n');
|
||||
const hasUsageLine = lines.some((l) => l.startsWith('Usage:'));
|
||||
const hasCommandsLine = lines.some((l) => l.startsWith('Commands:'));
|
||||
const hasDiscoveryHint = lines.some((l) => /argument requirements|without args|invoke the command/i.test(l));
|
||||
assert.ok(hasUsageLine, 'first section: Usage');
|
||||
assert.ok(hasCommandsLine, 'second section: Commands');
|
||||
assert.ok(hasDiscoveryHint, 'third section: how to discover per-command args');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -794,3 +794,154 @@ describe('verify codebase-drift — workflow config read from nested shape (#149
|
||||
'1 structural file must not exceed threshold of 100');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-619-codebase-drift-gate-shim.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-619-codebase-drift-gate-shim (consolidation epic #1969 B6 #1975)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #619)
|
||||
// codebase-drift-gate.md is the shipped orchestration step contract. Bug #619:
|
||||
// the initial drift check ran the bare PATH binary `gsd-tools verify codebase-drift`.
|
||||
// On a shim-only install (gsd-tools.cjs present, `gsd-tools` not on PATH) that exits
|
||||
// 127, `2>/dev/null` hides it, and the `|| echo` fallback marks the gate skipped —
|
||||
// so post-execution drift detection silently never runs. The fix resolves gsd-tools
|
||||
// through the runtime shim launcher (gsd_run), defining the canonical preamble once in
|
||||
// this always-run block so the file stays compliant with the single-preamble parity
|
||||
// invariant (the conditional auto-remap block reuses the launcher via shared shell scope).
|
||||
//
|
||||
// This file locks the source contract AND behaviorally proves the shim resolves: it runs
|
||||
// the exact shipped drift-check block against a shim-only topology and asserts the shim
|
||||
// actually executes, where the old bare-binary form would have skipped.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const GATE_MD = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase', 'steps', 'codebase-drift-gate.md',
|
||||
);
|
||||
const SNIPPET_FILE = path.join(__dirname, '..', 'gsd-core', 'workflows', '_runtime-launcher.snippet.sh');
|
||||
|
||||
function readGate() {
|
||||
return fs.readFileSync(GATE_MD, 'utf8');
|
||||
}
|
||||
|
||||
// Extract the Nth (0-based) ```bash fenced block body from the file.
|
||||
function bashBlock(content, n) {
|
||||
const blocks = [];
|
||||
const re = /```bash\r?\n([\s\S]*?)```/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) blocks.push(m[1]);
|
||||
assert.ok(blocks.length > n, `expected at least ${n + 1} bash blocks, found ${blocks.length}`);
|
||||
return blocks[n];
|
||||
}
|
||||
|
||||
describe('bug #619 — codebase-drift-gate resolves gsd-tools via the runtime shim, not the bare PATH binary', () => {
|
||||
test('codebase-drift-gate.md is readable', () => {
|
||||
assert.ok(readGate().length > 0, 'codebase-drift-gate.md must not be empty');
|
||||
});
|
||||
|
||||
// ── Source contract (the .md is the product) ──────────────────────────────
|
||||
|
||||
test('the drift check resolves gsd-tools via the shim launcher (gsd_run), not the bare binary (#619)', () => {
|
||||
const content = readGate();
|
||||
assert.match(
|
||||
content,
|
||||
/DRIFT=\$\(gsd_run verify codebase-drift 2>\/dev\/null \|\| echo '\{"skipped":true,"reason":"sdk-failed"\}'\)/,
|
||||
'drift check must call `gsd_run verify codebase-drift` with the non-blocking skip fallback',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
content,
|
||||
/\bgsd-tools verify codebase-drift\b/,
|
||||
'the bare `gsd-tools verify codebase-drift` PATH-binary call (the #619 bug) must be gone',
|
||||
);
|
||||
});
|
||||
|
||||
test('non-blocking contract preserved: the skip JSON fallback is intact (#619)', () => {
|
||||
const content = readGate();
|
||||
assert.match(
|
||||
content,
|
||||
/\|\| echo '\{"skipped":true,"reason":"sdk-failed"\}'/,
|
||||
'an internal drift-command failure must still fall through to the skip JSON',
|
||||
);
|
||||
});
|
||||
|
||||
test('exactly one canonical launcher preamble, in the drift-check block, before any launcher call (#619)', () => {
|
||||
const content = readGate();
|
||||
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8').replace(/\r?\n$/, '');
|
||||
|
||||
// Count canonical preamble occurrences across the whole file (parity: exactly one).
|
||||
let count = 0;
|
||||
let pos = 0;
|
||||
for (;;) {
|
||||
const idx = content.indexOf(snippet, pos);
|
||||
if (idx === -1) break;
|
||||
count++;
|
||||
pos = idx + snippet.length;
|
||||
}
|
||||
assert.equal(count, 1, `expected exactly one canonical preamble; found ${count}`);
|
||||
|
||||
// The preamble must live in the first (drift-check) bash block, before the DRIFT call.
|
||||
const block0 = bashBlock(content, 0);
|
||||
assert.ok(block0.includes(snippet), 'the canonical preamble must be in the drift-check block');
|
||||
assert.ok(
|
||||
block0.indexOf(snippet) < block0.indexOf('gsd_run verify codebase-drift'),
|
||||
'the preamble must precede the gsd_run drift call in the same block',
|
||||
);
|
||||
|
||||
// The auto-remap block reuses gsd_run but must NOT carry its own preamble.
|
||||
const content2 = content.slice(content.indexOf('AGENT_SKILLS_MAPPER'));
|
||||
assert.ok(!content2.includes(snippet), 'the auto-remap block must not re-declare the preamble (single-preamble parity)');
|
||||
});
|
||||
|
||||
// ── Behavioral proof: the shim resolves on a shim-only topology ───────────
|
||||
|
||||
test('shipped drift-check block runs the shim (gsd-tools.cjs), not skip, on a shim-only install (#619)', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-619-'));
|
||||
try {
|
||||
// Shim-only topology: gsd-tools.cjs present under RUNTIME_DIR; no `gsd-tools` on PATH.
|
||||
const binDir = path.join(tmp, 'gsd-core', 'bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(binDir, 'gsd-tools.cjs'),
|
||||
'if (process.argv[2] === "verify" && process.argv[3] === "codebase-drift") {\n' +
|
||||
' process.stdout.write(JSON.stringify({ action_required: false, sentinel: "SHIM_RAN" }));\n' +
|
||||
'}\n',
|
||||
);
|
||||
|
||||
const block = bashBlock(readGate(), 0) + '\nprintf "%s" "$DRIFT"\n';
|
||||
const out = execFileSync('bash', ['-c', block], {
|
||||
env: { ...process.env, RUNTIME_DIR: tmp },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.match(out, /SHIM_RAN/, 'the drift check must execute the resolved shim, proving gsd_run resolution');
|
||||
assert.doesNotMatch(out, /sdk-failed/, 'the gate must NOT silently skip when the shim is present (#619)');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('red-proof: the old bare `gsd-tools` form would skip when gsd-tools is not on PATH', () => {
|
||||
// Documents the #619 bug: the pre-fix bare-binary call, with no `gsd-tools` on PATH,
|
||||
// hits the 127 → `|| echo` skip path even though the shim (gsd-tools.cjs) exists.
|
||||
const oldForm =
|
||||
'DRIFT=$(gsd-tools verify codebase-drift 2>/dev/null || echo \'{"skipped":true,"reason":"sdk-failed"}\'); printf "%s" "$DRIFT"';
|
||||
const out = execFileSync('bash', ['-c', 'export PATH=/nonexistent-empty-path; ' + oldForm], {
|
||||
env: { ...process.env },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.match(out, /sdk-failed/, 'sanity: the bare-binary form skips without gsd-tools on PATH — the bug the fix removes');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
'use strict';
|
||||
// allow-test-rule: source-text-is-the-product see #1592
|
||||
// The plan-phase.md host-dispatch assertions below read the workflow .md file — its text IS the
|
||||
// deployed contract the runtime loads (CONTRIBUTING.md exemption category). The registry assertions
|
||||
// are behavioral: they build the registry from the REAL capabilities/drift declaration via the
|
||||
// generator, so they fail if the plan:pre gate is ever removed or mutated.
|
||||
|
||||
/**
|
||||
* Enhancement (#1592): plan-time codebase-map freshness pre-check.
|
||||
*
|
||||
* The `drift` capability gains a non-blocking `plan:pre` codebase-drift gate so a stale codebase map is
|
||||
* flagged BEFORE planning, instead of being discovered mid-execution by the existing
|
||||
* `execute:wave:post` codebase-drift gate. Warn-only at `plan:pre` (no mapper-agent spawn): the
|
||||
* capability's `drift_action: auto-remap` stays at `execute:wave:post`, so plan time never pays
|
||||
* speculative mapper-agent cost.
|
||||
*
|
||||
* Per maintainer review on #1592 (mod 1a), the plan:pre gate is gated on a DEDICATED
|
||||
* `workflow.plan_drift_precheck` toggle (default true) rather than reusing `workflow.schema_drift_gate`,
|
||||
* so autonomous/CI runs can silence the plan-time advisory without disabling the execute-time gates.
|
||||
* The gate declaration conforms to ADR-857 (`plan:pre` is an enumerated, additive-only loop point).
|
||||
*
|
||||
* Issue: #1592 (open-gsd/gsd-core).
|
||||
*/
|
||||
|
||||
const { describe, test, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { loadAndValidate, buildRegistry } = require('../scripts/gen-capability-registry.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const DRIFT_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'capabilities', 'drift', 'capability.json'), 'utf8'),
|
||||
);
|
||||
const PLAN_PHASE = fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Track every temp dir created so the suite can remove them on teardown — leaked
|
||||
// mkdtemp dirs have been a flake source here before (per #1592 review).
|
||||
const tempCapDirs = [];
|
||||
|
||||
function makeTempCapDir(capabilities) {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'enh-1592-'));
|
||||
tempCapDirs.push(tmpDir);
|
||||
for (const [id, cap] of Object.entries(capabilities)) {
|
||||
const subDir = path.join(tmpDir, id);
|
||||
fs.mkdirSync(subDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(subDir, 'capability.json'), JSON.stringify(cap), 'utf8');
|
||||
}
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
after(() => {
|
||||
for (const dir of tempCapDirs) {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
function planPreDriftGate() {
|
||||
const capDir = makeTempCapDir({ drift: DRIFT_CAP });
|
||||
const { capMap, errors } = loadAndValidate(new Set(), capDir);
|
||||
assert.deepEqual(errors, [], 'drift capability should validate cleanly: ' + JSON.stringify(errors));
|
||||
const registry = buildRegistry(capMap);
|
||||
const planPreGates = registry.byLoopPoint['plan:pre'].gates;
|
||||
assert.ok(Array.isArray(planPreGates), 'plan:pre.gates should be an array');
|
||||
return planPreGates.find(
|
||||
(g) => g.capId === 'drift' && g.check && g.check.query === 'verify.codebase-drift',
|
||||
);
|
||||
}
|
||||
|
||||
describe('#1592 — drift plan:pre codebase-drift gate (registry, behavioral)', () => {
|
||||
test('the real drift capability registers a non-blocking plan:pre codebase-drift gate', () => {
|
||||
const driftGate = planPreDriftGate();
|
||||
assert.ok(driftGate, 'plan:pre.gates must contain the drift codebase-drift gate');
|
||||
assert.strictEqual(driftGate.blocking, false, 'plan-time drift gate must be NON-blocking');
|
||||
assert.strictEqual(driftGate.onError, 'skip', 'must fail-soft (skip) — never halt planning');
|
||||
});
|
||||
|
||||
test('the plan:pre gate is gated on the dedicated plan_drift_precheck toggle (mod 1a)', () => {
|
||||
const driftGate = planPreDriftGate();
|
||||
assert.strictEqual(
|
||||
driftGate.when,
|
||||
'workflow.plan_drift_precheck',
|
||||
'plan:pre drift gate must use the dedicated toggle so CI/autonomous runs can silence it ' +
|
||||
'without disabling the execute-time gates',
|
||||
);
|
||||
});
|
||||
|
||||
test('the execute:wave:post codebase-drift gate is preserved and keeps its OWN toggle (no regression)', () => {
|
||||
const capDir = makeTempCapDir({ drift: DRIFT_CAP });
|
||||
const { capMap } = loadAndValidate(new Set(), capDir);
|
||||
const registry = buildRegistry(capMap);
|
||||
|
||||
const execGates = registry.byLoopPoint['execute:wave:post'].gates;
|
||||
const stillThere = execGates.find(
|
||||
(g) => g.capId === 'drift' && g.check && g.check.query === 'verify.codebase-drift',
|
||||
);
|
||||
assert.ok(stillThere, 'execute:wave:post codebase-drift gate must remain after adding the plan:pre gate');
|
||||
assert.strictEqual(stillThere.blocking, false, 'execute codebase-drift gate stays non-blocking');
|
||||
assert.strictEqual(
|
||||
stillThere.when,
|
||||
'workflow.schema_drift_gate',
|
||||
'the execute-time gate keeps schema_drift_gate — the plan-time toggle is separable from it',
|
||||
);
|
||||
});
|
||||
|
||||
test('plan_drift_precheck is a separate toggle from schema_drift_gate (silencing is independent)', () => {
|
||||
const planWhen = planPreDriftGate().when;
|
||||
assert.notStrictEqual(
|
||||
planWhen,
|
||||
'workflow.schema_drift_gate',
|
||||
'silencing the plan-time advisory must not require disabling the execute-time gates',
|
||||
);
|
||||
});
|
||||
|
||||
test('plan_drift_precheck is declared as a boolean defaulting to true', () => {
|
||||
const cfg = DRIFT_CAP.config['workflow.plan_drift_precheck'];
|
||||
assert.ok(cfg, 'workflow.plan_drift_precheck must be declared in the drift capability config');
|
||||
assert.strictEqual(cfg.type, 'boolean', 'plan_drift_precheck must be a boolean');
|
||||
assert.strictEqual(cfg.default, true, 'plan_drift_precheck must default to true (on by default)');
|
||||
});
|
||||
|
||||
test('exactly one new config key is introduced (the dedicated plan_drift_precheck toggle)', () => {
|
||||
const keys = Object.keys(DRIFT_CAP.config).sort();
|
||||
assert.deepStrictEqual(
|
||||
keys,
|
||||
[
|
||||
'workflow.drift_action',
|
||||
'workflow.drift_threshold',
|
||||
'workflow.plan_drift_precheck',
|
||||
'workflow.schema_drift_gate',
|
||||
],
|
||||
'the plan:pre gate adds exactly the dedicated plan_drift_precheck toggle — no other new keys',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#1592 — plan-phase host dispatches the drift plan:pre gate before planning', () => {
|
||||
const SECTION = PLAN_PHASE.slice(
|
||||
PLAN_PHASE.indexOf('5.65. Codebase Map Freshness Pre-Check'),
|
||||
PLAN_PHASE.indexOf('## 6. Check Existing Plans'),
|
||||
);
|
||||
|
||||
test('§5.65 invokes the verify codebase-drift check', () => {
|
||||
assert.match(PLAN_PHASE, /5\.65\. Codebase Map Freshness Pre-Check/, 'plan-phase must declare §5.65');
|
||||
assert.match(PLAN_PHASE, /gsd_run verify codebase-drift/, '§5.65 must invoke `verify codebase-drift`');
|
||||
});
|
||||
|
||||
test('the drift pre-check runs BEFORE the planner spawn (load-bearing ordering)', () => {
|
||||
const preCheckIdx = PLAN_PHASE.indexOf('5.65. Codebase Map Freshness Pre-Check');
|
||||
const plannerIdx = PLAN_PHASE.indexOf('## 8. Spawn gsd-planner Agent');
|
||||
assert.ok(preCheckIdx > 0, '§5.65 must exist');
|
||||
assert.ok(plannerIdx > 0, '§8 planner spawn must exist');
|
||||
assert.ok(
|
||||
preCheckIdx < plannerIdx,
|
||||
'the drift map-freshness pre-check must run before the planner is spawned — the whole point of #1592',
|
||||
);
|
||||
});
|
||||
|
||||
test('§5.65 is documented as non-blocking and warn-only (no spawn)', () => {
|
||||
assert.match(SECTION, /non-blocking/i, '§5.65 must state the gate is non-blocking');
|
||||
assert.match(SECTION, /never blocks, never spawns/i, '§5.65 must state it never spawns the mapper at plan time');
|
||||
});
|
||||
|
||||
test('§5.65 gates on the dedicated plan_drift_precheck toggle (mod 1a)', () => {
|
||||
assert.match(
|
||||
SECTION,
|
||||
/workflow\.plan_drift_precheck/,
|
||||
'§5.65 must dispatch on the dedicated plan_drift_precheck toggle, not schema_drift_gate',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
||||
// runtime loads — testing text content tests the deployed contract.
|
||||
|
||||
/**
|
||||
* Tests for #2380 — /gsd-sync-skills cross-runtime skill sync.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. install.js --skills-root <runtime> resolves correct paths
|
||||
* 2. sync-skills.md workflow covers required behavioral specs
|
||||
* 3. commands/gsd/sync-skills.md slash command exists
|
||||
* 4. INVENTORY in sync
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const os = require('node:os');
|
||||
|
||||
const INSTALL_JS = path.join(__dirname, '../bin/install.js');
|
||||
const WORKFLOW = path.join(__dirname, '../gsd-core/workflows/sync-skills.md');
|
||||
const COMMAND = path.join(__dirname, '../commands/gsd/sync-skills.md');
|
||||
|
||||
function readWorkflow() {
|
||||
return fs.readFileSync(WORKFLOW, 'utf-8');
|
||||
}
|
||||
|
||||
// ── install.js --skills-root ──────────────────────────────────────────────────
|
||||
|
||||
describe('install.js --skills-root', () => {
|
||||
const CASES = [
|
||||
{ runtime: 'claude', expected: path.join(os.homedir(), '.claude', 'skills') },
|
||||
{ runtime: 'codex', expected: path.join(os.homedir(), '.codex', 'skills') },
|
||||
{ runtime: 'copilot', expected: path.join(os.homedir(), '.copilot', 'skills') },
|
||||
{ runtime: 'cursor', expected: path.join(os.homedir(), '.cursor', 'skills') },
|
||||
{ runtime: 'gemini', expected: path.join(os.homedir(), '.gemini', 'skills') },
|
||||
];
|
||||
|
||||
for (const { runtime, expected } of CASES) {
|
||||
test(`resolves correct skills root for ${runtime}`, () => {
|
||||
const result = spawnSync(process.execPath, [INSTALL_JS, '--skills-root', runtime], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, GSD_TEST_MODE: undefined }, // ensure not in test mode
|
||||
});
|
||||
// Strip trailing newline
|
||||
const actual = result.stdout.trim();
|
||||
assert.strictEqual(actual, expected, `Expected ${expected}, got ${actual}`);
|
||||
});
|
||||
}
|
||||
|
||||
test('exits non-zero when runtime arg is missing', () => {
|
||||
const result = spawnSync(process.execPath, [INSTALL_JS, '--skills-root'], {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
assert.notStrictEqual(result.status, 0, 'Should exit with error when runtime arg is missing');
|
||||
});
|
||||
|
||||
test('returns a path ending in /skills', () => {
|
||||
const result = spawnSync(process.execPath, [INSTALL_JS, '--skills-root', 'windsurf'], {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
assert.ok(result.stdout.trim().endsWith('skills'), 'Skills root must end in /skills');
|
||||
});
|
||||
});
|
||||
|
||||
// ── sync-skills.md workflow content ──────────────────────────────────────────
|
||||
|
||||
describe('sync-skills.md — required behavioral specs', () => {
|
||||
let content;
|
||||
|
||||
test('workflow file exists', () => {
|
||||
content = readWorkflow();
|
||||
assert.ok(content.length > 0, 'sync-skills.md must exist and be non-empty');
|
||||
});
|
||||
|
||||
test('--dry-run is the default (no writes without --apply)', () => {
|
||||
content = content || readWorkflow();
|
||||
assert.ok(
|
||||
content.includes('dry-run') && (content.includes('default') || content.includes('Default')),
|
||||
'workflow must document --dry-run as default'
|
||||
);
|
||||
});
|
||||
|
||||
test('--apply flag is required to execute writes', () => {
|
||||
content = content || readWorkflow();
|
||||
assert.ok(content.includes('--apply'), 'workflow must document --apply flag');
|
||||
});
|
||||
|
||||
test('--from flag documented', () => {
|
||||
content = content || readWorkflow();
|
||||
assert.ok(content.includes('--from'), 'workflow must document --from flag');
|
||||
});
|
||||
|
||||
test('--to flag documented (runtime|all)', () => {
|
||||
content = content || readWorkflow();
|
||||
assert.ok(
|
||||
content.includes('--to') && content.includes('all'),
|
||||
'workflow must document --to flag with "all" option'
|
||||
);
|
||||
});
|
||||
|
||||
test('only gsd-* directories are touched (non-GSD preservation)', () => {
|
||||
content = content || readWorkflow();
|
||||
assert.ok(
|
||||
content.includes('gsd-*') && (content.includes('non-GSD') || content.includes('Non-GSD') || content.includes('not starting with')),
|
||||
'workflow must document that only gsd-* dirs are modified'
|
||||
);
|
||||
});
|
||||
|
||||
test('idempotency documented (second apply = zero changes)', () => {
|
||||
content = content || readWorkflow();
|
||||
assert.ok(
|
||||
content.includes('dempoten') || content.includes('Idempoten') || content.includes('zero changes') || content.includes('second run'),
|
||||
'workflow must document idempotency'
|
||||
);
|
||||
});
|
||||
|
||||
test('install.js --skills-root is used for path resolution', () => {
|
||||
content = content || readWorkflow();
|
||||
assert.ok(
|
||||
content.includes('--skills-root'),
|
||||
'workflow must reference install.js --skills-root for path resolution'
|
||||
);
|
||||
});
|
||||
|
||||
test('diff report format: CREATE / UPDATE / REMOVE documented', () => {
|
||||
content = content || readWorkflow();
|
||||
assert.ok(content.includes('CREATE'), 'workflow must document CREATE in diff report');
|
||||
assert.ok(content.includes('UPDATE'), 'workflow must document UPDATE in diff report');
|
||||
assert.ok(content.includes('REMOVE'), 'workflow must document REMOVE in diff report');
|
||||
});
|
||||
|
||||
test('source-not-found error guidance documented', () => {
|
||||
content = content || readWorkflow();
|
||||
assert.ok(
|
||||
content.includes('source skills root not found') || content.includes('source root') || content.includes('not found'),
|
||||
'workflow must document error when source skills root is missing'
|
||||
);
|
||||
});
|
||||
|
||||
test('safety rule: dry-run performs no writes', () => {
|
||||
content = content || readWorkflow();
|
||||
const safetySection = content.includes('Safety Rules') || content.includes('safety');
|
||||
assert.ok(
|
||||
safetySection || content.includes('no writes') || content.includes('--dry-run performs no writes'),
|
||||
'workflow must have a safety rule that dry-run performs no writes'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── commands/gsd/sync-skills.md ───────────────────────────────────────────────
|
||||
// #2790: sync-skills.md was consolidated into update.md as the --sync flag.
|
||||
|
||||
describe('commands/gsd/sync-skills.md', () => {
|
||||
test('sync-skills is now --sync flag on update.md (#2790)', () => {
|
||||
const updateCmd = path.join(__dirname, '../commands/gsd/update.md');
|
||||
assert.ok(fs.existsSync(updateCmd), 'commands/gsd/update.md must exist');
|
||||
const content = fs.readFileSync(updateCmd, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('--sync'),
|
||||
'update.md must document --sync flag (absorbed sync-skills)'
|
||||
);
|
||||
});
|
||||
|
||||
test('sync-skills.md command file is deleted (#2790)', () => {
|
||||
assert.ok(!fs.existsSync(COMMAND), 'commands/gsd/sync-skills.md should be deleted (consolidated into update.md)');
|
||||
});
|
||||
});
|
||||
|
||||
// ── INVENTORY sync ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('INVENTORY sync', () => {
|
||||
test('INVENTORY.md lists /gsd-update --sync command (#2790: absorbed /gsd-sync-skills)', () => {
|
||||
const inventory = fs.readFileSync(path.join(__dirname, '../docs/INVENTORY.md'), 'utf-8');
|
||||
assert.ok(inventory.includes('/gsd-update --sync'), 'INVENTORY.md must list /gsd-update --sync (absorbed /gsd-sync-skills in #2790)');
|
||||
});
|
||||
|
||||
test('INVENTORY.md lists sync-skills.md workflow', () => {
|
||||
const inventory = fs.readFileSync(path.join(__dirname, '../docs/INVENTORY.md'), 'utf-8');
|
||||
assert.ok(inventory.includes('sync-skills.md'), 'INVENTORY.md must list sync-skills.md workflow');
|
||||
});
|
||||
|
||||
test('INVENTORY-MANIFEST.json includes /gsd-update (#2790: sync-skills absorbed into update.md --sync)', () => {
|
||||
// #2790: /gsd-sync-skills was absorbed into /gsd-update as the --sync flag.
|
||||
// The manifest now records /gsd-update instead of /gsd-sync-skills.
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '../docs/INVENTORY-MANIFEST.json'), 'utf-8')
|
||||
);
|
||||
assert.ok(
|
||||
manifest.families.commands.includes('/gsd-update'),
|
||||
'INVENTORY-MANIFEST.json must include /gsd-update in commands (absorbed /gsd-sync-skills via #2790)'
|
||||
);
|
||||
});
|
||||
|
||||
test('INVENTORY-MANIFEST.json includes sync-skills.md', () => {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '../docs/INVENTORY-MANIFEST.json'), 'utf-8')
|
||||
);
|
||||
assert.ok(
|
||||
manifest.families.workflows.includes('sync-skills.md'),
|
||||
'INVENTORY-MANIFEST.json must include sync-skills.md in workflows'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// commands/gsd/*.md text IS what the runtime loads — testing description
|
||||
// length tests the deployed system-prompt contract.
|
||||
|
||||
/**
|
||||
* Tests for #2789 — Trim skill description anti-patterns; enforce 100-char budget
|
||||
*
|
||||
* Verifies:
|
||||
* 1. All skill descriptions in commands/gsd/*.md are <= 100 chars
|
||||
* 2. No descriptions contain flag documentation anti-patterns (Use --)
|
||||
* 3. No descriptions contain "Triggers:" keyword stuffing
|
||||
* 4. lint-descriptions.cjs rejects descriptions over 100 chars
|
||||
* 5. lint-descriptions.cjs accepts descriptions under 100 chars
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const os = require('node:os');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const COMMANDS_DIR = path.join(__dirname, '../commands/gsd');
|
||||
const LINT_SCRIPT = path.join(__dirname, '../scripts/lint-descriptions.cjs');
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 100;
|
||||
|
||||
/**
|
||||
* Parse the description field from a frontmatter block in a .md file.
|
||||
* Returns null if no description is found.
|
||||
*/
|
||||
function parseDescription(content) {
|
||||
// Extract frontmatter block between --- markers
|
||||
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!fmMatch) return null;
|
||||
const fm = fmMatch[1];
|
||||
|
||||
// Handle multi-line or quoted values: description: "..." or description: plain text
|
||||
// Match: description: "value" or description: value (to end of line)
|
||||
const quoted = fm.match(/^description:\s+"((?:[^"\\]|\\.)*)"\s*$/m);
|
||||
if (quoted) return quoted[1];
|
||||
|
||||
const plain = fm.match(/^description:\s+(.+)$/m);
|
||||
if (plain) return plain[1].trim();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all .md files in commands/gsd/ with their descriptions.
|
||||
*/
|
||||
function getAllCommandDescriptions() {
|
||||
const files = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md'));
|
||||
return files.map(file => {
|
||||
const filePath = path.join(COMMANDS_DIR, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const description = parseDescription(content);
|
||||
return { file, filePath, description };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Test 1: All descriptions <= 100 chars ────────────────────────────────────
|
||||
|
||||
describe('description length budget', () => {
|
||||
test('all commands/gsd/*.md descriptions are <= 100 chars', () => {
|
||||
const commands = getAllCommandDescriptions();
|
||||
const violators = commands
|
||||
.filter(c => c.description !== null && c.description.length > MAX_DESCRIPTION_LENGTH)
|
||||
.map(c => [
|
||||
'length=' + c.description.length,
|
||||
'file=' + c.file,
|
||||
'desc=' + c.description,
|
||||
].join(' | '));
|
||||
|
||||
assert.strictEqual(
|
||||
violators.length,
|
||||
0,
|
||||
[
|
||||
`${violators.length} description(s) exceed ${MAX_DESCRIPTION_LENGTH} chars:`,
|
||||
...violators.map(v => ' ' + v),
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Test 2: No flag documentation anti-patterns ──────────────────────────────
|
||||
|
||||
describe('description anti-patterns', () => {
|
||||
test('no descriptions contain flag documentation (Use --, use --, via --)', () => {
|
||||
const commands = getAllCommandDescriptions();
|
||||
const FLAG_PATTERNS = ['Use --', 'use --', 'via --'];
|
||||
const violators = commands
|
||||
.filter(c => {
|
||||
if (!c.description) return false;
|
||||
return FLAG_PATTERNS.some(p => c.description.includes(p));
|
||||
})
|
||||
.map(c => 'file=' + c.file + ' | desc=' + c.description);
|
||||
|
||||
assert.strictEqual(
|
||||
violators.length,
|
||||
0,
|
||||
[
|
||||
`${violators.length} description(s) contain flag documentation anti-patterns:`,
|
||||
...violators.map(v => ' ' + v),
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 3: No Triggers: keyword stuffing ─────────────────────────────────
|
||||
|
||||
test('no descriptions contain "Triggers:" keyword stuffing', () => {
|
||||
const commands = getAllCommandDescriptions();
|
||||
const violators = commands
|
||||
.filter(c => c.description && /triggers:/i.test(c.description))
|
||||
.map(c => 'file=' + c.file + ' | desc=' + c.description);
|
||||
|
||||
assert.strictEqual(
|
||||
violators.length,
|
||||
0,
|
||||
[
|
||||
`${violators.length} description(s) contain "Triggers:" keyword stuffing:`,
|
||||
...violators.map(v => ' ' + v),
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Test 4 & 5: lint-descriptions.cjs script ─────────────────────────────────
|
||||
|
||||
describe('lint-descriptions.cjs', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-desc-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('rejects a command file with a description over 100 chars', () => {
|
||||
const longDesc = 'A'.repeat(101);
|
||||
const content = [
|
||||
'---',
|
||||
'name: gsd:test-long',
|
||||
'description: ' + longDesc,
|
||||
'---',
|
||||
'',
|
||||
'Body text.',
|
||||
].join('\n');
|
||||
|
||||
const tmpFile = path.join(tmpDir, 'long-desc.md');
|
||||
fs.writeFileSync(tmpFile, content, 'utf-8');
|
||||
|
||||
const result = spawnSync(process.execPath, [LINT_SCRIPT, tmpFile], {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
assert.notStrictEqual(result.status, 0, [
|
||||
'lint-descriptions.cjs should exit non-zero for description > 100 chars',
|
||||
'stdout: ' + result.stdout,
|
||||
'stderr: ' + result.stderr,
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('accepts a command file with a description under 100 chars', () => {
|
||||
const shortDesc = 'Short routing description for this skill.';
|
||||
const content = [
|
||||
'---',
|
||||
'name: gsd:test-short',
|
||||
'description: ' + shortDesc,
|
||||
'---',
|
||||
'',
|
||||
'Body text.',
|
||||
].join('\n');
|
||||
|
||||
const tmpFile = path.join(tmpDir, 'short-desc.md');
|
||||
fs.writeFileSync(tmpFile, content, 'utf-8');
|
||||
|
||||
const result = spawnSync(process.execPath, [LINT_SCRIPT, tmpFile], {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, [
|
||||
'lint-descriptions.cjs should exit 0 for description <= 100 chars',
|
||||
'stdout: ' + result.stdout,
|
||||
'stderr: ' + result.stderr,
|
||||
].join('\n'));
|
||||
});
|
||||
});
|
||||
@@ -1,413 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// commands/gsd/*.md files ARE what the runtime loads — testing their
|
||||
// existence/non-existence tests the deployed skill surface contract.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { assertWithinAllowlist } = require('../scripts/lib/allowlist-ratchet.cjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Allowlisted set of user-invocable skills (commands/gsd/*.md, ns-* excluded).
|
||||
// Consolidation target ~58; this set may only SHRINK.
|
||||
// Adding a new skill requires adding it here with justification.
|
||||
// Removing a consolidated skill requires pruning it here.
|
||||
// ---------------------------------------------------------------------------
|
||||
const KNOWN_SKILLS = new Set([
|
||||
'add-tests.md',
|
||||
'ai-integration-phase.md',
|
||||
'audit-fix.md',
|
||||
'audit-milestone.md',
|
||||
'audit-uat.md',
|
||||
'autonomous.md',
|
||||
'capture.md',
|
||||
'cleanup.md',
|
||||
'code-review.md',
|
||||
'complete-milestone.md',
|
||||
'config.md',
|
||||
'debug.md',
|
||||
'discuss-phase.md',
|
||||
'docs-update.md',
|
||||
'eval-review.md',
|
||||
'execute-phase.md',
|
||||
'explore.md',
|
||||
'extract-learnings.md',
|
||||
'fast.md',
|
||||
'forensics.md',
|
||||
'graphify.md',
|
||||
'health.md',
|
||||
'help.md',
|
||||
'import.md',
|
||||
'inbox.md',
|
||||
'ingest-docs.md',
|
||||
'manager.md',
|
||||
'map-codebase.md',
|
||||
'mempalace-capture.md',
|
||||
'mempalace-recall.md',
|
||||
'milestone-summary.md',
|
||||
'mvp-phase.md',
|
||||
'new-milestone.md',
|
||||
'new-project.md',
|
||||
'pause-work.md',
|
||||
'phase.md',
|
||||
'plan-phase.md',
|
||||
'plan-review-convergence.md',
|
||||
'pr-branch.md',
|
||||
'profile-user.md',
|
||||
'progress.md',
|
||||
'quick.md',
|
||||
'resume-work.md',
|
||||
'review-backlog.md',
|
||||
'review.md',
|
||||
'secure-phase.md',
|
||||
'settings.md',
|
||||
'ship.md',
|
||||
'sketch.md',
|
||||
'spec-phase.md',
|
||||
'spike.md',
|
||||
'stats.md',
|
||||
'surface.md',
|
||||
'thread.md',
|
||||
'ui-phase.md',
|
||||
'ui-review.md',
|
||||
'ultraplan-phase.md',
|
||||
'undo.md',
|
||||
'update.md',
|
||||
'validate-phase.md',
|
||||
'verify-work.md',
|
||||
'workspace.md',
|
||||
'workstreams.md',
|
||||
]);
|
||||
|
||||
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
|
||||
/**
|
||||
* Parse the YAML frontmatter from a skill .md file.
|
||||
* Returns an object with the frontmatter fields as strings.
|
||||
* Only handles simple scalar and array values needed by these tests.
|
||||
*/
|
||||
function parseFrontmatter(filePath) {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
// CRLF-tolerant: Windows checkouts leave \r on every line. lines.indexOf('---', 1)
|
||||
// would never match because elements would be '---\r' instead of '---'.
|
||||
const lines = raw.split(/\r?\n/);
|
||||
if (lines[0].trim() !== '---') return {};
|
||||
const endIdx = lines.indexOf('---', 1);
|
||||
if (endIdx === -1) return {};
|
||||
const fmLines = lines.slice(1, endIdx);
|
||||
const result = {};
|
||||
let currentKey = null;
|
||||
for (const line of fmLines) {
|
||||
const kvMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)/);
|
||||
if (kvMatch) {
|
||||
currentKey = kvMatch[1];
|
||||
result[currentKey] = kvMatch[2].trim();
|
||||
} else if (currentKey && line.match(/^\s+-\s+/)) {
|
||||
// array item — append to existing string value so callers can check membership
|
||||
const item = line.replace(/^\s+-\s+/, '').trim();
|
||||
result[currentKey] = result[currentKey] ? [result[currentKey], item].join('\n') : item;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function skillPath(name) {
|
||||
return path.join(COMMANDS_DIR, `${name}.md`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group: New consolidated skills exist
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('new consolidated skills exist', () => {
|
||||
test('commands/gsd/capture.md exists', () => {
|
||||
assert.ok(fs.existsSync(skillPath('capture')), 'capture.md does not exist');
|
||||
});
|
||||
|
||||
test('commands/gsd/phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(skillPath('phase')), 'phase.md does not exist');
|
||||
});
|
||||
|
||||
test('commands/gsd/config.md exists', () => {
|
||||
assert.ok(fs.existsSync(skillPath('config')), 'config.md does not exist');
|
||||
});
|
||||
|
||||
test('commands/gsd/workspace.md exists', () => {
|
||||
assert.ok(fs.existsSync(skillPath('workspace')), 'workspace.md does not exist');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group: Absorbed skills are removed
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('absorbed skills are removed', () => {
|
||||
const absorbed = [
|
||||
['add-todo', 'absorbed into capture.md'],
|
||||
['note', 'absorbed into capture.md'],
|
||||
['add-backlog', 'absorbed into capture.md'],
|
||||
['plant-seed', 'absorbed into capture.md'],
|
||||
['check-todos', 'absorbed into capture.md'],
|
||||
['add-phase', 'absorbed into phase.md'],
|
||||
['insert-phase', 'absorbed into phase.md'],
|
||||
['remove-phase', 'absorbed into phase.md'],
|
||||
['edit-phase', 'absorbed into phase.md'],
|
||||
['settings-advanced', 'absorbed into config.md'],
|
||||
['settings-integrations', 'absorbed into config.md'],
|
||||
['set-profile', 'absorbed into config.md'],
|
||||
['new-workspace', 'absorbed into workspace.md'],
|
||||
['list-workspaces', 'absorbed into workspace.md'],
|
||||
['remove-workspace', 'absorbed into workspace.md'],
|
||||
['sync-skills', 'absorbed into update.md'],
|
||||
['reapply-patches', 'absorbed into update.md'],
|
||||
['sketch-wrap-up', 'absorbed into sketch.md'],
|
||||
['spike-wrap-up', 'absorbed into spike.md'],
|
||||
['scan', 'absorbed into map-codebase.md'],
|
||||
['intel', 'absorbed into map-codebase.md'],
|
||||
['code-review-fix', 'absorbed into code-review.md'],
|
||||
['next', 'absorbed into progress.md'],
|
||||
['do', 'absorbed into progress.md'],
|
||||
];
|
||||
|
||||
for (const [name, reason] of absorbed) {
|
||||
test(`commands/gsd/${name}.md does NOT exist (${reason})`, () => {
|
||||
assert.ok(
|
||||
!fs.existsSync(skillPath(name)),
|
||||
[
|
||||
`${name}.md still exists but should have been deleted`,
|
||||
`(${reason})`,
|
||||
].join(' '),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group: Outright deletions
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('outright deleted dead skills are removed', () => {
|
||||
const deleted = [
|
||||
'join-discord',
|
||||
// research-phase → plan-phase --research-phase (PR #3045, already absorbed)
|
||||
// plan-milestone-gaps → inline in audit-milestone (PR #3038, already absorbed)
|
||||
// list-phase-assumptions → discuss-phase --assumptions (pending #3131)
|
||||
// session-report → pause-work --report (pending #3131)
|
||||
// analyze-dependencies → manager --analyze-deps (pending #3131)
|
||||
// from-gsd2 → import --from-gsd2 (pending #3131)
|
||||
];
|
||||
|
||||
for (const name of deleted) {
|
||||
test(`commands/gsd/${name}.md does NOT exist`, () => {
|
||||
assert.ok(
|
||||
!fs.existsSync(skillPath(name)),
|
||||
`${name}.md still exists but should have been deleted (outright dead skill)`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group: #3131 — re-wired workflows absorbed as flags
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('#3131 re-wired workflows: standalone command files must not exist', () => {
|
||||
const rewired = [
|
||||
['list-phase-assumptions', 'absorbed into discuss-phase.md --assumptions'],
|
||||
['session-report', 'absorbed into pause-work.md --report'],
|
||||
['analyze-dependencies', 'absorbed into manager.md --analyze-deps'],
|
||||
['from-gsd2', 'absorbed into import.md --from-gsd2'],
|
||||
];
|
||||
|
||||
for (const [name, reason] of rewired) {
|
||||
test(`commands/gsd/${name}.md does NOT exist (${reason})`, () => {
|
||||
assert.ok(
|
||||
!fs.existsSync(skillPath(name)),
|
||||
`${name}.md still exists as a standalone command but should be absorbed (${reason})`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#3131 re-wired workflows: parent command argument-hints advertise the new flags', () => {
|
||||
test('discuss-phase.md argument-hint contains --assumptions', () => {
|
||||
const fm = parseFrontmatter(skillPath('discuss-phase'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--assumptions'),
|
||||
'discuss-phase.md argument-hint does not contain --assumptions. got: ' + (fm['argument-hint'] || '(none)'),
|
||||
);
|
||||
});
|
||||
|
||||
test('pause-work.md argument-hint contains --report', () => {
|
||||
const fm = parseFrontmatter(skillPath('pause-work'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--report'),
|
||||
'pause-work.md argument-hint does not contain --report. got: ' + (fm['argument-hint'] || '(none)'),
|
||||
);
|
||||
});
|
||||
|
||||
test('manager.md argument-hint contains --analyze-deps', () => {
|
||||
const fm = parseFrontmatter(skillPath('manager'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--analyze-deps'),
|
||||
'manager.md argument-hint does not contain --analyze-deps. got: ' + (fm['argument-hint'] || '(none)'),
|
||||
);
|
||||
});
|
||||
|
||||
test('import.md argument-hint contains --from-gsd2', () => {
|
||||
const fm = parseFrontmatter(skillPath('import'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--from-gsd2'),
|
||||
'import.md argument-hint does not contain --from-gsd2. got: ' + (fm['argument-hint'] || '(none)'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3131 re-wired workflows: parent command bodies dispatch to workflow files', () => {
|
||||
function bodyContains(name, substring) {
|
||||
const raw = fs.readFileSync(skillPath(name), 'utf8');
|
||||
return raw.includes(substring);
|
||||
}
|
||||
|
||||
test('discuss-phase.md body references list-phase-assumptions.md', () => {
|
||||
assert.ok(
|
||||
bodyContains('discuss-phase', 'list-phase-assumptions.md'),
|
||||
'discuss-phase.md body does not reference list-phase-assumptions.md — --assumptions flag dispatch is missing',
|
||||
);
|
||||
});
|
||||
|
||||
test('pause-work.md body references session-report.md', () => {
|
||||
assert.ok(
|
||||
bodyContains('pause-work', 'session-report.md'),
|
||||
'pause-work.md body does not reference session-report.md — --report flag dispatch is missing',
|
||||
);
|
||||
});
|
||||
|
||||
test('manager.md body references analyze-dependencies.md', () => {
|
||||
assert.ok(
|
||||
bodyContains('manager', 'analyze-dependencies.md'),
|
||||
'manager.md body does not reference analyze-dependencies.md — --analyze-deps flag dispatch is missing',
|
||||
);
|
||||
});
|
||||
|
||||
test('import.md body references from-gsd2', () => {
|
||||
assert.ok(
|
||||
bodyContains('import', 'from-gsd2'),
|
||||
'import.md body does not reference from-gsd2 — --from-gsd2 flag dispatch is missing',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group: Parent skills updated with new flags
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('parent skills updated with new flags in argument-hint', () => {
|
||||
test('update.md argument-hint contains --sync', () => {
|
||||
const fm = parseFrontmatter(skillPath('update'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--sync'),
|
||||
[
|
||||
'update.md argument-hint does not contain --sync',
|
||||
'got: ' + (fm['argument-hint'] || '(none)'),
|
||||
].join('. '),
|
||||
);
|
||||
});
|
||||
|
||||
test('update.md argument-hint contains --reapply', () => {
|
||||
const fm = parseFrontmatter(skillPath('update'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--reapply'),
|
||||
[
|
||||
'update.md argument-hint does not contain --reapply',
|
||||
'got: ' + (fm['argument-hint'] || '(none)'),
|
||||
].join('. '),
|
||||
);
|
||||
});
|
||||
|
||||
test('sketch.md argument-hint contains --wrap-up', () => {
|
||||
const fm = parseFrontmatter(skillPath('sketch'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--wrap-up'),
|
||||
[
|
||||
'sketch.md argument-hint does not contain --wrap-up',
|
||||
'got: ' + (fm['argument-hint'] || '(none)'),
|
||||
].join('. '),
|
||||
);
|
||||
});
|
||||
|
||||
test('spike.md argument-hint contains --wrap-up', () => {
|
||||
const fm = parseFrontmatter(skillPath('spike'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--wrap-up'),
|
||||
[
|
||||
'spike.md argument-hint does not contain --wrap-up',
|
||||
'got: ' + (fm['argument-hint'] || '(none)'),
|
||||
].join('. '),
|
||||
);
|
||||
});
|
||||
|
||||
test('map-codebase.md argument-hint contains --fast', () => {
|
||||
const fm = parseFrontmatter(skillPath('map-codebase'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--fast'),
|
||||
[
|
||||
'map-codebase.md argument-hint does not contain --fast',
|
||||
'got: ' + (fm['argument-hint'] || '(none)'),
|
||||
].join('. '),
|
||||
);
|
||||
});
|
||||
|
||||
test('code-review.md argument-hint contains --fix', () => {
|
||||
const fm = parseFrontmatter(skillPath('code-review'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--fix'),
|
||||
[
|
||||
'code-review.md argument-hint does not contain --fix',
|
||||
'got: ' + (fm['argument-hint'] || '(none)'),
|
||||
].join('. '),
|
||||
);
|
||||
});
|
||||
|
||||
test('progress.md argument-hint contains --do', () => {
|
||||
const fm = parseFrontmatter(skillPath('progress'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--do'),
|
||||
[
|
||||
'progress.md argument-hint does not contain --do',
|
||||
'got: ' + (fm['argument-hint'] || '(none)'),
|
||||
].join('. '),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group: settings.md is NOT deleted
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('settings.md is kept (merged into config entry point or remains standalone)', () => {
|
||||
test('commands/gsd/settings.md still exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(skillPath('settings')),
|
||||
'settings.md was deleted — it should be kept (or renamed to config.md, but not both missing)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group: Skill set allowlisted (identity-based, consolidating toward ~58)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('skill set', () => {
|
||||
test('user-invocable skill set is allowlisted (consolidating toward ~58)', () => {
|
||||
// Exclude `ns-*.md` namespace meta-skills (#2792) from this guard.
|
||||
// Those are descriptor-only routers selected first by the model and
|
||||
// are not part of the consolidation surface this test tracks; their
|
||||
// own contract is enforced by tests/enh-2792-namespace-skills.test.cjs.
|
||||
const currentBasenames = fs.readdirSync(COMMANDS_DIR)
|
||||
.filter((f) => f.endsWith('.md') && !f.startsWith('ns-'));
|
||||
assertWithinAllowlist({
|
||||
label: 'user-invocable skills (commands/gsd)',
|
||||
current: currentBasenames,
|
||||
known: KNOWN_SKILLS,
|
||||
fail: assert.fail,
|
||||
pruneHint: 'edit KNOWN_SKILLS in tests/enh-2790-skill-consolidation.test.cjs',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
// allow-test-rule: integration-test-input
|
||||
// Reads execute-phase.md to extract + execute the cwd-drift guard bash snippet against real git worktrees.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execSync, spawnSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract the cwd-drift guard bash block from execute-phase.md
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reads execute-phase.md and extracts the bash fenced block that implements
|
||||
* the orchestrator cwd-drift guard inside <step name="execute_waves">.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Find <step name="execute_waves">
|
||||
* 2. After that, find the first occurrence of "cwd-drift guard"
|
||||
* 3. After that, find the first ```bash fence
|
||||
* 4. Return the body between ```bash\n and the closing ```
|
||||
*
|
||||
* Throws with a clear message if any step fails or sanity checks don't pass.
|
||||
*/
|
||||
function extractCwdGuardBash() {
|
||||
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
||||
|
||||
const stepMarker = '<step name="execute_waves">';
|
||||
const stepIdx = content.indexOf(stepMarker);
|
||||
if (stepIdx === -1) {
|
||||
throw new Error(`extractCwdGuardBash: could not find "${stepMarker}" in ${EXECUTE_PHASE_PATH}`);
|
||||
}
|
||||
|
||||
const afterStep = content.slice(stepIdx + stepMarker.length);
|
||||
|
||||
const driftMarker = 'cwd-drift guard';
|
||||
const driftIdx = afterStep.indexOf(driftMarker);
|
||||
if (driftIdx === -1) {
|
||||
throw new Error(`extractCwdGuardBash: could not find "${driftMarker}" after execute_waves step in ${EXECUTE_PHASE_PATH}`);
|
||||
}
|
||||
|
||||
const afterDrift = afterStep.slice(driftIdx + driftMarker.length);
|
||||
|
||||
// Extract the first ```bash|sh fenced block using a CRLF-safe regex.
|
||||
// \r?\n tolerates both LF (Unix) and CRLF (Windows autocrlf=true checkouts).
|
||||
const fenceRe = /```(?:bash|sh)\r?\n([\s\S]*?)```/;
|
||||
const fenceMatch = fenceRe.exec(afterDrift);
|
||||
if (!fenceMatch) {
|
||||
throw new Error(`extractCwdGuardBash: could not find \`\`\`bash fence after cwd-drift guard heading in ${EXECUTE_PHASE_PATH}`);
|
||||
}
|
||||
|
||||
const guardBash = fenceMatch[1];
|
||||
|
||||
if (!guardBash.trim()) {
|
||||
throw new Error('extractCwdGuardBash: extracted bash block is empty');
|
||||
}
|
||||
if (!guardBash.includes('git rev-parse --show-toplevel')) {
|
||||
throw new Error('extractCwdGuardBash: sanity check failed — extracted block does not contain "git rev-parse --show-toplevel"');
|
||||
}
|
||||
if (!guardBash.includes('worktree-agent-')) {
|
||||
throw new Error('extractCwdGuardBash: sanity check failed — extracted block does not contain "worktree-agent-"');
|
||||
}
|
||||
|
||||
return guardBash;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run guard helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run the guard bash snippet in a given cwd using bash -c.
|
||||
* Returns { status, stderr }.
|
||||
*/
|
||||
function runGuard(guardBash, cwd) {
|
||||
const result = spawnSync('bash', ['-c', guardBash], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return { status: result.status, stderr: result.stderr || '' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let upstreamDir; // bare upstream git repo (the main worktree)
|
||||
let featureDir; // normal feature worktree on branch workspace/feature-x
|
||||
let agentWtDir; // agent worktree on branch worktree-agent-deadbeef
|
||||
let agentSubdir; // subdirectory inside agentWtDir
|
||||
let legitUnderClaude; // non-agent worktree whose PATH is under .claude/worktrees/
|
||||
const dirsToCleanup = [];
|
||||
|
||||
function git(cwd, args) {
|
||||
return execSync(`git ${args.map(a => `"${a}"`).join(' ')}`, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
before(() => {
|
||||
// --- upstream: the main repo with an initial commit ---
|
||||
upstreamDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-48-upstream-'));
|
||||
dirsToCleanup.push(upstreamDir);
|
||||
|
||||
git(upstreamDir, ['init', '-b', 'main']);
|
||||
git(upstreamDir, ['config', 'user.email', 'test@example.com']);
|
||||
git(upstreamDir, ['config', 'user.name', 'Test User']);
|
||||
git(upstreamDir, ['config', 'commit.gpgsign', 'false']);
|
||||
fs.writeFileSync(path.join(upstreamDir, 'README.md'), '# test\n');
|
||||
git(upstreamDir, ['add', 'README.md']);
|
||||
git(upstreamDir, ['commit', '-m', 'chore: init']);
|
||||
|
||||
// --- feature worktree: non-agent branch, path outside .claude/worktrees ---
|
||||
featureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-48-feature-'));
|
||||
dirsToCleanup.push(featureDir);
|
||||
// git worktree add creates the directory itself; remove so it can do so
|
||||
fs.rmdirSync(featureDir);
|
||||
git(upstreamDir, ['worktree', 'add', '-b', 'workspace/feature-x', featureDir]);
|
||||
|
||||
// --- agent worktree: branch worktree-agent-deadbeef ---
|
||||
// Sits under featureDir/.claude/worktrees/agent-deadbeef
|
||||
const agentWtParent = path.join(featureDir, '.claude', 'worktrees');
|
||||
fs.mkdirSync(agentWtParent, { recursive: true });
|
||||
agentWtDir = path.join(agentWtParent, 'agent-deadbeef');
|
||||
git(upstreamDir, ['worktree', 'add', '-b', 'worktree-agent-deadbeef', agentWtDir]);
|
||||
|
||||
// --- subdir inside agent worktree ---
|
||||
agentSubdir = path.join(agentWtDir, 'src', 'deep');
|
||||
fs.mkdirSync(agentSubdir, { recursive: true });
|
||||
|
||||
// --- legitUnderClaude: non-agent worktree whose PATH is under .claude/worktrees/ ---
|
||||
// This proves the guard discriminates by branch name, not path.
|
||||
const legitParent = path.join(upstreamDir, '.claude', 'worktrees');
|
||||
fs.mkdirSync(legitParent, { recursive: true });
|
||||
legitUnderClaude = path.join(legitParent, 'legit-feature');
|
||||
git(upstreamDir, ['worktree', 'add', '-b', 'workspace/legit', legitUnderClaude]);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
// Prune stale worktree metadata before removing dirs
|
||||
try { git(upstreamDir, ['worktree', 'prune']); } catch (_) { /* best-effort */ }
|
||||
for (const d of dirsToCleanup) {
|
||||
try { cleanup(d); } catch (_) { /* best-effort */ }
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #48: orchestrator cwd-drift guard — executable e2e', () => {
|
||||
let guardBash;
|
||||
|
||||
before(() => {
|
||||
guardBash = extractCwdGuardBash();
|
||||
});
|
||||
|
||||
test('guard passes from a feature worktree on a non-agent branch (exit 0)', () => {
|
||||
const { status, stderr } = runGuard(guardBash, featureDir);
|
||||
assert.equal(
|
||||
status, 0,
|
||||
`Expected exit 0 from feature worktree, got ${status}. stderr: ${stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('guard fails closed (exit 1) when cwd is inside an agent worktree', () => {
|
||||
const { status, stderr } = runGuard(guardBash, agentWtDir);
|
||||
assert.equal(
|
||||
status, 1,
|
||||
`Expected exit 1 from agent worktree, got ${status}. stderr: ${stderr}`,
|
||||
);
|
||||
assert.match(
|
||||
stderr,
|
||||
/agent worktree/i,
|
||||
`Expected stderr to mention "agent worktree", got: ${stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('guard fails closed (exit 1) from a SUBDIRECTORY of an agent worktree (root resolution)', () => {
|
||||
// git rev-parse --show-toplevel resolves to the worktree root regardless of cwd subdir.
|
||||
// The guard must catch this via the branch-name check, not the path check.
|
||||
const { status, stderr } = runGuard(guardBash, agentSubdir);
|
||||
assert.equal(
|
||||
status, 1,
|
||||
`Expected exit 1 from agent worktree subdir, got ${status}. stderr: ${stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('guard does NOT blanket-refuse a non-agent worktree located under .claude/worktrees/ (exit 0)', () => {
|
||||
// Discriminator is the worktree-agent-* branch namespace, NOT the path.
|
||||
const { status, stderr } = runGuard(guardBash, legitUnderClaude);
|
||||
assert.equal(
|
||||
status, 0,
|
||||
`Expected exit 0 from non-agent worktree under .claude/worktrees/, got ${status}. stderr: ${stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('guard fails closed (exit 1) when not inside a git repo', (t) => {
|
||||
const nonRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-48-nongit-'));
|
||||
try {
|
||||
// Verify that git rev-parse --show-toplevel actually fails here.
|
||||
// On some systems /tmp itself might be inside a git repo (e.g. if the
|
||||
// user's HOME is a git repo). If it resolves, we must skip this test.
|
||||
const check = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
||||
cwd: nonRepoDir,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
if (check.status === 0) {
|
||||
t.skip('nonRepoDir unexpectedly resolved to a git repo — skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const { status, stderr } = runGuard(guardBash, nonRepoDir);
|
||||
assert.equal(
|
||||
status, 1,
|
||||
`Expected exit 1 when not inside a git repo, got ${status}. stderr: ${stderr}`,
|
||||
);
|
||||
} finally {
|
||||
try { cleanup(nonRepoDir); } catch (_) { /* best-effort */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,355 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// `workflows/help/modes/*.md` files ARE the help output — their text is what
|
||||
// the runtime emits when the user runs `/gsd:help [--brief|--full|<topic>]`.
|
||||
// Asserting on their structure tests the deployed contract directly.
|
||||
|
||||
/**
|
||||
* Feature #3039: tiered /gsd:help output.
|
||||
*
|
||||
* The legacy single-file 747-line help is replaced by:
|
||||
* - workflows/help.md — small dispatcher (progressive disclosure)
|
||||
* - workflows/help/modes/brief.md — ~one-liner refresher
|
||||
* - workflows/help/modes/default.md — one-page newcomer tour
|
||||
* - workflows/help/modes/full.md — complete reference (former help.md body)
|
||||
* - workflows/help/modes/topic.md — section-extraction logic + alias table
|
||||
*
|
||||
* This test enforces the contract:
|
||||
* 1. All four mode files exist with a single `<reference>` block.
|
||||
* 2. brief and default fit a "one screen" budget; full stays under LARGE tier cap.
|
||||
* 3. The dispatcher routes on $ARGUMENTS to all four mode files (structural parse).
|
||||
* 4. Dispatcher conflict-resolution rules are documented:
|
||||
* - `--brief` + `--full` without a topic → prefer `--full`
|
||||
* - `--brief <topic>` → topic.md in compact scope (composable)
|
||||
* - bare or `--full <topic>` → topic.md in full scope
|
||||
* 5. topic.md documents an explicit routing preamble + compact-scope rule.
|
||||
* 6. Every topic alias in topic.md resolves to a heading that exists in full.md.
|
||||
* 7. Every /gsd:* sub-block token in topic.md's alias table appears in full.md.
|
||||
* 8. Every full.md heading is either aliased or in the intentional-orphan allowlist.
|
||||
* 9. The `commands/gsd/help.md` shim passes `$ARGUMENTS` through and advertises
|
||||
* the composable `--brief <topic>` form.
|
||||
*
|
||||
* Tighten-only invariant (issue #597): ceilings track the per-tier high-water mark
|
||||
* within GRACE lines. Budgets may only decrease, never silently creep upward.
|
||||
* The assertTightCeiling() calls below enforce this automatically.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { assertTightCeiling } = require('../scripts/lib/allowlist-ratchet.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const WORKFLOWS = path.join(ROOT, 'gsd-core', 'workflows');
|
||||
const MODES = path.join(WORKFLOWS, 'help', 'modes');
|
||||
const DISPATCHER = path.join(WORKFLOWS, 'help.md');
|
||||
const COMMAND_SHIM = path.join(ROOT, 'commands', 'gsd', 'help.md');
|
||||
|
||||
const MODE_FILES = ['brief.md', 'default.md', 'full.md', 'topic.md'];
|
||||
|
||||
// "One screen" budgets, including frontmatter/<purpose>/<reference> tags.
|
||||
// These are conservative (one-page conceptual size of ~25 lines of usable
|
||||
// content) but allow for the wrapping tags. Tighten as content stabilizes.
|
||||
//
|
||||
// Ceilings tightened to actualMax + SMALL_GRACE per the ratchet-down rule (#597).
|
||||
// BRIEF ceiling kept at 30 (actualMax=22, slack=8 ≤ SMALL_GRACE=10).
|
||||
const BRIEF_BUDGET = 30;
|
||||
// DEFAULT ceiling lowered from 70 → 60 (actualMax=50; #597 ratchet-down).
|
||||
const DEFAULT_BUDGET = 60;
|
||||
// full.md is the LARGE tier (see workflow-size-budget.test.cjs — now byte-based per #717;
|
||||
// this FULL_BUDGET is a separate line-count budget for help/modes/full.md).
|
||||
// The size-budget test is non-recursive so full.md is not covered there; cap it here.
|
||||
// FULL ceiling lowered from 1500 → 844 (actualMax=784; #597 ratchet-down).
|
||||
const FULL_BUDGET = 844;
|
||||
|
||||
// Grace bands:
|
||||
// SMALL_GRACE — for the tiny brief/default/dispatcher files (≤ ~70 lines):
|
||||
// 10 lines of breathing room is proportionate and prevents trivial edits from
|
||||
// failing while still catching any meaningful upward creep.
|
||||
// LARGE_GRACE — for full.md where content fluctuates more:
|
||||
// 60 lines matches the line-budget GRACE used in the other size-budget tests.
|
||||
const SMALL_GRACE = 10;
|
||||
const LARGE_GRACE = 60;
|
||||
|
||||
function read(file) {
|
||||
return fs.readFileSync(file, 'utf8');
|
||||
}
|
||||
|
||||
function lineCount(file) {
|
||||
const c = read(file);
|
||||
if (c.length === 0) return 0;
|
||||
const trail = c.endsWith('\n') ? 1 : 0;
|
||||
return c.split('\n').length - trail;
|
||||
}
|
||||
|
||||
describe('feature #3039: tiered help — file structure', () => {
|
||||
for (const f of MODE_FILES) {
|
||||
test(`mode file exists: ${f}`, () => {
|
||||
assert.ok(fs.existsSync(path.join(MODES, f)), `missing ${path.join(MODES, f)}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Dispatcher ceiling lowered from 40 → 34 (actualMax=24; #597 ratchet-down).
|
||||
const DISPATCHER_BUDGET = 34;
|
||||
test(`dispatcher exists and is small (≤ ${DISPATCHER_BUDGET} lines)`, () => {
|
||||
assert.ok(fs.existsSync(DISPATCHER));
|
||||
const n = lineCount(DISPATCHER);
|
||||
assert.ok(n <= DISPATCHER_BUDGET, `dispatcher should be small; got ${n} lines`);
|
||||
assertTightCeiling({ label: 'dispatcher', actualMax: n, ceiling: DISPATCHER_BUDGET, grace: SMALL_GRACE, fail: assert.fail });
|
||||
});
|
||||
|
||||
for (const f of MODE_FILES) {
|
||||
test(`${f} has exactly one <reference> block (line-anchored)`, () => {
|
||||
const content = read(path.join(MODES, f));
|
||||
// Anchor on start-of-line so prose mentions of `<reference>` inside
|
||||
// <purpose> blocks aren't counted.
|
||||
const opens = (content.match(/^<reference>$/gm) || []).length;
|
||||
const closes = (content.match(/^<\/reference>$/gm) || []).length;
|
||||
assert.equal(opens, 1, `${f}: expected 1 <reference> opening line, got ${opens}`);
|
||||
assert.equal(closes, 1, `${f}: expected 1 </reference> closing line, got ${closes}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('feature #3039: tiered help — size budgets', () => {
|
||||
test(`brief.md fits one screen (≤ ${BRIEF_BUDGET} lines)`, () => {
|
||||
const n = lineCount(path.join(MODES, 'brief.md'));
|
||||
assert.ok(n <= BRIEF_BUDGET, `brief.md is ${n} lines, budget ${BRIEF_BUDGET}`);
|
||||
assertTightCeiling({ label: 'BRIEF', actualMax: n, ceiling: BRIEF_BUDGET, grace: SMALL_GRACE, fail: assert.fail });
|
||||
});
|
||||
|
||||
test(`default.md fits one screen (≤ ${DEFAULT_BUDGET} lines)`, () => {
|
||||
const n = lineCount(path.join(MODES, 'default.md'));
|
||||
assert.ok(n <= DEFAULT_BUDGET, `default.md is ${n} lines, budget ${DEFAULT_BUDGET}`);
|
||||
assertTightCeiling({ label: 'DEFAULT', actualMax: n, ceiling: DEFAULT_BUDGET, grace: SMALL_GRACE, fail: assert.fail });
|
||||
});
|
||||
|
||||
test('full.md preserves the complete reference (≥ 600 lines)', () => {
|
||||
// The pre-#3039 reference was 747 lines. Guard against accidental shrinkage
|
||||
// that would amount to silently removing content from --full.
|
||||
const n = lineCount(path.join(MODES, 'full.md'));
|
||||
assert.ok(n >= 600, `full.md is ${n} lines — too small, content may have been lost`);
|
||||
});
|
||||
|
||||
test(`full.md stays under LARGE workflow budget (≤ ${FULL_BUDGET} lines)`, () => {
|
||||
// full.md lives in a subdirectory and is not enumerated by the non-recursive
|
||||
// workflow-size-budget.test.cjs. Cap it here at the LARGE tier limit.
|
||||
const n = lineCount(path.join(MODES, 'full.md'));
|
||||
assert.ok(n <= FULL_BUDGET, `full.md grew to ${n} lines (LARGE budget: ${FULL_BUDGET})`);
|
||||
assertTightCeiling({ label: 'FULL', actualMax: n, ceiling: FULL_BUDGET, grace: LARGE_GRACE, fail: assert.fail });
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature #3039: tiered help — dispatcher routing (structural)', () => {
|
||||
const dispatcher = read(DISPATCHER);
|
||||
|
||||
function extractDisclosureBlock(src) {
|
||||
const m = src.match(/<progressive_disclosure>([\s\S]*?)<\/progressive_disclosure>/);
|
||||
assert.ok(m, 'dispatcher must contain a <progressive_disclosure> block');
|
||||
return m[1];
|
||||
}
|
||||
|
||||
test('dispatcher <progressive_disclosure> block has exactly 5 routing rows', () => {
|
||||
// 4 base tiers (brief, full, default, topic) + 1 composable row (--brief <topic>).
|
||||
const block = extractDisclosureBlock(dispatcher);
|
||||
// Table rows are lines starting with `|`, excluding the header and separator rows.
|
||||
const rows = block.split('\n')
|
||||
.filter(l => /^\|/.test(l))
|
||||
.filter(l => !/^\|\s*[-:]+\s*\|/.test(l)) // strip separator rows
|
||||
.filter(l => !/when.*arguments/i.test(l)); // strip header row
|
||||
assert.equal(rows.length, 5,
|
||||
`dispatcher routing table must have exactly 5 rows; got ${rows.length}:\n${rows.join('\n')}`);
|
||||
});
|
||||
|
||||
test('dispatcher routes --brief to brief.md', () => {
|
||||
const block = extractDisclosureBlock(dispatcher);
|
||||
assert.match(block, /`--brief`[\s\S]*?brief\.md/);
|
||||
});
|
||||
|
||||
test('dispatcher routes --full to full.md', () => {
|
||||
const block = extractDisclosureBlock(dispatcher);
|
||||
assert.match(block, /`--full`[\s\S]*?full\.md/);
|
||||
});
|
||||
|
||||
test('dispatcher routes empty/no-flag args to default.md', () => {
|
||||
const block = extractDisclosureBlock(dispatcher);
|
||||
assert.match(block, /(empty|unset)[\s\S]*?default\.md/i);
|
||||
});
|
||||
|
||||
test('dispatcher routes topic args to topic.md', () => {
|
||||
const block = extractDisclosureBlock(dispatcher);
|
||||
assert.match(block, /topic[\s\S]*?topic\.md/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature #3039: tiered help — dispatcher conflict-resolution rules', () => {
|
||||
const dispatcher = read(DISPATCHER);
|
||||
|
||||
test('dispatcher documents --brief + --full (without topic) conflict resolution (prefer --full)', () => {
|
||||
// help.md argument parsing rules: "if both appear *without* a topic, prefer `--full`"
|
||||
assert.match(dispatcher, /prefer.*--full/);
|
||||
});
|
||||
|
||||
test('dispatcher routes --brief <topic> to topic.md in compact scope (composable)', () => {
|
||||
// help.md argument parsing rules: "--brief combined with a topic invokes topic.md
|
||||
// in compact scope" — the composable scoped-lookup form (trek-e review finding #4).
|
||||
assert.match(dispatcher, /--brief[^|]*<topic>[\s\S]*?topic\.md[\s\S]*?compact/i);
|
||||
});
|
||||
|
||||
test('dispatcher routes --full <topic> (or bare topic) to topic.md in full scope', () => {
|
||||
// Bare topic, `--full <topic>`, or topic with leading `--` → full scope.
|
||||
assert.match(dispatcher, /(bare topic|--full <topic>)[\s\S]*?full scope/i);
|
||||
});
|
||||
|
||||
test('dispatcher tells topic.md to retain --brief when delegating', () => {
|
||||
// The dispatcher passes $ARGUMENTS through; topic.md needs to see --brief to
|
||||
// choose compact scope. Guard against accidental flag-stripping.
|
||||
assert.match(dispatcher, /retain.*--brief|pass.*--brief/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature #3039: tiered help — command shim passes $ARGUMENTS', () => {
|
||||
const shim = read(COMMAND_SHIM);
|
||||
|
||||
test('shim references $ARGUMENTS', () => {
|
||||
assert.match(shim, /\$ARGUMENTS/);
|
||||
});
|
||||
|
||||
test('shim declares argument-hint frontmatter', () => {
|
||||
assert.match(shim, /argument-hint:/);
|
||||
});
|
||||
|
||||
test('shim argument-hint advertises composable --brief <topic>', () => {
|
||||
// Discoverability: users need to know the composable form is supported
|
||||
// (trek-e review finding #4).
|
||||
assert.match(shim, /argument-hint:[^\n]*--brief[^\n]*<topic>/);
|
||||
});
|
||||
|
||||
test('shim references the help workflow', () => {
|
||||
assert.match(shim, /workflows\/help\.md/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature #3039: tiered help — topic.md routing visibility + compact scope', () => {
|
||||
const topicSrc = read(path.join(MODES, 'topic.md'));
|
||||
|
||||
test('topic.md documents an explicit resolved-routing preamble', () => {
|
||||
// Trek-e review finding #3: routing must be explicit in output so the user
|
||||
// can see which alias matched which heading and at what scope.
|
||||
assert.match(topicSrc, /\*\*Topic:\*\*[\s\S]*<alias>[\s\S]*<heading>/);
|
||||
assert.match(topicSrc, /scope:.*full.*\|.*compact/i);
|
||||
});
|
||||
|
||||
test('topic.md documents a compact scope distinct from full scope', () => {
|
||||
// Trek-e review finding #4: --brief <topic> must produce a compact
|
||||
// scoped lookup (signature + one-line summary), not the full section.
|
||||
assert.match(topicSrc, /compact scope/i);
|
||||
assert.match(topicSrc, /signature.*one-line summary|signature \+ one-line/i);
|
||||
});
|
||||
|
||||
test('topic.md parses --brief flag and strips it before resolving the alias', () => {
|
||||
// Compact scope must trigger off the --brief flag in $ARGUMENTS; the
|
||||
// remaining token is the alias.
|
||||
assert.match(topicSrc, /--brief.*-b.*compact scope|compact scope[\s\S]*--brief/i);
|
||||
});
|
||||
|
||||
test('topic.md closing "More:" line advertises the composable form', () => {
|
||||
assert.match(topicSrc, /More:[\s\S]*--brief <topic>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature #3039: tiered help — topic alias coverage', () => {
|
||||
const topicSrc = read(path.join(MODES, 'topic.md'));
|
||||
const fullSrc = read(path.join(MODES, 'full.md'));
|
||||
|
||||
// Extract the alias table portion of topic.md (before "**Output rules:**")
|
||||
function aliasTableSection(src) {
|
||||
return src.split('**Output rules:**')[0];
|
||||
}
|
||||
|
||||
// Extract the canonical heading text referenced from each row of the
|
||||
// alias table. Rows look like: `| aliases | \`## Heading\` ... |`.
|
||||
// We accept either ## or ### and pull the literal heading text.
|
||||
function extractReferencedHeadings(src) {
|
||||
const headings = new Set();
|
||||
const re = /`(#{2,3} [^`]+?)`/g;
|
||||
let m;
|
||||
while ((m = re.exec(src)) !== null) {
|
||||
headings.add(m[1].trim());
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
function fullHeadings(src) {
|
||||
const set = new Set();
|
||||
for (const line of src.split('\n')) {
|
||||
const m = line.match(/^(#{2,3}) (.+?)\s*$/);
|
||||
if (m) set.add(`${m[1]} ${m[2]}`);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
test('every heading referenced in topic.md exists in full.md', () => {
|
||||
const referenced = extractReferencedHeadings(aliasTableSection(topicSrc));
|
||||
const present = fullHeadings(fullSrc);
|
||||
const missing = [...referenced].filter((h) => !present.has(h)).sort();
|
||||
assert.deepEqual(missing, [],
|
||||
`topic.md references headings not present in full.md: ${missing.join(' | ')}`);
|
||||
});
|
||||
|
||||
test('every /gsd:* sub-block token in topic.md alias table exists in full.md', () => {
|
||||
// Validates fix for review finding #2: sub-block aliases reference bold-line
|
||||
// anchors (**`/gsd:X`**) — assert each token actually appears in full.md.
|
||||
const tableSection = aliasTableSection(topicSrc);
|
||||
const tokens = [...tableSection.matchAll(/`(\/gsd:[a-z-]+(?:\s+--[a-z-]+)?)`/g)].map(m => m[1]);
|
||||
assert.ok(tokens.length > 0, 'expected at least one /gsd:* token in alias table');
|
||||
const missing = tokens.filter(t => !fullSrc.includes(t));
|
||||
assert.deepEqual(missing, [],
|
||||
`topic.md references /gsd:* tokens not present in full.md: ${missing.join(' | ')}`);
|
||||
});
|
||||
|
||||
test('every full.md heading is either aliased or in the intentional-orphan allowlist', () => {
|
||||
// Catches newly added headings that have no alias (contributor must either
|
||||
// alias the section or explicitly add it to INTENTIONAL_ORPHANS below).
|
||||
const INTENTIONAL_ORPHANS = new Set([
|
||||
'## Quick Start',
|
||||
'## Staying Updated',
|
||||
'### Utility Commands', // covered by cleanup/update sub-block aliases
|
||||
'## Additional Commands',
|
||||
'### Discovery & Specification',
|
||||
'### Planning & Execution',
|
||||
'### Quality, Review & Verification',
|
||||
'### Diagnostics & Maintenance',
|
||||
'### Knowledge & Context',
|
||||
'### Workflow & Orchestration',
|
||||
'### Repository Integration',
|
||||
'### Namespace Routers (model-facing meta-skills)',
|
||||
]);
|
||||
|
||||
const allHeadings = fullSrc.split('\n')
|
||||
.filter(l => /^#{2,3} /.test(l))
|
||||
.map(l => l.trim());
|
||||
|
||||
const aliased = extractReferencedHeadings(aliasTableSection(topicSrc));
|
||||
|
||||
const orphans = allHeadings.filter(h => !aliased.has(h) && !INTENTIONAL_ORPHANS.has(h));
|
||||
assert.deepEqual(orphans, [],
|
||||
`full.md headings not aliased in topic.md (add to INTENTIONAL_ORPHANS if intentional): ${orphans.join(' | ')}`);
|
||||
});
|
||||
|
||||
test('topic.md covers the core topics promised in default.md', () => {
|
||||
// Surface contract: default.md advertises a "Topics:" line. Each alias
|
||||
// there must appear as a recognized topic in topic.md's alias table.
|
||||
const def = read(path.join(MODES, 'default.md'));
|
||||
const topicsLine = def.split('\n').find((l) => /^Topics:/i.test(l));
|
||||
assert.ok(topicsLine, 'default.md must advertise a "Topics:" line for users');
|
||||
// Strip the leading "Topics:" prefix, then pull every backticked token.
|
||||
const aliases = [...topicsLine.matchAll(/`([a-z][a-z0-9-]*)`/g)].map((m) => m[1]);
|
||||
assert.ok(aliases.length >= 5, `expected at least 5 promoted topic aliases; got ${aliases.length}`);
|
||||
const missing = aliases.filter((a) => !new RegExp(`\`${a}\``).test(topicSrc));
|
||||
assert.deepEqual(missing, [],
|
||||
`default.md promotes topic aliases that topic.md does not recognize: ${missing.join(', ')}`);
|
||||
});
|
||||
});
|
||||
@@ -1,303 +0,0 @@
|
||||
/**
|
||||
* CLI negative matrix for the `config` command family (#3593).
|
||||
*
|
||||
* Exercises the 12 adversarial input categories enumerated in
|
||||
* CONTRIBUTING.md §"QA Matrix Requirements / CLI and command routing"
|
||||
* against `config-get` and `config-set`. The harness in
|
||||
* `tests/helpers/cli-negative.cjs` shapes spawnSync output into a typed
|
||||
* IR so every assertion runs on `result.reason`, `result.status`, and
|
||||
* `result.hasStackTrace` — never on stderr/stdout prose.
|
||||
*
|
||||
* Each test gets its own temp project (no shared state) so concurrent
|
||||
* runs can't observe each other's filesystem mutations. Hostile values
|
||||
* (shell metacharacters, null bytes, unicode, very long strings) reach
|
||||
* the CLI as single argv elements via spawnSync — never composed into
|
||||
* a shell string — so the test framework itself can't be the source of
|
||||
* a false positive on shell-injection assertions.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { runCli } = require('./helpers/cli-negative.cjs');
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
/**
|
||||
* Universal invariants every adversarial case must satisfy when the
|
||||
* CLI is invoked with --json-errors. Bundling these in a helper keeps
|
||||
* each test focused on the case-specific reason assertion.
|
||||
*/
|
||||
function assertSafeFailure(result, msg = '') {
|
||||
assert.notEqual(result.status, 0, `${msg} :: expected non-zero exit`);
|
||||
assert.equal(result.signal, null, `${msg} :: must exit cleanly, not via signal`);
|
||||
assert.equal(result.hasStackTrace, false, `${msg} :: stderr must not leak a V8 stack frame`);
|
||||
assert.equal(result.ok, false, `${msg} :: JSON payload ok must be false`);
|
||||
assert.equal(typeof result.reason, 'string', `${msg} :: reason must be a string`);
|
||||
assert.notEqual(result.reason, '', `${msg} :: reason must not be empty`);
|
||||
// The harness's JSON-shape detection runs on the trimmed stderr; if we
|
||||
// got here with reason set, the payload was a valid object — that already
|
||||
// implies no rogue prose was mixed in. Re-asserting the trimmed form would
|
||||
// be redundant.
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the file inventory of a directory so a later assertion can
|
||||
* prove the failing CLI invocation did NOT create or modify any file.
|
||||
*/
|
||||
function snapshotInventory(dir) {
|
||||
const entries = [];
|
||||
function walk(rel) {
|
||||
const abs = path.join(dir, rel);
|
||||
let stat;
|
||||
try { stat = fs.lstatSync(abs); } catch { return; }
|
||||
if (stat.isDirectory()) {
|
||||
for (const name of fs.readdirSync(abs).sort()) walk(path.join(rel, name));
|
||||
} else {
|
||||
entries.push(`${rel}\t${stat.size}\t${stat.mtimeMs}`);
|
||||
}
|
||||
}
|
||||
walk('.');
|
||||
return entries.join('\n');
|
||||
}
|
||||
|
||||
// ─── 1. Missing required arg ────────────────────────────────────────────────
|
||||
|
||||
test('config-get with no key fails with a typed reason and no stack trace', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-1-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const before = snapshotInventory(projectDir);
|
||||
const result = runCli(['config-get'], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'config-get missing key');
|
||||
assert.equal(snapshotInventory(projectDir), before, 'failing read must not mutate FS');
|
||||
});
|
||||
|
||||
test('config-set with no key fails with a typed reason', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-2-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['config-set'], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'config-set missing key');
|
||||
});
|
||||
|
||||
test('config-set with key but no value fails with a typed reason', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-3-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['config-set', 'model_profile'], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'config-set missing value');
|
||||
});
|
||||
|
||||
// ─── 2/3. Empty / whitespace arg ────────────────────────────────────────────
|
||||
|
||||
test('config-get with empty-string key fails safely', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-4-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const before = snapshotInventory(projectDir);
|
||||
const result = runCli(['config-get', ''], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'config-get empty key');
|
||||
assert.equal(snapshotInventory(projectDir), before, 'failing read must not mutate FS');
|
||||
});
|
||||
|
||||
test('config-get with whitespace-only key fails safely', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-5-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['config-get', ' \t '], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'config-get whitespace key');
|
||||
});
|
||||
|
||||
test('config-set with empty key string fails safely', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-6-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['config-set', '', 'value'], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'config-set empty key');
|
||||
});
|
||||
|
||||
// ─── 4. Duplicate flags ─────────────────────────────────────────────────────
|
||||
|
||||
test('--cwd specified twice does not silently use the wrong one', (t) => {
|
||||
// Make two real but distinct dirs so the test can't accidentally pass
|
||||
// because one of the paths is invalid.
|
||||
const a = createTempProject('cli-neg-config-7a-');
|
||||
const b = createTempProject('cli-neg-config-7b-');
|
||||
t.after(() => { cleanup(a); cleanup(b); });
|
||||
// No --json-errors here on purpose: --cwd is parsed before json mode is
|
||||
// applied, so we exercise both code paths by running once each.
|
||||
const result = runCli(['--cwd', a, '--cwd', b, 'config-get', 'model_profile'], { cwd: process.cwd() });
|
||||
// Either: (a) the CLI rejects duplicate --cwd with a typed reason; OR
|
||||
// (b) it commits to one of the values deterministically. The safety
|
||||
// bar is "no stack trace, no half-state mutation in EITHER dir".
|
||||
assert.equal(result.hasStackTrace, false, 'duplicate --cwd must not crash with a stack trace');
|
||||
// Neither tmp dir should have a written config since model_profile is
|
||||
// a read, not a write, and it didn't exist beforehand. Prove the read
|
||||
// didn't accidentally trigger a write side effect.
|
||||
assert.equal(fs.existsSync(path.join(a, '.planning', 'config.json')), false);
|
||||
assert.equal(fs.existsSync(path.join(b, '.planning', 'config.json')), false);
|
||||
});
|
||||
|
||||
// ─── 5. Conflicting flags ───────────────────────────────────────────────────
|
||||
|
||||
test('--json-errors with --no-such-flag does not crash with a stack trace', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-8-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['--no-such-flag', 'config-get', 'model_profile'], { cwd: projectDir });
|
||||
assert.equal(result.hasStackTrace, false, 'unknown global flag must not crash with a stack trace');
|
||||
assert.notEqual(result.status, 0, 'unknown global flag must fail');
|
||||
});
|
||||
|
||||
// ─── 6. Malformed assignment / unknown subcommand ──────────────────────────
|
||||
|
||||
test('config-FAKE subcommand fails with a typed reason', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-9-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['config-FAKE'], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'unknown config-* command');
|
||||
});
|
||||
|
||||
// ─── 7. Unknown subcommands at each command depth ───────────────────────────
|
||||
|
||||
test('config family — bare top-level "config" without a subcommand fails safely', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-10-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['config'], { cwd: projectDir });
|
||||
// Either "missing subcommand" usage or genuine no-op behavior — what we
|
||||
// pin is "no stack trace, no FS mutation".
|
||||
assert.equal(result.hasStackTrace, false);
|
||||
});
|
||||
|
||||
// ─── 8. Values that look like flags ─────────────────────────────────────────
|
||||
|
||||
test('config-set value that starts with -- is treated as a value, not a flag', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-11-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
// First create a config.json so the set has a target file.
|
||||
runCli(['config-ensure-section'], { cwd: projectDir });
|
||||
const result = runCli(['config-set', 'project_code', '--weird'], { cwd: projectDir });
|
||||
// Acceptable outcomes:
|
||||
// (a) CLI accepts --weird as the value (and persists it),
|
||||
// (b) CLI rejects it as a usage error.
|
||||
// Either way: no stack trace, no half-written corrupt config.
|
||||
assert.equal(result.hasStackTrace, false, 'value-looking-like-a-flag must not crash');
|
||||
const configPath = path.join(projectDir, '.planning', 'config.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
// If a config exists, it must still be valid JSON — no half-write corruption.
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
assert.doesNotThrow(() => JSON.parse(raw), 'config.json must remain parseable after a failed set');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 9. Invalid JSON / corrupt config file ──────────────────────────────────
|
||||
|
||||
test('config-get against a corrupt config.json fails with a parse-failed reason', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-12-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const configPath = path.join(projectDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, '{ this is not json'); // deliberate corruption
|
||||
const originalCorrupt = fs.readFileSync(configPath, 'utf-8');
|
||||
const result = runCli(['config-get', 'model_profile'], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'corrupt config.json');
|
||||
// The corrupt file must remain untouched — the CLI must not "helpfully"
|
||||
// overwrite an unparseable config in the failure path.
|
||||
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalCorrupt, 'corrupt file must be preserved as-is');
|
||||
// Specific reason: CONFIG_PARSE_FAILED (or equivalent) — pin this so a
|
||||
// regression where parse failure leaks as "unknown" is caught.
|
||||
assert.match(
|
||||
result.reason,
|
||||
/^(config_parse_failed|config_no_file|config_invalid_key|usage)$/,
|
||||
`parse-failure reason must be from the typed ERROR_REASON enum (got: ${result.reason})`,
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 10. Very long arg ──────────────────────────────────────────────────────
|
||||
|
||||
test('config-get with a very long key (50KB) fails safely without hanging', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-13-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const longKey = 'x'.repeat(50000);
|
||||
const result = runCli(['config-get', longKey], { cwd: projectDir, timeoutMs: 8000 });
|
||||
assert.equal(result.signal, null, 'long input must not trigger the harness timeout');
|
||||
assert.equal(result.hasStackTrace, false, 'long input must not crash');
|
||||
assert.notEqual(result.status, 0, 'unknown 50KB key must fail');
|
||||
});
|
||||
|
||||
// ─── 11. Unicode / non-ASCII ────────────────────────────────────────────────
|
||||
|
||||
test('config-get with a Unicode key fails safely', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-14-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['config-get', 'workflow.🔥_mode'], { cwd: projectDir });
|
||||
assertSafeFailure(result, 'unicode key');
|
||||
});
|
||||
|
||||
test('config-set with an emoji value persists or rejects without corrupting JSON', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-15-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
runCli(['config-ensure-section'], { cwd: projectDir });
|
||||
const result = runCli(['config-set', 'project_code', '🔥👾'], { cwd: projectDir });
|
||||
assert.equal(result.hasStackTrace, false);
|
||||
// If it accepted, the JSON must round-trip cleanly.
|
||||
const configPath = path.join(projectDir, '.planning', 'config.json');
|
||||
if (result.status === 0 && fs.existsSync(configPath)) {
|
||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
assert.equal(typeof parsed, 'object', 'config.json must be a valid object');
|
||||
if (parsed.project_code != null) {
|
||||
assert.equal(typeof parsed.project_code, 'string', 'project_code must remain a string');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 12. Shell metacharacters (the security-critical case) ──────────────────
|
||||
|
||||
const SHELL_PAYLOADS = [
|
||||
// Each one would, if shell-interpreted, create a sentinel file
|
||||
// adjacent to the project tree. Argv-based invocation must treat them
|
||||
// as opaque text.
|
||||
'$(touch ${PROJECT}/INJ-dollar-paren)',
|
||||
'`touch ${PROJECT}/INJ-backtick`',
|
||||
'; touch ${PROJECT}/INJ-semicolon;',
|
||||
'&& touch ${PROJECT}/INJ-and',
|
||||
'|| touch ${PROJECT}/INJ-or',
|
||||
'| tee ${PROJECT}/INJ-pipe',
|
||||
'> ${PROJECT}/INJ-redirect',
|
||||
// Quote-balanced payloads — these have historically broken naive
|
||||
// shell-string composition even when the rest of the code uses argv.
|
||||
'"; touch ${PROJECT}/INJ-quote;"',
|
||||
'\'; touch ${PROJECT}/INJ-quote;\'',
|
||||
];
|
||||
|
||||
for (const payload of SHELL_PAYLOADS) {
|
||||
test(`config-get with shell-metachar key (${payload.slice(0, 25)}…) does NOT execute the payload`, (t) => {
|
||||
const projectDir = createTempProject('cli-neg-config-shell-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const resolvedPayload = payload.replace(/\$\{PROJECT\}/g, projectDir);
|
||||
const result = runCli(['config-get', resolvedPayload], { cwd: projectDir });
|
||||
// No shell interpretation: none of the INJ-* sentinel files must
|
||||
// exist after the run. Walk the project dir and assert.
|
||||
const entries = fs.readdirSync(projectDir);
|
||||
const sentinels = entries.filter((n) => n.startsWith('INJ-'));
|
||||
assert.deepEqual(sentinels, [], `shell payload must NOT create sentinel files (found: ${sentinels.join(', ')})`);
|
||||
// The CLI may exit 0 (legitimate — the metacharacter-laden key
|
||||
// simply doesn't exist in config) or non-zero (typed reason). Both
|
||||
// are acceptable as long as no payload was executed.
|
||||
assert.equal(result.hasStackTrace, false);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Cross-cutting: --cwd points at a non-existent path ────────────────────
|
||||
|
||||
test('--cwd pointing at a non-existent path fails with a typed usage reason', (_t) => {
|
||||
const nonExistent = path.join(require('os').tmpdir(), 'cli-neg-no-such-dir-' + Date.now() + '-' + Math.random());
|
||||
assert.equal(fs.existsSync(nonExistent), false, 'pre-check: path must not exist');
|
||||
const result = runCli(['--cwd', nonExistent, 'config-get', 'model_profile'], { cwd: process.cwd() });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(result.hasStackTrace, false);
|
||||
// gsd-tools validates --cwd up-front and emits ERROR_REASON.USAGE.
|
||||
assert.equal(result.reason, 'usage', `expected reason=usage for invalid --cwd, got: ${result.reason}`);
|
||||
});
|
||||
|
||||
test('--cwd with an empty value fails with a typed usage reason', () => {
|
||||
const result = runCli(['--cwd', '', 'config-get', 'model_profile'], { cwd: process.cwd() });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(result.hasStackTrace, false);
|
||||
assert.equal(result.reason, 'usage');
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* Meta-test for the CLI negative-matrix harness (#3593).
|
||||
*
|
||||
* The harness in `tests/helpers/cli-negative.cjs` shapes spawnSync
|
||||
* results into a typed IR that adversarial-input tests consume. This
|
||||
* file pins the IR contract by exercising the harness against
|
||||
* deliberate scenarios — not as a placeholder for the real matrix tests
|
||||
* (those live in sibling feat-3593-* files) but to surface harness
|
||||
* regressions before they cascade through every matrix test.
|
||||
*
|
||||
* Tests deliberately avoid prose-matching: they assert on numeric exit
|
||||
* codes, boolean flags, and reason codes pulled from the parsed JSON
|
||||
* payload.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { runCli, parseSpawnResult } = require('./helpers/cli-negative.cjs');
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
test('runCli rejects non-array argv with TypeError', () => {
|
||||
assert.throws(
|
||||
() => runCli('config-get', { cwd: '/tmp' }),
|
||||
(err) => err instanceof TypeError && /argv/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
test('runCli rejects missing cwd with TypeError', () => {
|
||||
assert.throws(
|
||||
() => runCli(['config-get'], {}),
|
||||
(err) => err instanceof TypeError && /cwd/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
test('runCli surfaces typed reason from a known failure path', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-harness-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
// Unknown command — gsd-tools emits ERROR_REASON.SDK_UNKNOWN_COMMAND or
|
||||
// USAGE depending on dispatch depth. Either is a real reason string;
|
||||
// the contract we pin here is just "the IR carries a reason from the
|
||||
// ERROR_REASON enum, never null".
|
||||
const result = runCli(['this-command-does-not-exist'], { cwd: projectDir });
|
||||
assert.notEqual(result.status, 0, 'unknown command must exit non-zero');
|
||||
assert.equal(result.ok, false, 'JSON payload must report ok=false');
|
||||
assert.equal(typeof result.reason, 'string', 'reason must be a string from ERROR_REASON');
|
||||
assert.notEqual(result.reason, null);
|
||||
assert.notEqual(result.reason, '');
|
||||
assert.equal(result.hasStackTrace, false, 'a typed failure must NOT print a V8 stack trace');
|
||||
});
|
||||
|
||||
test('parseSpawnResult detects stack-trace leakage in stderr', () => {
|
||||
const fakeSpawn = {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: 'Error: boom\n at Object.<anonymous> (/some/file.js:10:5)\n at Module._compile\n',
|
||||
error: null,
|
||||
};
|
||||
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: false });
|
||||
assert.equal(ir.hasStackTrace, true, 'stack frames in stderr must be flagged');
|
||||
assert.equal(ir.reason, null, 'non-JSON stderr leaves reason null');
|
||||
});
|
||||
|
||||
test('parseSpawnResult does NOT match the literal word "at" in prose', () => {
|
||||
// Guard against a regex regression that would catch sentences like
|
||||
// "command failed at startup" as stack frames.
|
||||
const fakeSpawn = {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: 'Error: command failed at startup\nbecause no project was found.\n',
|
||||
error: null,
|
||||
};
|
||||
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: false });
|
||||
assert.equal(ir.hasStackTrace, false, 'prose containing the word "at" is not a stack frame');
|
||||
});
|
||||
|
||||
test('parseSpawnResult extracts ok/reason/message from a json-errors payload', () => {
|
||||
const payload = { ok: false, reason: 'config_invalid_key', message: 'no such key: foo' };
|
||||
const fakeSpawn = {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: JSON.stringify(payload) + '\n',
|
||||
error: null,
|
||||
};
|
||||
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: true });
|
||||
assert.equal(ir.ok, false);
|
||||
assert.equal(ir.reason, 'config_invalid_key');
|
||||
assert.equal(ir.message, 'no such key: foo');
|
||||
assert.equal(ir.hasStackTrace, false);
|
||||
});
|
||||
|
||||
test('parseSpawnResult ignores malformed JSON in stderr without throwing', () => {
|
||||
const fakeSpawn = {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '{ ok: false, reason }', // missing quotes — invalid JSON
|
||||
error: null,
|
||||
};
|
||||
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: true });
|
||||
assert.equal(ir.ok, null, 'malformed JSON must NOT promote partial data into ok');
|
||||
assert.equal(ir.reason, null);
|
||||
assert.equal(ir.message, null);
|
||||
});
|
||||
|
||||
test('parseSpawnResult ignores JSON arrays and primitives, only accepts objects', () => {
|
||||
const cases = [
|
||||
'["ok", false]', // array
|
||||
'"just a string"', // primitive
|
||||
'null', // null literal
|
||||
'42', // number
|
||||
];
|
||||
for (const stderr of cases) {
|
||||
const ir = parseSpawnResult(
|
||||
{ status: 1, signal: null, stdout: '', stderr, error: null },
|
||||
{ jsonErrorsRequested: true },
|
||||
);
|
||||
assert.equal(ir.ok, null, `non-object JSON (${stderr}) must not set ok`);
|
||||
assert.equal(ir.reason, null);
|
||||
}
|
||||
});
|
||||
|
||||
test('runCli treats jsonErrors=false as an explicit human-formatter path', (t) => {
|
||||
const projectDir = createTempProject('cli-neg-harness-text-');
|
||||
t.after(() => cleanup(projectDir));
|
||||
const result = runCli(['this-command-does-not-exist'], { cwd: projectDir, jsonErrors: false });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(result.jsonErrorsRequested, false);
|
||||
// Reason fields stay null in human-mode because stderr is prose, not JSON.
|
||||
assert.equal(result.ok, null);
|
||||
assert.equal(result.reason, null);
|
||||
// But the prose still must not include a V8 stack trace.
|
||||
assert.equal(result.hasStackTrace, false);
|
||||
});
|
||||
@@ -1,269 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product see #1464
|
||||
// Tutorial docs are the product surface users follow. Reading JSON code blocks
|
||||
// from them and validating through validateCapability is behavioral, not
|
||||
// source-grep — it proves the manifests work, not just that they "mention" a term.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { validateCapability } = require('../scripts/gen-capability-registry.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
// ─── Extractor ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Required top-level fields that distinguish a complete capability manifest
|
||||
// from a partial output snippet (list-entry, install-result, etc.).
|
||||
// Partial output snippets have id+role but lack steps/contributions/gates/config.
|
||||
const MANIFEST_REQUIRED_KEYS = new Set([
|
||||
'id', 'role', 'title', 'description', 'tier',
|
||||
'requires', 'runtimeCompat', 'skills', 'agents',
|
||||
'config', 'steps', 'contributions', 'gates',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract JSON code blocks from markdown that are complete capability manifests.
|
||||
* A complete manifest has ALL keys in MANIFEST_REQUIRED_KEYS.
|
||||
* Partial output snippets (list-entries, install-results) have only id+role and are skipped.
|
||||
*/
|
||||
function extractManifests(mdContent) {
|
||||
const manifests = [];
|
||||
const fenceRe = /```json\s*\r?\n([\s\S]*?)```/g;
|
||||
let match;
|
||||
while ((match = fenceRe.exec(mdContent)) !== null) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(match[1]);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const keys = new Set(Object.keys(parsed));
|
||||
if ([...MANIFEST_REQUIRED_KEYS].every((k) => keys.has(k))) {
|
||||
manifests.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return manifests;
|
||||
}
|
||||
|
||||
// ─── Suite 1: tutorial manifests validate ────────────────────────────────────
|
||||
|
||||
describe('docs tutorial manifests pass validateCapability (#1464 regression)', () => {
|
||||
test('build-your-first-capability.md: every manifest passes', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(ROOT, 'docs', 'tutorials', 'build-your-first-capability.md'),
|
||||
'utf8',
|
||||
);
|
||||
const manifests = extractManifests(content);
|
||||
assert.ok(
|
||||
manifests.length > 0,
|
||||
'expected at least one capability manifest in build tutorial',
|
||||
);
|
||||
for (const cap of manifests) {
|
||||
const errors = validateCapability(cap, cap.id);
|
||||
assert.deepStrictEqual(
|
||||
errors,
|
||||
[],
|
||||
`build tutorial manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('install-your-first-capability.md: every manifest passes', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(ROOT, 'docs', 'tutorials', 'install-your-first-capability.md'),
|
||||
'utf8',
|
||||
);
|
||||
const manifests = extractManifests(content);
|
||||
assert.ok(
|
||||
manifests.length > 0,
|
||||
'expected at least one capability manifest in install tutorial',
|
||||
);
|
||||
for (const cap of manifests) {
|
||||
const errors = validateCapability(cap, cap.id);
|
||||
assert.deepStrictEqual(
|
||||
errors,
|
||||
[],
|
||||
`install tutorial manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('capability-manifest.md reference example passes', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(ROOT, 'docs', 'reference', 'capability-manifest.md'),
|
||||
'utf8',
|
||||
);
|
||||
const manifests = extractManifests(content);
|
||||
assert.ok(
|
||||
manifests.length > 0,
|
||||
'expected at least one capability manifest in reference doc',
|
||||
);
|
||||
for (const cap of manifests) {
|
||||
const errors = validateCapability(cap, cap.id);
|
||||
assert.deepStrictEqual(
|
||||
errors,
|
||||
[],
|
||||
`reference manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: adversarial — #1464 failure modes caught ───────────────────────
|
||||
//
|
||||
// These are the EXACT failure shapes from issue #1464.
|
||||
// They must fail validateCapability — proving this test would have caught the bug.
|
||||
|
||||
describe('validateCapability catches original #1464 bug shapes', () => {
|
||||
// #1464 high-1: step missing ref → validateStep rejects it
|
||||
test('step without ref fails (the original broken tutorial step)', () => {
|
||||
const cap = {
|
||||
id: 'hello-note',
|
||||
role: 'feature',
|
||||
version: '0.1.0',
|
||||
title: 'Hello Note',
|
||||
description: 'Test fixture for #1464 regression.',
|
||||
tier: 'standard',
|
||||
requires: [],
|
||||
engines: { gsd: '>=1.6.0' },
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [],
|
||||
agents: [],
|
||||
config: {},
|
||||
steps: [
|
||||
{
|
||||
// Missing ref — this was the #1464 high-1 bug in the original tutorial
|
||||
point: 'plan:pre',
|
||||
produces: ['HELLO.md'],
|
||||
consumes: [],
|
||||
onError: 'skip',
|
||||
},
|
||||
],
|
||||
contributions: [],
|
||||
gates: [],
|
||||
};
|
||||
const errors = validateCapability(cap, 'hello-note');
|
||||
assert.ok(errors.length > 0, 'expected validation errors for step without ref');
|
||||
assert.ok(
|
||||
errors.some((e) => /ref/.test(e)),
|
||||
`expected an error mentioning "ref"; got: ${errors.join('; ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
// #1464 shape: id must match folder name (folderId contract)
|
||||
test('id not matching folderId fails', () => {
|
||||
const cap = {
|
||||
id: 'hello-note',
|
||||
role: 'feature',
|
||||
version: '0.1.0',
|
||||
title: 'Hello Note',
|
||||
description: 'Test fixture for id/folderId mismatch.',
|
||||
tier: 'standard',
|
||||
requires: [],
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [],
|
||||
agents: [],
|
||||
config: {},
|
||||
steps: [],
|
||||
contributions: [],
|
||||
gates: [],
|
||||
};
|
||||
const errors = validateCapability(cap, 'wrong-folder');
|
||||
assert.ok(errors.length > 0, 'expected id/folderId mismatch to fail validation');
|
||||
assert.ok(
|
||||
errors.some((e) => /folder/.test(e) || /equal/.test(e) || /id/.test(e)),
|
||||
`expected error about id/folderId mismatch; got: ${errors.join('; ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Corrected shape: contribution with fragment + into (the PR #1495 fix)
|
||||
test('contribution with fragment.path + into passes (the PR #1495 fix shape)', () => {
|
||||
const cap = {
|
||||
id: 'hello-note',
|
||||
role: 'feature',
|
||||
version: '0.1.0',
|
||||
title: 'Hello Note',
|
||||
description: 'Injects a greeting note at plan:pre and produces HELLO.md.',
|
||||
tier: 'standard',
|
||||
requires: [],
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [],
|
||||
agents: [],
|
||||
config: {},
|
||||
steps: [],
|
||||
contributions: [
|
||||
{
|
||||
point: 'plan:pre',
|
||||
into: 'planner',
|
||||
fragment: { path: 'fragments/plan-pre.md' },
|
||||
produces: ['HELLO.md'],
|
||||
consumes: [],
|
||||
onError: 'skip',
|
||||
},
|
||||
],
|
||||
gates: [],
|
||||
};
|
||||
const errors = validateCapability(cap, 'hello-note');
|
||||
assert.deepStrictEqual(
|
||||
errors,
|
||||
[],
|
||||
`corrected contribution manifest has unexpected errors: ${errors.join('; ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 3: extractManifests helper ────────────────────────────────────────
|
||||
|
||||
describe('extractManifests helper unit tests', () => {
|
||||
test('returns empty array for plain text with no JSON fences', () => {
|
||||
assert.deepStrictEqual(extractManifests('No code blocks here.'), []);
|
||||
});
|
||||
|
||||
test('skips JSON blocks without all required manifest keys', () => {
|
||||
// Partial list-entry block — only has id, role, version but not steps/contributions/etc.
|
||||
const md = '```json\n{"id":"x","role":"feature","version":"1.0.0"}\n```';
|
||||
assert.deepStrictEqual(extractManifests(md), []);
|
||||
});
|
||||
|
||||
function makeCompleteManifest(overrides) {
|
||||
return {
|
||||
id: 'test-cap', role: 'feature', title: 'T', description: 'D',
|
||||
tier: 'standard', requires: [], runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [], agents: [], config: {}, steps: [], contributions: [], gates: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('extracts a complete manifest (all required keys present)', () => {
|
||||
const cap = makeCompleteManifest({ id: 'x' });
|
||||
const md = '```json\n' + JSON.stringify(cap, null, 2) + '\n```';
|
||||
const result = extractManifests(md);
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.strictEqual(result[0].id, 'x');
|
||||
});
|
||||
|
||||
test('skips malformed JSON blocks silently', () => {
|
||||
const complete = makeCompleteManifest({ id: 'y' });
|
||||
const md = '```json\n{bad json here\n```\n```json\n' + JSON.stringify(complete) + '\n```';
|
||||
const result = extractManifests(md);
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.strictEqual(result[0].id, 'y');
|
||||
});
|
||||
|
||||
test('extracts multiple complete manifests from one doc', () => {
|
||||
const a = makeCompleteManifest({ id: 'cap-a' });
|
||||
const b = makeCompleteManifest({ id: 'cap-b' });
|
||||
const md = [
|
||||
'```json\n' + JSON.stringify(a) + '\n```',
|
||||
'```json\n' + JSON.stringify(b) + '\n```',
|
||||
].join('\n');
|
||||
const result = extractManifests(md);
|
||||
assert.strictEqual(result.length, 2);
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* E2E regression tests for #1521: real install path (copyWithPathReplacement)
|
||||
* MUST stamp non-Claude runtime defaults into emitted gsd-core/workflows/*.md.
|
||||
*
|
||||
* The earlier unit tests in fix-1521-non-claude-runtime-default-resolution.test.cjs
|
||||
* only verify the engine (_applyRuntimeRewrites). This test verifies the wiring:
|
||||
* that a REAL `node bin/install.js --codex/--cursor --global` actually emits
|
||||
* execute-phase.md with --default codex / --default cursor (not --default claude).
|
||||
*
|
||||
* Root cause: copyWithPathReplacement is the emit path for gsd-core/workflows/*.md;
|
||||
* it did its own inline path rewrites but never called _stampNonClaudeRuntimeDefaults,
|
||||
* so the stamping was dead-on-arrival in real installs.
|
||||
*
|
||||
* This test must be RED before the fix is applied (Step 1) and GREEN after (Step 2).
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const INSTALL = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
|
||||
/**
|
||||
* Run a real install into a temp config dir and return the emitted
|
||||
* execute-phase.md content.
|
||||
* @param {string} runtime e.g. 'codex', 'cursor', 'claude'
|
||||
* @returns {string}
|
||||
*/
|
||||
function installAndRead(runtime) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-inst-${runtime}-`));
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL, `--${runtime}`, '--global', '--config-dir', dir],
|
||||
{ encoding: 'utf8', timeout: 120000 },
|
||||
);
|
||||
assert.strictEqual(res.status, 0, `install --${runtime} failed: ${res.stderr || res.stdout}`);
|
||||
const wf = path.join(dir, 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
assert.ok(fs.existsSync(wf), `emitted workflow missing for ${runtime}: ${wf}`);
|
||||
const content = fs.readFileSync(wf, 'utf8');
|
||||
cleanup(dir);
|
||||
return content;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RED tests: these MUST FAIL before the copyWithPathReplacement wiring is added
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('real install: codex-emitted execute-phase.md resolves runtime=codex and defaults worktrees off (#1521)', () => {
|
||||
const c = installAndRead('codex');
|
||||
assert.ok(
|
||||
c.includes('config-get runtime --default codex --raw'),
|
||||
'codex runtime default not stamped in real install',
|
||||
);
|
||||
assert.ok(
|
||||
c.includes('config-get workflow.use_worktrees --default false --raw'),
|
||||
'codex use_worktrees not defaulted false in real install',
|
||||
);
|
||||
assert.ok(
|
||||
!c.includes('config-get runtime --default claude --raw'),
|
||||
'residual claude default in codex install',
|
||||
);
|
||||
});
|
||||
|
||||
test('real install: cursor-emitted execute-phase.md resolves runtime=cursor (#1521)', () => {
|
||||
const c = installAndRead('cursor');
|
||||
assert.ok(
|
||||
c.includes('config-get runtime --default cursor --raw'),
|
||||
'cursor runtime default not stamped in real install',
|
||||
);
|
||||
assert.ok(
|
||||
!c.includes('config-get runtime --default claude --raw'),
|
||||
'residual claude default in cursor install',
|
||||
);
|
||||
});
|
||||
|
||||
test('real install: claude-emitted execute-phase.md keeps claude default + worktrees on (#1521)', () => {
|
||||
const c = installAndRead('claude');
|
||||
assert.ok(
|
||||
c.includes('config-get runtime --default claude --raw'),
|
||||
'claude default changed in claude install',
|
||||
);
|
||||
assert.ok(
|
||||
c.includes('config-get workflow.use_worktrees --raw 2>/dev/null || echo "true"'),
|
||||
'claude worktrees default changed (should still be true)',
|
||||
);
|
||||
assert.ok(
|
||||
!c.includes('config-get workflow.use_worktrees --default false --raw'),
|
||||
'claude install must NOT have use_worktrees=false stamped',
|
||||
);
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression tests for #1920: the installer must produce a capability-ecosystem-
|
||||
* complete flattened layout, and the capability loader must resolve the real host
|
||||
* version in that layout.
|
||||
*
|
||||
* Two gaps broke third-party capabilities on installed (flattened) layouts:
|
||||
*
|
||||
* Gap 1 — host version read as 0.0.0. `readHostVersion()` resolved the running GSD
|
||||
* version via require('../../../package.json'), which in the installed layout is the
|
||||
* marker package.json ({"type":"commonjs"}, no version) → the fail-closed fallback
|
||||
* reported 0.0.0, so `capability install` rejected any manifest with a real
|
||||
* engines.gsd range as "incompatible with GSD 0.0.0". Worse, for runtimes that get
|
||||
* no marker and for local installs, that walked-up package.json could be the USER's
|
||||
* own project, reporting a wrong version. Fix: readHostVersion() prefers the
|
||||
* authoritative gsd-core/VERSION the installer writes for EVERY runtime.
|
||||
*
|
||||
* Gap 2 — the registry generator was never shipped. The loader composes overlays via
|
||||
* require('../../../scripts/gen-capability-registry.cjs'); the installer never copied
|
||||
* it (nor its sibling gen-loop-host-contract.cjs), so the never-crash invariant
|
||||
* discarded EVERY overlay and fell back to the frozen first-party registry —
|
||||
* installed third-party capabilities were silently inert. Same class of gap as #1223
|
||||
* (scripts/fix-slash-commands.cjs).
|
||||
*
|
||||
* These tests are RED before the fix (loader/install.js) and GREEN after.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const INSTALL = path.join(ROOT, 'bin', 'install.js');
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
|
||||
// The generator scripts the capability loader requires by relative path.
|
||||
const GENERATORS = ['gen-capability-registry.cjs', 'gen-loop-host-contract.cjs'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gap 1 — readHostVersion() prefers gsd-core/VERSION (the installer-written,
|
||||
// all-runtime authoritative source) over an ambient/absent package.json.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Gap 1: readHostVersion resolves the real host version in an installed layout (#1920)', () => {
|
||||
const { readHostVersion } = require('../gsd-core/bin/lib/capability-loader.cjs');
|
||||
|
||||
/** Build a fake installed tree and return its gsd-core/bin/lib dir (the module libDir). */
|
||||
function fakeTree({ version, pkg }) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1920-ver-'));
|
||||
const libDir = path.join(root, 'gsd-core', 'bin', 'lib');
|
||||
fs.mkdirSync(libDir, { recursive: true });
|
||||
if (version !== undefined) fs.writeFileSync(path.join(root, 'gsd-core', 'VERSION'), version);
|
||||
if (pkg !== undefined) fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify(pkg));
|
||||
return { root, libDir };
|
||||
}
|
||||
|
||||
test('prefers gsd-core/VERSION over a wrong ambient package.json version', () => {
|
||||
// The walked-up package.json belongs to the user's project (wrong version) — must be ignored.
|
||||
const { root, libDir } = fakeTree({ version: '9.9.9\n', pkg: { name: 'user-app', version: '1.2.3', type: 'commonjs' } });
|
||||
try {
|
||||
assert.strictEqual(readHostVersion(libDir), '9.9.9');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to the runtime-root package.json when no VERSION file (dev/source tree)', () => {
|
||||
const { root, libDir } = fakeTree({ pkg: { version: '2.3.4' } });
|
||||
try {
|
||||
assert.strictEqual(readHostVersion(libDir), '2.3.4');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('fail-closes to 0.0.0 when neither VERSION nor a package.json version resolves', () => {
|
||||
const { root, libDir } = fakeTree({});
|
||||
try {
|
||||
assert.strictEqual(readHostVersion(libDir), '0.0.0');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('a real global install writes gsd-core/VERSION carrying the host version', () => {
|
||||
const dir = realInstall();
|
||||
try {
|
||||
const vfile = path.join(dir, 'gsd-core', 'VERSION');
|
||||
assert.ok(fs.existsSync(vfile), 'installer must write gsd-core/VERSION');
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(vfile, 'utf8').trim(),
|
||||
require('../package.json').version,
|
||||
'gsd-core/VERSION must carry the real host version readHostVersion() reads',
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gap 2 — the installer ships (and uninstalls / manifest-tracks) the capability
|
||||
// registry generator scripts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Run a real global install into a fresh temp config dir; return that dir. */
|
||||
function realInstall() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1920-'));
|
||||
// The module-level GSD_TEST_MODE=1 gates the installer's main() off entirely —
|
||||
// strip it from the child env for the spawned REAL install.
|
||||
const childEnv = { ...process.env };
|
||||
delete childEnv.GSD_TEST_MODE;
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL, '--claude', '--global', '--config-dir', dir],
|
||||
{ encoding: 'utf8', timeout: 120000, env: childEnv },
|
||||
);
|
||||
assert.strictEqual(res.status, 0, `install --claude failed: ${res.stderr || res.stdout}`);
|
||||
return dir;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gap 1 (end-to-end CLI) — the actual repro: `gsd-tools capability install` on an
|
||||
// INSTALLED layout must resolve the real host version for the engines.gsd gate, not
|
||||
// 0.0.0. The dev tree always has a versioned package.json two levels up, so this only
|
||||
// reproduces against a real install (where ../../package.json is the versionless marker
|
||||
// and gsd-core/VERSION carries the truth). The CLI computes hostVersion itself, so this
|
||||
// covers capHostVersion() in gsd-tools.cjs — a path the loader unit test does not touch.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Gap 1 (end-to-end CLI): installed capability install uses the real host version (#1920)', () => {
|
||||
const HOST_MAJOR = require('../package.json').version.split('.')[0];
|
||||
|
||||
function writeProbeCapability(engines) {
|
||||
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1920-cap-'));
|
||||
const cap = {
|
||||
id: 'p1920-probe', role: 'feature', version: '1.0.0', title: 'probe',
|
||||
description: 'test capability', tier: 'standard', requires: [],
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [], agents: [], hooks: [], config: {}, steps: [],
|
||||
contributions: [], gates: [], engines,
|
||||
};
|
||||
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
|
||||
return src;
|
||||
}
|
||||
|
||||
test('a capability requiring engines.gsd ">=<host major>.0.0" is not rejected as GSD 0.0.0', () => {
|
||||
const dir = realInstall();
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1920-home-'));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1920-cwd-'));
|
||||
fs.mkdirSync(path.join(cwd, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(cwd, '.planning', 'config.json'), '{}');
|
||||
const src = writeProbeCapability({ gsd: `>=${HOST_MAJOR}.0.0` });
|
||||
try {
|
||||
const installedTools = path.join(dir, 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
const env = { ...process.env, GSD_HOME: home, GSD_WORKSTREAM: '', GSD_PROJECT: '', GSD_SESSION_KEY: '', CLAUDE_SESSION_ID: '' };
|
||||
delete env.GSD_TEST_MODE;
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
[installedTools, 'capability', 'install', src, '--scope', 'global', '--yes', '--json'],
|
||||
{ cwd, env, encoding: 'utf8', timeout: 60000 },
|
||||
);
|
||||
const combined = `${res.stdout || ''}\n${res.stderr || ''}`;
|
||||
assert.doesNotMatch(
|
||||
combined,
|
||||
/incompatible with GSD 0\.0\.0/,
|
||||
`installed CLI saw host version 0.0.0 — the engines gate read the versionless marker: ${combined}`,
|
||||
);
|
||||
assert.strictEqual(res.status, 0, `capability install failed on the installed layout: ${combined}`);
|
||||
} finally {
|
||||
cleanup(dir); cleanup(home); cleanup(cwd); cleanup(src);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gap 2: installer ships the capability registry generator scripts (#1920)', () => {
|
||||
test('the generator scripts are copied into scripts/', () => {
|
||||
const dir = realInstall();
|
||||
try {
|
||||
for (const gen of GENERATORS) {
|
||||
const dest = path.join(dir, 'scripts', gen);
|
||||
assert.ok(fs.existsSync(dest), `installer must ship scripts/${gen}`);
|
||||
assert.ok(fs.statSync(dest).size > 0, `scripts/${gen} must not be empty`);
|
||||
}
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped generator scripts are tracked in the file manifest', () => {
|
||||
const dir = realInstall();
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST_NAME), 'utf8'));
|
||||
for (const gen of GENERATORS) {
|
||||
assert.ok(
|
||||
manifest.files[`scripts/${gen}`],
|
||||
`manifest must track scripts/${gen} for drift/uninstall accounting`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -436,3 +436,260 @@ describe('bug #2004: pr-branch preserves structural planning commits', () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2916-handle-branching-default-base.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2916-handle-branching-default-base (consolidation epic #1969 B6 #1975)", () => {
|
||||
/**
|
||||
* Regression test for #2916: execute-phase `handle_branching` step creates the
|
||||
* per-phase branch off whatever HEAD is currently checked out (typically the
|
||||
* previous phase's unmerged branch) instead of off `origin/HEAD`.
|
||||
*
|
||||
* The bug compounded phases on top of each other and stranded them unpushed
|
||||
* for weeks. The fix:
|
||||
* 1. Detect the default branch via `git symbolic-ref refs/remotes/origin/HEAD`.
|
||||
* 2. If $BRANCH_NAME exists, switch to it (preserve existing behavior).
|
||||
* 3. Otherwise, ff-update the default branch from origin and create the new
|
||||
* phase branch off the default-branch tip.
|
||||
* 4. Refuse-or-warn on dirty working tree.
|
||||
* 5. Post-creation, assert `git rev-list --count $DEFAULT_BRANCH..HEAD == 0`.
|
||||
*
|
||||
* This test extracts the bash payload from the <step name="handle_branching">
|
||||
* block in execute-phase.md (parsed structurally — no regex on prose), executes
|
||||
* it inside a fixture git repo where HEAD sits on a previous-phase branch with
|
||||
* extra commits, and asserts that the new phase branch's tip equals
|
||||
* `origin/main` (no commits inherited from the previous phase).
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const EXECUTE_PHASE_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'execute-phase.md'
|
||||
);
|
||||
|
||||
const GIT_ENV = Object.freeze({
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'Test',
|
||||
GIT_AUTHOR_EMAIL: 'test@test.com',
|
||||
GIT_COMMITTER_NAME: 'Test',
|
||||
GIT_COMMITTER_EMAIL: 'test@test.com',
|
||||
});
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
env: GIT_ENV,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Structurally extract the bash code that the handle_branching step instructs
|
||||
* the agent to run. We:
|
||||
* 1. Locate the <step name="handle_branching"> ... </step> block.
|
||||
* 2. Walk its body looking for fenced ```bash blocks.
|
||||
* 3. Concatenate every bash block in the step (the fix may use more than one).
|
||||
*
|
||||
* No `.includes()` content checks — we parse fence-delimited code blocks the
|
||||
* same way a markdown parser would.
|
||||
*/
|
||||
function extractHandleBranchingBash() {
|
||||
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
let start = -1;
|
||||
let end = -1;
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
if (start === -1 && /^<step\s+name="handle_branching">\s*$/.test(lines[i])) {
|
||||
start = i + 1;
|
||||
} else if (start !== -1 && /^<\/step>\s*$/.test(lines[i])) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error(
|
||||
'execute-phase.md does not contain a <step name="handle_branching"> ... </step> block'
|
||||
);
|
||||
}
|
||||
|
||||
const bashBlocks = [];
|
||||
let inBash = false;
|
||||
let buffer = [];
|
||||
for (let i = start; i < end; i += 1) {
|
||||
const line = lines[i];
|
||||
if (!inBash && /^```bash\s*$/.test(line)) {
|
||||
inBash = true;
|
||||
buffer = [];
|
||||
continue;
|
||||
}
|
||||
if (inBash && /^```\s*$/.test(line)) {
|
||||
bashBlocks.push(buffer.join('\n'));
|
||||
inBash = false;
|
||||
continue;
|
||||
}
|
||||
if (inBash) buffer.push(line);
|
||||
}
|
||||
if (bashBlocks.length === 0) {
|
||||
throw new Error(
|
||||
'handle_branching step contains no ```bash code blocks to execute'
|
||||
);
|
||||
}
|
||||
return bashBlocks.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fixture: a bare "origin" repo with the named default branch (one
|
||||
* commit), a clone with `origin/HEAD` pointed at it, and a checked-out
|
||||
* previous-phase branch carrying its own unmerged commit.
|
||||
*
|
||||
* `defaultBranch` is parameterized so callers can lock in that the workflow
|
||||
* honors `git symbolic-ref refs/remotes/origin/HEAD` rather than silently
|
||||
* defaulting to `main` (#2921 CR feedback — quick-branching.test.cjs got the
|
||||
* same treatment in 80f14cac; this test deserves the same coverage).
|
||||
*/
|
||||
function setupFixture(defaultBranch = 'main') {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2916-'));
|
||||
const seedPath = path.join(root, 'seed');
|
||||
const originPath = path.join(root, 'origin.git');
|
||||
const clonePath = path.join(root, 'clone');
|
||||
|
||||
fs.mkdirSync(seedPath);
|
||||
git(seedPath, 'init', '-b', defaultBranch);
|
||||
git(seedPath, 'config', 'commit.gpgsign', 'false');
|
||||
fs.writeFileSync(path.join(seedPath, 'README.md'), '# seed\n');
|
||||
git(seedPath, 'add', 'README.md');
|
||||
git(seedPath, 'commit', '-m', 'initial');
|
||||
|
||||
git(root, 'clone', '--bare', seedPath, originPath);
|
||||
git(originPath, 'symbolic-ref', 'HEAD', `refs/heads/${defaultBranch}`);
|
||||
|
||||
git(root, 'clone', originPath, clonePath);
|
||||
git(clonePath, 'config', 'commit.gpgsign', 'false');
|
||||
git(clonePath, 'config', 'user.email', 'test@test.com');
|
||||
git(clonePath, 'config', 'user.name', 'Test');
|
||||
|
||||
// Simulate finishing a previous phase: branch off the default branch, add
|
||||
// a commit, and *stay* on it (the failure scenario described in the bug).
|
||||
git(clonePath, 'checkout', '-b', 'feature/phase-01-foundation');
|
||||
fs.writeFileSync(path.join(clonePath, 'phase01.txt'), 'phase 1 work\n');
|
||||
git(clonePath, 'add', 'phase01.txt');
|
||||
git(clonePath, 'commit', '-m', 'phase 01 work');
|
||||
|
||||
return { root, clonePath, defaultBranch };
|
||||
}
|
||||
|
||||
function runHandleBranchingStep(bash, cwd, branchName) {
|
||||
// Write the script to a sibling tempdir, not inside the repo — putting it in
|
||||
// `cwd` would create an untracked file that trips `git status --porcelain`
|
||||
// and steers the step into its dirty-tree fallback path.
|
||||
const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2916-step-'));
|
||||
const scriptPath = path.join(scriptDir, 'handle-branching.sh');
|
||||
const script = `#!/usr/bin/env bash\nset -uo pipefail\nBRANCH_NAME="${branchName}"\n${bash}\n`;
|
||||
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
|
||||
try {
|
||||
return execFileSync('bash', [scriptPath], {
|
||||
cwd,
|
||||
env: GIT_ENV,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).toString();
|
||||
} finally {
|
||||
cleanup(scriptDir);
|
||||
}
|
||||
}
|
||||
|
||||
describe('handle_branching branches off origin/HEAD, not current HEAD (#2916)', () => {
|
||||
// Run against `main` (conventional default) and `trunk` (non-main default
|
||||
// exercising the symbolic-ref code path) so a regression that hard-codes
|
||||
// `main` instead of consulting origin/HEAD will fail the trunk variant.
|
||||
for (const defaultBranch of ['main', 'trunk']) {
|
||||
test(`new phase branch branches off origin/${defaultBranch} with 0 inherited commits`, () => {
|
||||
const bash = extractHandleBranchingBash();
|
||||
const { root, clonePath } = setupFixture(defaultBranch);
|
||||
|
||||
try {
|
||||
const upstream = `origin/${defaultBranch}`;
|
||||
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
||||
'feature/phase-01-foundation'
|
||||
);
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`),
|
||||
'1',
|
||||
`fixture should be 1 commit ahead of ${upstream}`
|
||||
);
|
||||
|
||||
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
|
||||
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
||||
'feature/phase-02-content-sync',
|
||||
'handle_branching should switch to the new phase branch'
|
||||
);
|
||||
|
||||
const inherited = git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`);
|
||||
assert.equal(
|
||||
inherited,
|
||||
'0',
|
||||
`new phase branch must branch off ${upstream}, but inherited ${inherited} commit(s) from previous-phase HEAD`
|
||||
);
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', 'HEAD'),
|
||||
git(clonePath, 'rev-parse', upstream),
|
||||
`new phase branch tip must equal ${upstream} tip`
|
||||
);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('handle_branching reuses an existing branch instead of forking again', () => {
|
||||
const bash = extractHandleBranchingBash();
|
||||
const { root, clonePath } = setupFixture();
|
||||
|
||||
try {
|
||||
// Pre-create the target branch off origin/main with its own commit, then
|
||||
// walk away to a different branch — the step must switch back to it.
|
||||
git(clonePath, 'checkout', '-B', 'feature/phase-02-content-sync', 'origin/main');
|
||||
fs.writeFileSync(path.join(clonePath, 'phase02.txt'), 'phase 2 work\n');
|
||||
git(clonePath, 'add', 'phase02.txt');
|
||||
git(clonePath, 'commit', '-m', 'phase 02 wip');
|
||||
const phase02Sha = git(clonePath, 'rev-parse', 'HEAD');
|
||||
git(clonePath, 'checkout', 'feature/phase-01-foundation');
|
||||
|
||||
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
|
||||
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
||||
'feature/phase-02-content-sync'
|
||||
);
|
||||
assert.equal(
|
||||
git(clonePath, 'rev-parse', 'HEAD'),
|
||||
phase02Sha,
|
||||
'existing-branch tip must be preserved (no rebase/reset)'
|
||||
);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -573,3 +573,230 @@ describe('regressions', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-622-graphify-optional-graph-html.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-622-graphify-optional-graph-html (consolidation epic #1969 B6 #1975)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #622)
|
||||
// This test extracts the deployed Step 3 shell block from commands/gsd/graphify.md
|
||||
// and executes it to prove that a skipped graph.html (due to the graphify HTML viz
|
||||
// node limit) does not abort the chain (#622). The deployed markdown text IS the
|
||||
// product surface — the block the runtime executes — so asserting on its execution
|
||||
// behavior requires reading the source text.
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression test for bug #622.
|
||||
*
|
||||
* The `/gsd-graphify build` Step 3 shell chain in commands/gsd/graphify.md
|
||||
* aborted when `graph.html` was intentionally skipped (graph exceeds the HTML
|
||||
* viz node limit, default 5000). The unconditional `cp graphify-out/graph.html`
|
||||
* failed with "cannot stat", and the `&&` chain aborted before the
|
||||
* GRAPH_REPORT.md copy, snapshot, and status steps ran.
|
||||
*
|
||||
* Fix: guard the graph.html copy with
|
||||
* `{ [ -f graphify-out/graph.html ] && cp … || true; }`
|
||||
* so the chain continues when the file is absent.
|
||||
*/
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// Path to the command doc (relative to repo root)
|
||||
const GRAPHIFY_MD = path.join(__dirname, '..', 'commands', 'gsd', 'graphify.md');
|
||||
|
||||
/**
|
||||
* Extract the Step 3 fenced bash block from graphify.md.
|
||||
* The block starts with the line `graphify update .` and ends at the next
|
||||
* closing ``` fence.
|
||||
*
|
||||
* Returns the bash source text (without the fence lines themselves).
|
||||
*/
|
||||
function extractStep3Block() {
|
||||
const content = fs.readFileSync(GRAPHIFY_MD, 'utf-8');
|
||||
// Capture the full body of the ```bash fence that CONTAINS `graphify update .`
|
||||
// (including any leading preamble line), without crossing into other fences.
|
||||
const match = content.match(/```bash\r?\n((?:(?!```)[\s\S])*?graphify update \.(?:(?!```)[\s\S])*?)\r?\n```/);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
// ─── shared sandbox dirs ──────────────────────────────────────────────────────
|
||||
|
||||
let sandbox;
|
||||
let fakeBin;
|
||||
let fakeHome;
|
||||
|
||||
before(() => {
|
||||
sandbox = createTempDir('gsd-622-sandbox-');
|
||||
fakeBin = createTempDir('gsd-622-fakebin-');
|
||||
fakeHome = createTempDir('gsd-622-fakehome-');
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(sandbox);
|
||||
cleanup(fakeBin);
|
||||
cleanup(fakeHome);
|
||||
});
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write a minimal fake `graphify` executable into fakeBin.
|
||||
* It just exits 0 so the `graphify update .` step succeeds.
|
||||
*/
|
||||
function writeFakeGraphify() {
|
||||
const exe = path.join(fakeBin, 'graphify');
|
||||
fs.writeFileSync(exe, ['#!/bin/sh', 'exit 0'].join('\n'), { mode: 0o755 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a minimal gsd-tools.cjs stub into fakeHome that exits 0 for any
|
||||
* invocation (covers the `graphify build snapshot` and `graphify status` steps).
|
||||
*/
|
||||
function writeFakeGsdTools() {
|
||||
const binDir = path.join(fakeHome, '.claude', 'gsd-core', 'bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(binDir, 'gsd-tools.cjs'),
|
||||
['#!/usr/bin/env node', 'process.exit(0);'].join('\n'),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the sandbox with the minimal directory structure and output files
|
||||
* that a real `graphify update .` would produce. `includeHtml` controls
|
||||
* whether graphify-out/graph.html is created (simulating the node-limit skip
|
||||
* when false).
|
||||
*/
|
||||
function populateSandbox(includeHtml) {
|
||||
// graphify-out/ — simulates graphify CLI output directory
|
||||
const outDir = path.join(sandbox, 'graphify-out');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, 'graph.json'), '{}');
|
||||
fs.writeFileSync(path.join(outDir, 'GRAPH_REPORT.md'), '# report');
|
||||
if (includeHtml) {
|
||||
fs.writeFileSync(path.join(outDir, 'graph.html'), '<html/>');
|
||||
}
|
||||
|
||||
// .planning/graphs/ — destination directory
|
||||
const graphsDir = path.join(sandbox, '.planning', 'graphs');
|
||||
fs.mkdirSync(graphsDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the extracted Step 3 block in the sandbox.
|
||||
*/
|
||||
function runBlock(block) {
|
||||
return spawnSync('bash', ['-c', block], {
|
||||
cwd: sandbox,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: fakeBin + ':' + process.env.PATH,
|
||||
HOME: fakeHome,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #622: graph.html absence must not abort the Step 3 shell chain', () => {
|
||||
let block;
|
||||
|
||||
before(() => {
|
||||
block = extractStep3Block();
|
||||
});
|
||||
|
||||
test('Step 3 bash block is present in graphify.md (sanity gate)', () => {
|
||||
assert.ok(block !== null, 'Step 3 bash block starting with "graphify update ." was not found in commands/gsd/graphify.md');
|
||||
assert.ok(block.length > 0, 'Extracted bash block must not be empty');
|
||||
});
|
||||
|
||||
test('graph.html absent: chain exits 0 and all other artifacts are copied (#622 regression)', (t) => {
|
||||
// Use t.after for per-test cleanup so sandbox is fresh for each test
|
||||
t.after(() => {
|
||||
// Remove and recreate sandbox so the next test starts with an empty dir
|
||||
cleanup(sandbox);
|
||||
fs.mkdirSync(sandbox, { recursive: true });
|
||||
});
|
||||
|
||||
writeFakeGraphify();
|
||||
writeFakeGsdTools();
|
||||
populateSandbox(false); // no graph.html — simulates node-limit skip
|
||||
|
||||
const result = runBlock(block);
|
||||
|
||||
// Chain must not abort
|
||||
assert.equal(result.status, 0, [
|
||||
'Expected exit 0 but got ' + result.status,
|
||||
'stderr: ' + result.stderr,
|
||||
'stdout: ' + result.stdout,
|
||||
].join('\n'));
|
||||
|
||||
// graph.json was copied (step before the guarded line)
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.json')),
|
||||
'.planning/graphs/graph.json must be copied even when graph.html is absent',
|
||||
);
|
||||
|
||||
// GRAPH_REPORT.md was copied (step AFTER the guarded line — key regression assertion)
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'GRAPH_REPORT.md')),
|
||||
'.planning/graphs/GRAPH_REPORT.md must be copied (the chain must not abort at graph.html)',
|
||||
);
|
||||
|
||||
// graph.html must NOT exist in the destination (correctly skipped)
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.html')),
|
||||
'.planning/graphs/graph.html must NOT be created when source is absent',
|
||||
);
|
||||
});
|
||||
|
||||
test('graph.html present: chain exits 0 and graph.html is copied (happy path)', (t) => {
|
||||
t.after(() => {
|
||||
cleanup(sandbox);
|
||||
fs.mkdirSync(sandbox, { recursive: true });
|
||||
});
|
||||
|
||||
writeFakeGraphify();
|
||||
writeFakeGsdTools();
|
||||
populateSandbox(true); // include graph.html
|
||||
|
||||
const result = runBlock(block);
|
||||
|
||||
assert.equal(result.status, 0, [
|
||||
'Expected exit 0 but got ' + result.status,
|
||||
'stderr: ' + result.stderr,
|
||||
'stdout: ' + result.stdout,
|
||||
].join('\n'));
|
||||
|
||||
// graph.html must exist in the destination (normal copy)
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.html')),
|
||||
'.planning/graphs/graph.html must be copied when the source file is present',
|
||||
);
|
||||
|
||||
// Other artifacts also copied
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.json')),
|
||||
'.planning/graphs/graph.json must be copied',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'GRAPH_REPORT.md')),
|
||||
'.planning/graphs/GRAPH_REPORT.md must be copied',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2271,3 +2271,199 @@ describe('#1912 — init.progress fails safe in workstream mode with no active w
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// roadmap analyze command
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3491-nested-git-worktree.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3491-nested-git-worktree (consolidation epic #1969 B6 #1975)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #3491)
|
||||
// Bug #3491 — new-project workflow creates nested .git in subdirectory when
|
||||
// parent already has git repo.
|
||||
//
|
||||
// The workflow's `has_git` boolean was derived from `pathExists(cwd, '.git')`
|
||||
// — a shallow check that only sees a `.git` entry directly in the current
|
||||
// directory. Subdirectories of an existing git worktree therefore reported
|
||||
// `has_git: false`, causing the workflow's `git init` step to create a nested
|
||||
// `.git` inside the outer repo's worktree. Subsequent gsd-sdk commits then
|
||||
// targeted the nested repo instead of the outer one, silently dropping all
|
||||
// planning artefacts from the outer repo's history.
|
||||
//
|
||||
// This test asserts the corrected semantics, mirroring `git rev-parse
|
||||
// --is-inside-work-tree`:
|
||||
//
|
||||
// - `has_git: true` is reported whenever the cwd is inside a git worktree,
|
||||
// even when no `.git` entry is in cwd itself.
|
||||
// - The init payload surfaces `git_worktree_root` and `in_nested_subdir` so
|
||||
// the workflow can warn the user and skip `git init`.
|
||||
// - The workflow markdown's `git init` step is gated on
|
||||
// `in_nested_subdir: false`, never unconditional under `has_git: false`.
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execSync } = require('node:child_process');
|
||||
|
||||
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const WORKFLOW_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'new-project.md',
|
||||
);
|
||||
|
||||
// ─── Helper: create outer git repo with a nested workstream subdir ─────────
|
||||
|
||||
// On Windows the runtime emits forward slashes (git's convention) while
|
||||
// path.join produces backslashes — normalize both sides via the shared
|
||||
// toPosixPath helper before any equality comparison.
|
||||
const { toPosixPath: normalizePath } = require('./helpers.cjs');
|
||||
|
||||
function createOuterRepoWithSubdir(prefix = 'bug-3491-') {
|
||||
const outer = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
// macOS /tmp -> /private/tmp; on Windows the runner's %TEMP% is the 8.3
|
||||
// short-name (RUNNER~1) and the runtime resolves to the long form.
|
||||
// realpathSync.native handles both; then normalize separators for compare.
|
||||
const outerReal = fs.realpathSync.native(outer);
|
||||
execSync('git init', { cwd: outerReal, stdio: 'pipe' });
|
||||
execSync('git config user.email "test@test.com"', { cwd: outerReal, stdio: 'pipe' });
|
||||
execSync('git config user.name "Test"', { cwd: outerReal, stdio: 'pipe' });
|
||||
execSync('git config commit.gpgsign false', { cwd: outerReal, stdio: 'pipe' });
|
||||
fs.writeFileSync(path.join(outerReal, 'README.md'), '# outer\n');
|
||||
execSync('git add -A', { cwd: outerReal, stdio: 'pipe' });
|
||||
execSync('git commit -m "initial"', { cwd: outerReal, stdio: 'pipe' });
|
||||
|
||||
const subdir = path.join(outerReal, 'workstreams', 'my-project');
|
||||
fs.mkdirSync(subdir, { recursive: true });
|
||||
return { outer: outerReal, subdir };
|
||||
}
|
||||
|
||||
// ─── Behavioural tests against the live `init new-project` handler ─────────
|
||||
|
||||
test('bug-3491: init new-project reports has_git: true inside parent git worktree', () => {
|
||||
const { outer, subdir } = createOuterRepoWithSubdir();
|
||||
try {
|
||||
const result = runGsdTools('init new-project', subdir);
|
||||
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
||||
|
||||
const payload = JSON.parse(result.output);
|
||||
|
||||
// Core fix: shallow `.git in cwd` check was wrong — we are inside the
|
||||
// outer worktree, so the workflow MUST see has_git: true.
|
||||
assert.strictEqual(
|
||||
payload.has_git,
|
||||
true,
|
||||
'expected has_git=true when cwd is inside an existing git worktree (parent .git)',
|
||||
);
|
||||
|
||||
// The workflow needs the worktree root and a nesting flag to decide
|
||||
// whether to skip `git init` and emit a friendly warning.
|
||||
assert.strictEqual(
|
||||
normalizePath(payload.git_worktree_root),
|
||||
normalizePath(outer),
|
||||
`expected git_worktree_root to be the outer repo (${outer}), got: ${payload.git_worktree_root}`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
payload.in_nested_subdir,
|
||||
true,
|
||||
'expected in_nested_subdir=true when cwd is a subdirectory of the worktree root',
|
||||
);
|
||||
} finally {
|
||||
cleanup(outer);
|
||||
}
|
||||
});
|
||||
|
||||
test('bug-3491: init new-project reports has_git: true at worktree root with in_nested_subdir: false', () => {
|
||||
const { outer } = createOuterRepoWithSubdir();
|
||||
try {
|
||||
const result = runGsdTools('init new-project', outer);
|
||||
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
||||
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.strictEqual(payload.has_git, true, 'has_git must be true at the worktree root');
|
||||
assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer));
|
||||
assert.strictEqual(
|
||||
payload.in_nested_subdir,
|
||||
false,
|
||||
'at the worktree root, in_nested_subdir must be false',
|
||||
);
|
||||
} finally {
|
||||
cleanup(outer);
|
||||
}
|
||||
});
|
||||
|
||||
test('bug-3491: init new-project reports has_git: false outside any git worktree', () => {
|
||||
const tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3491-bare-')));
|
||||
try {
|
||||
const result = runGsdTools('init new-project', tmp);
|
||||
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.strictEqual(payload.has_git, false);
|
||||
assert.strictEqual(payload.in_nested_subdir, false);
|
||||
assert.strictEqual(payload.git_worktree_root, null);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('bug-3491: init ingest-docs mirrors the same has_git semantics', () => {
|
||||
// ingest-docs.md has the same shallow check and the same nested-init risk.
|
||||
const { outer, subdir } = createOuterRepoWithSubdir('bug-3491-ingest-');
|
||||
try {
|
||||
const result = runGsdTools('init ingest-docs', subdir);
|
||||
assert.ok(result.success, `init ingest-docs failed: ${result.error}`);
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.strictEqual(
|
||||
payload.has_git,
|
||||
true,
|
||||
'init ingest-docs must also detect parent worktree (#3491 related path)',
|
||||
);
|
||||
assert.strictEqual(normalizePath(payload.git_worktree_root), normalizePath(outer));
|
||||
assert.strictEqual(payload.in_nested_subdir, true);
|
||||
} finally {
|
||||
cleanup(outer);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Workflow-text test: the deployed `new-project.md` must gate `git init` ─
|
||||
|
||||
test('bug-3491: new-project.md gates `git init` on in_nested_subdir, not just has_git', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
|
||||
// The pre-fix workflow had the literal sequence:
|
||||
//
|
||||
// **If `has_git` is false:** Initialize git:
|
||||
// ```bash
|
||||
// git init
|
||||
// ```
|
||||
//
|
||||
// …which fires for any subdirectory of an existing repo. The fix must
|
||||
// either gate the init on `in_nested_subdir`/worktree-root semantics or
|
||||
// drop the unconditional `git init` block entirely.
|
||||
const unconditionalInitPattern =
|
||||
/\*\*If `has_git` is false:\*\* Initialize git:\s*\r?\n+```bash\s*\r?\ngit init\s*\r?\n```/;
|
||||
assert.ok(
|
||||
!unconditionalInitPattern.test(content),
|
||||
'new-project.md must not run `git init` unconditionally on has_git=false (#3491). ' +
|
||||
'Gate it on `in_nested_subdir === false` so the workflow refuses to create ' +
|
||||
'a nested .git inside an existing worktree.',
|
||||
);
|
||||
|
||||
// The fixed workflow MUST mention the new field so reviewers can see the
|
||||
// gating exists. (Workflow markdown IS the deployed product — testing it
|
||||
// as text is the only end-to-end signal we have.)
|
||||
assert.ok(
|
||||
/in_nested_subdir/.test(content),
|
||||
'new-project.md must reference `in_nested_subdir` after the #3491 fix',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -638,3 +638,150 @@ describe('decodeFishUniversalValue: round-trip properties (#323)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3509-path-spaces.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3509-path-spaces (consolidation epic #1969 B6 #1975)", () => {
|
||||
/**
|
||||
* Regression tests for #3509 — CLI breaks when repo path contains spaces
|
||||
*
|
||||
* Root cause: test code embedded space-containing paths into runGsdTools()
|
||||
* string args; the helper's whitespace tokenizer truncated paths at the first
|
||||
* space. All calls that carry dynamic paths must use the array form of
|
||||
* runGsdTools() so execFileSync receives the full path as a single argv slot.
|
||||
*
|
||||
* These tests create a tmpdir whose prefix intentionally contains a space so
|
||||
* they remain red on a broken codebase regardless of the host machine's
|
||||
* tmpdir location.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// Create a tmpdir whose name always contains a space — this is the invariant
|
||||
// that was violated on /Volumes/Mini Me/... machines.
|
||||
function createSpacedTmpDir(prefix = 'path with spaces-') {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
// ─── dispatcher --cwd= with space in path ────────────────────────────────────
|
||||
|
||||
describe('bug-3509: --cwd= survives spaces in path', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createSpacedTmpDir();
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
'# Project State\n\n## Current Position\n\nPhase: 1 of 1 (Test)\n'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('--cwd= array form passes full path with spaces to dispatcher', () => {
|
||||
// Array form: path is a single argv slot, never split on whitespace
|
||||
const result = runGsdTools(['--cwd=' + tmpDir, 'state', 'load'], process.cwd());
|
||||
assert.ok(result.success, `--cwd= with spaced path should succeed, got: ${result.error}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── frontmatter-cli file path with spaces ───────────────────────────────────
|
||||
|
||||
describe('bug-3509: frontmatter get/set/merge/validate survive spaces in file path', () => {
|
||||
let tmpDir;
|
||||
let tmpFile;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createSpacedTmpDir();
|
||||
tmpFile = path.join(tmpDir, 'test.md');
|
||||
fs.writeFileSync(tmpFile, '---\nphase: 01\nplan: 01\ntype: execute\n---\nbody');
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('frontmatter get returns parsed fields when file path contains spaces', () => {
|
||||
const result = runGsdTools(['frontmatter', 'get', tmpFile]);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.strictEqual(parsed.phase, '01', 'phase field should be "01"');
|
||||
});
|
||||
|
||||
test('frontmatter set works when file path contains spaces', () => {
|
||||
const setResult = runGsdTools(['frontmatter', 'set', tmpFile, '--field', 'phase', '--value', '02']);
|
||||
assert.ok(setResult.success, `set failed: ${setResult.error}`);
|
||||
// Verify behaviorally — round-trip via frontmatter get rather than reading the file
|
||||
// and grepping (which trips lint-no-source-grep even on tmp files).
|
||||
const getResult = runGsdTools(['frontmatter', 'get', tmpFile]);
|
||||
assert.ok(getResult.success, `get failed: ${getResult.error}`);
|
||||
const parsed = JSON.parse(getResult.output);
|
||||
assert.strictEqual(parsed.phase, '02', 'field should be updated to "02"');
|
||||
});
|
||||
|
||||
test('frontmatter validate works when file path contains spaces', () => {
|
||||
// Plan frontmatter schema — file path contains a space; must reach validation, not fail on path
|
||||
const result = runGsdTools(['frontmatter', 'validate', tmpFile, '--schema', 'plan']);
|
||||
// Should succeed (exit 0) and return structured JSON with valid/missing, not a path-split error
|
||||
assert.ok(result.success, `Command should exit 0, got: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.ok('valid' in out, 'should return structured JSON with "valid" field');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── verify-path-exists with absolute path containing spaces ─────────────────
|
||||
|
||||
describe('bug-3509: verify-path-exists survives absolute paths with spaces', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createSpacedTmpDir();
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('absolute path with spaces resolves correctly via array form', () => {
|
||||
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, 'file should be found');
|
||||
assert.strictEqual(output.type, 'file');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── profile-pipeline --path with spaces ─────────────────────────────────────
|
||||
|
||||
describe('bug-3509: scan-sessions --path survives spaces in path', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createSpacedTmpDir();
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('scan-sessions --path with spaces returns empty array, not path-split error', () => {
|
||||
const sessionsDir = path.join(tmpDir, 'projects');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const result = runGsdTools(['scan-sessions', '--path', sessionsDir, '--raw'], tmpDir);
|
||||
assert.ok(result.success, `Failed: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.ok(Array.isArray(out), 'should return an array');
|
||||
assert.strictEqual(out.length, 0, 'should be empty for empty sessions dir');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1750,3 +1750,909 @@ describe('N3: Windows-separator confinement logic (path.win32 semantics)', () =>
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2998-pristine-dir-populated.test.cjs — consolidation epic #1969 (B1 #1970)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2998-pristine-dir-populated (consolidation epic #1969 B1 #1970)", () => {
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2998: gsd-pristine/ snapshot is documented but never populated by
|
||||
* the installer. saveLocalPatches declared a pristineDir variable and
|
||||
* promised "saves pristine copies (from manifest) to gsd-pristine/ to
|
||||
* enable three-way merge during reapply-patches" -- but no code ever
|
||||
* wrote to that directory. Effect: the /gsd-reapply-patches Step 5
|
||||
* verifier (#2972) silently degrades to its over-broad fallback heuristic
|
||||
* ("every significant backup line"), exactly the silent-success-on-lost-
|
||||
* content failure mode #2969 was designed to prevent.
|
||||
*
|
||||
* Fix: new populatePristineDir({...}) helper runs the install transform
|
||||
* pipeline (copyWithPathReplacement) into a tmp staging dir, then copies
|
||||
* out the modified-file paths into gsd-pristine/. saveLocalPatches now
|
||||
* accepts a pristineCtx and calls the helper when local patches are
|
||||
* detected.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const INSTALL = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
describe('Bug #2998: populatePristineDir is exported and writes pristine for modified files', () => {
|
||||
test('exported as a function', () => {
|
||||
assert.equal(typeof INSTALL.populatePristineDir, 'function',
|
||||
'expected populatePristineDir in install.js exports (#2998)');
|
||||
});
|
||||
|
||||
test('returns 0 when no files are modified (no-op)', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-'));
|
||||
try {
|
||||
const written = INSTALL.populatePristineDir({
|
||||
packageSrc: ROOT,
|
||||
pristineDir: path.join(tmp, 'gsd-pristine'),
|
||||
modified: [],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
assert.equal(written, 0);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('writes one pristine file per modified path that exists in source', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-'));
|
||||
const pristineDir = path.join(tmp, 'gsd-pristine');
|
||||
try {
|
||||
// Pick a real installed-side relPath from the package source. The
|
||||
// install transforms map source `gsd-core/<rel>` to installed
|
||||
// `gsd-core/<rel>` for skills-aware runtimes (like claude),
|
||||
// so the relPath is the same on both sides.
|
||||
const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md');
|
||||
const sourcePath = path.join(ROOT, candidate);
|
||||
assert.equal(fs.existsSync(sourcePath), true,
|
||||
`precondition: source file exists at ${candidate}`);
|
||||
const written = INSTALL.populatePristineDir({
|
||||
packageSrc: ROOT,
|
||||
pristineDir,
|
||||
modified: [candidate],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
assert.equal(written, 1, 'expected exactly one pristine file written');
|
||||
const out = path.join(pristineDir, candidate);
|
||||
assert.equal(fs.existsSync(out), true, `expected pristine file at ${out}`);
|
||||
// The pristine content should be the transformed version (not raw source):
|
||||
// copyWithPathReplacement substitutes ~/.claude/ for the runtime path prefix.
|
||||
// For claude+global, the prefix is $HOME/.claude/ which equals the original,
|
||||
// so the transform is effectively identity here. We assert the content is a
|
||||
// non-empty markdown file rather than asserting on transform specifics.
|
||||
const content = fs.readFileSync(out, 'utf-8');
|
||||
assert.ok(content.length > 0, 'pristine file should be non-empty');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('skips paths not present in source (does not corrupt pristine with stale data)', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-'));
|
||||
const pristineDir = path.join(tmp, 'gsd-pristine');
|
||||
try {
|
||||
const written = INSTALL.populatePristineDir({
|
||||
packageSrc: ROOT,
|
||||
pristineDir,
|
||||
modified: ['gsd-core/this-path-does-not-exist.md'],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
assert.equal(written, 0, 'expected zero pristine files for non-existent source paths');
|
||||
const out = path.join(pristineDir, 'gsd-core/this-path-does-not-exist.md');
|
||||
assert.equal(fs.existsSync(out), false, 'pristine should not contain ghost paths');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('pristine files have stable content (transformations are deterministic)', () => {
|
||||
// Determinism is what makes the verifier's hash check meaningful:
|
||||
// backup-meta.json records pristine_hashes computed at this same step,
|
||||
// so re-running with the same inputs must yield byte-identical files.
|
||||
const tmp1 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d1-'));
|
||||
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d2-'));
|
||||
try {
|
||||
const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md');
|
||||
const ctx = {
|
||||
packageSrc: ROOT,
|
||||
modified: [candidate],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
};
|
||||
INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp1, 'gsd-pristine') }, ctx));
|
||||
INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp2, 'gsd-pristine') }, ctx));
|
||||
const a = fs.readFileSync(path.join(tmp1, 'gsd-pristine', candidate));
|
||||
const b = fs.readFileSync(path.join(tmp2, 'gsd-pristine', candidate));
|
||||
assert.equal(sha256(a), sha256(b), 'two runs of the same inputs must yield identical pristine content');
|
||||
} finally {
|
||||
cleanup(tmp1);
|
||||
cleanup(tmp2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3004 CR follow-up: multi-root pristine expansion ─────────────────────
|
||||
|
||||
describe('Bug #2998 (#3004 CR): pristine expansion covers every manifest install root', () => {
|
||||
test('paths under agents/ are staged via copyWithPathReplacement, not silently skipped', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-multi-'));
|
||||
const pristineDir = path.join(tmp, 'gsd-pristine');
|
||||
try {
|
||||
const candidate = path.join('agents', 'gsd-planner.md');
|
||||
const sourcePath = path.join(ROOT, candidate);
|
||||
assert.equal(fs.existsSync(sourcePath), true,
|
||||
`precondition: source file exists at ${candidate}`);
|
||||
const written = INSTALL.populatePristineDir({
|
||||
packageSrc: ROOT,
|
||||
pristineDir,
|
||||
modified: [candidate],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
assert.equal(written, 1, 'expected agents/ path to be staged and copied to pristine');
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, candidate)), true);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('a mix of gsd-core/ and agents/ paths in modified list are all staged', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-mix-'));
|
||||
const pristineDir = path.join(tmp, 'gsd-pristine');
|
||||
try {
|
||||
const a = path.join('gsd-core', 'workflows', 'reapply-patches.md');
|
||||
const b = path.join('agents', 'gsd-planner.md');
|
||||
assert.equal(fs.existsSync(path.join(ROOT, a)), true);
|
||||
assert.equal(fs.existsSync(path.join(ROOT, b)), true);
|
||||
const written = INSTALL.populatePristineDir({
|
||||
packageSrc: ROOT,
|
||||
pristineDir,
|
||||
modified: [a, b],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
assert.equal(written, 2, 'expected both top-level dirs to be staged');
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, a)), true);
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, b)), true);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #2998: saveLocalPatches no longer leaves the pristineDir variable unused', () => {
|
||||
test('saveLocalPatches accepts a pristineCtx and exposes the helper for direct testing', () => {
|
||||
// Structural assertion: the function exists with the new signature shape.
|
||||
// Behavioral end-to-end is covered by the populatePristineDir tests above
|
||||
// (that helper is what saveLocalPatches calls internally).
|
||||
assert.equal(typeof INSTALL.populatePristineDir, 'function');
|
||||
// The signature for saveLocalPatches isn't exported, but the helper IS,
|
||||
// and it's the unit of behavior the bug is about. Asserting on the helper
|
||||
// is the structural-IR equivalent of the no-source-grep convention.
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3407-pristine-stale-content.test.cjs — consolidation epic #1969 (B1 #1970)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3407-pristine-stale-content (consolidation epic #1969 B1 #1970)", () => {
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #3407: Installer leaves stale content in gsd-pristine/
|
||||
*
|
||||
* Root cause: populatePristineDir() in saveLocalPatches() snapshots from
|
||||
* pristineCtx.packageSrc — the NEWLY-downloaded release tree — and writes
|
||||
* those bytes into gsd-pristine/. For files changed between the old and new
|
||||
* release, this writes the NEW bytes into the pristine baseline instead of
|
||||
* the OLD bytes. The three-way-diff verifier then classifies upstream-changed
|
||||
* lines as user-added → Step 5a gate fails with false FAIL_USER_LINES_MISSING.
|
||||
*
|
||||
* The #3657 fix (OK_PRISTINE_DRIFT_DETECTED) was a symptom workaround: the
|
||||
* verifier detects hash mismatch (backup-meta.json records old-release hash
|
||||
* but gsd-pristine/ has new-release bytes) and skips to over-broad mode
|
||||
* instead of false-failing. The root-cause stale write was never fixed.
|
||||
*
|
||||
* Fix: when a correctly-populated gsd-pristine/ already exists from the
|
||||
* previous install (i.e., the file's sha256 matches the originalHash recorded
|
||||
* in the manifest), preserve it — do NOT wipe and re-populate from the new
|
||||
* release source. This ensures gsd-pristine/ holds old-release bytes even
|
||||
* after an upgrade where the file content changed upstream.
|
||||
*
|
||||
* Regression contract (byte-comparison):
|
||||
* After saveLocalPatches() is called with a user-modified file whose
|
||||
* gsd-pristine/ entry was correctly set by the previous install, the
|
||||
* gsd-pristine/ file MUST still contain the old-release bytes, not the
|
||||
* new-release bytes supplied in pristineCtx.packageSrc.
|
||||
*
|
||||
* Closes: #3407
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const INSTALL = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
const PATCHES_DIR_NAME = 'gsd-local-patches';
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash('sha256').update(content instanceof Buffer ? content : Buffer.from(content)).digest('hex');
|
||||
}
|
||||
|
||||
// ─── Bug #3407: gsd-pristine/ must preserve OLD-release bytes across upgrade ──
|
||||
|
||||
describe('Bug #3407: saveLocalPatches preserves old-release pristine across upgrade', () => {
|
||||
let tmpDir;
|
||||
let configDir;
|
||||
let fakeSrcDir;
|
||||
|
||||
beforeEach((t) => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3407-'));
|
||||
configDir = path.join(tmpDir, 'config');
|
||||
fakeSrcDir = path.join(tmpDir, 'new-release-src');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.mkdirSync(fakeSrcDir, { recursive: true });
|
||||
t.after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Core regression test.
|
||||
*
|
||||
* Timeline:
|
||||
* Install v1: file content = OLD_RELEASE_CONTENT, gsd-pristine/ROOT_FILE
|
||||
* = OLD_RELEASE_CONTENT (correctly set by previous install),
|
||||
* manifest hash = sha256(OLD_RELEASE_CONTENT)
|
||||
* User edits: configDir/ROOT_FILE = USER_MODIFIED_CONTENT
|
||||
* Upgrade v2: pristineCtx.packageSrc has NEW_RELEASE_CONTENT for ROOT_FILE
|
||||
* saveLocalPatches is called before the wipe.
|
||||
*
|
||||
* Expected AFTER fix: gsd-pristine/ROOT_FILE still == OLD_RELEASE_CONTENT
|
||||
* Actual BEFORE fix: gsd-pristine/ROOT_FILE == NEW_RELEASE_CONTENT (stale)
|
||||
*/
|
||||
test('gsd-pristine/ retains old-release bytes when upgrading a user-modified file', () => {
|
||||
const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1 pristine.\n';
|
||||
const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — upstream changed this line.\n';
|
||||
const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1 pristine.\n## User addition\nUser customization here.\n';
|
||||
|
||||
const oldHash = sha256(OLD_RELEASE_CONTENT);
|
||||
|
||||
// Simulate a root-level installed file. Root-level files in the manifest
|
||||
// are denoted without a subdirectory (slash-free relPath).
|
||||
const relPath = 'test-root-file.md';
|
||||
|
||||
// Set up configDir: user-modified installed file + manifest recording old hash
|
||||
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, MANIFEST_NAME),
|
||||
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
|
||||
);
|
||||
|
||||
// Set up fakeSrcDir (new release): the file has NEW content
|
||||
fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT);
|
||||
|
||||
// Set up gsd-pristine/ with OLD content (as correctly populated by previous install)
|
||||
const pristineDir = path.join(configDir, 'gsd-pristine');
|
||||
fs.mkdirSync(pristineDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pristineDir, relPath), OLD_RELEASE_CONTENT);
|
||||
|
||||
// Call saveLocalPatches with the new release as packageSrc (the buggy scenario)
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: fakeSrcDir,
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
// Assert: gsd-pristine/ must still contain OLD-release bytes
|
||||
const pristineFile = path.join(pristineDir, relPath);
|
||||
assert.ok(
|
||||
fs.existsSync(pristineFile),
|
||||
`gsd-pristine/${relPath} must exist after saveLocalPatches`
|
||||
);
|
||||
|
||||
const actualPristineContent = fs.readFileSync(pristineFile, 'utf8');
|
||||
assert.equal(
|
||||
sha256(actualPristineContent),
|
||||
oldHash,
|
||||
[
|
||||
`gsd-pristine/${relPath} must contain OLD-release bytes (sha256=${oldHash.slice(0, 12)}…)`,
|
||||
`but got sha256=${sha256(actualPristineContent).slice(0, 12)}…`,
|
||||
`(If equal to sha256(NEW_RELEASE_CONTENT)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… then #3407 is NOT fixed)`,
|
||||
].join(' ')
|
||||
);
|
||||
|
||||
// Secondary: confirm backup-meta records the old hash (not new)
|
||||
const backupMeta = JSON.parse(
|
||||
fs.readFileSync(path.join(configDir, PATCHES_DIR_NAME, 'backup-meta.json'), 'utf8')
|
||||
);
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(backupMeta.pristine_hashes, relPath),
|
||||
'backup-meta.json must record pristine_hash for modified file'
|
||||
);
|
||||
assert.equal(
|
||||
backupMeta.pristine_hashes[relPath],
|
||||
oldHash,
|
||||
'backup-meta.json pristine_hash must equal old-release hash (not new-release hash)'
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression test for Codex finding: when gsd-pristine/ entry is absent
|
||||
* (e.g., post-buggy-run deletion or first upgrade without prior pristine)
|
||||
* but the file is UNCHANGED between old and new release, the hash-validated
|
||||
* regeneration path must restore the pristine entry using new-release source.
|
||||
*
|
||||
* When sha256(newReleaseBytesForFile) === originalHash, the file is identical
|
||||
* between releases — new-release generated bytes ARE the old-release pristine
|
||||
* and may be safely promoted.
|
||||
*
|
||||
* Previously (before the regeneration path was added): missing entries were
|
||||
* left absent unconditionally, causing permanent over-broad fallback even
|
||||
* when the file was unchanged upstream.
|
||||
*/
|
||||
test('gsd-pristine/ is regenerated for missing entries when file is unchanged between releases', () => {
|
||||
const SHARED_RELEASE_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n';
|
||||
const USER_MODIFIED_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n## User addition\nCustom.\n';
|
||||
|
||||
const oldHash = sha256(SHARED_RELEASE_CONTENT);
|
||||
const relPath = 'test-unchanged-file.md';
|
||||
|
||||
// configDir has user-modified file + manifest with old-release hash
|
||||
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, MANIFEST_NAME),
|
||||
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
|
||||
);
|
||||
|
||||
// fakeSrcDir (new release) has the SAME content — file was not changed upstream
|
||||
fs.writeFileSync(path.join(fakeSrcDir, relPath), SHARED_RELEASE_CONTENT);
|
||||
|
||||
// NOTE: gsd-pristine/ does NOT exist (simulating post-buggy-run or first-time scenario)
|
||||
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: fakeSrcDir,
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
// The regeneration path should have detected that sha256(new-release candidate)
|
||||
// === originalHash, and promoted the candidate into gsd-pristine/.
|
||||
const pristineFile = path.join(configDir, 'gsd-pristine', relPath);
|
||||
assert.ok(
|
||||
fs.existsSync(pristineFile),
|
||||
[
|
||||
`gsd-pristine/${relPath} must exist after hash-validated regeneration.`,
|
||||
`When new-release bytes hash to originalHash, the file was unchanged between`,
|
||||
`releases and the candidate should be promoted to restore the pristine baseline.`,
|
||||
].join(' ')
|
||||
);
|
||||
|
||||
const actualContent = fs.readFileSync(pristineFile, 'utf8');
|
||||
assert.equal(
|
||||
sha256(actualContent),
|
||||
oldHash,
|
||||
[
|
||||
`gsd-pristine/${relPath} must contain bytes matching originalHash after regeneration`,
|
||||
`(sha256=${oldHash.slice(0, 12)}…)`,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Stale-pristine recovery test (pre-fix bug artifact).
|
||||
*
|
||||
* Timeline:
|
||||
* Buggy run: gsd-pristine/<rel> was written with NEW_RELEASE_CONTENT
|
||||
* (the exact #3407 artifact — stale bytes from a buggy populatePristineDir).
|
||||
* Fix run: saveLocalPatches detects the hash mismatch
|
||||
* (sha256(NEW_RELEASE_CONTENT) !== originalHash recorded in manifest),
|
||||
* removes the stale entry, then attempts regeneration.
|
||||
*
|
||||
* When the file CHANGED between releases (NEW !== OLD):
|
||||
* - The stale entry is removed.
|
||||
* - Regeneration discards the new-release candidate (hash mismatch).
|
||||
* - gsd-pristine/<rel> must be ABSENT (over-broad fallback — correct).
|
||||
*
|
||||
* When the file is UNCHANGED between releases (NEW === OLD):
|
||||
* - The stale entry (which happens to have correct bytes despite the bug) is
|
||||
* detected as correct (hash matches originalHash) and PRESERVED.
|
||||
* - gsd-pristine/<rel> must remain present with the correct bytes.
|
||||
*
|
||||
* This test covers the "file changed across release boundary" case.
|
||||
* The "unchanged" case is already covered by the regeneration test above.
|
||||
*/
|
||||
test('stale gsd-pristine/ entry (new-release bytes) is removed when file changed between releases', () => {
|
||||
const OLD_RELEASE_CONTENT = '# Old Release\nv1 content here.\n';
|
||||
const NEW_RELEASE_CONTENT = '# New Release\nv2 content — upstream changed this.\n';
|
||||
const USER_MODIFIED_CONTENT = '# Old Release\nv1 content here.\n## User section\nCustom work.\n';
|
||||
|
||||
const oldHash = sha256(OLD_RELEASE_CONTENT);
|
||||
const relPath = 'test-stale-recovery.md';
|
||||
|
||||
// configDir: user-modified file + manifest recording OLD hash
|
||||
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, MANIFEST_NAME),
|
||||
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
|
||||
);
|
||||
|
||||
// fakeSrcDir (new release): contains the NEW content
|
||||
fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT);
|
||||
|
||||
// Pre-populate gsd-pristine/ with NEW_RELEASE_CONTENT — the exact pre-fix bug artifact.
|
||||
// This simulates a prior buggy run that wrote new-release bytes into the pristine baseline.
|
||||
const STALE_BYTES = NEW_RELEASE_CONTENT; // named constant for clarity
|
||||
const pristineDir = path.join(configDir, 'gsd-pristine');
|
||||
fs.mkdirSync(pristineDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pristineDir, relPath), STALE_BYTES);
|
||||
|
||||
// Verify the pre-condition: stale bytes do NOT match the original hash.
|
||||
// If this assert fails, the test fixture is wrong (not a fix regression).
|
||||
assert.notEqual(
|
||||
sha256(STALE_BYTES),
|
||||
oldHash,
|
||||
'test fixture check: stale bytes must differ from originalHash'
|
||||
);
|
||||
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: fakeSrcDir,
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
// The fix must detect the hash mismatch (stale entry) and remove it.
|
||||
// The regeneration path discards the new-release candidate (its hash !== oldHash).
|
||||
// Result: gsd-pristine/<rel> must be ABSENT — over-broad fallback is the safe outcome.
|
||||
const pristineFile = path.join(pristineDir, relPath);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(pristineFile),
|
||||
false,
|
||||
[
|
||||
`expected gsd-pristine/${relPath} to be absent after stale-pristine recovery.`,
|
||||
`The stale entry (new-release bytes, sha256=${sha256(STALE_BYTES).slice(0, 12)}…)`,
|
||||
`must be removed; regeneration must discard the candidate because`,
|
||||
`sha256(new-release)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… !== originalHash=${oldHash.slice(0, 12)}….`,
|
||||
`Presence of the file means the stale bytes were NOT cleaned up (pre-fix behavior).`,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Second scenario: gsd-pristine/ does NOT pre-exist (first upgrade with no
|
||||
* prior pristine population). In this case there is no way to obtain the
|
||||
* old-release pristine bytes — populatePristineDir must NOT write the new-
|
||||
* release bytes either. The correct outcome is: gsd-pristine/ stays empty
|
||||
* for this file, and the verifier falls back to over-broad mode (safe).
|
||||
*/
|
||||
test('gsd-pristine/ stays empty when no prior pristine exists (first upgrade, no stale write)', () => {
|
||||
const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1.\n';
|
||||
const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — changed.\n';
|
||||
const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1.\n## User addition\nCustom.\n';
|
||||
|
||||
const oldHash = sha256(OLD_RELEASE_CONTENT);
|
||||
const relPath = 'test-first-upgrade.md';
|
||||
|
||||
// configDir has user-modified file + manifest
|
||||
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, MANIFEST_NAME),
|
||||
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
|
||||
);
|
||||
|
||||
// fakeSrcDir (new release) has new content
|
||||
fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT);
|
||||
|
||||
// NOTE: gsd-pristine/ does NOT exist yet (first upgrade)
|
||||
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: fakeSrcDir,
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
const pristineFile = path.join(configDir, 'gsd-pristine', relPath);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(pristineFile),
|
||||
false,
|
||||
[
|
||||
`expected gsd-pristine/${relPath} to be absent when file changed across release boundary.`,
|
||||
`Writing new-release bytes as pristine for a file whose hash is unknown leads to`,
|
||||
`false FAIL_USER_LINES_MISSING in the reapply-patches verifier (#3407).`,
|
||||
`Over-broad fallback mode is the correct outcome here.`,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The former "Antipattern hunt" describe block (structural typeof checks only) was
|
||||
// removed — it provided no real behavioral coverage and was a vacuous-truth pattern
|
||||
// per /test-rigor skill. Behavioral tests for populatePristineDir are covered above.
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2995-post-install-script-paths.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2995-post-install-script-paths (consolidation epic #1969 B6 #1975)", () => {
|
||||
'use strict';
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const { auditWorkflowScriptPaths, AUDIT_FINDING } = require(
|
||||
path.join(ROOT, 'scripts', 'audit-workflow-script-paths.cjs'),
|
||||
);
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// auditWorkflowScriptPaths is a pure function: it walks workflowsDir,
|
||||
// extracts every ${GSD_HOME}/<path> script reference, and returns a
|
||||
// structured report. Tests assert on the typed report — no regex on
|
||||
// console output.
|
||||
|
||||
// #2996 CR: per-fixture repos are rooted under a single tmpRoot so the
|
||||
// after()-hook actually cleans them up. The previous shape created tmpRoot
|
||||
// in before() but never used it, leaking each fixture's mkdtempSync dir.
|
||||
let tmpRoot;
|
||||
function fixtureRepo({ workflows, files }) {
|
||||
// workflows: { 'foo.md': '...content with ${GSD_HOME}/...' }
|
||||
// files: [ 'gsd-core/bin/x.cjs', ... ] — files to create in repo
|
||||
const repoRoot = fs.mkdtempSync(path.join(tmpRoot, 'repo-'));
|
||||
const workflowsDir = path.join(repoRoot, 'gsd-core', 'workflows');
|
||||
fs.mkdirSync(workflowsDir, { recursive: true });
|
||||
for (const [name, body] of Object.entries(workflows || {})) {
|
||||
fs.writeFileSync(path.join(workflowsDir, name), body);
|
||||
}
|
||||
for (const rel of files || []) {
|
||||
const full = path.join(repoRoot, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, '');
|
||||
}
|
||||
return { repoRoot, workflowsDir };
|
||||
}
|
||||
|
||||
before(() => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2995-')); });
|
||||
after(() => { cleanup(tmpRoot); });
|
||||
|
||||
describe('Bug #2995: post-install script-paths audit (#2995)', () => {
|
||||
test('AUDIT_FINDING enum exposes the documented codes', () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(AUDIT_FINDING).sort(),
|
||||
['MISSING_FROM_REPO', 'NOT_INSTALLED'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('returns { ok: true, findings: [] } when workflow refs an existing, installed-path script', () => {
|
||||
const { repoRoot, workflowsDir } = fixtureRepo({
|
||||
workflows: {
|
||||
'good.md': 'node "${GSD_HOME}/gsd-core/bin/foo.cjs" --json\n',
|
||||
},
|
||||
files: ['gsd-core/bin/foo.cjs'],
|
||||
});
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir,
|
||||
repoRoot,
|
||||
installedPrefixes: ['gsd-core', 'commands', 'agents', 'hooks'],
|
||||
});
|
||||
assert.deepEqual(r, { ok: true, findings: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #2995: detection paths', () => {
|
||||
const { auditWorkflowScriptPaths, AUDIT_FINDING } = require(require('node:path').join(__dirname, '..', 'scripts', 'audit-workflow-script-paths.cjs'));
|
||||
|
||||
test('reports MISSING_FROM_REPO when the referenced file does not exist in the repo', () => {
|
||||
const { repoRoot, workflowsDir } = fixtureRepo({
|
||||
workflows: {
|
||||
'foo.md': 'node "${GSD_HOME}/gsd-core/bin/typo.cjs" --json\n',
|
||||
},
|
||||
files: [],
|
||||
});
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir,
|
||||
repoRoot,
|
||||
installedPrefixes: ['gsd-core'],
|
||||
});
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.findings.length, 1);
|
||||
assert.deepEqual(r.findings[0], {
|
||||
workflow: 'foo.md',
|
||||
path: 'gsd-core/bin/typo.cjs',
|
||||
kind: AUDIT_FINDING.MISSING_FROM_REPO,
|
||||
});
|
||||
});
|
||||
|
||||
test('reports NOT_INSTALLED when first path segment is outside installedPrefixes (the #2994 case)', () => {
|
||||
const { repoRoot, workflowsDir } = fixtureRepo({
|
||||
workflows: {
|
||||
'foo.md': 'node "${GSD_HOME}/scripts/verify-reapply-patches.cjs"\n',
|
||||
},
|
||||
files: ['scripts/verify-reapply-patches.cjs'], // file exists, but `scripts/` not in installed prefixes
|
||||
});
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir,
|
||||
repoRoot,
|
||||
installedPrefixes: ['gsd-core', 'commands', 'agents', 'hooks'],
|
||||
});
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.findings.length, 1);
|
||||
assert.deepEqual(r.findings[0], {
|
||||
workflow: 'foo.md',
|
||||
path: 'scripts/verify-reapply-patches.cjs',
|
||||
kind: AUDIT_FINDING.NOT_INSTALLED,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles ${GSD_HOME:-$HOME/.claude}/... default-fallback syntax', () => {
|
||||
const { repoRoot, workflowsDir } = fixtureRepo({
|
||||
workflows: {
|
||||
'a.md': 'node "${GSD_HOME:-$HOME/.claude}/gsd-core/bin/x.cjs"\n',
|
||||
},
|
||||
files: ['gsd-core/bin/x.cjs'],
|
||||
});
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir,
|
||||
repoRoot,
|
||||
installedPrefixes: ['gsd-core'],
|
||||
});
|
||||
assert.deepEqual(r, { ok: true, findings: [] });
|
||||
});
|
||||
|
||||
test('reports both findings when one workflow has multiple problems', () => {
|
||||
const { repoRoot, workflowsDir } = fixtureRepo({
|
||||
workflows: {
|
||||
'multi.md': [
|
||||
'node "${GSD_HOME}/scripts/a.cjs"',
|
||||
'node "${GSD_HOME}/gsd-core/bin/b.cjs"',
|
||||
'node "${GSD_HOME}/gsd-core/bin/missing.cjs"',
|
||||
].join('\n') + '\n',
|
||||
},
|
||||
files: ['scripts/a.cjs', 'gsd-core/bin/b.cjs'],
|
||||
});
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir,
|
||||
repoRoot,
|
||||
installedPrefixes: ['gsd-core'],
|
||||
});
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.findings.length, 2);
|
||||
const kinds = r.findings.map((f) => f.kind).sort();
|
||||
assert.deepEqual(kinds, [AUDIT_FINDING.MISSING_FROM_REPO, AUDIT_FINDING.NOT_INSTALLED]);
|
||||
});
|
||||
|
||||
test('extracts no findings from a workflow without GSD_HOME script refs', () => {
|
||||
const { repoRoot, workflowsDir } = fixtureRepo({
|
||||
workflows: {
|
||||
'plain.md': '# A workflow\n\nSome prose, no script refs.\n',
|
||||
},
|
||||
});
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir,
|
||||
repoRoot,
|
||||
installedPrefixes: ['gsd-core'],
|
||||
});
|
||||
assert.deepEqual(r, { ok: true, findings: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #2995: real workflow audit', () => {
|
||||
const { auditWorkflowScriptPaths, AUDIT_FINDING } = require(require('node:path').join(__dirname, '..', 'scripts', 'audit-workflow-script-paths.cjs'));
|
||||
|
||||
// The set of top-level directories the installer (bin/install.js) actually
|
||||
// copies into ${configDir}/. Touching this set requires updating both
|
||||
// bin/install.js AND this constant — the parity is intentional.
|
||||
const INSTALLED_PREFIXES = [
|
||||
'gsd-core', // workflows, references, bin/lib, templates
|
||||
'commands', // commands/gsd/*.md (Claude Code local + Gemini global)
|
||||
'skills', // skills/gsd-*/SKILL.md (Claude Code 2.1.88+ global, Codex, etc.)
|
||||
'agents', // agents/gsd-*.md
|
||||
'hooks', // hooks/gsd-*.{sh,js}
|
||||
];
|
||||
|
||||
// Known existing gaps tracked in their own issues. Removing an entry should
|
||||
// land in the same PR that fixes the underlying issue; CI surfaces any NEW
|
||||
// gap as a hard failure.
|
||||
// (#2994 entry removed: this PR moves verify-reapply-patches.cjs to
|
||||
// gsd-core/bin/ which IS an installed prefix, closing the gap.)
|
||||
const KNOWN_GAPS = new Set();
|
||||
|
||||
test('no NEW workflow refs fail to resolve at the deployed path (KNOWN_GAPS allow-listed)', () => {
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir: require('node:path').join(ROOT, 'gsd-core', 'workflows'),
|
||||
repoRoot: ROOT,
|
||||
installedPrefixes: INSTALLED_PREFIXES,
|
||||
});
|
||||
const newGaps = r.findings.filter(
|
||||
(f) => !KNOWN_GAPS.has(`${f.workflow}|${f.path}|${f.kind}`),
|
||||
);
|
||||
if (newGaps.length > 0) {
|
||||
const summary = newGaps.map(
|
||||
(f) => ` ${f.workflow}: ${f.path} (${f.kind})`,
|
||||
).join('\n');
|
||||
assert.fail(
|
||||
`New workflow ref does not resolve at the deployed path:\n${summary}\n\n` +
|
||||
`Either move the script under one of [${INSTALLED_PREFIXES.join(', ')}], ` +
|
||||
`update bin/install.js to copy the new top-level directory, or ` +
|
||||
`(if intentionally tracked) add an entry to KNOWN_GAPS with the issue reference.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// #2996 CR: a reference that is both outside an installed prefix AND
|
||||
// missing from the repo must emit BOTH findings in one run. Previously
|
||||
// the code short-circuited on NOT_INSTALLED, hiding MISSING_FROM_REPO
|
||||
// until the developer fixed the prefix and re-ran CI.
|
||||
test('a reference that is both not-installed AND missing-from-repo emits both findings (no short-circuit)', () => {
|
||||
const { repoRoot, workflowsDir } = fixtureRepo({
|
||||
workflows: {
|
||||
'foo.md': '```bash\nnode "${GSD_HOME}/scripts/missing.cjs"\n```\n',
|
||||
},
|
||||
// Note: scripts/missing.cjs intentionally NOT created in the repo.
|
||||
});
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir,
|
||||
repoRoot,
|
||||
installedPrefixes: ['gsd-core', 'agents', 'hooks', 'commands'],
|
||||
});
|
||||
assert.equal(r.ok, false);
|
||||
const kinds = r.findings.filter((f) => f.path === 'scripts/missing.cjs').map((f) => f.kind).sort();
|
||||
assert.deepEqual(
|
||||
kinds,
|
||||
[AUDIT_FINDING.MISSING_FROM_REPO, AUDIT_FINDING.NOT_INSTALLED].sort(),
|
||||
'expected both NOT_INSTALLED and MISSING_FROM_REPO findings for the same ref',
|
||||
);
|
||||
});
|
||||
|
||||
test('KNOWN_GAPS entries still match real findings — fixed gaps must be removed from the allow-list', () => {
|
||||
const r = auditWorkflowScriptPaths({
|
||||
workflowsDir: require('node:path').join(ROOT, 'gsd-core', 'workflows'),
|
||||
repoRoot: ROOT,
|
||||
installedPrefixes: INSTALLED_PREFIXES,
|
||||
});
|
||||
const realKeys = new Set(r.findings.map((f) => `${f.workflow}|${f.path}|${f.kind}`));
|
||||
const stale = [...KNOWN_GAPS].filter((k) => !realKeys.has(k));
|
||||
assert.deepEqual(
|
||||
stale,
|
||||
[],
|
||||
`KNOWN_GAPS contains entries not present in audit findings — remove these: ${stale.join(', ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3442-shim-projection-drift-guard.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3442-shim-projection-drift-guard (consolidation epic #1969 B6 #1975)", () => {
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const DRIFT_LINT = path.join(ROOT, 'scripts', 'lint-shell-command-projection-drift.cjs');
|
||||
|
||||
function runLint(targetFile) {
|
||||
return spawnSync(process.execPath, [DRIFT_LINT, targetFile], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
// (The buildWindowsShimTriple parity test was removed with the gsd-sdk shim,
|
||||
// #191. The serialized-command drift guard below is retained and unaffected.)
|
||||
|
||||
describe('bug #3442: shim/wrapper serialized-command drift guard', () => {
|
||||
test('drift guard passes for current install.js', () => {
|
||||
const result = runLint(path.join(ROOT, 'bin', 'install.js'));
|
||||
assert.equal(result.status, 0, `expected lint pass, got:\n${result.stderr || result.stdout}`);
|
||||
});
|
||||
|
||||
test('drift guard fails when install-owned inline shim text builder is present', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3442-'));
|
||||
try {
|
||||
const fixture = path.join(tmp, 'install-inline-builder.js');
|
||||
fs.writeFileSync(
|
||||
fixture,
|
||||
[
|
||||
'function badBuilder() {',
|
||||
" return '@ECHO OFF\\r\\n@SETLOCAL\\r\\n@node \"C:/shim.js\" %*\\r\\n';",
|
||||
'}',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
const result = runLint(fixture);
|
||||
assert.notEqual(result.status, 0, 'inline shim renderer should be rejected by the drift guard');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('drift guard does not block safe subprocess execution patterns', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3442-'));
|
||||
try {
|
||||
const fixture = path.join(tmp, 'install-subprocess-safe.js');
|
||||
fs.writeFileSync(
|
||||
fixture,
|
||||
[
|
||||
"const cp = require('node:child_process');",
|
||||
"cp.spawnSync('cmd.exe', ['/c', 'echo ok']);",
|
||||
"cp.execFileSync('bash', ['-lc', 'printf %s \"$PATH\"']);",
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
const result = runLint(fixture);
|
||||
assert.equal(result.status, 0, `spawnSync/execFileSync should remain allowed:\n${result.stderr || result.stdout}`);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,3 +66,586 @@ describe('bug #2136: MANAGED_HOOKS must include all shipped hook files', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2136-sh-hook-version.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2136-sh-hook-version (consolidation epic #1969 B6 #1975)", () => {
|
||||
// Consolidation #1969: this block spawns a REAL install and asserts side effects.
|
||||
// The host suite sets GSD_TEST_MODE=1 at collection time, which the install child
|
||||
// inherits via process.env and which suppresses hook/skill writes. Clear it for
|
||||
// this block's duration (standalone had it unset); restore after.
|
||||
const { before: __gtmBefore, after: __gtmAfter } = require('node:test');
|
||||
let __savedGsdTestMode;
|
||||
__gtmBefore(() => { __savedGsdTestMode = process.env.GSD_TEST_MODE; delete process.env.GSD_TEST_MODE; });
|
||||
__gtmAfter(() => { if (__savedGsdTestMode === undefined) delete process.env.GSD_TEST_MODE; else process.env.GSD_TEST_MODE = __savedGsdTestMode; });
|
||||
|
||||
// allow-test-rule: structural-regression-guard (see #2136)
|
||||
// The shebang line must be `#!/usr/bin/env bash` (PATH-resolved) rather than
|
||||
// `#!/bin/bash` for cross-distro portability (NixOS, minimal Alpine do not
|
||||
// ship /bin/bash). This is an architectural constraint that cannot be verified
|
||||
// by executing the hooks — they run fine with either shebang on distros that
|
||||
// have /bin/bash, so only a source assertion catches a future regression.
|
||||
|
||||
/**
|
||||
* Regression tests for bug #2136 / #2206
|
||||
*
|
||||
* Root cause: three bash hooks (gsd-phase-boundary.sh, gsd-session-state.sh,
|
||||
* gsd-validate-commit.sh) shipped without a gsd-hook-version header, and the
|
||||
* stale-hook detector in gsd-check-update.js only matched JavaScript comment
|
||||
* syntax (//) — not bash comment syntax (#).
|
||||
*
|
||||
* Result: every session showed "⚠ stale hooks — run /gsd-update" immediately
|
||||
* after a fresh install, because the detector saw hookVersion: 'unknown' for
|
||||
* all three bash hooks.
|
||||
*
|
||||
* This fix requires THREE parts working in concert:
|
||||
* 1. Bash hooks ship with "# gsd-hook-version: {{GSD_VERSION}}"
|
||||
* 2. install.js substitutes {{GSD_VERSION}} in .sh files at install time
|
||||
* 3. gsd-check-update.js regex matches both "//" and "#" comment styles
|
||||
*
|
||||
* Neither fix alone is sufficient:
|
||||
* - Headers + regex fix only (no install.js fix): installed hooks contain
|
||||
* literal "{{GSD_VERSION}}" — the {{-guard silently skips them, making
|
||||
* bash hook staleness permanently undetectable after future updates.
|
||||
* - Headers + install.js fix only (no regex fix): installed hooks are
|
||||
* stamped correctly but the detector still can't read bash "#" comments,
|
||||
* so they still land in the "unknown / stale" branch on every session.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// NOTE: Do NOT set GSD_TEST_MODE here — the E2E install tests spawn the
|
||||
// real installer subprocess, which skips all install logic when GSD_TEST_MODE=1.
|
||||
|
||||
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
|
||||
const _CHECK_UPDATE_FILE = path.join(HOOKS_DIR, 'gsd-check-update.js');
|
||||
const WORKER_FILE = path.join(HOOKS_DIR, 'gsd-check-update-worker.js');
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
|
||||
const SH_HOOKS = [
|
||||
'gsd-phase-boundary.sh',
|
||||
'gsd-session-state.sh',
|
||||
'gsd-validate-commit.sh',
|
||||
];
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
||||
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup() helper wrapping rmSync; cannot use imported cleanup() without naming collision
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function runInstaller(configDir) {
|
||||
// --no-sdk: this test covers .sh hook version stamping only; skip SDK
|
||||
// build (covered by install-smoke.yml).
|
||||
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
|
||||
});
|
||||
return path.join(configDir, 'hooks');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Part 1: Bash hook sources carry the version header placeholder
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #2136 part 1: bash hook sources carry gsd-hook-version placeholder', () => {
|
||||
for (const sh of SH_HOOKS) {
|
||||
test(`${sh} contains "# gsd-hook-version: {{GSD_VERSION}}"`, () => {
|
||||
const content = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8');
|
||||
assert.ok(
|
||||
content.includes('# gsd-hook-version: {{GSD_VERSION}}'),
|
||||
`${sh} must include "# gsd-hook-version: {{GSD_VERSION}}" so the ` +
|
||||
`installer can stamp it and gsd-check-update.js can detect staleness`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('version header is on line 2 (immediately after shebang)', () => {
|
||||
// Placing the header immediately after the shebang ensures it is always
|
||||
// found regardless of how much of the file is read. The shebang itself
|
||||
// must use `#!/usr/bin/env bash` (PATH-resolved) rather than `#!/bin/bash`
|
||||
// — POSIX guarantees /bin/sh but not /bin/bash, and distros like NixOS
|
||||
// do not ship /bin/bash by default.
|
||||
for (const sh of SH_HOOKS) {
|
||||
const lines = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8').split(/\r?\n/);
|
||||
assert.strictEqual(
|
||||
lines[0],
|
||||
'#!/usr/bin/env bash',
|
||||
`${sh} line 1 must be "#!/usr/bin/env bash" for cross-distro portability`
|
||||
);
|
||||
assert.ok(
|
||||
lines[1].startsWith('# gsd-hook-version:'),
|
||||
`${sh} line 2 must be the gsd-hook-version header (got: "${lines[1]}")`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Part 2: gsd-check-update-worker.js regex handles bash "#" comment syntax
|
||||
// (Logic moved from inline -e template literal to dedicated worker file)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #2136 part 2: stale-hook detector handles bash comment syntax', () => {
|
||||
let src;
|
||||
|
||||
before(() => {
|
||||
src = fs.readFileSync(WORKER_FILE, 'utf8');
|
||||
});
|
||||
|
||||
test('version regex in source matches "#" comment syntax in addition to "//"', () => {
|
||||
// The regex string in the source must contain the alternation for "#".
|
||||
// The worker uses plain JS (no template-literal escaping), so the form is
|
||||
// "(?:\/\/|#)" directly in source.
|
||||
const hasBashAlternative =
|
||||
src.includes('(?:\\/\\/|#)') || // escaped form (old template-literal style)
|
||||
src.includes('(?://|#)'); // direct form in plain JS worker
|
||||
assert.ok(
|
||||
hasBashAlternative,
|
||||
'gsd-check-update-worker.js version regex must include an alternative for bash "#" comments. ' +
|
||||
'Expected to find (?:\\/\\/|#) or (?://|#) in the source. ' +
|
||||
'The original "//" only regex causes bash hooks to always report hookVersion: "unknown"'
|
||||
);
|
||||
});
|
||||
|
||||
test('version regex does not use the old JS-only form as the sole pattern', () => {
|
||||
// The old regex inside the template literal was the string:
|
||||
// /\\/\\/ gsd-hook-version:\\s*(.+)/
|
||||
// which, when evaluated in the subprocess, produced: /\/\/ gsd-hook-version:\s*(.+)/
|
||||
// That only matched JS "//" comments — never bash "#".
|
||||
// We verify that the old exact string no longer appears.
|
||||
assert.ok(
|
||||
!src.includes('\\/\\/ gsd-hook-version'),
|
||||
'gsd-check-update-worker.js must not use the old JS-only (\\/\\/ gsd-hook-version) ' +
|
||||
'escape form as the sole version matcher — it cannot match bash "#" comments'
|
||||
);
|
||||
});
|
||||
|
||||
test('version regex correctly matches both bash and JS hook version headers', () => {
|
||||
// Verify that the versionMatch line in the source uses a regex that matches
|
||||
// both bash "#" and JS "//" comment styles. We check the source contains the
|
||||
// expected alternation, then directly test the known required pattern.
|
||||
//
|
||||
// We do NOT try to extract and evaluate the regex from source (it contains ")"
|
||||
// which breaks simple extraction), so instead we confirm the source matches
|
||||
// our expectation and run the regex itself.
|
||||
assert.ok(
|
||||
src.includes('gsd-hook-version'),
|
||||
'gsd-check-update-worker.js must contain a gsd-hook-version version check'
|
||||
);
|
||||
|
||||
// The fixed regex that must be present: matches both comment styles
|
||||
const fixedRegex = /(?:\/\/|#) gsd-hook-version:\s*(.+)/;
|
||||
|
||||
assert.ok(
|
||||
fixedRegex.test('# gsd-hook-version: 1.36.0'),
|
||||
'bash-style "# gsd-hook-version: X" must be matchable by the required regex'
|
||||
);
|
||||
assert.ok(
|
||||
fixedRegex.test('// gsd-hook-version: 1.36.0'),
|
||||
'JS-style "// gsd-hook-version: X" must still match (no regression)'
|
||||
);
|
||||
assert.ok(
|
||||
!fixedRegex.test('gsd-hook-version: 1.36.0'),
|
||||
'line without a comment prefix must not match (prevents false positives)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Part 4: End-to-end — installed .sh hooks have stamped version, not placeholder
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #2136 part 4: installed .sh hooks contain stamped concrete version', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-2136-install-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('installed .sh hooks contain a concrete version string, not the template placeholder', () => {
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
|
||||
for (const sh of SH_HOOKS) {
|
||||
const hookPath = path.join(hooksDir, sh);
|
||||
assert.ok(fs.existsSync(hookPath), `${sh} must be installed`);
|
||||
|
||||
const content = fs.readFileSync(hookPath, 'utf8');
|
||||
|
||||
assert.ok(
|
||||
content.includes('# gsd-hook-version:'),
|
||||
`installed ${sh} must contain a "# gsd-hook-version:" header`
|
||||
);
|
||||
assert.ok(
|
||||
!content.includes('{{GSD_VERSION}}'),
|
||||
`installed ${sh} must not contain literal "{{GSD_VERSION}}" — ` +
|
||||
`install.js must substitute it with the concrete package version`
|
||||
);
|
||||
|
||||
const versionMatch = content.match(/# gsd-hook-version:\s*(\S+)/);
|
||||
assert.ok(versionMatch, `installed ${sh} version header must have a version value`);
|
||||
assert.match(
|
||||
versionMatch[1],
|
||||
/^\d+\.\d+\.\d+/,
|
||||
`installed ${sh} version "${versionMatch[1]}" must be a semver-like string`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('stale-hook detector reports zero stale bash hooks immediately after fresh install', () => {
|
||||
// This is the definitive end-to-end proof: after install, run the actual
|
||||
// version-check logic (extracted from gsd-check-update.js) against the
|
||||
// installed hooks and verify none are flagged stale.
|
||||
const hooksDir = runInstaller(tmpDir);
|
||||
const pkg = require(path.join(__dirname, '..', 'package.json'));
|
||||
const installedVersion = pkg.version;
|
||||
|
||||
// Build a subprocess that runs the staleness check logic in isolation.
|
||||
// We pass the installed version, hooks dir, and hook filenames as JSON
|
||||
// to avoid any injection risk.
|
||||
const checkScript = `
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function isNewer(a, b) {
|
||||
const pa = (a || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
||||
const pb = (b || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (pa[i] > pb[i]) return true;
|
||||
if (pa[i] < pb[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const hooksDir = ${JSON.stringify(hooksDir)};
|
||||
const installed = ${JSON.stringify(installedVersion)};
|
||||
const shHooks = ${JSON.stringify(SH_HOOKS)};
|
||||
// Use the same regex that the fixed gsd-check-update.js uses
|
||||
const versionRe = /(?:\\/\\/|#) gsd-hook-version:\\s*(.+)/;
|
||||
|
||||
const staleHooks = [];
|
||||
for (const hookFile of shHooks) {
|
||||
const hookPath = path.join(hooksDir, hookFile);
|
||||
if (!fs.existsSync(hookPath)) {
|
||||
staleHooks.push({ file: hookFile, hookVersion: 'missing' });
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(hookPath, 'utf8');
|
||||
const m = content.match(versionRe);
|
||||
if (m) {
|
||||
const hookVersion = m[1].trim();
|
||||
if (isNewer(installed, hookVersion) && !hookVersion.includes('{{')) {
|
||||
staleHooks.push({ file: hookFile, hookVersion, installedVersion: installed });
|
||||
}
|
||||
} else {
|
||||
staleHooks.push({ file: hookFile, hookVersion: 'unknown', installedVersion: installed });
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(staleHooks));
|
||||
`;
|
||||
|
||||
const result = execFileSync(process.execPath, ['-e', checkScript], { encoding: 'utf8' });
|
||||
const staleHooks = JSON.parse(result);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
staleHooks,
|
||||
[],
|
||||
`Fresh install must produce zero stale bash hooks.\n` +
|
||||
`Got: ${JSON.stringify(staleHooks, null, 2)}\n` +
|
||||
`This indicates either the version header was not stamped by install.js, ` +
|
||||
`or the detector regex cannot match bash "#" comment syntax.`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-941-managed-hooks-registry-manifest.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-941-managed-hooks-registry-manifest (consolidation epic #1969 B6 #1975)", () => {
|
||||
/**
|
||||
* Regression test for bug #941
|
||||
*
|
||||
* `managed-hooks-registry.cjs` is shipped alongside gsd-check-update-worker.js
|
||||
* in hooks/dist/ (it is listed in HOOKS_TO_COPY in scripts/build-hooks.js).
|
||||
* However, the manifest-writing loop in bin/install.js gated on
|
||||
* file.startsWith('gsd-') && (file.endsWith('.js') || file.endsWith('.sh'))
|
||||
* — which `managed-hooks-registry.cjs` fails on both predicates (wrong prefix,
|
||||
* .cjs extension). The result: after every install, `detect-custom-files`
|
||||
* found the installed file in the hooks/ dir but had no manifest entry for it
|
||||
* and reported a perpetual false-positive "Found 1 custom file(s)" warning on
|
||||
* every `/gsd-update`.
|
||||
*
|
||||
* Fix: drive the manifest hooks loop from HOOKS_TO_COPY (the canonical build
|
||||
* set), so the manifest set is structurally identical to what was installed.
|
||||
*
|
||||
* Closes: #941
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
|
||||
const { HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before any install test ────────────────
|
||||
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup helper, swallows ENOENT
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the installer targeting a temp directory as the claude global config dir.
|
||||
* Returns the path to configDir.
|
||||
*/
|
||||
function runInstaller(configDir) {
|
||||
// Clear GSD_TEST_MODE so the installer's main() block actually runs.
|
||||
// The test file sets GSD_TEST_MODE=1 (top of file) to suppress in-process
|
||||
// import side effects, but when install.js is spawned as a subprocess it
|
||||
// must not skip the main() gate or the install is a no-op.
|
||||
const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir };
|
||||
delete env.GSD_TEST_MODE;
|
||||
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
});
|
||||
return configDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run detect-custom-files and return parsed JSON output.
|
||||
*/
|
||||
function detectCustomFiles(configDir) {
|
||||
const result = execFileSync(process.execPath, [TOOLS_PATH, 'detect-custom-files', '--config-dir', configDir], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, GSD_SESSION_KEY: '' },
|
||||
});
|
||||
return JSON.parse(result.trim());
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #941 — managed-hooks-registry.cjs recorded in file manifest', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-bug-941-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('managed-hooks-registry.cjs appears in gsd-file-manifest.json after install', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
|
||||
assert.ok(
|
||||
fs.existsSync(manifestPath),
|
||||
`${MANIFEST_NAME} must exist after install (not found at ${manifestPath})`,
|
||||
);
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
assert.ok(
|
||||
typeof manifest.files === 'object' && manifest.files !== null,
|
||||
'manifest must have a files map',
|
||||
);
|
||||
|
||||
// The key must use forward slashes (cross-platform manifest format)
|
||||
const key = 'hooks/managed-hooks-registry.cjs';
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(manifest.files, key),
|
||||
[
|
||||
`manifest.files must contain '${key}' — managed-hooks-registry.cjs is`,
|
||||
'shipped to users but was not recorded in the manifest, causing',
|
||||
`detect-custom-files to flag it as a perpetual false-positive custom file.`,
|
||||
`Actual manifest hook keys: ${Object.keys(manifest.files).filter(k => k.startsWith('hooks/')).join(', ')}`,
|
||||
].join(' '),
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-file-manifest.json covers the full HOOKS_TO_COPY set (forward-proof)', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
|
||||
assert.ok(fs.existsSync(manifestPath), `${MANIFEST_NAME} must exist after install`);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
const hooksDir = path.join(tmpDir, 'hooks');
|
||||
|
||||
// Every hook in HOOKS_TO_COPY that was actually installed must have a
|
||||
// manifest entry. This assertion is forward-proof: adding any new hook to
|
||||
// HOOKS_TO_COPY without updating the manifest loop will fail this test.
|
||||
for (const hook of HOOKS_TO_COPY) {
|
||||
const installed = path.join(hooksDir, hook);
|
||||
if (!fs.existsSync(installed)) {
|
||||
// Skip hooks that weren't installed (e.g. .sh hooks on non-unix skip
|
||||
// chmod but still install — only skip if truly absent).
|
||||
continue;
|
||||
}
|
||||
const key = `hooks/${hook}`;
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(manifest.files, key),
|
||||
[
|
||||
`manifest.files must contain '${key}'.`,
|
||||
`HOOKS_TO_COPY lists '${hook}' and it was installed, but the manifest`,
|
||||
`loop in writeManifest() did not record it.`,
|
||||
`Actual manifest hook keys: ${Object.keys(manifest.files).filter(k => k.startsWith('hooks/')).join(', ')}`,
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('detect-custom-files reports zero custom files after a clean install (no false positives)', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
let detected;
|
||||
try {
|
||||
detected = detectCustomFiles(tmpDir);
|
||||
} catch (err) {
|
||||
assert.fail(
|
||||
`detect-custom-files failed: ${err.message}\nstderr: ${err.stderr || '(none)'}`,
|
||||
);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
detected.manifest_found,
|
||||
'detect-custom-files must find the manifest after install',
|
||||
);
|
||||
|
||||
const hookCustomFiles = (detected.custom_files || []).filter(f => f.startsWith('hooks/'));
|
||||
assert.strictEqual(
|
||||
hookCustomFiles.length,
|
||||
0,
|
||||
[
|
||||
`detect-custom-files must report 0 custom hook files after a clean install, but got ${hookCustomFiles.length}:`,
|
||||
JSON.stringify(hookCustomFiles, null, 2),
|
||||
'This is the perpetual false-positive bug #941 — hooks in HOOKS_TO_COPY that',
|
||||
'were not recorded in the manifest appear as custom files.',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('manifest hook keys use forward slashes (cross-platform compatibility)', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, MANIFEST_NAME), 'utf-8'));
|
||||
const hookKeys = Object.keys(manifest.files).filter(k => k.startsWith('hooks/'));
|
||||
|
||||
assert.ok(hookKeys.length > 0, 'manifest must contain at least one hooks/ entry');
|
||||
|
||||
for (const key of hookKeys) {
|
||||
assert.ok(
|
||||
!key.includes('\\'),
|
||||
`manifest key '${key}' must use forward slashes, not backslashes`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('manifest hash for managed-hooks-registry.cjs matches the installed file contents', () => {
|
||||
// Strengthened assertion: proves the manifest not only records the right KEY
|
||||
// but stores a hash that matches the ACTUAL installed file bytes. A future
|
||||
// refactor that records the key from the wrong path/content would fail here
|
||||
// even if the key is present.
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
|
||||
const key = 'hooks/managed-hooks-registry.cjs';
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(manifest.files, key),
|
||||
`manifest.files must contain '${key}' before hash comparison`,
|
||||
);
|
||||
|
||||
// Recompute the hash the same way the installer's fileHash() does:
|
||||
// sha256 of the raw file bytes as a hex string.
|
||||
const installedPath = path.join(tmpDir, 'hooks', 'managed-hooks-registry.cjs');
|
||||
assert.ok(
|
||||
fs.existsSync(installedPath),
|
||||
`installed file must exist at ${installedPath}`,
|
||||
);
|
||||
const actualHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(installedPath))
|
||||
.digest('hex');
|
||||
|
||||
assert.strictEqual(
|
||||
manifest.files[key],
|
||||
actualHash,
|
||||
[
|
||||
`manifest hash for '${key}' does not match the installed file's actual contents.`,
|
||||
`This means writeManifest() hashed the wrong path or wrong content.`,
|
||||
`Expected (from installed file): ${actualHash}`,
|
||||
`Got (from manifest): ${manifest.files[key]}`,
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -167,3 +167,123 @@ describe('#114: npm integrity gate — --help output', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3588-npm-audit-clean.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3588-npm-audit-clean (consolidation epic #1969 B6 #1975)", () => {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression test for #3588 — production dependency tree must not carry
|
||||
* high or moderate npm-audit advisories.
|
||||
*
|
||||
* Strategy: run `npm audit --omit=dev --json` against both the root
|
||||
* workspace and the embedded SDK package and assert that the metadata
|
||||
* vulnerability counts are zero across info/low/moderate/high/critical.
|
||||
*
|
||||
* The test is intentionally strict — any advisory of any severity (other
|
||||
* than 'low' if the maintainer accepts it; that branch is left explicit
|
||||
* here) blocks CI. If a future advisory lands without an upstream patch,
|
||||
* either bump the patched transitive (preferred), or annotate the
|
||||
* acceptance below with a justification AND a link to the upstream tracker.
|
||||
*
|
||||
* Skips automatically when `node_modules/` is absent (a fresh checkout
|
||||
* before `npm install`) so the test does not falsely report on developer
|
||||
* machines mid-setup.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SDK = path.join(ROOT, 'sdk');
|
||||
const AUDIT_TIMEOUT_MS = 180_000;
|
||||
const TEST_TIMEOUT_MS = AUDIT_TIMEOUT_MS + 30_000;
|
||||
|
||||
function auditProductionVulns(cwd) {
|
||||
if (!fs.existsSync(path.join(cwd, 'package.json'))) {
|
||||
return null; // signal "skip" to caller
|
||||
}
|
||||
if (!fs.existsSync(path.join(cwd, 'node_modules'))) {
|
||||
return null; // signal "skip" to caller
|
||||
}
|
||||
const isWindows = process.platform === 'win32';
|
||||
const npmCandidates = isWindows ? ['npm.cmd', 'npm'] : ['npm'];
|
||||
const args = ['audit', '--omit=dev', '--json'];
|
||||
let out;
|
||||
let lastErr = null;
|
||||
for (const npmCmd of npmCandidates) {
|
||||
try {
|
||||
out = execFileSync(
|
||||
npmCmd,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: AUDIT_TIMEOUT_MS,
|
||||
shell: isWindows,
|
||||
}
|
||||
);
|
||||
lastErr = null;
|
||||
break;
|
||||
} catch (e) {
|
||||
// `npm audit` exits non-zero when advisories are present; the JSON is
|
||||
// still on stdout in that case. Recover and let the assertion classify.
|
||||
if (e && typeof e.stdout !== 'undefined' && e.stdout !== undefined && e.stdout !== null) {
|
||||
out = Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout);
|
||||
lastErr = null;
|
||||
break;
|
||||
}
|
||||
lastErr = e;
|
||||
}
|
||||
}
|
||||
if (lastErr) throw lastErr;
|
||||
const parsed = JSON.parse(out);
|
||||
// `null` is reserved for the "node_modules missing → skip" signal above.
|
||||
// Any other unexpected JSON shape is a real failure of the audit harness
|
||||
// (npm changed its output format, audit aborted before metadata, etc.) —
|
||||
// throw so the test fails loudly instead of skipping silently.
|
||||
if (parsed && parsed.metadata && parsed.metadata.vulnerabilities) {
|
||||
return parsed.metadata.vulnerabilities;
|
||||
}
|
||||
throw new Error(`Unexpected npm audit JSON shape in ${cwd}: missing metadata.vulnerabilities`);
|
||||
}
|
||||
|
||||
describe('#3588: npm audit --omit=dev reports zero advisories', () => {
|
||||
test('root workspace production tree has no advisories', { timeout: TEST_TIMEOUT_MS }, (t) => {
|
||||
const vulns = auditProductionVulns(ROOT);
|
||||
if (vulns === null) {
|
||||
t.skip('auditable npm package not present or node_modules/ missing');
|
||||
return;
|
||||
}
|
||||
assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`);
|
||||
assert.strictEqual(vulns.high, 0, `expected 0 high; got ${vulns.high}`);
|
||||
assert.strictEqual(vulns.moderate, 0, `expected 0 moderate; got ${vulns.moderate}`);
|
||||
// Low advisories are not explicitly forbidden by the #3588 acceptance
|
||||
// criterion but the issue listed only high/moderate as actual findings —
|
||||
// tighten if any future low advisory is introduced.
|
||||
assert.strictEqual(vulns.low, 0, `expected 0 low; got ${vulns.low}`);
|
||||
});
|
||||
|
||||
test('sdk/ production tree has no advisories', { timeout: TEST_TIMEOUT_MS }, (t) => {
|
||||
const vulns = auditProductionVulns(SDK);
|
||||
if (vulns === null) {
|
||||
t.skip('sdk/ is not an auditable npm package or sdk/node_modules/ is missing');
|
||||
return;
|
||||
}
|
||||
assert.strictEqual(vulns.critical, 0, `expected 0 critical; got ${vulns.critical}`);
|
||||
assert.strictEqual(vulns.high, 0, `expected 0 high; got ${vulns.high}`);
|
||||
assert.strictEqual(vulns.moderate, 0, `expected 0 moderate; got ${vulns.moderate}`);
|
||||
assert.strictEqual(vulns.low, 0, `expected 0 low; got ${vulns.low}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,3 +68,276 @@ describe('pause-work improvements', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3446-resume-continue-here-discovery.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3446-resume-continue-here-discovery (consolidation epic #1969 B6 #1975)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #3446)
|
||||
// Workflow `.md` files are the runtime contract executed by Claude Code as
|
||||
// embedded bash. This test extracts the actual `check_incomplete_work` bash
|
||||
// block from resume-project.md and exercises it against a planted directory
|
||||
// layout — that's a behavioral integration test of the workflow contract,
|
||||
// not regex-on-source.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'resume-project.md');
|
||||
|
||||
// Extract the first ```bash``` code block inside the
|
||||
// `<step name="check_incomplete_work">` element. That's the snippet the
|
||||
// runtime actually executes; it's what we want to validate.
|
||||
function extractCheckBlock() {
|
||||
const md = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const stepStart = md.indexOf('<step name="check_incomplete_work">');
|
||||
assert.ok(stepStart >= 0, 'resume-project.md must contain a check_incomplete_work step');
|
||||
const stepEnd = md.indexOf('</step>', stepStart);
|
||||
assert.ok(
|
||||
stepEnd >= 0,
|
||||
'check_incomplete_work step must have a closing </step> tag',
|
||||
);
|
||||
const stepBody = md.slice(stepStart, stepEnd);
|
||||
const fenceMatch = stepBody.match(/```(?:bash|sh)\r?\n([\s\S]*?)\r?\n```/);
|
||||
assert.ok(fenceMatch, 'check_incomplete_work step must embed a ```bash code block');
|
||||
return fenceMatch[1];
|
||||
}
|
||||
|
||||
function runSnippet(cwd, snippet) {
|
||||
// has_interrupted_agent is a downstream-orchestrator variable; default it
|
||||
// to "false" so the embedded `if` branch is a no-op during this test.
|
||||
return spawnSync('bash', ['-c', snippet], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, has_interrupted_agent: 'false', interrupted_agent_id: '' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('bug #3446: resume-project detects non-phase and legacy continue-here handoffs', () => {
|
||||
let tmpDir;
|
||||
let snippet;
|
||||
|
||||
before(() => {
|
||||
snippet = extractCheckBlock();
|
||||
tmpDir = createTempDir('gsd-bug-3446-');
|
||||
|
||||
// Plant the three discovery surfaces that bug #3446 was originally
|
||||
// filed to cover.
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', '.continue-here.md'),
|
||||
'---\ncontext: default\n---\nroot-of-.planning handoff\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'sketches', 'SKETCH-001'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'sketches', 'SKETCH-001', '.continue-here.md'),
|
||||
'---\ncontext: sketch\n---\nsketch handoff\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.continue-here.md'),
|
||||
'---\ncontext: legacy\n---\nlegacy repo-root handoff\n',
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('check_incomplete_work surfaces .planning/.continue-here.md (depth 1 under .planning)', () => {
|
||||
const result = runSnippet(tmpDir, snippet);
|
||||
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/\.planning\/\.continue-here\.md/,
|
||||
`expected .planning/.continue-here.md in stdout; got: ${JSON.stringify(result.stdout)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('check_incomplete_work surfaces .planning/sketches/SKETCH-001/.continue-here.md (depth 3 under .planning)', () => {
|
||||
const result = runSnippet(tmpDir, snippet);
|
||||
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/\.planning\/sketches\/SKETCH-001\/\.continue-here\.md/,
|
||||
`expected sketch handoff in stdout; got: ${JSON.stringify(result.stdout)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('check_incomplete_work surfaces legacy repo-root .continue-here.md', () => {
|
||||
const result = runSnippet(tmpDir, snippet);
|
||||
assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/(^|\n)\.\/\.continue-here\.md(\n|$)/,
|
||||
`expected legacy ./.continue-here.md in stdout; got: ${JSON.stringify(result.stdout)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3689-resume-glob-nomatch.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3689-resume-glob-nomatch (consolidation epic #1969 B6 #1975)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #3689)
|
||||
// Workflow `.md` files are the runtime contract executed by Claude Code as
|
||||
// embedded bash. Asserting on the staged text of resume-project.md and on the
|
||||
// behavior of the embedded snippet under real shells is a behavioral test of
|
||||
// the workflow itself, not source-grep theater.
|
||||
|
||||
/**
|
||||
* Regression for #3689 — /gsd-resume-work silently drops
|
||||
* `.planning/.continue-here*.md` checkpoints under zsh's default NOMATCH.
|
||||
*
|
||||
* Root cause: the `check_incomplete_work` step in
|
||||
* `gsd-core/workflows/resume-project.md` used a chained `ls` with six
|
||||
* bare-glob arguments. Under zsh's default `NOMATCH` setopt the first
|
||||
* non-matching glob aborts the entire command during word-expansion — every
|
||||
* pattern after that point is never evaluated, including the one that holds
|
||||
* valid pause checkpoints (`.planning/.continue-here*.md`). `2>/dev/null ||
|
||||
* true` only suppresses ls's own stderr / exit code; it has no effect on the
|
||||
* shell's pre-exec abort.
|
||||
*
|
||||
* Fix: replace the chained `ls` with two `find` calls. `find` does not use
|
||||
* shell glob expansion, and `find <missing-dir> -maxdepth N -name PATTERN
|
||||
* -print 2>/dev/null` tolerates absent directories on both bash and zsh.
|
||||
*
|
||||
* This test covers:
|
||||
* 1. zsh under `-o nomatch`: checkpoint at `.planning/.continue-here-*.md`
|
||||
* is listed even when `.planning/spikes`, `.planning/sketches`,
|
||||
* `.planning/deliberations` are absent (the common new-project layout).
|
||||
* 2. bash default: same behavior.
|
||||
* 3. zsh `-o nomatch` with no `.continue-here` files anywhere: exits 0,
|
||||
* no output, no error.
|
||||
* 4. Text invariant: resume-project.md no longer carries the brittle
|
||||
* chained-ls pattern.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const WORKFLOW_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'resume-project.md');
|
||||
|
||||
// The exact snippet the workflow now embeds. Keep in sync with
|
||||
// resume-project.md `check_incomplete_work` step.
|
||||
const FIND_SNIPPET = [
|
||||
"find .planning -maxdepth 3 -name '.continue-here*.md' -print 2>/dev/null || true",
|
||||
"find . -maxdepth 1 -name '.continue-here*.md' -print 2>/dev/null || true",
|
||||
].join('\n');
|
||||
|
||||
function hasShell(name) {
|
||||
const result = spawnSync('which', [name], { encoding: 'utf8' });
|
||||
return result.status === 0 && result.stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
describe('bug #3689 — resume-project.md continue-here scan under zsh NOMATCH', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = createTempDir('gsd-bug-3689-');
|
||||
// Reproduce the common new-project layout: a `.planning/` with a
|
||||
// suffixed continue-here file and *no* spike / sketch / deliberation
|
||||
// subdirectories.
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', '.continue-here-AT-1234.md'),
|
||||
'---\ncontext: default\n---\nhandoff body\n',
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('zsh -o nomatch lists the .planning/.continue-here-* checkpoint', { skip: !hasShell('zsh') }, () => {
|
||||
const result = spawnSync('zsh', ['-o', 'nomatch', '-c', FIND_SNIPPET], {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, `zsh exited ${result.status}; stderr=${result.stderr}`);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/\.planning\/\.continue-here-AT-1234\.md/,
|
||||
`expected checkpoint in stdout, got: ${JSON.stringify(result.stdout)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('bash default lists the .planning/.continue-here-* checkpoint', { skip: !hasShell('bash') }, () => {
|
||||
const result = spawnSync('bash', ['-c', FIND_SNIPPET], {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, `bash exited ${result.status}; stderr=${result.stderr}`);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/\.planning\/\.continue-here-AT-1234\.md/,
|
||||
`expected checkpoint in stdout, got: ${JSON.stringify(result.stdout)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #3689 — empty workspace exits cleanly', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = createTempDir('gsd-bug-3689-');
|
||||
// No .planning/ at all, no .continue-here files. Pure greenfield.
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('zsh -o nomatch with no checkpoints exits 0, empty output', { skip: !hasShell('zsh') }, () => {
|
||||
const result = spawnSync('zsh', ['-o', 'nomatch', '-c', FIND_SNIPPET], {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, `zsh exited ${result.status}; stderr=${result.stderr}`);
|
||||
assert.equal(result.stdout.trim(), '', `expected no stdout, got: ${JSON.stringify(result.stdout)}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #3689 — workflow text invariant', () => {
|
||||
test('resume-project.md no longer chains bare globs through ls', () => {
|
||||
const body = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
assert.doesNotMatch(
|
||||
body,
|
||||
/ls\s+\.planning\/spikes\/\*\/\.continue-here/,
|
||||
'resume-project.md still contains the chained `ls .planning/spikes/*/.continue-here*.md` pattern that aborts under zsh NOMATCH; the find-based scan should replace it.',
|
||||
);
|
||||
assert.match(
|
||||
body,
|
||||
/find \.planning -maxdepth 3 -name '\.continue-here\*\.md'/,
|
||||
'resume-project.md must use the find-based scan introduced by the #3689 fix.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -279,3 +279,681 @@ describe('perf #317: warn sentinel absent/present (exercises sentinel ENOENT pat
|
||||
'escalated message must say CONTEXT CRITICAL');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-1974-context-exhaustion-record.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-1974-context-exhaustion-record (consolidation epic #1969 B6 #1975)", () => {
|
||||
/**
|
||||
* Integration tests for gsd-context-monitor.js auto-record on CRITICAL (#1974).
|
||||
*
|
||||
* Verifies:
|
||||
* 1. On CRITICAL + active GSD project, the hook sets criticalRecorded in the
|
||||
* warn sentinel AND the state record-session command writes the "Stopped At"
|
||||
* field to STATE.md.
|
||||
* 2. Subsequent CRITICAL firings within the same session do NOT re-fire
|
||||
* the subprocess (sentinel guard prevents repeated overwrites).
|
||||
* 3. When no .planning/STATE.md exists, the subprocess is not spawned.
|
||||
* 4. Path resolution uses __dirname, not hardcoded ~/.claude/.
|
||||
* 5. A WARNING-only fire does NOT set criticalRecorded (selectivity counter-test).
|
||||
*
|
||||
* Design note (#3726, #3775): the original test used a short wall-clock poll
|
||||
* against a fire-and-forget spawn().unref() subprocess and flaked under load.
|
||||
* We keep one deterministic assertion (criticalRecorded sentinel is written
|
||||
* before hook exit), and use a bounded poll window for the detached writer's
|
||||
* STATE.md update. A separate test verifies direct record-session invocation.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { cleanup, delay } = require('./helpers.cjs');
|
||||
|
||||
const HOOK_PATH = path.resolve(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
|
||||
const GSD_TOOLS = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
// Windows can hold a transient handle on the temp dir after a spawnSync child
|
||||
// exits (AV scanner / handle-release lag), so cleanup()'s internal rmSync retry
|
||||
// (~5s) occasionally still throws EBUSY/EPERM/ENOTEMPTY under CI load. Restore a
|
||||
// bounded outer retry with async backoff via the shared delay() helper.
|
||||
// Re-adds the guard removed in #482. Refs #490.
|
||||
async function cleanupWithRetry(dir, attempts = 8) {
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
try { cleanup(dir); return; }
|
||||
catch (err) {
|
||||
const transient = err && (err.code === 'EBUSY' || err.code === 'EPERM' || err.code === 'ENOTEMPTY');
|
||||
if (!transient || i === attempts - 1) throw err;
|
||||
await delay(100 * (i + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the hook with a given session id and context percentage.
|
||||
* Writes a bridge metrics file first, then pipes the hook input via stdin.
|
||||
* Returns after the hook exits.
|
||||
*/
|
||||
function runHook(sessionId, remainingPct, cwd) {
|
||||
// Write the bridge metrics file the hook reads
|
||||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||||
fs.writeFileSync(bridgePath, JSON.stringify({
|
||||
session_id: sessionId,
|
||||
remaining_percentage: remainingPct,
|
||||
used_pct: 100 - remainingPct,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
}));
|
||||
|
||||
const input = JSON.stringify({
|
||||
session_id: sessionId,
|
||||
cwd,
|
||||
});
|
||||
|
||||
const result = spawnSync(process.execPath, [HOOK_PATH], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
env: { ...process.env, HOME: process.env.HOME },
|
||||
});
|
||||
|
||||
return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run gsd-tools state record-session synchronously.
|
||||
* Returns { exitCode, stdout, stderr }.
|
||||
* Used to verify the persistence seam deterministically without relying on
|
||||
* the fire-and-forget subprocess timing that caused flake (#3726).
|
||||
*/
|
||||
function runRecordSession(cwd, stoppedAt) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[GSD_TOOLS, 'state', 'record-session', '--stopped-at', stoppedAt, '--cwd', cwd],
|
||||
{ encoding: 'utf-8', timeout: 30000 }
|
||||
);
|
||||
return {
|
||||
exitCode: result.status,
|
||||
signal: result.signal,
|
||||
error: result.error,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse the warn sentinel file for a session.
|
||||
* Returns the parsed object, or null if the file does not exist.
|
||||
*/
|
||||
function readWarnData(sessionId) {
|
||||
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(warnPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe('#1974 context exhaustion auto-record', () => {
|
||||
let tmpDir;
|
||||
let statePath;
|
||||
let sessionId;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1974-'));
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
|
||||
// Minimal STATE.md with Stopped At field
|
||||
statePath = path.join(planningDir, 'STATE.md');
|
||||
fs.writeFileSync(statePath, [
|
||||
'# Session State',
|
||||
'',
|
||||
'**Current Phase:** 1',
|
||||
'**Status:** executing',
|
||||
'**Last session:** unset',
|
||||
'**Last Date:** unset',
|
||||
'**Stopped At:** None',
|
||||
'**Resume File:** None',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
// Minimal config.json required by gsd-tools
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'TEST' }));
|
||||
|
||||
sessionId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// cleanupWithRetry wraps cleanup() with a bounded outer retry (async setTimeout
|
||||
// backoff, no Atomics.wait) to handle cases where windows-2022 CI load keeps
|
||||
// the temp dir EBUSY beyond rmSync's internal ~5s retry window. Refs #490.
|
||||
await cleanupWithRetry(tmpDir);
|
||||
// Clean up bridge files
|
||||
try {
|
||||
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
|
||||
if (fs.existsSync(warnPath)) fs.unlinkSync(warnPath);
|
||||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||||
if (fs.existsSync(bridgePath)) fs.unlinkSync(bridgePath);
|
||||
} catch { /* noop */ }
|
||||
});
|
||||
|
||||
test('sets criticalRecorded sentinel on CRITICAL (synchronous assertion only)', () => {
|
||||
// Trigger CRITICAL — remaining <= 25
|
||||
// The detached record-session subprocess timing assertion (waitForStateMatch,
|
||||
// 45s poll) was removed per #453 (clock-seam): flaky under load. The
|
||||
// deterministic coverage for STATE.md persistence lives in the
|
||||
// 'state record-session command persists Stopped At when invoked directly'
|
||||
// test below, which uses spawnSync instead of a fire-and-forget subprocess.
|
||||
const result = runHook(sessionId, 20, tmpDir);
|
||||
assert.strictEqual(result.exitCode, 0, `hook should exit 0: ${result.stderr}`);
|
||||
|
||||
// Deterministic: hook writes criticalRecorded:true to warnPath SYNCHRONOUSLY
|
||||
// before the hook process exits, before the fire-and-forget subprocess runs.
|
||||
// Since runHook() uses spawnSync, this is guaranteed readable now.
|
||||
const warnData = readWarnData(sessionId);
|
||||
assert.ok(warnData, 'warn sentinel file must exist after CRITICAL fire');
|
||||
assert.strictEqual(
|
||||
warnData.criticalRecorded,
|
||||
true,
|
||||
'hook must set criticalRecorded:true in warn sentinel on CRITICAL'
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT spawn subprocess when .planning/STATE.md is absent', () => {
|
||||
// Delete STATE.md to simulate non-GSD project
|
||||
fs.unlinkSync(statePath);
|
||||
|
||||
const result = runHook(sessionId, 20, tmpDir);
|
||||
assert.strictEqual(result.exitCode, 0);
|
||||
|
||||
// The hook checks isGsdActive via fs.existsSync(STATE.md) before setting
|
||||
// criticalRecorded. If STATE.md is absent, criticalRecorded must NOT be set.
|
||||
const warnData = readWarnData(sessionId);
|
||||
// warnData may exist (hook still debounces) but criticalRecorded must be absent/falsy.
|
||||
const criticalRecorded = warnData && warnData.criticalRecorded;
|
||||
assert.ok(!criticalRecorded, 'criticalRecorded must not be set when STATE.md is absent');
|
||||
assert.ok(!fs.existsSync(statePath), 'STATE.md should not be recreated when absent');
|
||||
});
|
||||
|
||||
test('sentinel prevents repeated firing within same session', () => {
|
||||
// First CRITICAL fire — should set criticalRecorded synchronously.
|
||||
const result1 = runHook(sessionId, 20, tmpDir);
|
||||
assert.strictEqual(result1.exitCode, 0, `first hook fire should exit 0: ${result1.stderr}`);
|
||||
|
||||
const warnData1 = readWarnData(sessionId);
|
||||
assert.ok(warnData1, 'warn sentinel must exist after first CRITICAL fire');
|
||||
assert.strictEqual(warnData1.criticalRecorded, true, 'first fire must set criticalRecorded:true');
|
||||
|
||||
// Second CRITICAL fire — same session, criticalRecorded already true in
|
||||
// warnPath. Advance callsSinceWarn past DEBOUNCE_CALLS (5, see hook
|
||||
// line 29) so the hook processes the warning message path and exercises
|
||||
// the sentinel guard. Using 10 (2× DEBOUNCE_CALLS) ensures we clear the
|
||||
// debounce threshold regardless of any future DEBOUNCE_CALLS adjustment.
|
||||
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
|
||||
const warnDataPatched = { ...warnData1, callsSinceWarn: 10 };
|
||||
fs.writeFileSync(warnPath, JSON.stringify(warnDataPatched));
|
||||
|
||||
const result2 = runHook(sessionId, 18, tmpDir);
|
||||
assert.strictEqual(result2.exitCode, 0, `second hook fire should exit 0: ${result2.stderr}`);
|
||||
|
||||
// The warnData must still carry criticalRecorded:true — the guard was
|
||||
// active and the hook did not reset or clear it.
|
||||
const warnData2 = readWarnData(sessionId);
|
||||
assert.strictEqual(warnData2 && warnData2.criticalRecorded, true, 'sentinel must remain true after second fire');
|
||||
|
||||
// The hook's stdout must still emit a CRITICAL warning message (so the
|
||||
// agent sees context warnings) even though record-session was NOT re-fired.
|
||||
const output2 = result2.stdout ? (() => { try { return JSON.parse(result2.stdout); } catch { return null; } })() : null;
|
||||
assert.ok(
|
||||
output2 && output2.hookSpecificOutput && /CONTEXT CRITICAL/.test(output2.hookSpecificOutput.additionalContext),
|
||||
'second CRITICAL fire must still emit CONTEXT CRITICAL warning to the agent'
|
||||
);
|
||||
});
|
||||
|
||||
test('state record-session command persists Stopped At when invoked directly', () => {
|
||||
const recordResult = runRecordSession(tmpDir, 'context exhaustion at 80% (2026-01-01)');
|
||||
assert.strictEqual(
|
||||
recordResult.exitCode,
|
||||
0,
|
||||
`record-session should exit 0 (signal=${recordResult.signal || 'none'} error=${recordResult.error ? recordResult.error.message : 'none'}): ${recordResult.stderr}`
|
||||
);
|
||||
const content = fs.readFileSync(statePath, 'utf-8');
|
||||
assert.match(content, /context exhaustion at 80% \(2026-01-01\)/, 'STATE.md must contain direct record-session value');
|
||||
});
|
||||
|
||||
test('WARNING-only fire does NOT set criticalRecorded (selectivity counter-test)', () => {
|
||||
// Trigger WARNING (remaining 30% — below WARNING_THRESHOLD=35, above CRITICAL_THRESHOLD=25)
|
||||
const result = runHook(sessionId, 30, tmpDir);
|
||||
assert.strictEqual(result.exitCode, 0, `hook should exit 0: ${result.stderr}`);
|
||||
|
||||
// criticalRecorded must NOT be set on a WARNING-only fire
|
||||
const warnData = readWarnData(sessionId);
|
||||
const criticalRecorded = warnData && warnData.criticalRecorded;
|
||||
assert.ok(!criticalRecorded, 'WARNING-only fire must not set criticalRecorded');
|
||||
});
|
||||
|
||||
// 'hook uses __dirname-based path (runtime-agnostic)' deleted per #453 (clock-seam):
|
||||
// source-grep of HOOK_PATH for path.join(__dirname is brittle. The behavioral equivalent
|
||||
// (hook successfully resolves gsd-tools.cjs from any working directory) is already covered
|
||||
// by the runHook() helper throughout this test file — it calls the hook from an arbitrary
|
||||
// tmpDir and all tests pass, proving __dirname-relative resolution works.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2451-context-monitor-over-report.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2451-context-monitor-over-report (consolidation epic #1969 B6 #1975)", () => {
|
||||
/**
|
||||
* Regression test for bug #2451
|
||||
*
|
||||
* The GSD context monitor hook over-reports usage by ~13 percentage points
|
||||
* compared to Claude Code's native /context command. The root cause:
|
||||
*
|
||||
* gsd-statusline.js writes two values to the bridge file:
|
||||
* - remaining_percentage: raw remaining from CC (e.g. 35%)
|
||||
* - used_pct: normalized "usable" percentage (e.g. 78%) — accounts for
|
||||
* the 16.5% autocompact buffer by scaling: (100 - remaining - buffer) /
|
||||
* (100 - buffer) * 100
|
||||
*
|
||||
* gsd-context-monitor.js displays used_pct (78%) in warning messages.
|
||||
* But CC's native /context shows raw used = 100 - remaining = 65%.
|
||||
* The 13-point gap is exactly the buffer normalization overhead.
|
||||
*
|
||||
* Fix: the bridge must write used_pct as the raw value (Math.round(100 -
|
||||
* remaining)), not the buffer-normalized value. The statusline progress bar
|
||||
* continues to use the normalized value for its own display; only the bridge
|
||||
* value that feeds the context monitor needs to be raw/CC-consistent.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js');
|
||||
const MONITOR_PATH = path.join(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
|
||||
|
||||
/**
|
||||
* Run the statusline hook with a synthetic payload and return the full
|
||||
* bridge JSON object written to /tmp/claude-ctx-{sessionId}.json.
|
||||
*/
|
||||
function runStatuslineHook(remainingPct, totalTokens = 1_000_000, acwEnv = null) {
|
||||
const sessionId = `test-2451-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const payload = JSON.stringify({
|
||||
model: { display_name: 'Claude' },
|
||||
workspace: { current_dir: os.tmpdir() },
|
||||
session_id: sessionId,
|
||||
context_window: {
|
||||
remaining_percentage: remainingPct,
|
||||
total_tokens: totalTokens,
|
||||
},
|
||||
});
|
||||
|
||||
const env = { ...process.env };
|
||||
if (acwEnv != null) {
|
||||
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(acwEnv);
|
||||
} else {
|
||||
delete env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(process.execPath, [HOOK_PATH], {
|
||||
input: payload,
|
||||
env,
|
||||
timeout: 4000,
|
||||
});
|
||||
} catch { /* non-zero exit is fine; we only need the bridge file */ }
|
||||
|
||||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||||
const bridge = JSON.parse(fs.readFileSync(bridgePath, 'utf-8'));
|
||||
fs.unlinkSync(bridgePath);
|
||||
return bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the context monitor hook with a pre-written bridge file and return
|
||||
* the parsed additionalContext string from its stdout.
|
||||
*/
|
||||
function runMonitorHook(remainingPct, usedPct) {
|
||||
const sessionId = `test-2451-mon-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||||
fs.writeFileSync(bridgePath, JSON.stringify({
|
||||
session_id: sessionId,
|
||||
remaining_percentage: remainingPct,
|
||||
used_pct: usedPct,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
}));
|
||||
|
||||
const input = JSON.stringify({ session_id: sessionId, cwd: os.tmpdir() });
|
||||
let stdout = '';
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, [MONITOR_PATH], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (e) {
|
||||
stdout = e.stdout || '';
|
||||
} finally {
|
||||
try { fs.unlinkSync(bridgePath); } catch { /* noop */ }
|
||||
try { fs.unlinkSync(path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`)); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
if (!stdout) return null;
|
||||
const out = JSON.parse(stdout);
|
||||
return out?.hookSpecificOutput?.additionalContext || null;
|
||||
}
|
||||
|
||||
// ─── Bridge file used_pct accuracy ──────────────────────────────────────────
|
||||
|
||||
describe('bug #2451: bridge used_pct matches CC native reporting', () => {
|
||||
test('used_pct is raw (100 - remaining), not buffer-normalized', () => {
|
||||
// CC reports remaining_percentage=35 → CC native "used" = 100-35 = 65%
|
||||
// Buffer-normalized would give: (100 - (35-16.5)/(100-16.5)*100) ≈ 78%
|
||||
// The bridge used_pct must be 65 (raw), not 78 (normalized).
|
||||
const bridge = runStatuslineHook(35);
|
||||
assert.strictEqual(
|
||||
bridge.used_pct,
|
||||
65,
|
||||
`used_pct should be 65 (raw: 100 - 35) but got ${bridge.used_pct}. ` +
|
||||
'Buffer normalization must NOT be applied to the bridge used_pct, ' +
|
||||
'otherwise context monitor messages over-report usage by ~13 points ' +
|
||||
'compared to CC native /context (root cause of #2451).'
|
||||
);
|
||||
});
|
||||
|
||||
test('used_pct is raw for high remaining (low usage scenario)', () => {
|
||||
// remaining=80 → raw used = 20
|
||||
const bridge = runStatuslineHook(80);
|
||||
assert.strictEqual(bridge.used_pct, 20,
|
||||
`used_pct should be 20 (raw: 100-80) but got ${bridge.used_pct}`);
|
||||
});
|
||||
|
||||
test('used_pct is raw for near-critical remaining', () => {
|
||||
// remaining=20 → raw used = 80
|
||||
const bridge = runStatuslineHook(20);
|
||||
assert.strictEqual(bridge.used_pct, 80,
|
||||
`used_pct should be 80 (raw: 100-20) but got ${bridge.used_pct}`);
|
||||
});
|
||||
|
||||
test('remaining_percentage in bridge matches raw CC value', () => {
|
||||
// The bridge remaining_percentage should be the exact raw value from CC
|
||||
const bridge = runStatuslineHook(42);
|
||||
assert.strictEqual(bridge.remaining_percentage, 42,
|
||||
'bridge remaining_percentage must be the raw CC value (no normalization)');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Context monitor message accuracy ───────────────────────────────────────
|
||||
|
||||
describe('bug #2451: context monitor warning messages show CC-consistent percentages', () => {
|
||||
test('WARNING message shows raw used_pct consistent with CC reporting', () => {
|
||||
// remaining=30 → raw used=70; bridge stores used_pct=70
|
||||
// Monitor message must say "Usage at 70%", not a buffer-inflated value
|
||||
const msg = runMonitorHook(30, 70);
|
||||
assert.ok(msg, 'hook should emit a warning when remaining=30 (below WARNING_THRESHOLD=35)');
|
||||
assert.match(
|
||||
msg,
|
||||
/Usage at 70%/,
|
||||
`Warning message should say "Usage at 70%" (raw), got: ${msg}`
|
||||
);
|
||||
});
|
||||
|
||||
test('CRITICAL message shows raw used_pct consistent with CC reporting', () => {
|
||||
// remaining=20 → raw used=80
|
||||
const msg = runMonitorHook(20, 80);
|
||||
assert.ok(msg, 'hook should emit a critical warning when remaining=20 (below CRITICAL_THRESHOLD=25)');
|
||||
assert.match(
|
||||
msg,
|
||||
/Usage at 80%/,
|
||||
`Critical message should say "Usage at 80%" (raw), got: ${msg}`
|
||||
);
|
||||
});
|
||||
|
||||
test('gap between hook used_pct and raw CC value is at most 1 (rounding)', () => {
|
||||
// With the fix, the only acceptable deviation is ±1 due to Math.round
|
||||
const rawRemaining = 35;
|
||||
const bridge = runStatuslineHook(rawRemaining);
|
||||
const ccNativeUsed = 100 - rawRemaining; // 65
|
||||
const gap = Math.abs(bridge.used_pct - ccNativeUsed);
|
||||
assert.ok(
|
||||
gap <= 1,
|
||||
`Gap between hook used_pct (${bridge.used_pct}) and CC native used (${ccNativeUsed}) ` +
|
||||
`is ${gap} points — must be ≤1 (rounding). Larger gaps indicate buffer normalization ` +
|
||||
'is still being applied to bridge used_pct (root cause of #2451).'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-925-context-monitor-hook-event-name.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-925-context-monitor-hook-event-name (consolidation epic #1969 B6 #1975)", () => {
|
||||
/**
|
||||
* Regression test for bug #925
|
||||
*
|
||||
* hooks/gsd-context-monitor.js hardcodes `hookEventName: "PostToolUse"` (or
|
||||
* "AfterTool" for Gemini) regardless of which hook event invoked it. Since
|
||||
* PR #821 the same script is also registered under Stop, SubagentStop, and
|
||||
* PreCompact in hooks/hooks.json. Claude Code rejects output whose
|
||||
* hookSpecificOutput.hookEventName doesn't echo the triggering event:
|
||||
*
|
||||
* "expected Stop but got PostToolUse"
|
||||
*
|
||||
* Fix: derive hookEventName from the parsed stdin payload's `hook_event_name`
|
||||
* field (already available in the data object), falling back to the
|
||||
* Gemini / non-Gemini heuristic for runtimes that don't send it.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const MONITOR_PATH = path.join(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
|
||||
|
||||
/**
|
||||
* Write a bridge metrics file and invoke the context monitor with the given
|
||||
* payload fields. Returns the parsed stdout object (or null if the hook
|
||||
* produced no output).
|
||||
*
|
||||
* remainingPct must be <= 35 to cross the WARNING threshold so the hook
|
||||
* actually emits output.
|
||||
*/
|
||||
function runMonitor({ hookEventName, sessionId, remainingPct = 30, usedPct = 70, env = {} }) {
|
||||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||||
fs.writeFileSync(bridgePath, JSON.stringify({
|
||||
session_id: sessionId,
|
||||
remaining_percentage: remainingPct,
|
||||
used_pct: usedPct,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
}));
|
||||
|
||||
const payload = { session_id: sessionId, cwd: os.tmpdir() };
|
||||
if (hookEventName !== undefined) {
|
||||
payload.hook_event_name = hookEventName;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, [MONITOR_PATH], {
|
||||
input: JSON.stringify(payload),
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
} catch (e) {
|
||||
stdout = e.stdout || '';
|
||||
} finally {
|
||||
try { fs.unlinkSync(bridgePath); } catch { /* noop */ }
|
||||
try {
|
||||
fs.unlinkSync(path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`));
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
if (!stdout) return null;
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
function makeSessionId(suffix) {
|
||||
return `test-925-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
// ─── hookEventName echoing ────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #925: context monitor echoes the invoking hook event name', () => {
|
||||
test('hookEventName is "Stop" when payload contains hook_event_name: "Stop"', () => {
|
||||
const out = runMonitor({ hookEventName: 'Stop', sessionId: makeSessionId('stop') });
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold (remaining=30)');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'Stop',
|
||||
`Expected hookEventName "Stop" but got "${out.hookSpecificOutput?.hookEventName}". ` +
|
||||
'The hook must echo the hook_event_name from stdin, not hardcode "PostToolUse".'
|
||||
);
|
||||
});
|
||||
|
||||
test('hookEventName is "SubagentStop" when payload contains hook_event_name: "SubagentStop"', () => {
|
||||
const out = runMonitor({ hookEventName: 'SubagentStop', sessionId: makeSessionId('subagent-stop') });
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'SubagentStop',
|
||||
`Expected hookEventName "SubagentStop" but got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
});
|
||||
|
||||
test('hookEventName is "PreCompact" when payload contains hook_event_name: "PreCompact"', () => {
|
||||
const out = runMonitor({ hookEventName: 'PreCompact', sessionId: makeSessionId('precompact') });
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'PreCompact',
|
||||
`Expected hookEventName "PreCompact" but got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
});
|
||||
|
||||
test('hookEventName is "PostToolUse" when payload contains hook_event_name: "PostToolUse"', () => {
|
||||
const out = runMonitor({ hookEventName: 'PostToolUse', sessionId: makeSessionId('posttools') });
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'PostToolUse',
|
||||
`Expected hookEventName "PostToolUse" but got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fallback behaviour (no hook_event_name in payload) ──────────────────────
|
||||
|
||||
describe('bug #925: context monitor falls back to heuristic when hook_event_name absent', () => {
|
||||
test('falls back to "PostToolUse" when hook_event_name is absent (non-Gemini)', () => {
|
||||
const env = { ...process.env };
|
||||
delete env.GEMINI_API_KEY;
|
||||
const out = runMonitor({
|
||||
hookEventName: undefined,
|
||||
sessionId: makeSessionId('fallback-non-gemini'),
|
||||
env: { GEMINI_API_KEY: '' }, // ensure unset
|
||||
});
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'PostToolUse',
|
||||
`Expected fallback "PostToolUse" for non-Gemini but got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to "AfterTool" when hook_event_name is absent and GEMINI_API_KEY is set', () => {
|
||||
const out = runMonitor({
|
||||
hookEventName: undefined,
|
||||
sessionId: makeSessionId('fallback-gemini'),
|
||||
env: { GEMINI_API_KEY: 'fake-key-for-test' },
|
||||
});
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'AfterTool',
|
||||
`Expected fallback "AfterTool" for Gemini but got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to "PostToolUse" when hook_event_name is an empty string (non-Gemini)', () => {
|
||||
const out = runMonitor({
|
||||
hookEventName: '',
|
||||
sessionId: makeSessionId('fallback-empty'),
|
||||
env: { GEMINI_API_KEY: '' },
|
||||
});
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'PostToolUse',
|
||||
`Expected fallback "PostToolUse" for empty hook_event_name but got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to "PostToolUse" when hook_event_name is whitespace-only (non-Gemini)', () => {
|
||||
// trim() makes " " → "" which is falsy, so the || fallback fires
|
||||
const out = runMonitor({
|
||||
hookEventName: ' ',
|
||||
sessionId: makeSessionId('fallback-whitespace'),
|
||||
env: { GEMINI_API_KEY: '' },
|
||||
});
|
||||
assert.ok(out, 'hook must emit output when context is below WARNING threshold');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'PostToolUse',
|
||||
`Expected fallback "PostToolUse" for whitespace-only hook_event_name but got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Critical threshold also echoes the event name ───────────────────────────
|
||||
|
||||
describe('bug #925: critical threshold warning also uses correct hookEventName', () => {
|
||||
test('CRITICAL warning emitted under Stop also echoes "Stop"', () => {
|
||||
const out = runMonitor({
|
||||
hookEventName: 'Stop',
|
||||
sessionId: makeSessionId('critical-stop'),
|
||||
remainingPct: 20,
|
||||
usedPct: 80,
|
||||
});
|
||||
assert.ok(out, 'hook must emit output at critical threshold (remaining=20)');
|
||||
assert.strictEqual(
|
||||
out.hookSpecificOutput?.hookEventName,
|
||||
'Stop',
|
||||
`Expected hookEventName "Stop" at critical threshold, got "${out.hookSpecificOutput?.hookEventName}".`
|
||||
);
|
||||
assert.match(
|
||||
out.hookSpecificOutput?.additionalContext || '',
|
||||
/CONTEXT CRITICAL/,
|
||||
'Output should be a CRITICAL warning at remaining=20'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -254,3 +254,253 @@ describe('gsd-read-guard hook', () => {
|
||||
assert.equal(result.stdout, '', 'should produce no output on Claude Code');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2344-read-guard-claudecode-env.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2344-read-guard-claudecode-env (consolidation epic #1969 B6 #1975)", () => {
|
||||
/**
|
||||
* Regression test for bug #2344
|
||||
*
|
||||
* gsd-read-guard.js checked process.env.CLAUDE_SESSION_ID to detect the
|
||||
* Claude Code runtime and skip its advisory. However, Claude Code CLI exports
|
||||
* CLAUDECODE=1, not CLAUDE_SESSION_ID. The skip never fired, so the
|
||||
* READ-BEFORE-EDIT advisory injected on every Edit/Write call inside Claude
|
||||
* Code — producing noise in long-running sessions.
|
||||
*
|
||||
* Fix: check CLAUDECODE (and CLAUDE_SESSION_ID for back-compat) before
|
||||
* emitting the advisory.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-guard.js');
|
||||
|
||||
function runHook(payload, envOverrides = {}) {
|
||||
const input = JSON.stringify(payload);
|
||||
const env = {
|
||||
...process.env,
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDECODE: '',
|
||||
CLAUDE_CODE_ENTRYPOINT: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
CLAUDE_PROJECT_DIR: '',
|
||||
...envOverrides,
|
||||
};
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
|
||||
} catch (err) {
|
||||
return {
|
||||
exitCode: err.status ?? 1,
|
||||
stdout: (err.stdout || '').toString().trim(),
|
||||
stderr: (err.stderr || '').toString().trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('bug #2344: read guard skips on CLAUDECODE env var', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempDir('gsd-read-guard-2344-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('skips advisory when CLAUDECODE=1 is set (Claude Code CLI env)', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
const result = runHook(
|
||||
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
||||
{ CLAUDECODE: '1' }
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDECODE=1');
|
||||
});
|
||||
|
||||
test('skips advisory when CLAUDE_SESSION_ID is set (back-compat)', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
const result = runHook(
|
||||
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
||||
{ CLAUDE_SESSION_ID: 'test-session-123' }
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDE_SESSION_ID is set');
|
||||
});
|
||||
|
||||
test('still injects advisory when neither CLAUDECODE nor CLAUDE_SESSION_ID is set', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
const result = runHook(
|
||||
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
||||
{ CLAUDECODE: '', CLAUDE_SESSION_ID: '' }
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.ok(result.stdout.length > 0, 'advisory should fire on non-Claude-Code runtimes');
|
||||
const output = JSON.parse(result.stdout);
|
||||
assert.ok(output.hookSpecificOutput?.additionalContext?.includes('Read'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2520-read-guard-hook-subprocess-env.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2520-read-guard-hook-subprocess-env (consolidation epic #1969 B6 #1975)", () => {
|
||||
/**
|
||||
* Regression test for bug #2520
|
||||
*
|
||||
* The fix for #2344 added `|| process.env.CLAUDECODE` to the Claude Code
|
||||
* skip check. That works in principle — CLAUDECODE=1 is propagated to Bash
|
||||
* tool subprocesses — but it does NOT reach hook subprocesses on Claude Code
|
||||
* v2.1.116. Claude Code applies a separate env filter when spawning
|
||||
* PreToolUse hook commands; that filter drops bare CLAUDECODE and
|
||||
* CLAUDE_SESSION_ID and keeps only CLAUDE_CODE_*-prefixed vars plus
|
||||
* CLAUDE_PROJECT_DIR. `data.session_id` is, however, reliably delivered via
|
||||
* the hook's stdin JSON payload (documented part of Claude Code's hook
|
||||
* input schema).
|
||||
*
|
||||
* Fix: use `data.session_id` as the primary Claude Code signal, with
|
||||
* CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SSE_PORT as env-var fallbacks, and
|
||||
* keep legacy CLAUDECODE / CLAUDE_SESSION_ID for back-compat and
|
||||
* future-proofing.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-guard.js');
|
||||
|
||||
/**
|
||||
* Spawn the hook with an env that mirrors the actual Claude Code hook
|
||||
* subprocess env: CLAUDECODE and CLAUDE_SESSION_ID are stripped, only
|
||||
* CLAUDE_CODE_*-prefixed vars (plus CLAUDE_PROJECT_DIR) remain. Extra env
|
||||
* overrides can be supplied via `envOverrides`.
|
||||
*/
|
||||
function runHookInClaudeCodeSubprocess(payload, envOverrides = {}) {
|
||||
const input = JSON.stringify(payload);
|
||||
const baseEnv = { ...process.env };
|
||||
// Strip env vars Claude Code does NOT propagate to hook subprocesses.
|
||||
delete baseEnv.CLAUDECODE;
|
||||
delete baseEnv.CLAUDE_SESSION_ID;
|
||||
const env = {
|
||||
...baseEnv,
|
||||
// Env vars Claude Code DOES propagate to hook subprocesses (observed on
|
||||
// Claude Code CLI 2.1.116).
|
||||
CLAUDE_CODE_ENTRYPOINT: 'cli',
|
||||
CLAUDE_CODE_SSE_PORT: '51291',
|
||||
CLAUDE_PROJECT_DIR: process.cwd(),
|
||||
...envOverrides,
|
||||
};
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
|
||||
} catch (err) {
|
||||
return {
|
||||
exitCode: err.status ?? 1,
|
||||
stdout: (err.stdout || '').toString().trim(),
|
||||
stderr: (err.stderr || '').toString().trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('bug #2520: read guard detects Claude Code without relying on CLAUDECODE env', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempDir('gsd-read-guard-2520-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('skips advisory when stdin payload includes session_id (Claude Code hook-subprocess env)', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
// Isolate the stdin `session_id` signal by clearing the CLAUDE_CODE_*
|
||||
// env fallbacks the helper normally provides. Without this the env
|
||||
// fallback would rescue the skip even if session_id detection broke,
|
||||
// hiding a regression of the primary signal.
|
||||
const result = runHookInClaudeCodeSubprocess(
|
||||
{
|
||||
session_id: 'e7123e54-0977-45dd-848a-b9c8a45a5cd3',
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' },
|
||||
},
|
||||
{ CLAUDE_CODE_ENTRYPOINT: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_PROJECT_DIR: '' },
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(
|
||||
result.stdout,
|
||||
'',
|
||||
'advisory must not fire when session_id is present on stdin (real Claude Code hook env)',
|
||||
);
|
||||
});
|
||||
|
||||
test('skips advisory when CLAUDE_CODE_ENTRYPOINT is set (env-var fallback, no session_id on stdin)', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
const result = runHookInClaudeCodeSubprocess(
|
||||
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
||||
{ CLAUDE_CODE_ENTRYPOINT: 'cli', CLAUDE_CODE_SSE_PORT: '' },
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.stdout, '', 'advisory must not fire when CLAUDE_CODE_ENTRYPOINT is set');
|
||||
});
|
||||
|
||||
test('still injects advisory when no Claude Code signal is present (non-Claude host)', () => {
|
||||
const filePath = path.join(tmpDir, 'existing.js');
|
||||
fs.writeFileSync(filePath, 'const x = 1;\n');
|
||||
|
||||
const result = runHookInClaudeCodeSubprocess(
|
||||
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
|
||||
{ CLAUDE_CODE_ENTRYPOINT: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_PROJECT_DIR: '' },
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.ok(result.stdout.length > 0, 'advisory should fire on non-Claude-Code hosts');
|
||||
const output = JSON.parse(result.stdout);
|
||||
assert.ok(output.hookSpecificOutput?.additionalContext?.includes('Read'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,3 +178,257 @@ describe('release-tarball-smoke', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-131-release-tarball-smoke-explicit-home.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-131-release-tarball-smoke-explicit-home (consolidation epic #1969 B6 #1975)", () => {
|
||||
// allow-test-rule: integration-test-input (see #131)
|
||||
// Regression test for #131: runNpm() must not fail when HOME points at an
|
||||
// unwritable directory. The before() hook in release-tarball-smoke.install.test.cjs
|
||||
// calls runNpm(['pack', ...]) and runNpm(['install', '-g', ...]) — if those inherit
|
||||
// an unwritable HOME from the environment (common in constrained Docker hosts),
|
||||
// the entire hook fails and all 6 subtests are cancelled.
|
||||
//
|
||||
// Fix: runNpm() must inject an explicit HOME, npm_config_cache, and
|
||||
// npm_config_userconfig that point into a temp directory it owns, so that npm
|
||||
// never reads from or writes to the caller's HOME.
|
||||
//
|
||||
// Test 3 (added in the second fix pass) verifies that isolatedNpmEnv() — the
|
||||
// companion export that lets runSmoke() apply the same isolation — also redirects
|
||||
// HOME away from the caller's HOME. Without this, subtests A-F of
|
||||
// release-tarball-smoke.install.test.cjs still fail because runSmoke() calls
|
||||
// spawnSync('npm', ...) internally and was not covered by the runNpm() fix.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
// The helpers under test.
|
||||
const { isolatedNpmEnv, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// Resolve a filesystem path to its canonical (symlink-free) form even if the
|
||||
// leaf does not exist yet (e.g. ~/.npm before npm has written its cache).
|
||||
// Walks up to the nearest existing ancestor, resolves that, then re-appends
|
||||
// the trailing segments. This handles macOS /var → /private/var symlinks for
|
||||
// paths created under os.tmpdir() where the leaf directory may not exist yet.
|
||||
function safeRealpath(p) {
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch (_) {
|
||||
// Leaf does not exist — resolve the nearest existing ancestor then
|
||||
// reconstruct the original suffix so the result is still canonical.
|
||||
const segments = [];
|
||||
let cur = p;
|
||||
for (;;) {
|
||||
const parent = path.dirname(cur);
|
||||
if (parent === cur) {
|
||||
// Reached filesystem root — return original path unchanged.
|
||||
return p;
|
||||
}
|
||||
segments.unshift(path.basename(cur));
|
||||
cur = parent;
|
||||
try {
|
||||
return path.join(fs.realpathSync(cur), ...segments);
|
||||
} catch (__) {
|
||||
// Keep walking up.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('bug-131: runNpm isolates HOME from the caller environment', () => {
|
||||
// ── Test 1 — runNpm works with an unwritable HOME ────────────────────────
|
||||
// Spawn a child Node process that sets HOME to a chmod-0500 directory, then
|
||||
// invokes runNpm(['--version']). Without the fix, npm tries to read/write
|
||||
// HOME/.npmrc and HOME/.npm, fails with EACCES, and runNpm throws.
|
||||
// With the fix, runNpm injects its own isolated HOME and npm succeeds.
|
||||
test('runNpm succeeds even when process HOME is unwritable', () => {
|
||||
// Create an unwritable dir to serve as a poisoned HOME.
|
||||
const poisonedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-bug131-poison-'));
|
||||
try {
|
||||
fs.chmodSync(poisonedHome, 0o500); // r-x only — not writable
|
||||
|
||||
// We exercise the real runNpm() path by running a tiny inline Node script
|
||||
// that requires helpers.cjs and calls runNpm(['--version']) with HOME set
|
||||
// to the unwritable dir. The script exits 0 on success, non-zero on throw.
|
||||
const script = `
|
||||
process.env.HOME = ${JSON.stringify(poisonedHome)};
|
||||
process.env.USERPROFILE = ${JSON.stringify(poisonedHome)};
|
||||
const { runNpm } = require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});
|
||||
try {
|
||||
const out = runNpm(['--version']);
|
||||
if (!out || out.trim() === '') process.exit(2); // vacuous success guard
|
||||
process.stdout.write(out);
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
process.stderr.write(e.message + '\\n');
|
||||
process.exit(1);
|
||||
}
|
||||
`;
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let exitCode = 0;
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, ['-e', script], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch (err) {
|
||||
stdout = err.stdout || '';
|
||||
stderr = err.stderr || '';
|
||||
exitCode = err.status ?? 1;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
exitCode,
|
||||
0,
|
||||
`runNpm should succeed with an unwritable HOME but exited ${exitCode}. stderr: ${stderr}`,
|
||||
);
|
||||
// npm --version returns something like "10.x.y"
|
||||
assert.match(
|
||||
stdout.trim(),
|
||||
/^\d+\.\d+/,
|
||||
`expected semver output from npm --version, got: ${stdout}`,
|
||||
);
|
||||
} finally {
|
||||
// Restore write permission before cleanup so the directory can be deleted.
|
||||
try { fs.chmodSync(poisonedHome, 0o700); } catch (_) { /* best-effort */ }
|
||||
cleanup(poisonedHome);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Test 2 — runNpm does not leak a caller-supplied HOME into npm ────────
|
||||
// Even if the caller exports HOME=/some/real/path, the injected HOME must be
|
||||
// a different (temp) path so npm writes never touch the caller's $HOME.
|
||||
test('runNpm injects a HOME distinct from process.env.HOME', () => {
|
||||
// Capture what HOME runNpm actually passes to npm by asking npm to print
|
||||
// the value it sees for the $HOME env var. We do this via `npm config get
|
||||
// cache` which reveals the cache path — if it's under process.env.HOME,
|
||||
// the fix is absent; if it's under a tmp dir, the fix is present.
|
||||
|
||||
const script = `
|
||||
const { runNpm } = require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});
|
||||
try {
|
||||
// npm config get cache prints the effective cache directory.
|
||||
const out = runNpm(['config', 'get', 'cache']);
|
||||
process.stdout.write(out.trim());
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
process.stderr.write(e.message + '\\n');
|
||||
process.exit(1);
|
||||
}
|
||||
`;
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let exitCode = 0;
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, ['-e', script], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch (err) {
|
||||
stdout = err.stdout || '';
|
||||
stderr = err.stderr || '';
|
||||
exitCode = err.status ?? 1;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
exitCode,
|
||||
0,
|
||||
`runNpm config get cache failed with exit ${exitCode}. stderr: ${stderr}`,
|
||||
);
|
||||
|
||||
const effectiveCacheDir = stdout.trim();
|
||||
|
||||
// The effective npm cache must NOT be inside the calling process's HOME.
|
||||
// If it is, the fix was not applied and the Docker regression can still occur.
|
||||
const callerHome = os.homedir();
|
||||
assert.ok(
|
||||
!effectiveCacheDir.startsWith(callerHome),
|
||||
`npm cache dir ${effectiveCacheDir} is still under caller HOME ${callerHome} — fix not applied`,
|
||||
);
|
||||
|
||||
// It must be somewhere under the system tmp dir, confirming isolation.
|
||||
// Use safeRealpath on both sides so that macOS /var→/private/var symlinks
|
||||
// do not cause a false mismatch when os.tmpdir() and the resolved cache
|
||||
// path differ only in symlink expansion. The cache sub-directory (.npm) may
|
||||
// not exist yet; safeRealpath walks up to the nearest existing ancestor.
|
||||
const sysTmp = safeRealpath(os.tmpdir());
|
||||
const realCacheDir = safeRealpath(effectiveCacheDir);
|
||||
assert.ok(
|
||||
realCacheDir.startsWith(sysTmp),
|
||||
`npm cache dir ${realCacheDir} should be under tmpdir ${sysTmp}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 3 — isolatedNpmEnv() redirects HOME away from the caller's HOME ──
|
||||
// runSmoke() calls spawnSync('npm', ...) with npmEnv from isolatedNpmEnv().
|
||||
// If isolatedNpmEnv() didn't redirect HOME, subtests A-F would still fail on
|
||||
// Docker hosts with an unwritable HOME (the original bug #131 root cause,
|
||||
// manifesting via the sibling runSmoke() path). (#131)
|
||||
test('isolatedNpmEnv() HOME is distinct from the caller HOME and lives under tmpdir', () => {
|
||||
const env = isolatedNpmEnv();
|
||||
|
||||
// Must expose a HOME key.
|
||||
assert.ok(
|
||||
typeof env.HOME === 'string' && env.HOME.length > 0,
|
||||
'isolatedNpmEnv() must set HOME',
|
||||
);
|
||||
|
||||
// Must not be the caller's HOME.
|
||||
const callerHome = os.homedir();
|
||||
assert.notEqual(
|
||||
env.HOME,
|
||||
callerHome,
|
||||
`isolatedNpmEnv() HOME must differ from caller HOME ${callerHome}`,
|
||||
);
|
||||
|
||||
// Must live under the system tmpdir, confirming it is an isolated temp directory.
|
||||
// Use safeRealpath on both sides so that macOS /var→/private/var symlinks
|
||||
// do not cause a false mismatch.
|
||||
const sysTmp = safeRealpath(os.tmpdir());
|
||||
const realHome = safeRealpath(env.HOME);
|
||||
assert.ok(
|
||||
realHome.startsWith(sysTmp),
|
||||
`isolatedNpmEnv() HOME ${realHome} should be under tmpdir ${sysTmp}`,
|
||||
);
|
||||
|
||||
// npm_config_cache and npm_config_userconfig must also be set and under the isolated HOME.
|
||||
assert.ok(
|
||||
typeof env.npm_config_cache === 'string' && env.npm_config_cache.startsWith(env.HOME),
|
||||
`npm_config_cache ${env.npm_config_cache} should be under isolated HOME ${env.HOME}`,
|
||||
);
|
||||
assert.ok(
|
||||
typeof env.npm_config_userconfig === 'string' && env.npm_config_userconfig.startsWith(env.HOME),
|
||||
`npm_config_userconfig ${env.npm_config_userconfig} should be under isolated HOME ${env.HOME}`,
|
||||
);
|
||||
assert.equal(
|
||||
env.npm_config_loglevel,
|
||||
'error',
|
||||
'isolatedNpmEnv() should suppress npm notice/warn chatter in test gates',
|
||||
);
|
||||
assert.equal(
|
||||
env.npm_config_update_notifier,
|
||||
'false',
|
||||
'isolatedNpmEnv() should disable npm update-notifier notices in test gates',
|
||||
);
|
||||
assert.equal(
|
||||
env.NO_UPDATE_NOTIFIER,
|
||||
'1',
|
||||
'isolatedNpmEnv() should disable npm update-notifier notices for npm versions that honor NO_UPDATE_NOTIFIER',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -761,3 +761,509 @@ describe('parseShardArg (#1212)', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-969-test-infra-flake-hardening.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-969-test-infra-flake-hardening (consolidation epic #1969 B6 #1975)", () => {
|
||||
'use strict';
|
||||
/**
|
||||
* Regression tests for bug #969 — test-infra flake hardening.
|
||||
*
|
||||
* Two root causes addressed:
|
||||
*
|
||||
* A. SIGNATURE A: "X is not a function"
|
||||
* ensureBuiltArtifacts() previously short-circuited on a single sentinel
|
||||
* (semver-compare.cjs). If any other migrated .cjs was stale or absent,
|
||||
* it would be silently loaded in that broken state. This test proves the
|
||||
* unconditional-build fix: deleting a non-sentinel artifact and invoking
|
||||
* ensureBuiltArtifacts() regenerates it even when the sentinel is present.
|
||||
*
|
||||
* B. SIGNATURE B: misleading assertion failures from killed subprocesses
|
||||
* runGsdTools() previously had no timeout, so an OOM/SIGKILL'd subprocess
|
||||
* returned { success: false } and looked like a product error. This test
|
||||
* proves the kill-discrimination fix: a killed/timed-out invocation now
|
||||
* throws a labeled resource-starvation error, while a clean non-zero exit
|
||||
* still returns { success: false, exitCode: N }.
|
||||
*
|
||||
* C. SIGNATURE C: "Failed to install hooks: directory is empty" in scoped CI
|
||||
* hooks/dist is gitignored and NOT built by `prepare` (build:lib only), so
|
||||
* the scoped test lane starts with it absent. The first install test's
|
||||
* before() hook triggers build-hooks.js, which creates DIST_DIR empty then
|
||||
* fills it file-by-file — a window where a concurrently-spawned install
|
||||
* reader sees zero hooks and hard-fails. ensureBuiltHooks() builds hooks/dist
|
||||
* ONCE upfront (same chokepoint as ensureBuiltArtifacts) so the empty window
|
||||
* never exists during concurrent test execution. These tests prove it
|
||||
* rebuilds when dist is absent/empty/incomplete and no-ops when complete.
|
||||
*
|
||||
* RULESET.TESTS.regression-must-fail-first: each test section documents what
|
||||
* the old behavior would have been (fail-before) and asserts the new behavior
|
||||
* (pass-after), using only behavioral invocations — no source-grep.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { ensureBuiltArtifacts, ensureBuiltHooks } = require('../scripts/run-tests.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Part A — ensureBuiltArtifacts: unconditional rebuild
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #969 A — ensureBuiltArtifacts rebuilds stale artifacts', () => {
|
||||
/**
|
||||
* Helper: create a self-contained temp TypeScript project with two source files
|
||||
* (sentinelmod.cts and targetmod.cts) and a tsconfig that emits to <tmp>/out.
|
||||
* Returns { tmp, overrides, sentinelOut, targetOut, tsBuildInfoPath }.
|
||||
*
|
||||
* HERMETIC: all destructive tests use this helper. They NEVER touch the real
|
||||
* gsd-core/bin/lib/*.cjs or the real tsbuildinfo. (Regression from #996 fixed here.)
|
||||
*/
|
||||
function makeTempProject() {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-bug969-'));
|
||||
const srcDir = path.join(tmp, 'src');
|
||||
const outDir = path.join(tmp, 'out');
|
||||
const tsBuildInfoPath = path.join(outDir, '.tsbuildinfo');
|
||||
const tsconfigPath = path.join(tmp, 'tsconfig.build.json');
|
||||
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(srcDir, 'sentinelmod.cts'), 'export const sentinelValue = 1;\n');
|
||||
fs.writeFileSync(path.join(srcDir, 'targetmod.cts'), 'export const targetValue = 2;\n');
|
||||
|
||||
fs.writeFileSync(tsconfigPath, JSON.stringify({
|
||||
compilerOptions: {
|
||||
rootDir: 'src',
|
||||
outDir: 'out',
|
||||
module: 'commonjs',
|
||||
target: 'es2022',
|
||||
esModuleInterop: true,
|
||||
noEmitOnError: true,
|
||||
incremental: true,
|
||||
tsBuildInfoFile: 'out/.tsbuildinfo',
|
||||
},
|
||||
include: ['src/**/*.cts'],
|
||||
}, null, 2));
|
||||
|
||||
const overrides = { root: tmp, srcDir, outDir, tsBuildInfoPath, tsconfigPath };
|
||||
const sentinelOut = path.join(outDir, 'sentinelmod.cjs');
|
||||
const targetOut = path.join(outDir, 'targetmod.cjs');
|
||||
return { tmp, overrides, sentinelOut, targetOut, tsBuildInfoPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* FAIL-BEFORE (origin/next behavior):
|
||||
* The old code contained `if (existsSync(sentinel)) return;`. When the
|
||||
* sentinel (semver-compare.cjs) was present, the function returned early
|
||||
* without touching any other .cjs. This test confirms the new code always
|
||||
* invokes tsc — it would have returned immediately on origin/next.
|
||||
*
|
||||
* Specifically: on origin/next, after deleting a non-sentinel artifact +
|
||||
* its tsbuildinfo and calling ensureBuiltArtifacts() with sentinel present,
|
||||
* the artifact would remain absent. On the fix, tsc runs unconditionally
|
||||
* and recreates it.
|
||||
*
|
||||
* PASS-AFTER (fix):
|
||||
* The sentinel guard is removed. ensureBuiltArtifacts() always invokes tsc.
|
||||
* With no tsbuildinfo present (clean state), tsc performs a full emit and
|
||||
* recreates all .cjs outputs including the deleted non-sentinel artifact.
|
||||
*
|
||||
* HERMETIC: this test operates on a self-contained temp project. It NEVER
|
||||
* touches gsd-core/bin/lib/core.cjs or the real tsbuildinfo. (Fixed from #996.)
|
||||
*/
|
||||
test('rebuilds a non-sentinel artifact (with no tsbuildinfo) even when sentinel exists', () => {
|
||||
const { tmp, overrides, sentinelOut, targetOut, tsBuildInfoPath } = makeTempProject();
|
||||
try {
|
||||
// Initial build — both outputs must appear.
|
||||
ensureBuiltArtifacts(overrides);
|
||||
assert.ok(fs.existsSync(sentinelOut), 'initial build: sentinelmod.cjs must exist');
|
||||
assert.ok(fs.existsSync(targetOut), 'initial build: targetmod.cjs must exist');
|
||||
|
||||
// Simulate: fresh CI checkout — target artifact missing, no tsbuildinfo.
|
||||
fs.unlinkSync(targetOut);
|
||||
if (fs.existsSync(tsBuildInfoPath)) fs.unlinkSync(tsBuildInfoPath);
|
||||
|
||||
assert.ok(!fs.existsSync(targetOut), 'pre-condition: targetmod.cjs must be absent');
|
||||
assert.ok(fs.existsSync(sentinelOut), 'pre-condition: sentinelmod.cjs must still be present');
|
||||
|
||||
// Under the OLD code this returned immediately (sentinel present → return).
|
||||
// Under the NEW code this calls tsc unconditionally → full emit → recreated.
|
||||
ensureBuiltArtifacts(overrides);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(targetOut),
|
||||
'ensureBuiltArtifacts must recreate targetmod.cjs even when sentinelmod.cjs ' +
|
||||
'exists (sentinel-short-circuit was removed in fix #969)'
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PASS-AFTER: the unconditional build emits the expected output (sentinelmod.cjs).
|
||||
* Uses the temp project helper so this test is fully hermetic — it never touches
|
||||
* the real gsd-core/bin/lib tree.
|
||||
*/
|
||||
test('sentinel (semver-compare.cjs) still exists after unconditional build', () => {
|
||||
const { tmp, overrides, sentinelOut } = makeTempProject();
|
||||
try {
|
||||
ensureBuiltArtifacts(overrides);
|
||||
assert.ok(fs.existsSync(sentinelOut), 'sentinel output (sentinelmod.cjs) must exist after ensureBuiltArtifacts');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PERSISTENT-MIRROR CASE — the residual hole found by adversarial review.
|
||||
*
|
||||
* FAIL-BEFORE (incremental: true — the old behavior on this branch):
|
||||
* With "incremental": true in tsconfig.build.json, tsc reads the .tsbuildinfo
|
||||
* on disk. If sources are unchanged since the last build, tsc skips re-emitting
|
||||
* any outputs — including outputs that were deleted or overwritten by an rsync
|
||||
* from a different branch. This is the persistent-docker-mirror scenario:
|
||||
* 1. A prior branch rsync'd a stale core.cjs into bin/lib/
|
||||
* 2. A stale tsbuildinfo is present (from that same branch)
|
||||
* 3. ensureBuiltArtifacts() calls tsc (incremental)
|
||||
* 4. tsc sees "sources unchanged vs tsbuildinfo" → no-ops → stale .cjs served
|
||||
* With "incremental": true this test would FAIL because targetmod.cjs remains absent.
|
||||
*
|
||||
* PASS-AFTER (step-3 unlink+clean-reemit logic):
|
||||
* When a missing/zero-bytes output is detected after the incremental pass,
|
||||
* ensureBuiltArtifacts() unlinks the tsbuildinfo and runs tsc a second time
|
||||
* (clean re-emit). The stale/missing output is always regenerated.
|
||||
*
|
||||
* HERMETIC: this test operates on a self-contained temp project. It NEVER
|
||||
* touches gsd-core/bin/lib/core.cjs or the real tsbuildinfo. (Fixed from #996.)
|
||||
*/
|
||||
test('PERSISTENT-MIRROR: rebuilds stale output even when tsbuildinfo is present (non-incremental is authoritative)', () => {
|
||||
const { tmp, overrides, targetOut, tsBuildInfoPath } = makeTempProject();
|
||||
const STALE_TSBUILDINFO = JSON.stringify({
|
||||
program: { fileNames: [], options: { incremental: true } },
|
||||
version: '5.0.0',
|
||||
_gsd_test_marker: 'stale-persistent-mirror',
|
||||
});
|
||||
|
||||
try {
|
||||
// Initial build to populate outputs.
|
||||
ensureBuiltArtifacts(overrides);
|
||||
assert.ok(fs.existsSync(targetOut), 'initial build: targetmod.cjs must exist');
|
||||
|
||||
// Inject a stale tsbuildinfo (mirrors: old branch rsync'd state onto workspace).
|
||||
fs.writeFileSync(tsBuildInfoPath, STALE_TSBUILDINFO);
|
||||
// Delete the output .cjs (mirrors: stale/missing output on the persistent mirror).
|
||||
fs.unlinkSync(targetOut);
|
||||
|
||||
assert.ok(!fs.existsSync(targetOut), 'pre-condition: targetmod.cjs must be absent');
|
||||
assert.ok(fs.existsSync(tsBuildInfoPath), 'pre-condition: tsbuildinfo must be present');
|
||||
|
||||
// FAIL-BEFORE (incremental: true, no step-3): tsc would read the stale
|
||||
// tsbuildinfo, see "sources unchanged", and skip re-emitting targetmod.cjs
|
||||
// → it would remain absent.
|
||||
//
|
||||
// PASS-AFTER (step-3 unlink+clean-reemit): missing output detected after
|
||||
// incremental pass → tsbuildinfo unlinked → tsc runs again → targetmod.cjs
|
||||
// is regenerated unconditionally.
|
||||
ensureBuiltArtifacts(overrides);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(targetOut),
|
||||
'ensureBuiltArtifacts must regenerate targetmod.cjs even when a stale ' +
|
||||
'tsbuildinfo is present on disk (persistent-mirror scenario — ' +
|
||||
'incremental:true alone would have no-op\'d here)'
|
||||
);
|
||||
|
||||
// Verify the regenerated file is valid JS.
|
||||
const regenerated = fs.readFileSync(targetOut, 'utf-8');
|
||||
assert.ok(regenerated.length > 0, 'regenerated targetmod.cjs must be non-empty');
|
||||
assert.ok(
|
||||
regenerated.includes('exports.') || regenerated.includes('"use strict"'),
|
||||
'regenerated targetmod.cjs must look like a valid CommonJS module'
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Part B — runGsdTools: timeout + kill-signal discrimination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #969 B — runGsdTools kill-signal discrimination', () => {
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
/**
|
||||
* Shared helper that mirrors the production runGsdTools implementation
|
||||
* (from tests/helpers.cjs) but accepts an explicit timeout so we can
|
||||
* trigger the kill path in tests without waiting 60 seconds.
|
||||
*
|
||||
* IMPORTANT: this helper is intentionally self-contained so that the test
|
||||
* proves the CONTRACT of the implementation, not just calls the real
|
||||
* runGsdTools (which would need a real 60s+ hang to trigger in tests).
|
||||
* We test the identical logic paths using a tiny timeout.
|
||||
*/
|
||||
function runGsdToolsWithTimeout(args, cwd, env, timeoutMs) {
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
};
|
||||
try {
|
||||
let result;
|
||||
const childEnv = { ...process.env, ...TEST_ENV_BASE, ...(env || {}) };
|
||||
const argv = Array.isArray(args)
|
||||
? args
|
||||
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
|
||||
.map(t => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
|
||||
result = execFileSync(process.execPath, [TOOLS_PATH, ...argv], {
|
||||
cwd: cwd || process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: childEnv,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
return { success: true, output: result.trim(), exitCode: 0 };
|
||||
} catch (err) {
|
||||
// Production kill-discrimination logic (verbatim from helpers.cjs fix).
|
||||
if (err.killed || err.signal != null || err.code === 'ETIMEDOUT') {
|
||||
throw new Error(
|
||||
`[runGsdTools: resource-starvation / subprocess-kill] ` +
|
||||
`gsd-tools was killed before completion ` +
|
||||
`(signal=${err.signal}, code=${err.code}, killed=${err.killed}). ` +
|
||||
`This indicates host OOM or scheduler contention, not a product bug. ` +
|
||||
`stdout=${err.stdout?.toString().trim() || ''} ` +
|
||||
`stderr=${err.stderr?.toString().trim() || ''}`
|
||||
);
|
||||
}
|
||||
const stderrRaw = err.stderr?.toString().trim() || '';
|
||||
const error = stderrRaw || `${err.message} [stderr: (empty) exit:${err.status ?? 1}]`;
|
||||
return {
|
||||
success: false,
|
||||
output: err.stdout?.toString().trim() || '',
|
||||
error,
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FAIL-BEFORE (origin/next behavior):
|
||||
* Without a timeout, an OOM-killed subprocess threw with err.killed=true
|
||||
* but the catch block fell through to `return { success: false, ... }`.
|
||||
* The test consumer saw a normal {success:false} result and tried to parse
|
||||
* gsd-tools output from it, causing a confusing downstream assertion fail.
|
||||
*
|
||||
* PASS-AFTER (fix):
|
||||
* The kill-discrimination guard rethrows immediately with a labeled error
|
||||
* message containing "resource-starvation / subprocess-kill". The test
|
||||
* asserts on that throw rather than getting a silent {success:false}.
|
||||
*
|
||||
* Mechanism: we use a tiny timeout (1ms) to guarantee a timeout-kill on a
|
||||
* real gsd-tools invocation (even `--help` takes >1ms to start node).
|
||||
*/
|
||||
test('throws a resource-starvation error when subprocess is killed/times out', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-'));
|
||||
try {
|
||||
// 1ms timeout guarantees ETIMEDOUT / killed before gsd-tools can respond.
|
||||
assert.throws(
|
||||
() => runGsdToolsWithTimeout(['--help'], tmpDir, {}, 1),
|
||||
(err) => {
|
||||
assert.ok(
|
||||
err.message.includes('resource-starvation / subprocess-kill'),
|
||||
`Expected labeled resource-starvation error, got: ${err.message}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that a normal fast command still returns { success: true } and does
|
||||
* NOT throw — i.e., the timeout addition does not break the happy path.
|
||||
*/
|
||||
test('returns { success: true } for a normal fast command with generous timeout', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-'));
|
||||
try {
|
||||
// 30s timeout; gsd-tools --help completes in well under 1s.
|
||||
const result = runGsdToolsWithTimeout(['--help'], tmpDir, {}, 30000);
|
||||
assert.ok(result.success === true, `Expected success:true, got ${JSON.stringify(result)}`);
|
||||
assert.ok(typeof result.output === 'string', 'output must be a string');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that a clean non-zero exit (a real gsd-tools application error, not
|
||||
* a kill) still returns { success: false } WITHOUT throwing. This preserves
|
||||
* existing test behavior that asserts on error shape.
|
||||
*
|
||||
* We trigger a clean non-zero by invoking a command that is known to fail
|
||||
* cleanly (no project directory set up).
|
||||
*/
|
||||
test('returns { success: false } for a clean non-zero exit (no throw)', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-'));
|
||||
try {
|
||||
// 'phase list' on a directory with no .planning/ produces a clean error exit.
|
||||
const result = runGsdToolsWithTimeout(['phase', 'list'], tmpDir, {}, 30000);
|
||||
assert.ok(result.success === false, `Expected success:false for clean error, got ${JSON.stringify(result)}`);
|
||||
assert.ok(result.exitCode !== 0, 'exitCode must be non-zero');
|
||||
// Must NOT have thrown — the clean-error path returns normally.
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Part C — ensureBuiltHooks: build hooks/dist once, closing the scoped-CI
|
||||
// first-build empty-dir race.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #969 C — ensureBuiltHooks populates hooks/dist before concurrent tests', () => {
|
||||
/**
|
||||
* Helper: a hermetic temp dist dir + a runBuild spy. The spy records how many
|
||||
* times a build was requested and, when invoked, writes the given hook files
|
||||
* (simulating build-hooks.js populating DIST_DIR) so idempotency is testable.
|
||||
*
|
||||
* HERMETIC: never touches the real hooks/dist. Uses dependency-injected
|
||||
* overrides (distDir, hookNames, runBuild) — no fs monkeypatching, so the test
|
||||
* is deterministic and root/OS-independent.
|
||||
*/
|
||||
function makeHooksFixture(hookNames) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-969-hooks-'));
|
||||
const distDir = path.join(tmp, 'hooks', 'dist');
|
||||
let buildCalls = 0;
|
||||
const runBuild = () => {
|
||||
buildCalls += 1;
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
for (const h of hookNames) {
|
||||
fs.writeFileSync(path.join(distDir, h), `// ${h}\nmodule.exports = {};\n`);
|
||||
}
|
||||
};
|
||||
const overrides = () => ({ distDir, hookNames, runBuild });
|
||||
return { tmp, distDir, hookNames, overrides, calls: () => buildCalls };
|
||||
}
|
||||
|
||||
const HOOKS = ['a-hook.js', 'b-hook.js', 'c-hook.sh'];
|
||||
|
||||
/**
|
||||
* FAIL-BEFORE (origin/next): ensureBuiltHooks did not exist, so the export was
|
||||
* undefined and there was no upfront hooks build — the first concurrent
|
||||
* install test raced build-hooks.js's empty-then-fill window. The import at the
|
||||
* top of this file (ensureBuiltHooks) is itself the fail-first anchor: on
|
||||
* origin/next it is undefined and every test below throws "not a function".
|
||||
*
|
||||
* PASS-AFTER: ensureBuiltHooks() builds when hooks/dist is entirely absent.
|
||||
*/
|
||||
test('builds when hooks/dist is absent (fresh checkout / scoped CI)', () => {
|
||||
const fx = makeHooksFixture(HOOKS);
|
||||
try {
|
||||
assert.equal(typeof ensureBuiltHooks, 'function',
|
||||
'ensureBuiltHooks must be exported (absent on origin/next — the fail-first anchor)');
|
||||
assert.ok(!fs.existsSync(fx.distDir), 'pre-condition: hooks/dist must be absent');
|
||||
ensureBuiltHooks(fx.overrides());
|
||||
assert.equal(fx.calls(), 1, 'a build must be triggered when dist is absent');
|
||||
for (const h of HOOKS) {
|
||||
assert.ok(fs.existsSync(path.join(fx.distDir, h)), `${h} must exist after build`);
|
||||
}
|
||||
} finally {
|
||||
cleanup(fx.tmp);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* BOUNDARY: dist exists but is empty (0 of N hooks) — the exact transient state
|
||||
* build-hooks.js exposes between mkdir(DIST_DIR) and the first file rename.
|
||||
*/
|
||||
test('builds when hooks/dist exists but is empty (0 of N)', () => {
|
||||
const fx = makeHooksFixture(HOOKS);
|
||||
try {
|
||||
fs.mkdirSync(fx.distDir, { recursive: true }); // empty dir — the race window
|
||||
ensureBuiltHooks(fx.overrides());
|
||||
assert.equal(fx.calls(), 1, 'an empty dist must trigger a build');
|
||||
} finally {
|
||||
cleanup(fx.tmp);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* BOUNDARY: dist has all-but-one hook (N-1 of N) — a partially-filled dir mid
|
||||
* first-build. Must still be treated as incomplete and rebuilt.
|
||||
*/
|
||||
test('builds when hooks/dist is partial (N-1 of N)', () => {
|
||||
const fx = makeHooksFixture(HOOKS);
|
||||
try {
|
||||
fs.mkdirSync(fx.distDir, { recursive: true });
|
||||
for (const h of HOOKS.slice(0, HOOKS.length - 1)) {
|
||||
fs.writeFileSync(path.join(fx.distDir, h), 'x');
|
||||
}
|
||||
ensureBuiltHooks(fx.overrides());
|
||||
assert.equal(fx.calls(), 1, 'a partial dist (missing one hook) must trigger a build');
|
||||
} finally {
|
||||
cleanup(fx.tmp);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* BOUNDARY: a zero-byte hook (N of N present, but one is 0 bytes) — a truncated
|
||||
* mid-write file. statSync().size === 0 must count as incomplete → rebuild.
|
||||
*/
|
||||
test('builds when a hook file is present but zero-byte', () => {
|
||||
const fx = makeHooksFixture(HOOKS);
|
||||
try {
|
||||
fs.mkdirSync(fx.distDir, { recursive: true });
|
||||
HOOKS.forEach((h, i) => {
|
||||
fs.writeFileSync(path.join(fx.distDir, h), i === 0 ? '' : 'ok'); // first is 0 bytes
|
||||
});
|
||||
ensureBuiltHooks(fx.overrides());
|
||||
assert.equal(fx.calls(), 1, 'a zero-byte hook must be treated as incomplete → rebuild');
|
||||
} finally {
|
||||
cleanup(fx.tmp);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* BOUNDARY + idempotency: dist is complete (all N present, non-empty). No build
|
||||
* must fire — this keeps nested run-tests spawns and repeat invocations cheap
|
||||
* and avoids a redundant concurrent build against an already-populated dist.
|
||||
*/
|
||||
test('no-op when hooks/dist is complete (N of N non-empty)', () => {
|
||||
const fx = makeHooksFixture(HOOKS);
|
||||
try {
|
||||
fs.mkdirSync(fx.distDir, { recursive: true });
|
||||
for (const h of HOOKS) fs.writeFileSync(path.join(fx.distDir, h), 'ok');
|
||||
ensureBuiltHooks(fx.overrides());
|
||||
assert.equal(fx.calls(), 0, 'a complete dist must NOT trigger a build (idempotent no-op)');
|
||||
} finally {
|
||||
cleanup(fx.tmp);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Integration guard: the REAL default hook set (from build-hooks.js) is what
|
||||
* ensureBuiltHooks checks when no override is given. Prove the real export is a
|
||||
* non-empty list so the completeness predicate can never vacuously pass.
|
||||
*/
|
||||
test('default hook set (build-hooks.js HOOKS_TO_COPY) is a non-empty list', () => {
|
||||
const { HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
|
||||
assert.ok(Array.isArray(HOOKS_TO_COPY) && HOOKS_TO_COPY.length > 0,
|
||||
'HOOKS_TO_COPY must be a non-empty array or the completeness check is vacuous');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -212,3 +212,994 @@ describe('skill frontmatter: /gsd-plan-phase --research-phase flag absorbs the s
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/enh-2789-description-budget.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:enh-2789-description-budget (consolidation epic #1969 B6 #1975)", () => {
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product (see #2789)
|
||||
// commands/gsd/*.md text IS what the runtime loads — testing description
|
||||
// length tests the deployed system-prompt contract.
|
||||
|
||||
/**
|
||||
* Tests for #2789 — Trim skill description anti-patterns; enforce 100-char budget
|
||||
*
|
||||
* Verifies:
|
||||
* 1. All skill descriptions in commands/gsd/*.md are <= 100 chars
|
||||
* 2. No descriptions contain flag documentation anti-patterns (Use --)
|
||||
* 3. No descriptions contain "Triggers:" keyword stuffing
|
||||
* 4. lint-descriptions.cjs rejects descriptions over 100 chars
|
||||
* 5. lint-descriptions.cjs accepts descriptions under 100 chars
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const os = require('node:os');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const COMMANDS_DIR = path.join(__dirname, '../commands/gsd');
|
||||
const LINT_SCRIPT = path.join(__dirname, '../scripts/lint-descriptions.cjs');
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 100;
|
||||
|
||||
/**
|
||||
* Parse the description field from a frontmatter block in a .md file.
|
||||
* Returns null if no description is found.
|
||||
*/
|
||||
function parseDescription(content) {
|
||||
// Extract frontmatter block between --- markers
|
||||
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!fmMatch) return null;
|
||||
const fm = fmMatch[1];
|
||||
|
||||
// Handle multi-line or quoted values: description: "..." or description: plain text
|
||||
// Match: description: "value" or description: value (to end of line)
|
||||
const quoted = fm.match(/^description:\s+"((?:[^"\\]|\\.)*)"\s*$/m);
|
||||
if (quoted) return quoted[1];
|
||||
|
||||
const plain = fm.match(/^description:\s+(.+)$/m);
|
||||
if (plain) return plain[1].trim();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all .md files in commands/gsd/ with their descriptions.
|
||||
*/
|
||||
function getAllCommandDescriptions() {
|
||||
const files = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md'));
|
||||
return files.map(file => {
|
||||
const filePath = path.join(COMMANDS_DIR, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const description = parseDescription(content);
|
||||
return { file, filePath, description };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Test 1: All descriptions <= 100 chars ────────────────────────────────────
|
||||
|
||||
describe('description length budget', () => {
|
||||
test('all commands/gsd/*.md descriptions are <= 100 chars', () => {
|
||||
const commands = getAllCommandDescriptions();
|
||||
const violators = commands
|
||||
.filter(c => c.description !== null && c.description.length > MAX_DESCRIPTION_LENGTH)
|
||||
.map(c => [
|
||||
'length=' + c.description.length,
|
||||
'file=' + c.file,
|
||||
'desc=' + c.description,
|
||||
].join(' | '));
|
||||
|
||||
assert.strictEqual(
|
||||
violators.length,
|
||||
0,
|
||||
[
|
||||
`${violators.length} description(s) exceed ${MAX_DESCRIPTION_LENGTH} chars:`,
|
||||
...violators.map(v => ' ' + v),
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Test 2: No flag documentation anti-patterns ──────────────────────────────
|
||||
|
||||
describe('description anti-patterns', () => {
|
||||
test('no descriptions contain flag documentation (Use --, use --, via --)', () => {
|
||||
const commands = getAllCommandDescriptions();
|
||||
const FLAG_PATTERNS = ['Use --', 'use --', 'via --'];
|
||||
const violators = commands
|
||||
.filter(c => {
|
||||
if (!c.description) return false;
|
||||
return FLAG_PATTERNS.some(p => c.description.includes(p));
|
||||
})
|
||||
.map(c => 'file=' + c.file + ' | desc=' + c.description);
|
||||
|
||||
assert.strictEqual(
|
||||
violators.length,
|
||||
0,
|
||||
[
|
||||
`${violators.length} description(s) contain flag documentation anti-patterns:`,
|
||||
...violators.map(v => ' ' + v),
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 3: No Triggers: keyword stuffing ─────────────────────────────────
|
||||
|
||||
test('no descriptions contain "Triggers:" keyword stuffing', () => {
|
||||
const commands = getAllCommandDescriptions();
|
||||
const violators = commands
|
||||
.filter(c => c.description && /triggers:/i.test(c.description))
|
||||
.map(c => 'file=' + c.file + ' | desc=' + c.description);
|
||||
|
||||
assert.strictEqual(
|
||||
violators.length,
|
||||
0,
|
||||
[
|
||||
`${violators.length} description(s) contain "Triggers:" keyword stuffing:`,
|
||||
...violators.map(v => ' ' + v),
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Test 4 & 5: lint-descriptions.cjs script ─────────────────────────────────
|
||||
|
||||
describe('lint-descriptions.cjs', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-desc-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('rejects a command file with a description over 100 chars', () => {
|
||||
const longDesc = 'A'.repeat(101);
|
||||
const content = [
|
||||
'---',
|
||||
'name: gsd:test-long',
|
||||
'description: ' + longDesc,
|
||||
'---',
|
||||
'',
|
||||
'Body text.',
|
||||
].join('\n');
|
||||
|
||||
const tmpFile = path.join(tmpDir, 'long-desc.md');
|
||||
fs.writeFileSync(tmpFile, content, 'utf-8');
|
||||
|
||||
const result = spawnSync(process.execPath, [LINT_SCRIPT, tmpFile], {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
assert.notStrictEqual(result.status, 0, [
|
||||
'lint-descriptions.cjs should exit non-zero for description > 100 chars',
|
||||
'stdout: ' + result.stdout,
|
||||
'stderr: ' + result.stderr,
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('accepts a command file with a description under 100 chars', () => {
|
||||
const shortDesc = 'Short routing description for this skill.';
|
||||
const content = [
|
||||
'---',
|
||||
'name: gsd:test-short',
|
||||
'description: ' + shortDesc,
|
||||
'---',
|
||||
'',
|
||||
'Body text.',
|
||||
].join('\n');
|
||||
|
||||
const tmpFile = path.join(tmpDir, 'short-desc.md');
|
||||
fs.writeFileSync(tmpFile, content, 'utf-8');
|
||||
|
||||
const result = spawnSync(process.execPath, [LINT_SCRIPT, tmpFile], {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, [
|
||||
'lint-descriptions.cjs should exit 0 for description <= 100 chars',
|
||||
'stdout: ' + result.stdout,
|
||||
'stderr: ' + result.stderr,
|
||||
].join('\n'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/enh-2790-skill-consolidation.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:enh-2790-skill-consolidation (consolidation epic #1969 B6 #1975)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #2790)
|
||||
// commands/gsd/*.md files ARE what the runtime loads — testing their
|
||||
// existence/non-existence tests the deployed skill surface contract.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { assertWithinAllowlist } = require('../scripts/lib/allowlist-ratchet.cjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Allowlisted set of user-invocable skills (commands/gsd/*.md, ns-* excluded).
|
||||
// Consolidation target ~58; this set may only SHRINK.
|
||||
// Adding a new skill requires adding it here with justification.
|
||||
// Removing a consolidated skill requires pruning it here.
|
||||
// ---------------------------------------------------------------------------
|
||||
const KNOWN_SKILLS = new Set([
|
||||
'add-tests.md',
|
||||
'ai-integration-phase.md',
|
||||
'audit-fix.md',
|
||||
'audit-milestone.md',
|
||||
'audit-uat.md',
|
||||
'autonomous.md',
|
||||
'capture.md',
|
||||
'cleanup.md',
|
||||
'code-review.md',
|
||||
'complete-milestone.md',
|
||||
'config.md',
|
||||
'debug.md',
|
||||
'discuss-phase.md',
|
||||
'docs-update.md',
|
||||
'eval-review.md',
|
||||
'execute-phase.md',
|
||||
'explore.md',
|
||||
'extract-learnings.md',
|
||||
'fast.md',
|
||||
'forensics.md',
|
||||
'graphify.md',
|
||||
'health.md',
|
||||
'help.md',
|
||||
'import.md',
|
||||
'inbox.md',
|
||||
'ingest-docs.md',
|
||||
'manager.md',
|
||||
'map-codebase.md',
|
||||
'mempalace-capture.md',
|
||||
'mempalace-recall.md',
|
||||
'milestone-summary.md',
|
||||
'mvp-phase.md',
|
||||
'new-milestone.md',
|
||||
'new-project.md',
|
||||
'pause-work.md',
|
||||
'phase.md',
|
||||
'plan-phase.md',
|
||||
'plan-review-convergence.md',
|
||||
'pr-branch.md',
|
||||
'profile-user.md',
|
||||
'progress.md',
|
||||
'quick.md',
|
||||
'resume-work.md',
|
||||
'review-backlog.md',
|
||||
'review.md',
|
||||
'secure-phase.md',
|
||||
'settings.md',
|
||||
'ship.md',
|
||||
'sketch.md',
|
||||
'spec-phase.md',
|
||||
'spike.md',
|
||||
'stats.md',
|
||||
'surface.md',
|
||||
'thread.md',
|
||||
'ui-phase.md',
|
||||
'ui-review.md',
|
||||
'ultraplan-phase.md',
|
||||
'undo.md',
|
||||
'update.md',
|
||||
'validate-phase.md',
|
||||
'verify-work.md',
|
||||
'workspace.md',
|
||||
'workstreams.md',
|
||||
]);
|
||||
|
||||
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
|
||||
/**
|
||||
* Parse the YAML frontmatter from a skill .md file.
|
||||
* Returns an object with the frontmatter fields as strings.
|
||||
* Only handles simple scalar and array values needed by these tests.
|
||||
*/
|
||||
function parseFrontmatter(filePath) {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
// CRLF-tolerant: Windows checkouts leave \r on every line. lines.indexOf('---', 1)
|
||||
// would never match because elements would be '---\r' instead of '---'.
|
||||
const lines = raw.split(/\r?\n/);
|
||||
if (lines[0].trim() !== '---') return {};
|
||||
const endIdx = lines.indexOf('---', 1);
|
||||
if (endIdx === -1) return {};
|
||||
const fmLines = lines.slice(1, endIdx);
|
||||
const result = {};
|
||||
let currentKey = null;
|
||||
for (const line of fmLines) {
|
||||
const kvMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)/);
|
||||
if (kvMatch) {
|
||||
currentKey = kvMatch[1];
|
||||
result[currentKey] = kvMatch[2].trim();
|
||||
} else if (currentKey && line.match(/^\s+-\s+/)) {
|
||||
// array item — append to existing string value so callers can check membership
|
||||
const item = line.replace(/^\s+-\s+/, '').trim();
|
||||
result[currentKey] = result[currentKey] ? [result[currentKey], item].join('\n') : item;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function skillPath(name) {
|
||||
return path.join(COMMANDS_DIR, `${name}.md`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group: New consolidated skills exist
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('new consolidated skills exist', () => {
|
||||
test('commands/gsd/capture.md exists', () => {
|
||||
assert.ok(fs.existsSync(skillPath('capture')), 'capture.md does not exist');
|
||||
});
|
||||
|
||||
test('commands/gsd/phase.md exists', () => {
|
||||
assert.ok(fs.existsSync(skillPath('phase')), 'phase.md does not exist');
|
||||
});
|
||||
|
||||
test('commands/gsd/config.md exists', () => {
|
||||
assert.ok(fs.existsSync(skillPath('config')), 'config.md does not exist');
|
||||
});
|
||||
|
||||
test('commands/gsd/workspace.md exists', () => {
|
||||
assert.ok(fs.existsSync(skillPath('workspace')), 'workspace.md does not exist');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group: Absorbed skills are removed
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('absorbed skills are removed', () => {
|
||||
const absorbed = [
|
||||
['add-todo', 'absorbed into capture.md'],
|
||||
['note', 'absorbed into capture.md'],
|
||||
['add-backlog', 'absorbed into capture.md'],
|
||||
['plant-seed', 'absorbed into capture.md'],
|
||||
['check-todos', 'absorbed into capture.md'],
|
||||
['add-phase', 'absorbed into phase.md'],
|
||||
['insert-phase', 'absorbed into phase.md'],
|
||||
['remove-phase', 'absorbed into phase.md'],
|
||||
['edit-phase', 'absorbed into phase.md'],
|
||||
['settings-advanced', 'absorbed into config.md'],
|
||||
['settings-integrations', 'absorbed into config.md'],
|
||||
['set-profile', 'absorbed into config.md'],
|
||||
['new-workspace', 'absorbed into workspace.md'],
|
||||
['list-workspaces', 'absorbed into workspace.md'],
|
||||
['remove-workspace', 'absorbed into workspace.md'],
|
||||
['sync-skills', 'absorbed into update.md'],
|
||||
['reapply-patches', 'absorbed into update.md'],
|
||||
['sketch-wrap-up', 'absorbed into sketch.md'],
|
||||
['spike-wrap-up', 'absorbed into spike.md'],
|
||||
['scan', 'absorbed into map-codebase.md'],
|
||||
['intel', 'absorbed into map-codebase.md'],
|
||||
['code-review-fix', 'absorbed into code-review.md'],
|
||||
['next', 'absorbed into progress.md'],
|
||||
['do', 'absorbed into progress.md'],
|
||||
];
|
||||
|
||||
for (const [name, reason] of absorbed) {
|
||||
test(`commands/gsd/${name}.md does NOT exist (${reason})`, () => {
|
||||
assert.ok(
|
||||
!fs.existsSync(skillPath(name)),
|
||||
[
|
||||
`${name}.md still exists but should have been deleted`,
|
||||
`(${reason})`,
|
||||
].join(' '),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group: Outright deletions
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('outright deleted dead skills are removed', () => {
|
||||
const deleted = [
|
||||
'join-discord',
|
||||
// research-phase → plan-phase --research-phase (PR #3045, already absorbed)
|
||||
// plan-milestone-gaps → inline in audit-milestone (PR #3038, already absorbed)
|
||||
// list-phase-assumptions → discuss-phase --assumptions (pending #3131)
|
||||
// session-report → pause-work --report (pending #3131)
|
||||
// analyze-dependencies → manager --analyze-deps (pending #3131)
|
||||
// from-gsd2 → import --from-gsd2 (pending #3131)
|
||||
];
|
||||
|
||||
for (const name of deleted) {
|
||||
test(`commands/gsd/${name}.md does NOT exist`, () => {
|
||||
assert.ok(
|
||||
!fs.existsSync(skillPath(name)),
|
||||
`${name}.md still exists but should have been deleted (outright dead skill)`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group: #3131 — re-wired workflows absorbed as flags
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('#3131 re-wired workflows: standalone command files must not exist', () => {
|
||||
const rewired = [
|
||||
['list-phase-assumptions', 'absorbed into discuss-phase.md --assumptions'],
|
||||
['session-report', 'absorbed into pause-work.md --report'],
|
||||
['analyze-dependencies', 'absorbed into manager.md --analyze-deps'],
|
||||
['from-gsd2', 'absorbed into import.md --from-gsd2'],
|
||||
];
|
||||
|
||||
for (const [name, reason] of rewired) {
|
||||
test(`commands/gsd/${name}.md does NOT exist (${reason})`, () => {
|
||||
assert.ok(
|
||||
!fs.existsSync(skillPath(name)),
|
||||
`${name}.md still exists as a standalone command but should be absorbed (${reason})`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#3131 re-wired workflows: parent command argument-hints advertise the new flags', () => {
|
||||
test('discuss-phase.md argument-hint contains --assumptions', () => {
|
||||
const fm = parseFrontmatter(skillPath('discuss-phase'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--assumptions'),
|
||||
'discuss-phase.md argument-hint does not contain --assumptions. got: ' + (fm['argument-hint'] || '(none)'),
|
||||
);
|
||||
});
|
||||
|
||||
test('pause-work.md argument-hint contains --report', () => {
|
||||
const fm = parseFrontmatter(skillPath('pause-work'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--report'),
|
||||
'pause-work.md argument-hint does not contain --report. got: ' + (fm['argument-hint'] || '(none)'),
|
||||
);
|
||||
});
|
||||
|
||||
test('manager.md argument-hint contains --analyze-deps', () => {
|
||||
const fm = parseFrontmatter(skillPath('manager'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--analyze-deps'),
|
||||
'manager.md argument-hint does not contain --analyze-deps. got: ' + (fm['argument-hint'] || '(none)'),
|
||||
);
|
||||
});
|
||||
|
||||
test('import.md argument-hint contains --from-gsd2', () => {
|
||||
const fm = parseFrontmatter(skillPath('import'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--from-gsd2'),
|
||||
'import.md argument-hint does not contain --from-gsd2. got: ' + (fm['argument-hint'] || '(none)'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3131 re-wired workflows: parent command bodies dispatch to workflow files', () => {
|
||||
function bodyContains(name, substring) {
|
||||
const raw = fs.readFileSync(skillPath(name), 'utf8');
|
||||
return raw.includes(substring);
|
||||
}
|
||||
|
||||
test('discuss-phase.md body references list-phase-assumptions.md', () => {
|
||||
assert.ok(
|
||||
bodyContains('discuss-phase', 'list-phase-assumptions.md'),
|
||||
'discuss-phase.md body does not reference list-phase-assumptions.md — --assumptions flag dispatch is missing',
|
||||
);
|
||||
});
|
||||
|
||||
test('pause-work.md body references session-report.md', () => {
|
||||
assert.ok(
|
||||
bodyContains('pause-work', 'session-report.md'),
|
||||
'pause-work.md body does not reference session-report.md — --report flag dispatch is missing',
|
||||
);
|
||||
});
|
||||
|
||||
test('manager.md body references analyze-dependencies.md', () => {
|
||||
assert.ok(
|
||||
bodyContains('manager', 'analyze-dependencies.md'),
|
||||
'manager.md body does not reference analyze-dependencies.md — --analyze-deps flag dispatch is missing',
|
||||
);
|
||||
});
|
||||
|
||||
test('import.md body references from-gsd2', () => {
|
||||
assert.ok(
|
||||
bodyContains('import', 'from-gsd2'),
|
||||
'import.md body does not reference from-gsd2 — --from-gsd2 flag dispatch is missing',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group: Parent skills updated with new flags
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('parent skills updated with new flags in argument-hint', () => {
|
||||
test('update.md argument-hint contains --sync', () => {
|
||||
const fm = parseFrontmatter(skillPath('update'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--sync'),
|
||||
[
|
||||
'update.md argument-hint does not contain --sync',
|
||||
'got: ' + (fm['argument-hint'] || '(none)'),
|
||||
].join('. '),
|
||||
);
|
||||
});
|
||||
|
||||
test('update.md argument-hint contains --reapply', () => {
|
||||
const fm = parseFrontmatter(skillPath('update'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--reapply'),
|
||||
[
|
||||
'update.md argument-hint does not contain --reapply',
|
||||
'got: ' + (fm['argument-hint'] || '(none)'),
|
||||
].join('. '),
|
||||
);
|
||||
});
|
||||
|
||||
test('sketch.md argument-hint contains --wrap-up', () => {
|
||||
const fm = parseFrontmatter(skillPath('sketch'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--wrap-up'),
|
||||
[
|
||||
'sketch.md argument-hint does not contain --wrap-up',
|
||||
'got: ' + (fm['argument-hint'] || '(none)'),
|
||||
].join('. '),
|
||||
);
|
||||
});
|
||||
|
||||
test('spike.md argument-hint contains --wrap-up', () => {
|
||||
const fm = parseFrontmatter(skillPath('spike'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--wrap-up'),
|
||||
[
|
||||
'spike.md argument-hint does not contain --wrap-up',
|
||||
'got: ' + (fm['argument-hint'] || '(none)'),
|
||||
].join('. '),
|
||||
);
|
||||
});
|
||||
|
||||
test('map-codebase.md argument-hint contains --fast', () => {
|
||||
const fm = parseFrontmatter(skillPath('map-codebase'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--fast'),
|
||||
[
|
||||
'map-codebase.md argument-hint does not contain --fast',
|
||||
'got: ' + (fm['argument-hint'] || '(none)'),
|
||||
].join('. '),
|
||||
);
|
||||
});
|
||||
|
||||
test('code-review.md argument-hint contains --fix', () => {
|
||||
const fm = parseFrontmatter(skillPath('code-review'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--fix'),
|
||||
[
|
||||
'code-review.md argument-hint does not contain --fix',
|
||||
'got: ' + (fm['argument-hint'] || '(none)'),
|
||||
].join('. '),
|
||||
);
|
||||
});
|
||||
|
||||
test('progress.md argument-hint contains --do', () => {
|
||||
const fm = parseFrontmatter(skillPath('progress'));
|
||||
assert.ok(
|
||||
(fm['argument-hint'] || '').includes('--do'),
|
||||
[
|
||||
'progress.md argument-hint does not contain --do',
|
||||
'got: ' + (fm['argument-hint'] || '(none)'),
|
||||
].join('. '),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group: settings.md is NOT deleted
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('settings.md is kept (merged into config entry point or remains standalone)', () => {
|
||||
test('commands/gsd/settings.md still exists', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(skillPath('settings')),
|
||||
'settings.md was deleted — it should be kept (or renamed to config.md, but not both missing)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group: Skill set allowlisted (identity-based, consolidating toward ~58)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('skill set', () => {
|
||||
test('user-invocable skill set is allowlisted (consolidating toward ~58)', () => {
|
||||
// Exclude `ns-*.md` namespace meta-skills (#2792) from this guard.
|
||||
// Those are descriptor-only routers selected first by the model and
|
||||
// are not part of the consolidation surface this test tracks; their
|
||||
// own contract is enforced by tests/enh-2792-namespace-skills.test.cjs.
|
||||
const currentBasenames = fs.readdirSync(COMMANDS_DIR)
|
||||
.filter((f) => f.endsWith('.md') && !f.startsWith('ns-'));
|
||||
assertWithinAllowlist({
|
||||
label: 'user-invocable skills (commands/gsd)',
|
||||
current: currentBasenames,
|
||||
known: KNOWN_SKILLS,
|
||||
fail: assert.fail,
|
||||
pruneHint: 'edit KNOWN_SKILLS in tests/enh-2790-skill-consolidation.test.cjs',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/feat-3039-help-tiered.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:feat-3039-help-tiered (consolidation epic #1969 B6 #1975)", () => {
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product (see #3039)
|
||||
// `workflows/help/modes/*.md` files ARE the help output — their text is what
|
||||
// the runtime emits when the user runs `/gsd:help [--brief|--full|<topic>]`.
|
||||
// Asserting on their structure tests the deployed contract directly.
|
||||
|
||||
/**
|
||||
* Feature #3039: tiered /gsd:help output.
|
||||
*
|
||||
* The legacy single-file 747-line help is replaced by:
|
||||
* - workflows/help.md — small dispatcher (progressive disclosure)
|
||||
* - workflows/help/modes/brief.md — ~one-liner refresher
|
||||
* - workflows/help/modes/default.md — one-page newcomer tour
|
||||
* - workflows/help/modes/full.md — complete reference (former help.md body)
|
||||
* - workflows/help/modes/topic.md — section-extraction logic + alias table
|
||||
*
|
||||
* This test enforces the contract:
|
||||
* 1. All four mode files exist with a single `<reference>` block.
|
||||
* 2. brief and default fit a "one screen" budget; full stays under LARGE tier cap.
|
||||
* 3. The dispatcher routes on $ARGUMENTS to all four mode files (structural parse).
|
||||
* 4. Dispatcher conflict-resolution rules are documented:
|
||||
* - `--brief` + `--full` without a topic → prefer `--full`
|
||||
* - `--brief <topic>` → topic.md in compact scope (composable)
|
||||
* - bare or `--full <topic>` → topic.md in full scope
|
||||
* 5. topic.md documents an explicit routing preamble + compact-scope rule.
|
||||
* 6. Every topic alias in topic.md resolves to a heading that exists in full.md.
|
||||
* 7. Every /gsd:* sub-block token in topic.md's alias table appears in full.md.
|
||||
* 8. Every full.md heading is either aliased or in the intentional-orphan allowlist.
|
||||
* 9. The `commands/gsd/help.md` shim passes `$ARGUMENTS` through and advertises
|
||||
* the composable `--brief <topic>` form.
|
||||
*
|
||||
* Tighten-only invariant (issue #597): ceilings track the per-tier high-water mark
|
||||
* within GRACE lines. Budgets may only decrease, never silently creep upward.
|
||||
* The assertTightCeiling() calls below enforce this automatically.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { assertTightCeiling } = require('../scripts/lib/allowlist-ratchet.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const WORKFLOWS = path.join(ROOT, 'gsd-core', 'workflows');
|
||||
const MODES = path.join(WORKFLOWS, 'help', 'modes');
|
||||
const DISPATCHER = path.join(WORKFLOWS, 'help.md');
|
||||
const COMMAND_SHIM = path.join(ROOT, 'commands', 'gsd', 'help.md');
|
||||
|
||||
const MODE_FILES = ['brief.md', 'default.md', 'full.md', 'topic.md'];
|
||||
|
||||
// "One screen" budgets, including frontmatter/<purpose>/<reference> tags.
|
||||
// These are conservative (one-page conceptual size of ~25 lines of usable
|
||||
// content) but allow for the wrapping tags. Tighten as content stabilizes.
|
||||
//
|
||||
// Ceilings tightened to actualMax + SMALL_GRACE per the ratchet-down rule (#597).
|
||||
// BRIEF ceiling kept at 30 (actualMax=22, slack=8 ≤ SMALL_GRACE=10).
|
||||
const BRIEF_BUDGET = 30;
|
||||
// DEFAULT ceiling lowered from 70 → 60 (actualMax=50; #597 ratchet-down).
|
||||
const DEFAULT_BUDGET = 60;
|
||||
// full.md is the LARGE tier (see workflow-size-budget.test.cjs — now byte-based per #717;
|
||||
// this FULL_BUDGET is a separate line-count budget for help/modes/full.md).
|
||||
// The size-budget test is non-recursive so full.md is not covered there; cap it here.
|
||||
// FULL ceiling lowered from 1500 → 844 (actualMax=784; #597 ratchet-down).
|
||||
const FULL_BUDGET = 844;
|
||||
|
||||
// Grace bands:
|
||||
// SMALL_GRACE — for the tiny brief/default/dispatcher files (≤ ~70 lines):
|
||||
// 10 lines of breathing room is proportionate and prevents trivial edits from
|
||||
// failing while still catching any meaningful upward creep.
|
||||
// LARGE_GRACE — for full.md where content fluctuates more:
|
||||
// 60 lines matches the line-budget GRACE used in the other size-budget tests.
|
||||
const SMALL_GRACE = 10;
|
||||
const LARGE_GRACE = 60;
|
||||
|
||||
function read(file) {
|
||||
return fs.readFileSync(file, 'utf8');
|
||||
}
|
||||
|
||||
function lineCount(file) {
|
||||
const c = read(file);
|
||||
if (c.length === 0) return 0;
|
||||
const trail = c.endsWith('\n') ? 1 : 0;
|
||||
return c.split('\n').length - trail;
|
||||
}
|
||||
|
||||
describe('feature #3039: tiered help — file structure', () => {
|
||||
for (const f of MODE_FILES) {
|
||||
test(`mode file exists: ${f}`, () => {
|
||||
assert.ok(fs.existsSync(path.join(MODES, f)), `missing ${path.join(MODES, f)}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Dispatcher ceiling lowered from 40 → 34 (actualMax=24; #597 ratchet-down).
|
||||
const DISPATCHER_BUDGET = 34;
|
||||
test(`dispatcher exists and is small (≤ ${DISPATCHER_BUDGET} lines)`, () => {
|
||||
assert.ok(fs.existsSync(DISPATCHER));
|
||||
const n = lineCount(DISPATCHER);
|
||||
assert.ok(n <= DISPATCHER_BUDGET, `dispatcher should be small; got ${n} lines`);
|
||||
assertTightCeiling({ label: 'dispatcher', actualMax: n, ceiling: DISPATCHER_BUDGET, grace: SMALL_GRACE, fail: assert.fail });
|
||||
});
|
||||
|
||||
for (const f of MODE_FILES) {
|
||||
test(`${f} has exactly one <reference> block (line-anchored)`, () => {
|
||||
const content = read(path.join(MODES, f));
|
||||
// Anchor on start-of-line so prose mentions of `<reference>` inside
|
||||
// <purpose> blocks aren't counted.
|
||||
const opens = (content.match(/^<reference>$/gm) || []).length;
|
||||
const closes = (content.match(/^<\/reference>$/gm) || []).length;
|
||||
assert.equal(opens, 1, `${f}: expected 1 <reference> opening line, got ${opens}`);
|
||||
assert.equal(closes, 1, `${f}: expected 1 </reference> closing line, got ${closes}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('feature #3039: tiered help — size budgets', () => {
|
||||
test(`brief.md fits one screen (≤ ${BRIEF_BUDGET} lines)`, () => {
|
||||
const n = lineCount(path.join(MODES, 'brief.md'));
|
||||
assert.ok(n <= BRIEF_BUDGET, `brief.md is ${n} lines, budget ${BRIEF_BUDGET}`);
|
||||
assertTightCeiling({ label: 'BRIEF', actualMax: n, ceiling: BRIEF_BUDGET, grace: SMALL_GRACE, fail: assert.fail });
|
||||
});
|
||||
|
||||
test(`default.md fits one screen (≤ ${DEFAULT_BUDGET} lines)`, () => {
|
||||
const n = lineCount(path.join(MODES, 'default.md'));
|
||||
assert.ok(n <= DEFAULT_BUDGET, `default.md is ${n} lines, budget ${DEFAULT_BUDGET}`);
|
||||
assertTightCeiling({ label: 'DEFAULT', actualMax: n, ceiling: DEFAULT_BUDGET, grace: SMALL_GRACE, fail: assert.fail });
|
||||
});
|
||||
|
||||
test('full.md preserves the complete reference (≥ 600 lines)', () => {
|
||||
// The pre-#3039 reference was 747 lines. Guard against accidental shrinkage
|
||||
// that would amount to silently removing content from --full.
|
||||
const n = lineCount(path.join(MODES, 'full.md'));
|
||||
assert.ok(n >= 600, `full.md is ${n} lines — too small, content may have been lost`);
|
||||
});
|
||||
|
||||
test(`full.md stays under LARGE workflow budget (≤ ${FULL_BUDGET} lines)`, () => {
|
||||
// full.md lives in a subdirectory and is not enumerated by the non-recursive
|
||||
// workflow-size-budget.test.cjs. Cap it here at the LARGE tier limit.
|
||||
const n = lineCount(path.join(MODES, 'full.md'));
|
||||
assert.ok(n <= FULL_BUDGET, `full.md grew to ${n} lines (LARGE budget: ${FULL_BUDGET})`);
|
||||
assertTightCeiling({ label: 'FULL', actualMax: n, ceiling: FULL_BUDGET, grace: LARGE_GRACE, fail: assert.fail });
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature #3039: tiered help — dispatcher routing (structural)', () => {
|
||||
const dispatcher = read(DISPATCHER);
|
||||
|
||||
function extractDisclosureBlock(src) {
|
||||
const m = src.match(/<progressive_disclosure>([\s\S]*?)<\/progressive_disclosure>/);
|
||||
assert.ok(m, 'dispatcher must contain a <progressive_disclosure> block');
|
||||
return m[1];
|
||||
}
|
||||
|
||||
test('dispatcher <progressive_disclosure> block has exactly 5 routing rows', () => {
|
||||
// 4 base tiers (brief, full, default, topic) + 1 composable row (--brief <topic>).
|
||||
const block = extractDisclosureBlock(dispatcher);
|
||||
// Table rows are lines starting with `|`, excluding the header and separator rows.
|
||||
const rows = block.split('\n')
|
||||
.filter(l => /^\|/.test(l))
|
||||
.filter(l => !/^\|\s*[-:]+\s*\|/.test(l)) // strip separator rows
|
||||
.filter(l => !/when.*arguments/i.test(l)); // strip header row
|
||||
assert.equal(rows.length, 5,
|
||||
`dispatcher routing table must have exactly 5 rows; got ${rows.length}:\n${rows.join('\n')}`);
|
||||
});
|
||||
|
||||
test('dispatcher routes --brief to brief.md', () => {
|
||||
const block = extractDisclosureBlock(dispatcher);
|
||||
assert.match(block, /`--brief`[\s\S]*?brief\.md/);
|
||||
});
|
||||
|
||||
test('dispatcher routes --full to full.md', () => {
|
||||
const block = extractDisclosureBlock(dispatcher);
|
||||
assert.match(block, /`--full`[\s\S]*?full\.md/);
|
||||
});
|
||||
|
||||
test('dispatcher routes empty/no-flag args to default.md', () => {
|
||||
const block = extractDisclosureBlock(dispatcher);
|
||||
assert.match(block, /(empty|unset)[\s\S]*?default\.md/i);
|
||||
});
|
||||
|
||||
test('dispatcher routes topic args to topic.md', () => {
|
||||
const block = extractDisclosureBlock(dispatcher);
|
||||
assert.match(block, /topic[\s\S]*?topic\.md/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature #3039: tiered help — dispatcher conflict-resolution rules', () => {
|
||||
const dispatcher = read(DISPATCHER);
|
||||
|
||||
test('dispatcher documents --brief + --full (without topic) conflict resolution (prefer --full)', () => {
|
||||
// help.md argument parsing rules: "if both appear *without* a topic, prefer `--full`"
|
||||
assert.match(dispatcher, /prefer.*--full/);
|
||||
});
|
||||
|
||||
test('dispatcher routes --brief <topic> to topic.md in compact scope (composable)', () => {
|
||||
// help.md argument parsing rules: "--brief combined with a topic invokes topic.md
|
||||
// in compact scope" — the composable scoped-lookup form (trek-e review finding #4).
|
||||
assert.match(dispatcher, /--brief[^|]*<topic>[\s\S]*?topic\.md[\s\S]*?compact/i);
|
||||
});
|
||||
|
||||
test('dispatcher routes --full <topic> (or bare topic) to topic.md in full scope', () => {
|
||||
// Bare topic, `--full <topic>`, or topic with leading `--` → full scope.
|
||||
assert.match(dispatcher, /(bare topic|--full <topic>)[\s\S]*?full scope/i);
|
||||
});
|
||||
|
||||
test('dispatcher tells topic.md to retain --brief when delegating', () => {
|
||||
// The dispatcher passes $ARGUMENTS through; topic.md needs to see --brief to
|
||||
// choose compact scope. Guard against accidental flag-stripping.
|
||||
assert.match(dispatcher, /retain.*--brief|pass.*--brief/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature #3039: tiered help — command shim passes $ARGUMENTS', () => {
|
||||
const shim = read(COMMAND_SHIM);
|
||||
|
||||
test('shim references $ARGUMENTS', () => {
|
||||
assert.match(shim, /\$ARGUMENTS/);
|
||||
});
|
||||
|
||||
test('shim declares argument-hint frontmatter', () => {
|
||||
assert.match(shim, /argument-hint:/);
|
||||
});
|
||||
|
||||
test('shim argument-hint advertises composable --brief <topic>', () => {
|
||||
// Discoverability: users need to know the composable form is supported
|
||||
// (trek-e review finding #4).
|
||||
assert.match(shim, /argument-hint:[^\n]*--brief[^\n]*<topic>/);
|
||||
});
|
||||
|
||||
test('shim references the help workflow', () => {
|
||||
assert.match(shim, /workflows\/help\.md/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature #3039: tiered help — topic.md routing visibility + compact scope', () => {
|
||||
const topicSrc = read(path.join(MODES, 'topic.md'));
|
||||
|
||||
test('topic.md documents an explicit resolved-routing preamble', () => {
|
||||
// Trek-e review finding #3: routing must be explicit in output so the user
|
||||
// can see which alias matched which heading and at what scope.
|
||||
assert.match(topicSrc, /\*\*Topic:\*\*[\s\S]*<alias>[\s\S]*<heading>/);
|
||||
assert.match(topicSrc, /scope:.*full.*\|.*compact/i);
|
||||
});
|
||||
|
||||
test('topic.md documents a compact scope distinct from full scope', () => {
|
||||
// Trek-e review finding #4: --brief <topic> must produce a compact
|
||||
// scoped lookup (signature + one-line summary), not the full section.
|
||||
assert.match(topicSrc, /compact scope/i);
|
||||
assert.match(topicSrc, /signature.*one-line summary|signature \+ one-line/i);
|
||||
});
|
||||
|
||||
test('topic.md parses --brief flag and strips it before resolving the alias', () => {
|
||||
// Compact scope must trigger off the --brief flag in $ARGUMENTS; the
|
||||
// remaining token is the alias.
|
||||
assert.match(topicSrc, /--brief.*-b.*compact scope|compact scope[\s\S]*--brief/i);
|
||||
});
|
||||
|
||||
test('topic.md closing "More:" line advertises the composable form', () => {
|
||||
assert.match(topicSrc, /More:[\s\S]*--brief <topic>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature #3039: tiered help — topic alias coverage', () => {
|
||||
const topicSrc = read(path.join(MODES, 'topic.md'));
|
||||
const fullSrc = read(path.join(MODES, 'full.md'));
|
||||
|
||||
// Extract the alias table portion of topic.md (before "**Output rules:**")
|
||||
function aliasTableSection(src) {
|
||||
return src.split('**Output rules:**')[0];
|
||||
}
|
||||
|
||||
// Extract the canonical heading text referenced from each row of the
|
||||
// alias table. Rows look like: `| aliases | \`## Heading\` ... |`.
|
||||
// We accept either ## or ### and pull the literal heading text.
|
||||
function extractReferencedHeadings(src) {
|
||||
const headings = new Set();
|
||||
const re = /`(#{2,3} [^`]+?)`/g;
|
||||
let m;
|
||||
while ((m = re.exec(src)) !== null) {
|
||||
headings.add(m[1].trim());
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
function fullHeadings(src) {
|
||||
const set = new Set();
|
||||
for (const line of src.split('\n')) {
|
||||
const m = line.match(/^(#{2,3}) (.+?)\s*$/);
|
||||
if (m) set.add(`${m[1]} ${m[2]}`);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
test('every heading referenced in topic.md exists in full.md', () => {
|
||||
const referenced = extractReferencedHeadings(aliasTableSection(topicSrc));
|
||||
const present = fullHeadings(fullSrc);
|
||||
const missing = [...referenced].filter((h) => !present.has(h)).sort();
|
||||
assert.deepEqual(missing, [],
|
||||
`topic.md references headings not present in full.md: ${missing.join(' | ')}`);
|
||||
});
|
||||
|
||||
test('every /gsd:* sub-block token in topic.md alias table exists in full.md', () => {
|
||||
// Validates fix for review finding #2: sub-block aliases reference bold-line
|
||||
// anchors (**`/gsd:X`**) — assert each token actually appears in full.md.
|
||||
const tableSection = aliasTableSection(topicSrc);
|
||||
const tokens = [...tableSection.matchAll(/`(\/gsd:[a-z-]+(?:\s+--[a-z-]+)?)`/g)].map(m => m[1]);
|
||||
assert.ok(tokens.length > 0, 'expected at least one /gsd:* token in alias table');
|
||||
const missing = tokens.filter(t => !fullSrc.includes(t));
|
||||
assert.deepEqual(missing, [],
|
||||
`topic.md references /gsd:* tokens not present in full.md: ${missing.join(' | ')}`);
|
||||
});
|
||||
|
||||
test('every full.md heading is either aliased or in the intentional-orphan allowlist', () => {
|
||||
// Catches newly added headings that have no alias (contributor must either
|
||||
// alias the section or explicitly add it to INTENTIONAL_ORPHANS below).
|
||||
const INTENTIONAL_ORPHANS = new Set([
|
||||
'## Quick Start',
|
||||
'## Staying Updated',
|
||||
'### Utility Commands', // covered by cleanup/update sub-block aliases
|
||||
'## Additional Commands',
|
||||
'### Discovery & Specification',
|
||||
'### Planning & Execution',
|
||||
'### Quality, Review & Verification',
|
||||
'### Diagnostics & Maintenance',
|
||||
'### Knowledge & Context',
|
||||
'### Workflow & Orchestration',
|
||||
'### Repository Integration',
|
||||
'### Namespace Routers (model-facing meta-skills)',
|
||||
]);
|
||||
|
||||
const allHeadings = fullSrc.split('\n')
|
||||
.filter(l => /^#{2,3} /.test(l))
|
||||
.map(l => l.trim());
|
||||
|
||||
const aliased = extractReferencedHeadings(aliasTableSection(topicSrc));
|
||||
|
||||
const orphans = allHeadings.filter(h => !aliased.has(h) && !INTENTIONAL_ORPHANS.has(h));
|
||||
assert.deepEqual(orphans, [],
|
||||
`full.md headings not aliased in topic.md (add to INTENTIONAL_ORPHANS if intentional): ${orphans.join(' | ')}`);
|
||||
});
|
||||
|
||||
test('topic.md covers the core topics promised in default.md', () => {
|
||||
// Surface contract: default.md advertises a "Topics:" line. Each alias
|
||||
// there must appear as a recognized topic in topic.md's alias table.
|
||||
const def = read(path.join(MODES, 'default.md'));
|
||||
const topicsLine = def.split('\n').find((l) => /^Topics:/i.test(l));
|
||||
assert.ok(topicsLine, 'default.md must advertise a "Topics:" line for users');
|
||||
// Strip the leading "Topics:" prefix, then pull every backticked token.
|
||||
const aliases = [...topicsLine.matchAll(/`([a-z][a-z0-9-]*)`/g)].map((m) => m[1]);
|
||||
assert.ok(aliases.length >= 5, `expected at least 5 promoted topic aliases; got ${aliases.length}`);
|
||||
const missing = aliases.filter((a) => !new RegExp(`\`${a}\``).test(topicSrc));
|
||||
assert.deepEqual(missing, [],
|
||||
`default.md promotes topic aliases that topic.md does not recognize: ${missing.join(', ')}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
1178
tests/slash-command-namespace.test.cjs
Normal file
1178
tests/slash-command-namespace.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -203,3 +203,115 @@ describe('@file: handoff in workflows', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-685-windowshide-spawn.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-685-windowshide-spawn (consolidation epic #1969 B6 #1975)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #685)
|
||||
// These spawn/exec sites cannot be behaviourally tested for windowsHide
|
||||
// off-Windows; the source text is the runtime contract (issue #685). Without
|
||||
// windowsHide:true a detached or shell:true child allocates a visible console
|
||||
// window on Windows (the "gsd-core" flash).
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const read = (p) => fs.readFileSync(path.join(root, p), 'utf-8');
|
||||
|
||||
// Slice the exact body of one spawn site so the assertion binds that site,
|
||||
// not merely "windowsHide appears somewhere in the file".
|
||||
function regionBetween(src, startAnchor, endAnchor) {
|
||||
const i = src.indexOf(startAnchor);
|
||||
assert.notEqual(i, -1, `start anchor not found: ${startAnchor}`);
|
||||
const j = src.indexOf(endAnchor, i);
|
||||
assert.notEqual(j, -1, `end anchor not found after start: ${endAnchor}`);
|
||||
return src.slice(i, j);
|
||||
}
|
||||
|
||||
describe('bug #685: Windows spawns must set windowsHide:true (no console-window flash)', () => {
|
||||
test('gsd-context-monitor record-session spawn sets windowsHide', () => {
|
||||
const region = regionBetween(read('hooks/gsd-context-monitor.js'), "'record-session'", '.unref()');
|
||||
assert.match(region, /windowsHide:\s*true/, 'record-session spawn must set windowsHide: true');
|
||||
});
|
||||
|
||||
const cts = () => read('src/shell-command-projection.cts');
|
||||
const helpers = [
|
||||
['execGit', 'export function execGit', "_spawnResult(result, 'git')"],
|
||||
['execNpm', 'export function execNpm', "_spawnResult(result, 'npm')"],
|
||||
['execTool', 'export function execTool', '_spawnResult(result, program)'],
|
||||
];
|
||||
for (const [name, start, end] of helpers) {
|
||||
test(`shell-command-projection ${name} spawnSync sets windowsHide`, () => {
|
||||
const region = regionBetween(cts(), start, end);
|
||||
assert.match(region, /windowsHide:\s*true/, `${name} spawnSync must set windowsHide: true`);
|
||||
});
|
||||
}
|
||||
|
||||
test('gsd-worktree-path-guard SPAWNOPT sets windowsHide', () => {
|
||||
const region = regionBetween(read('hooks/gsd-worktree-path-guard.js'), 'const SPAWNOPT', '};');
|
||||
assert.match(region, /windowsHide:\s*true/, 'gsd-worktree-path-guard SPAWNOPT must set windowsHide: true');
|
||||
});
|
||||
|
||||
test('gsd-workflow-guard currentBranch spawnSync sets windowsHide', () => {
|
||||
const region = regionBetween(read('hooks/gsd-workflow-guard.js'), "spawnSync('git', ['branch'", '});');
|
||||
assert.match(region, /windowsHide:\s*true/, 'gsd-workflow-guard git-branch spawn must set windowsHide: true');
|
||||
});
|
||||
|
||||
test('check-command-router recentCommitMessages execFileSync sets windowsHide', () => {
|
||||
const region = regionBetween(read('src/check-command-router.cts'), "execFileSync('git', ['log'", '});');
|
||||
assert.match(region, /windowsHide:\s*true/, 'check-command-router git-log execFileSync must set windowsHide: true');
|
||||
});
|
||||
|
||||
test('roadmap-upgrade execSync git calls all set windowsHide', () => {
|
||||
const src = read('src/roadmap-upgrade.cts');
|
||||
const calls = src.match(/execSync\([^)]*\)/g) || [];
|
||||
// #1542 made rollback git-independent (surgical fs restore), so the only
|
||||
// remaining git execSync is the `git status --porcelain` precondition. The
|
||||
// durable guard is that EVERY git execSync still present sets windowsHide.
|
||||
assert.ok(calls.length >= 1, 'expected at least the roadmap-upgrade git status execSync call to be present');
|
||||
const missing = calls.filter((c) => !/windowsHide:\s*true/.test(c));
|
||||
assert.deepEqual(missing, [], `execSync without windowsHide:\n${missing.join('\n')}`);
|
||||
});
|
||||
|
||||
test('gsd-check-update spawn retains windowsHide (precedent guard)', () => {
|
||||
assert.match(read('hooks/gsd-check-update.js'), /windowsHide:\s*true/,
|
||||
'gsd-check-update.js must keep windowsHide: true');
|
||||
});
|
||||
|
||||
// Durable invariant: ANY external-binary process spawn in the runtime source
|
||||
// (hooks + src) must set windowsHide — catches future additions, not just the
|
||||
// sites known today. Handles the `{ ...CONST }` spread indirection.
|
||||
test('completeness: no external-binary spawn in runtime source omits windowsHide', () => {
|
||||
const listDir = (dir, re) =>
|
||||
fs.readdirSync(path.join(root, dir)).filter((f) => re.test(f)).map((f) => `${dir}/${f}`);
|
||||
const files = [...listDir('hooks', /\.js$/), ...listDir('src', /\.cts$/)];
|
||||
const callRe = /(?:execSync|execFileSync|spawnSync|spawn)\s*\(\s*(?:`|'|")?(?:git|npm|gh)\b|spawn\s*\(\s*process\.execPath/g;
|
||||
const offenders = [];
|
||||
for (const rel of files) {
|
||||
const src = read(rel);
|
||||
let m;
|
||||
while ((m = callRe.exec(src)) !== null) {
|
||||
const win = src.slice(m.index, m.index + 400);
|
||||
let ok = /windowsHide:\s*true/.test(win);
|
||||
if (!ok) {
|
||||
const spread = win.match(/\{\s*\.\.\.(\w+)/); // e.g. { ...SPAWNOPT, cwd }
|
||||
if (spread) {
|
||||
ok = new RegExp(`(?:const|let|var)\\s+${spread[1]}\\s*=\\s*\\{[^}]*windowsHide:\\s*true`).test(src);
|
||||
}
|
||||
}
|
||||
if (!ok) offenders.push(`${rel}: ...${src.slice(m.index, m.index + 48).replace(/\s+/g, ' ')}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, [], `external-binary spawns missing windowsHide:\n${offenders.join('\n')}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1272,3 +1272,429 @@ describe('bug-2838: SUMMARY rescue delegates to SDK (worktree.cleanup-wave)', ()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-630-wave-cleanup-orchestrator-root.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-630-wave-cleanup-orchestrator-root (consolidation epic #1969 B6 #1975)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #630)
|
||||
// execute-phase.md is the shipped orchestration contract for wave execution and
|
||||
// cleanup. Bug #630: the two wave-cleanup guards resolved PRIMARY_WT from
|
||||
// `git worktree list --porcelain`'s first entry — always the main checkout —
|
||||
// so an orchestrator running from a non-primary (per-phase lane) worktree was
|
||||
// cd'd off its own lane and tripped the #3174 branch-drift assertion at cleanup,
|
||||
// refusing merge-back. The fix persists the dispatch-time orchestrator root in
|
||||
// WAVE_WORKTREE_MANIFEST and pins cleanup to that, falling back to first-entry
|
||||
// only for pre-#630 manifests.
|
||||
//
|
||||
// This file locks the source contract (the .md is the product) AND behaviorally
|
||||
// proves the pivot by running the shipped manifest-reader one-liner against a
|
||||
// real non-primary-worktree git topology.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const EXECUTE_PHASE_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
|
||||
function readMd() {
|
||||
return fs.readFileSync(EXECUTE_PHASE_MD, 'utf8');
|
||||
}
|
||||
|
||||
// Pull the exact `node -e '...'` manifest-reader script shipped in the cleanup
|
||||
// guard, so the behavioral test exercises the real shipped code, not a copy.
|
||||
function extractManifestReaderScript() {
|
||||
const content = readMd();
|
||||
// Anchor on `PRIMARY_WT=$(MANIFEST=...` so we grab the cleanup READER, not the
|
||||
// dispatch-time writer one-liner (which shares the `MANIFEST="..." node -e` prefix).
|
||||
const m = content.match(/PRIMARY_WT=\$\(MANIFEST="\$WAVE_WORKTREE_MANIFEST" node -e '([^']*)'\)/);
|
||||
assert.ok(m, 'expected a `PRIMARY_WT=$(MANIFEST="$WAVE_WORKTREE_MANIFEST" node -e \'...\')` reader in execute-phase.md');
|
||||
return m[1];
|
||||
}
|
||||
|
||||
function git(cwd, args) {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
// Canonicalize a path the way the OS does. On Windows, os.tmpdir() can yield an 8.3
|
||||
// short name (RUNNER~1) while `git worktree list` reports the long form (runneradmin);
|
||||
// realpathSync.native reconciles both to the true canonical path so comparisons are stable.
|
||||
function canon(p) {
|
||||
return fs.realpathSync.native(p);
|
||||
}
|
||||
|
||||
describe('bug #630 — wave-cleanup pins to the orchestrator root, not git-worktree-list first entry', () => {
|
||||
test('execute-phase.md is readable', () => {
|
||||
assert.ok(readMd().length > 0, 'execute-phase.md must not be empty');
|
||||
});
|
||||
|
||||
// ── Source contract (the .md is the product) ──────────────────────────────
|
||||
|
||||
test('dispatch persists the orchestrator root into the manifest (#630)', () => {
|
||||
const content = readMd();
|
||||
assert.match(
|
||||
content,
|
||||
/ORCH_ROOT=\$\(git rev-parse --show-toplevel\)/,
|
||||
'manifest init must capture the dispatch-time orchestrator root via show-toplevel',
|
||||
);
|
||||
assert.match(
|
||||
content,
|
||||
/orchestrator_root:\s*process\.env\.ORCH_ROOT/,
|
||||
'manifest init must write orchestrator_root into WAVE_WORKTREE_MANIFEST',
|
||||
);
|
||||
});
|
||||
|
||||
test('both cleanup guards resolve PRIMARY_WT from the manifest orchestrator_root (#630)', () => {
|
||||
const content = readMd();
|
||||
const readers = content.match(
|
||||
/PRIMARY_WT=\$\(MANIFEST="\$WAVE_WORKTREE_MANIFEST" node -e '[^']*orchestrator_root[^']*'\)/g,
|
||||
);
|
||||
assert.ok(
|
||||
readers && readers.length >= 2,
|
||||
`both wave-cleanup guards (templated + cleanup-tail) must read orchestrator_root from the manifest; found ${readers ? readers.length : 0}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('first-entry resolution survives only as a guarded fallback, never the sole resolver (#630)', () => {
|
||||
const content = readMd();
|
||||
// Every remaining first-entry resolution must be preceded by the `[ -n "$PRIMARY_WT" ] ||`
|
||||
// guard, i.e. it only runs when the manifest lookup produced nothing.
|
||||
const firstEntryLines = content.match(/^.*git worktree list --porcelain \| awk '\/\^worktree \/.*$/gm) || [];
|
||||
for (const line of firstEntryLines) {
|
||||
assert.match(
|
||||
line,
|
||||
/\[ -n "\$PRIMARY_WT" \] \|\|/,
|
||||
`first-entry resolution must be a guarded fallback, not the primary resolver: ${line.trim()}`,
|
||||
);
|
||||
}
|
||||
assert.ok(firstEntryLines.length >= 2, 'expected the fallback in both cleanup guards');
|
||||
});
|
||||
|
||||
// ── Behavioral proof of the pivot ─────────────────────────────────────────
|
||||
|
||||
test('shipped manifest reader resolves to the lane worktree, while first-entry resolves to main (#630)', () => {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-630-'));
|
||||
try {
|
||||
const mainDir = path.join(tmpRoot, 'main');
|
||||
fs.mkdirSync(mainDir);
|
||||
git(mainDir, ['-c', 'init.defaultBranch=main', 'init', '-q']);
|
||||
git(mainDir, ['config', 'user.email', 'test@example.com']);
|
||||
git(mainDir, ['config', 'user.name', 'Test']);
|
||||
fs.writeFileSync(path.join(mainDir, 'f.txt'), 'x\n');
|
||||
git(mainDir, ['add', '.']);
|
||||
git(mainDir, ['commit', '-q', '-m', 'init']);
|
||||
|
||||
// Non-primary worktree on a per-phase lane branch.
|
||||
const laneDir = path.join(tmpRoot, 'lane');
|
||||
git(mainDir, ['worktree', 'add', '-q', '-b', 'feat/lane', laneDir]);
|
||||
|
||||
const realMain = canon(mainDir);
|
||||
const realLane = canon(laneDir);
|
||||
|
||||
// Manifest as written at dispatch: orchestrator_root is the lane (the orchestrator runs there).
|
||||
const manifest = path.join(tmpRoot, 'wave.json');
|
||||
fs.writeFileSync(manifest, JSON.stringify({ orchestrator_root: realLane, worktrees: [] }) + '\n');
|
||||
|
||||
// Run the EXACT shipped reader one-liner.
|
||||
const script = extractManifestReaderScript();
|
||||
const resolved = execFileSync('node', ['-e', script], {
|
||||
cwd: laneDir,
|
||||
env: { ...process.env, MANIFEST: manifest },
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
|
||||
// The buggy first-entry resolution (run from the lane) yields the MAIN checkout.
|
||||
const firstEntry = canon(
|
||||
git(laneDir, ['worktree', 'list', '--porcelain'])
|
||||
.split('\n')
|
||||
.find(l => l.startsWith('worktree '))
|
||||
.slice('worktree '.length),
|
||||
);
|
||||
|
||||
assert.equal(canon(resolved), realLane, 'manifest reader must resolve to the orchestrator lane worktree');
|
||||
assert.equal(firstEntry, realMain, 'sanity: first-entry resolution points at the main checkout (the #630 bug target)');
|
||||
assert.notEqual(canon(resolved), firstEntry, 'the fix must diverge from the old first-entry behavior for a lane orchestrator');
|
||||
|
||||
// The #3174 branch assertion now passes (pinned to lane → branch matches EXPECTED_BRANCH);
|
||||
// pinning to first-entry (main) would have failed it.
|
||||
const expectedBranch = 'feat/lane';
|
||||
assert.equal(git(resolved, ['rev-parse', '--abbrev-ref', 'HEAD']), expectedBranch, 'lane pin satisfies the #3174 branch check');
|
||||
assert.notEqual(git(firstEntry, ['rev-parse', '--abbrev-ref', 'HEAD']), expectedBranch, 'first-entry pin would have tripped the #3174 branch check');
|
||||
} finally {
|
||||
cleanup(tmpRoot);
|
||||
}
|
||||
});
|
||||
|
||||
test('manifest reader falls through (empty output) when orchestrator_root is absent — fallback engages (#630)', () => {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-630-fb-'));
|
||||
try {
|
||||
const manifest = path.join(tmpRoot, 'legacy.json');
|
||||
// Pre-#630 manifest shape: no orchestrator_root.
|
||||
fs.writeFileSync(manifest, JSON.stringify({ worktrees: [] }) + '\n');
|
||||
const script = extractManifestReaderScript();
|
||||
const out = execFileSync('node', ['-e', script], {
|
||||
env: { ...process.env, MANIFEST: manifest },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(out, '', 'reader must emit nothing for a manifest without orchestrator_root so the first-entry fallback engages');
|
||||
} finally {
|
||||
cleanup(tmpRoot);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/enh-48-cwd-drift-guard-e2e.test.cjs — consolidation epic #1969 (B6 #1975)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:enh-48-cwd-drift-guard-e2e (consolidation epic #1969 B6 #1975)", () => {
|
||||
// allow-test-rule: integration-test-input (see #48)
|
||||
// Reads execute-phase.md to extract + execute the cwd-drift guard bash snippet against real git worktrees.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execSync, spawnSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract the cwd-drift guard bash block from execute-phase.md
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reads execute-phase.md and extracts the bash fenced block that implements
|
||||
* the orchestrator cwd-drift guard inside <step name="execute_waves">.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Find <step name="execute_waves">
|
||||
* 2. After that, find the first occurrence of "cwd-drift guard"
|
||||
* 3. After that, find the first ```bash fence
|
||||
* 4. Return the body between ```bash\n and the closing ```
|
||||
*
|
||||
* Throws with a clear message if any step fails or sanity checks don't pass.
|
||||
*/
|
||||
function extractCwdGuardBash() {
|
||||
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
||||
|
||||
const stepMarker = '<step name="execute_waves">';
|
||||
const stepIdx = content.indexOf(stepMarker);
|
||||
if (stepIdx === -1) {
|
||||
throw new Error(`extractCwdGuardBash: could not find "${stepMarker}" in ${EXECUTE_PHASE_PATH}`);
|
||||
}
|
||||
|
||||
const afterStep = content.slice(stepIdx + stepMarker.length);
|
||||
|
||||
const driftMarker = 'cwd-drift guard';
|
||||
const driftIdx = afterStep.indexOf(driftMarker);
|
||||
if (driftIdx === -1) {
|
||||
throw new Error(`extractCwdGuardBash: could not find "${driftMarker}" after execute_waves step in ${EXECUTE_PHASE_PATH}`);
|
||||
}
|
||||
|
||||
const afterDrift = afterStep.slice(driftIdx + driftMarker.length);
|
||||
|
||||
// Extract the first ```bash|sh fenced block using a CRLF-safe regex.
|
||||
// \r?\n tolerates both LF (Unix) and CRLF (Windows autocrlf=true checkouts).
|
||||
const fenceRe = /```(?:bash|sh)\r?\n([\s\S]*?)```/;
|
||||
const fenceMatch = fenceRe.exec(afterDrift);
|
||||
if (!fenceMatch) {
|
||||
throw new Error(`extractCwdGuardBash: could not find \`\`\`bash fence after cwd-drift guard heading in ${EXECUTE_PHASE_PATH}`);
|
||||
}
|
||||
|
||||
const guardBash = fenceMatch[1];
|
||||
|
||||
if (!guardBash.trim()) {
|
||||
throw new Error('extractCwdGuardBash: extracted bash block is empty');
|
||||
}
|
||||
if (!guardBash.includes('git rev-parse --show-toplevel')) {
|
||||
throw new Error('extractCwdGuardBash: sanity check failed — extracted block does not contain "git rev-parse --show-toplevel"');
|
||||
}
|
||||
if (!guardBash.includes('worktree-agent-')) {
|
||||
throw new Error('extractCwdGuardBash: sanity check failed — extracted block does not contain "worktree-agent-"');
|
||||
}
|
||||
|
||||
return guardBash;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run guard helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run the guard bash snippet in a given cwd using bash -c.
|
||||
* Returns { status, stderr }.
|
||||
*/
|
||||
function runGuard(guardBash, cwd) {
|
||||
const result = spawnSync('bash', ['-c', guardBash], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return { status: result.status, stderr: result.stderr || '' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let upstreamDir; // bare upstream git repo (the main worktree)
|
||||
let featureDir; // normal feature worktree on branch workspace/feature-x
|
||||
let agentWtDir; // agent worktree on branch worktree-agent-deadbeef
|
||||
let agentSubdir; // subdirectory inside agentWtDir
|
||||
let legitUnderClaude; // non-agent worktree whose PATH is under .claude/worktrees/
|
||||
const dirsToCleanup = [];
|
||||
|
||||
function git(cwd, args) {
|
||||
return execSync(`git ${args.map(a => `"${a}"`).join(' ')}`, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
before(() => {
|
||||
// --- upstream: the main repo with an initial commit ---
|
||||
upstreamDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-48-upstream-'));
|
||||
dirsToCleanup.push(upstreamDir);
|
||||
|
||||
git(upstreamDir, ['init', '-b', 'main']);
|
||||
git(upstreamDir, ['config', 'user.email', 'test@example.com']);
|
||||
git(upstreamDir, ['config', 'user.name', 'Test User']);
|
||||
git(upstreamDir, ['config', 'commit.gpgsign', 'false']);
|
||||
fs.writeFileSync(path.join(upstreamDir, 'README.md'), '# test\n');
|
||||
git(upstreamDir, ['add', 'README.md']);
|
||||
git(upstreamDir, ['commit', '-m', 'chore: init']);
|
||||
|
||||
// --- feature worktree: non-agent branch, path outside .claude/worktrees ---
|
||||
featureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-48-feature-'));
|
||||
dirsToCleanup.push(featureDir);
|
||||
// git worktree add creates the directory itself; remove so it can do so
|
||||
fs.rmdirSync(featureDir);
|
||||
git(upstreamDir, ['worktree', 'add', '-b', 'workspace/feature-x', featureDir]);
|
||||
|
||||
// --- agent worktree: branch worktree-agent-deadbeef ---
|
||||
// Sits under featureDir/.claude/worktrees/agent-deadbeef
|
||||
const agentWtParent = path.join(featureDir, '.claude', 'worktrees');
|
||||
fs.mkdirSync(agentWtParent, { recursive: true });
|
||||
agentWtDir = path.join(agentWtParent, 'agent-deadbeef');
|
||||
git(upstreamDir, ['worktree', 'add', '-b', 'worktree-agent-deadbeef', agentWtDir]);
|
||||
|
||||
// --- subdir inside agent worktree ---
|
||||
agentSubdir = path.join(agentWtDir, 'src', 'deep');
|
||||
fs.mkdirSync(agentSubdir, { recursive: true });
|
||||
|
||||
// --- legitUnderClaude: non-agent worktree whose PATH is under .claude/worktrees/ ---
|
||||
// This proves the guard discriminates by branch name, not path.
|
||||
const legitParent = path.join(upstreamDir, '.claude', 'worktrees');
|
||||
fs.mkdirSync(legitParent, { recursive: true });
|
||||
legitUnderClaude = path.join(legitParent, 'legit-feature');
|
||||
git(upstreamDir, ['worktree', 'add', '-b', 'workspace/legit', legitUnderClaude]);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
// Prune stale worktree metadata before removing dirs
|
||||
try { git(upstreamDir, ['worktree', 'prune']); } catch (_) { /* best-effort */ }
|
||||
for (const d of dirsToCleanup) {
|
||||
try { cleanup(d); } catch (_) { /* best-effort */ }
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #48: orchestrator cwd-drift guard — executable e2e', () => {
|
||||
let guardBash;
|
||||
|
||||
before(() => {
|
||||
guardBash = extractCwdGuardBash();
|
||||
});
|
||||
|
||||
test('guard passes from a feature worktree on a non-agent branch (exit 0)', () => {
|
||||
const { status, stderr } = runGuard(guardBash, featureDir);
|
||||
assert.equal(
|
||||
status, 0,
|
||||
`Expected exit 0 from feature worktree, got ${status}. stderr: ${stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('guard fails closed (exit 1) when cwd is inside an agent worktree', () => {
|
||||
const { status, stderr } = runGuard(guardBash, agentWtDir);
|
||||
assert.equal(
|
||||
status, 1,
|
||||
`Expected exit 1 from agent worktree, got ${status}. stderr: ${stderr}`,
|
||||
);
|
||||
assert.match(
|
||||
stderr,
|
||||
/agent worktree/i,
|
||||
`Expected stderr to mention "agent worktree", got: ${stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('guard fails closed (exit 1) from a SUBDIRECTORY of an agent worktree (root resolution)', () => {
|
||||
// git rev-parse --show-toplevel resolves to the worktree root regardless of cwd subdir.
|
||||
// The guard must catch this via the branch-name check, not the path check.
|
||||
const { status, stderr } = runGuard(guardBash, agentSubdir);
|
||||
assert.equal(
|
||||
status, 1,
|
||||
`Expected exit 1 from agent worktree subdir, got ${status}. stderr: ${stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('guard does NOT blanket-refuse a non-agent worktree located under .claude/worktrees/ (exit 0)', () => {
|
||||
// Discriminator is the worktree-agent-* branch namespace, NOT the path.
|
||||
const { status, stderr } = runGuard(guardBash, legitUnderClaude);
|
||||
assert.equal(
|
||||
status, 0,
|
||||
`Expected exit 0 from non-agent worktree under .claude/worktrees/, got ${status}. stderr: ${stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('guard fails closed (exit 1) when not inside a git repo', (t) => {
|
||||
const nonRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-48-nongit-'));
|
||||
try {
|
||||
// Verify that git rev-parse --show-toplevel actually fails here.
|
||||
// On some systems /tmp itself might be inside a git repo (e.g. if the
|
||||
// user's HOME is a git repo). If it resolves, we must skip this test.
|
||||
const check = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
||||
cwd: nonRepoDir,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
if (check.status === 0) {
|
||||
t.skip('nonRepoDir unexpectedly resolved to a git repo — skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const { status, stderr } = runGuard(guardBash, nonRepoDir);
|
||||
assert.equal(
|
||||
status, 1,
|
||||
`Expected exit 1 when not inside a git repo, got ${status}. stderr: ${stderr}`,
|
||||
);
|
||||
} finally {
|
||||
try { cleanup(nonRepoDir); } catch (_) { /* best-effort */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user