From 0cc7a1a4265fb949016789de14961f33535c2fc6 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 3 Jul 2026 02:26:08 -0400 Subject: [PATCH] test(#1974): consolidate 27 installer/hooks remainder tests into module suites Fold 27 issue-named installer/hooks/statusline/migration/reapply regression files into their canonical module suites (installer-migrations, installer-migration-report, gsd-statusline, reapply-verify-hunks, install-*, gsd-check-update-worker-platform-gate, etc.). Verbatim block-scoped describe wrappers; 276 subtests conserved 1:1. No new files. The one subdir origin (tests/installer-migrations/001-legacy-orphan-files) moved up one level into installer-migrations.test.cjs; its single ../../ module require corrected to ../ so it resolves from tests/ root (verified). Host-env pre-check: no CLI-receiving host sets a redirecting GSD_WORKSTREAM/GSD_PROJECT value. Regenerates regression-name allowlist (222->205), ratchets file-count allowlist (verify 11->8, validate entry removed), makes 16 relocated allow-test-rule exemptions issue-ref- compliant (ADR-456; prunes stale ids). Repoints 15 tests/ references across state-md.md (EN + ja/ko/pt/zh). lint:ci green. Part of epic #1969. Closes #1974. Co-Authored-By: Claude Opus 4.8 --- docs/ja-JP/reference/state-md.md | 6 +- docs/ko-KR/reference/state-md.md | 6 +- docs/pt-BR/reference/state-md.md | 6 +- docs/reference/state-md.md | 6 +- docs/zh-CN/reference/state-md.md | 6 +- .../lint-allow-test-rule-refs.allowlist.json | 13 - .../lint-regression-test-names.allowlist.json | 17 - scripts/lint-test-file-count.allowlist.json | 3 - tests/bug-1891-file-resolution.test.cjs | 61 - .../bug-2784-update-cache-clear-path.test.cjs | 91 - .../bug-2969-verify-reapply-patches.test.cjs | 248 - ...bug-2973-profile-user-skills-path.test.cjs | 248 - tests/bug-2992-check-latest-version.test.cjs | 154 - ...fy-reapply-patches-installed-path.test.cjs | 81 - ...g-3129-validate-commit-git-bypass.test.cjs | 119 - ...codex-legacy-hooks-json-migration.test.cjs | 356 -- ...codex-legacy-hooks-json-migration.test.cjs | 58 - ...-migration-prompt-user-resolution.test.cjs | 234 - ...tion-bundled-hooks-classification.test.cjs | 189 - ...bundled-hook-classifier-whitelist.test.cjs | 140 - ...fy-reapply-patches-pristine-drift.test.cjs | 681 --- ...rsor-local-install-migration-lock.test.cjs | 313 -- ...35-profiles-core-includes-surface.test.cjs | 48 - .../bug-378-update-check-scoped-name.test.cjs | 105 - tests/bug-947-hermes-gsd-prefix.test.cjs | 454 -- tests/enh-191-retire-sdk-package.test.cjs | 62 - .../enh-2538-statusline-last-command.test.cjs | 127 - ...h-2833-phase-lifecycle-statusline.test.cjs | 302 -- ...-2937-statusline-context-position.test.cjs | 149 - tests/enh-770-claude-hook-events.test.cjs | 379 -- tests/enh-790-augment-commands.test.cjs | 212 - tests/feat-2795-update-banner.test.cjs | 365 -- tests/feat-443-effort-defaults-drift.test.cjs | 83 - .../fix-1679-destsubpath-confinement.test.cjs | 789 --- ...check-update-worker-platform-gate.test.cjs | 755 +++ tests/gsd-statusline.test.cjs | 608 +++ tests/install-minimal-hooks.test.cjs | 447 ++ tests/install-runtime-artifacts.test.cjs | 4697 +++++++++++++++++ tests/install-write-confinement.test.cjs | 799 +++ tests/install.test.cjs | 93 + tests/installer-migration-report.test.cjs | 661 +++ tests/installer-migrations.test.cjs | 778 +++ .../001-legacy-orphan-files.test.cjs | 79 - tests/io.test.cjs | 71 + tests/reapply-patches.test.cjs | 90 + tests/reapply-verify-hunks.test.cjs | 949 ++++ tests/repo-layout.test.cjs | 72 + tests/worktree-safety.test.cjs | 128 + 48 files changed, 10163 insertions(+), 6175 deletions(-) delete mode 100644 tests/bug-1891-file-resolution.test.cjs delete mode 100644 tests/bug-2784-update-cache-clear-path.test.cjs delete mode 100644 tests/bug-2969-verify-reapply-patches.test.cjs delete mode 100644 tests/bug-2973-profile-user-skills-path.test.cjs delete mode 100644 tests/bug-2992-check-latest-version.test.cjs delete mode 100644 tests/bug-2994-verify-reapply-patches-installed-path.test.cjs delete mode 100644 tests/bug-3129-validate-commit-git-bypass.test.cjs delete mode 100644 tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs delete mode 100644 tests/bug-3442-codex-legacy-hooks-json-migration.test.cjs delete mode 100644 tests/bug-3541-installer-migration-prompt-user-resolution.test.cjs delete mode 100644 tests/bug-3610-installer-migration-bundled-hooks-classification.test.cjs delete mode 100644 tests/bug-3628-bundled-hook-classifier-whitelist.test.cjs delete mode 100644 tests/bug-3657-verify-reapply-patches-pristine-drift.test.cjs delete mode 100644 tests/bug-3670-cursor-local-install-migration-lock.test.cjs delete mode 100644 tests/bug-3735-profiles-core-includes-surface.test.cjs delete mode 100644 tests/bug-378-update-check-scoped-name.test.cjs delete mode 100644 tests/bug-947-hermes-gsd-prefix.test.cjs delete mode 100644 tests/enh-191-retire-sdk-package.test.cjs delete mode 100644 tests/enh-2538-statusline-last-command.test.cjs delete mode 100644 tests/enh-2833-phase-lifecycle-statusline.test.cjs delete mode 100644 tests/enh-2937-statusline-context-position.test.cjs delete mode 100644 tests/enh-770-claude-hook-events.test.cjs delete mode 100644 tests/enh-790-augment-commands.test.cjs delete mode 100644 tests/feat-2795-update-banner.test.cjs delete mode 100644 tests/feat-443-effort-defaults-drift.test.cjs delete mode 100644 tests/fix-1679-destsubpath-confinement.test.cjs delete mode 100644 tests/installer-migrations/001-legacy-orphan-files.test.cjs diff --git a/docs/ja-JP/reference/state-md.md b/docs/ja-JP/reference/state-md.md index 706ebb7bc..254c5c3a2 100644 --- a/docs/ja-JP/reference/state-md.md +++ b/docs/ja-JP/reference/state-md.md @@ -111,7 +111,7 @@ paused_at: null | **3. マイルストーン完了** | `percent` が `100` または `completed_phases == total_phases` | `v2.0 [██████████] 100% · milestone complete` | | **4. デフォルトフォールバック** | 上記のいずれにも該当しない | `v1.9 Code Quality · executing · ph 1/5`(既存フォーマット) | -**シーン優先度:** `active_phase` と `next_action` が両方設定されている場合、シーン1が優先されます — オーケストレーターが実行中であるため「次の推奨」は誤解を招くためです。この優先度は `formatGsdState()` のチェック順序によって強制され、`tests/enh-2833-phase-lifecycle-statusline.test.cjs` の `"scene priority"` スイートでカバーされています。 +**シーン優先度:** `active_phase` と `next_action` が両方設定されている場合、シーン1が優先されます — オーケストレーターが実行中であるため「次の推奨」は誤解を招くためです。この優先度は `formatGsdState()` のチェック順序によって強制され、`tests/gsd-statusline.test.cjs` の `"scene priority"` スイートでカバーされています。 進捗バー(`[██░░░░░░░░] 20%`)はフロントマターに `progress.percent` が存在する場合のみマイルストーンセグメントに追加されます。不在の場合はバーは表示されません。 @@ -119,7 +119,7 @@ paused_at: null ## フロントマターパースの制約 -ステータスラインフックは正規表現ベースのパース(完全な YAML ライブラリを使用しない)を使用するため、以下の制約が適用されます。これらは `tests/enh-2833-phase-lifecycle-statusline.test.cjs` でテストされています。 +ステータスラインフックは正規表現ベースのパース(完全な YAML ライブラリを使用しない)を使用するため、以下の制約が適用されます。これらは `tests/gsd-statusline.test.cjs` でテストされています。 1. **フロントマターはファイルの先頭文字から始まる必要があります。** コメントを含む何かが開始 `---` の前にあると、マッチが無効になります。開始 `---` 行は末尾のスペースなしで正確にそれだけである必要があります。 @@ -189,7 +189,7 @@ paused_at: null - ライフサイクルフィールドの追加はオプトインです — フィールドが不在の場合レンダラーはグレースフルに縮退します。 - 進捗バーは `progress` ブロックが存在する場合でもオプトインです: バーをトリガーするのは `progress.percent` のみで、`total_phases` と `completed_phases` だけではトリガーされません。 -`tests/enh-2833-phase-lifecycle-statusline.test.cjs` の `formatGsdState #2833 backward compatibility` テストスイートがこの保証を固定しています。レガシー `STATE.md` 描画を壊す変更があればスイートが失敗します。 +`tests/gsd-statusline.test.cjs` の `formatGsdState #2833 backward compatibility` テストスイートがこの保証を固定しています。レガシー `STATE.md` 描画を壊す変更があればスイートが失敗します。 --- diff --git a/docs/ko-KR/reference/state-md.md b/docs/ko-KR/reference/state-md.md index 11e597480..e13b266e7 100644 --- a/docs/ko-KR/reference/state-md.md +++ b/docs/ko-KR/reference/state-md.md @@ -111,7 +111,7 @@ paused_at: null | **3. 마일스톤 완료** | `percent`가 `100`이거나 `completed_phases == total_phases`인 경우 | `v2.0 [██████████] 100% · milestone complete` | | **4. 기본 폴백** | 위 중 해당 없음 | `v1.9 Code Quality · executing · ph 1/5` (기존 형식) | -**장면 우선순위:** `active_phase`와 `next_action`이 모두 채워진 경우 장면 1이 우선합니다 — 오케스트레이터가 실행 중이므로 "다음 권장 사항"은 오해의 소지가 있습니다. 이 우선순위는 `formatGsdState()`의 확인 순서로 강제되며 `tests/enh-2833-phase-lifecycle-statusline.test.cjs`의 `"scene priority"` 스위트에서 테스트됩니다. +**장면 우선순위:** `active_phase`와 `next_action`이 모두 채워진 경우 장면 1이 우선합니다 — 오케스트레이터가 실행 중이므로 "다음 권장 사항"은 오해의 소지가 있습니다. 이 우선순위는 `formatGsdState()`의 확인 순서로 강제되며 `tests/gsd-statusline.test.cjs`의 `"scene priority"` 스위트에서 테스트됩니다. 진행 막대(`[██░░░░░░░░] 20%`)는 프론트매터에 `progress.percent`가 있을 때만 마일스톤 세그먼트에 추가됩니다. 없으면 막대가 표시되지 않습니다. @@ -119,7 +119,7 @@ paused_at: null ## 프론트매터 파싱 제약 사항 -상태 표시줄 훅은 정규식 기반 파싱을 사용합니다(YAML 라이브러리 없음). 따라서 다음 제약 사항이 적용됩니다. 이는 `tests/enh-2833-phase-lifecycle-statusline.test.cjs`에서 테스트됩니다. +상태 표시줄 훅은 정규식 기반 파싱을 사용합니다(YAML 라이브러리 없음). 따라서 다음 제약 사항이 적용됩니다. 이는 `tests/gsd-statusline.test.cjs`에서 테스트됩니다. 1. **프론트매터는 파일의 맨 첫 번째 문자에서 시작해야 합니다.** 주석을 포함한 어떤 것이든 여는 `---` 위에 있으면 매칭이 무효화됩니다. 여는 `---` 줄은 정확히 그것이어야 하며, 후행 공백이 없어야 합니다. @@ -189,7 +189,7 @@ paused_at: null - 생명주기 필드 추가는 선택 사항입니다 — 렌더러는 필드가 없을 때 우아하게 저하됩니다. - 진행 막대는 `progress` 블록이 있어도 선택 사항입니다: `progress.percent`만 막대를 트리거하고, `total_phases`와 `completed_phases`만으로는 트리거되지 않습니다. -`tests/enh-2833-phase-lifecycle-statusline.test.cjs`의 `formatGsdState #2833 backward compatibility` 테스트 스위트는 이 보장을 고정합니다. 레거시 `STATE.md` 렌더링을 깨는 변경 사항은 스위트에서 실패합니다. +`tests/gsd-statusline.test.cjs`의 `formatGsdState #2833 backward compatibility` 테스트 스위트는 이 보장을 고정합니다. 레거시 `STATE.md` 렌더링을 깨는 변경 사항은 스위트에서 실패합니다. --- diff --git a/docs/pt-BR/reference/state-md.md b/docs/pt-BR/reference/state-md.md index 12d6f1c65..deb0db1af 100644 --- a/docs/pt-BR/reference/state-md.md +++ b/docs/pt-BR/reference/state-md.md @@ -111,7 +111,7 @@ Quando um comando do orquestrador está em andamento, a convenção (issue #2833 | **3. Milestone completo** | `percent` é `100` OU `completed_phases == total_phases` | `v2.0 [██████████] 100% · milestone complete` | | **4. Fallback padrão** | Nenhuma das anteriores corresponde | `v1.9 Code Quality · executing · ph 1/5` (formato existente) | -**Prioridade de cena:** quando `active_phase` e `next_action` estão populados, a Cena 1 prevalece — um orquestrador está em andamento, portanto uma "próxima recomendação" seria enganosa. Essa prioridade é imposta pela ordem de verificação em `formatGsdState()` e coberta pelo conjunto `"scene priority"` em `tests/enh-2833-phase-lifecycle-statusline.test.cjs`. +**Prioridade de cena:** quando `active_phase` e `next_action` estão populados, a Cena 1 prevalece — um orquestrador está em andamento, portanto uma "próxima recomendação" seria enganosa. Essa prioridade é imposta pela ordem de verificação em `formatGsdState()` e coberta pelo conjunto `"scene priority"` em `tests/gsd-statusline.test.cjs`. A barra de progresso (`[██░░░░░░░░] 20%`) é anexada ao segmento do milestone somente quando `progress.percent` está presente no frontmatter; ausente significa sem barra. @@ -119,7 +119,7 @@ A barra de progresso (`[██░░░░░░░░] 20%`) é anexada ao segm ## Restrições de análise do frontmatter -O hook de linha de status usa análise baseada em regex (sem biblioteca YAML completa), portanto as seguintes restrições se aplicam. Elas são testadas em `tests/enh-2833-phase-lifecycle-statusline.test.cjs`. +O hook de linha de status usa análise baseada em regex (sem biblioteca YAML completa), portanto as seguintes restrições se aplicam. Elas são testadas em `tests/gsd-statusline.test.cjs`. 1. **O frontmatter deve começar no primeiro caractere do arquivo.** Qualquer coisa — incluindo comentários — acima do `---` de abertura invalida a correspondência. A linha `---` de abertura deve ser exatamente isso, sem espaços no final. @@ -189,7 +189,7 @@ Os campos de ciclo de vida de fase (`active_phase`, `next_action`, `next_phases` - Adicionar qualquer campo de ciclo de vida é opt-in — o renderizador degrada graciosamente quando os campos estão ausentes. - A barra de progresso é opt-in mesmo quando o bloco `progress` existe: somente `progress.percent` ativa a barra; `total_phases` e `completed_phases` sozinhos não ativam. -O conjunto de testes `formatGsdState #2833 backward compatibility` em `tests/enh-2833-phase-lifecycle-statusline.test.cjs` garante essa promessa; qualquer mudança que quebre a renderização legada do `STATE.md` fará o conjunto falhar. +O conjunto de testes `formatGsdState #2833 backward compatibility` em `tests/gsd-statusline.test.cjs` garante essa promessa; qualquer mudança que quebre a renderização legada do `STATE.md` fará o conjunto falhar. --- diff --git a/docs/reference/state-md.md b/docs/reference/state-md.md index d0ac788bf..967ec14ee 100644 --- a/docs/reference/state-md.md +++ b/docs/reference/state-md.md @@ -111,7 +111,7 @@ When an orchestrator command is in flight, the convention (issue #2833) is to wr | **3. Milestone complete** | `percent` is `100` OR `completed_phases == total_phases` | `v2.0 [██████████] 100% · milestone complete` | | **4. Default fallback** | None of the above match | `v1.9 Code Quality · executing · ph 1/5` (existing format) | -**Scene priority:** when both `active_phase` and `next_action` are populated, Scene 1 wins — an orchestrator is in flight, so a "next recommendation" would be misleading. This priority is enforced by check order in `formatGsdState()` and covered by the `"scene priority"` suite in `tests/enh-2833-phase-lifecycle-statusline.test.cjs`. +**Scene priority:** when both `active_phase` and `next_action` are populated, Scene 1 wins — an orchestrator is in flight, so a "next recommendation" would be misleading. This priority is enforced by check order in `formatGsdState()` and covered by the `"scene priority"` suite in `tests/gsd-statusline.test.cjs`. The progress bar (`[██░░░░░░░░] 20%`) is appended to the milestone segment only when `progress.percent` is present in frontmatter; absent means no bar. @@ -119,7 +119,7 @@ The progress bar (`[██░░░░░░░░] 20%`) is appended to the mil ## Frontmatter parsing constraints -The status-line hook uses regex-based parsing (no full YAML library), so the following constraints apply. They are tested in `tests/enh-2833-phase-lifecycle-statusline.test.cjs`. +The status-line hook uses regex-based parsing (no full YAML library), so the following constraints apply. They are tested in `tests/gsd-statusline.test.cjs`. 1. **Frontmatter must start at the very first character of the file.** Anything — including comments — above the opening `---` invalidates the match. The opening `---` line must be exactly that, with no trailing spaces. @@ -189,7 +189,7 @@ The phase-lifecycle fields (`active_phase`, `next_action`, `next_phases`, and `p - Adding any lifecycle field is opt-in — the renderer degrades gracefully when fields are absent. - The progress bar is opt-in even when the `progress` block exists: only `progress.percent` triggers the bar; `total_phases` and `completed_phases` alone do not. -The `formatGsdState #2833 backward compatibility` test suite in `tests/enh-2833-phase-lifecycle-statusline.test.cjs` locks this guarantee; any change that breaks legacy `STATE.md` rendering will fail the suite. +The `formatGsdState #2833 backward compatibility` test suite in `tests/gsd-statusline.test.cjs` locks this guarantee; any change that breaks legacy `STATE.md` rendering will fail the suite. --- diff --git a/docs/zh-CN/reference/state-md.md b/docs/zh-CN/reference/state-md.md index 5d2da91f8..b65b21be4 100644 --- a/docs/zh-CN/reference/state-md.md +++ b/docs/zh-CN/reference/state-md.md @@ -111,7 +111,7 @@ paused_at: null | **3. 里程碑完成** | `percent` 为 `100` 或 `completed_phases == total_phases` | `v2.0 [██████████] 100% · milestone complete` | | **4. 默认回退** | 以上均不匹配 | `v1.9 Code Quality · executing · ph 1/5`(现有格式) | -**场景优先级:** 当 `active_phase` 和 `next_action` 均已填充时,场景 1 优先——编排器正在运行,显示"下一步推荐"会造成误导。此优先级由 `formatGsdState()` 中的检查顺序强制执行,并由 `tests/enh-2833-phase-lifecycle-statusline.test.cjs` 中的 `"scene priority"` 测试套件覆盖。 +**场景优先级:** 当 `active_phase` 和 `next_action` 均已填充时,场景 1 优先——编排器正在运行,显示"下一步推荐"会造成误导。此优先级由 `formatGsdState()` 中的检查顺序强制执行,并由 `tests/gsd-statusline.test.cjs` 中的 `"scene priority"` 测试套件覆盖。 进度条(`[██░░░░░░░░] 20%`)仅在前置数据中存在 `progress.percent` 时才追加到里程碑段;缺失则不显示进度条。 @@ -119,7 +119,7 @@ paused_at: null ## 前置数据解析约束 -状态行钩子使用基于正则表达式的解析(无完整 YAML 库),因此以下约束适用。这些约束在 `tests/enh-2833-phase-lifecycle-statusline.test.cjs` 中经过测试。 +状态行钩子使用基于正则表达式的解析(无完整 YAML 库),因此以下约束适用。这些约束在 `tests/gsd-statusline.test.cjs` 中经过测试。 1. **前置数据必须从文件的第一个字符开始。** 任何内容——包括注释——出现在开头 `---` 之前都会使匹配失效。开头的 `---` 行必须恰好如此,不能有尾随空格。 @@ -189,7 +189,7 @@ paused_at: null - 添加任何生命周期字段是可选的——当字段缺失时,渲染器会优雅降级。 - 即使 `progress` 块存在,进度条也是可选的:只有 `progress.percent` 触发进度条;单独的 `total_phases` 和 `completed_phases` 不会触发。 -`tests/enh-2833-phase-lifecycle-statusline.test.cjs` 中的 `formatGsdState #2833 backward compatibility` 测试套件锁定了此保证;任何破坏旧版 `STATE.md` 渲染的变更都将导致该套件失败。 +`tests/gsd-statusline.test.cjs` 中的 `formatGsdState #2833 backward compatibility` 测试套件锁定了此保证;任何破坏旧版 `STATE.md` 渲染的变更都将导致该套件失败。 --- diff --git a/scripts/lint-allow-test-rule-refs.allowlist.json b/scripts/lint-allow-test-rule-refs.allowlist.json index 412f95378..3fa9a68d7 100644 --- a/scripts/lint-allow-test-rule-refs.allowlist.json +++ b/scripts/lint-allow-test-rule-refs.allowlist.json @@ -15,7 +15,6 @@ "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-1891-file-resolution.test.cjs :: structural-implementation-guard", "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-214-phase-researcher-write-truncation-contract.test.cjs :: source-text-is-the-product", @@ -27,13 +26,10 @@ "tests/bug-2559-stale-search-year.test.cjs :: source-text-is-the-product", "tests/bug-2686-review-fix-worktree.test.cjs :: source-text-is-the-product", "tests/bug-2772-gitmodules-path-intersection.test.cjs :: source-text-is-the-product", - "tests/bug-2784-update-cache-clear-path.test.cjs :: structural-regression-guard", "tests/bug-2808-skill-hyphen-name.test.cjs :: source-text-is-the-product", "tests/bug-2839-review-fix-transactional-cleanup.test.cjs :: source-text-is-the-product", - "tests/bug-2973-profile-user-skills-path.test.cjs :: source-text-is-the-product. profile-user.md IS the", "tests/bug-2990-code-fixer-worktree-branch.test.cjs :: source-text-is-the-product", "tests/bug-3097-3099-executor-worktree-path-safety.test.cjs :: reads markdown product files (gsd-executor.md, worktree-path-safety.md) to verify structural protocol — not source-grep", - "tests/bug-3129-validate-commit-git-bypass.test.cjs :: reads hook shell script to verify delegation pattern — structural contract test, not source-grep", "tests/bug-3290-intel-updater-layout-block.test.cjs :: source-text-is-the-product — agents/gsd-intel-updater.md IS", "tests/bug-33-settings-model-profile-adaptive.test.cjs :: source-text-is-the-product", "tests/bug-3384-secondary-defects.test.cjs :: source-text-is-the-product", @@ -43,15 +39,11 @@ "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-3605-stale-research-insert-phase-agent-refs.test.cjs :: source-text-is-the-product", - "tests/bug-3628-bundled-hook-classifier-whitelist.test.cjs :: architectural-invariant", - "tests/bug-3657-verify-reapply-patches-pristine-drift.test.cjs :: source-text-is-the-product.", - "tests/bug-3657-verify-reapply-patches-pristine-drift.test.cjs :: source-text-is-the-product — Finding 2 reads reapply-patches.md to", "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-378-update-check-scoped-name.test.cjs :: structural assertion on hook delegation; the behavior being", "tests/bug-3810-no-gsd-sdk-runtime-refs.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-571-doc-writer-fix-mode-edit-only.test.cjs :: source-text-is-the-product", @@ -60,7 +52,6 @@ "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/bug-947-hermes-gsd-prefix.test.cjs :: source-text-is-the-product", "tests/bug-patterns-reference.test.cjs :: source-text-is-the-product", "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", @@ -105,10 +96,6 @@ "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/enh-72-business-context.test.cjs :: source-text-is-the-product", - "tests/enh-770-claude-hook-events.test.cjs :: runtime-contract-is-the-product — hooks.json IS the", - "tests/enh-770-claude-hook-events.test.cjs :: runtime-contract-is-the-product — the hookEventName is", - "tests/enh-770-claude-hook-events.test.cjs :: runtime-contract-is-the-product — the stamp template token", - "tests/enh-770-claude-hook-events.test.cjs :: runtime-contract-is-the-product — the stdin-read and", "tests/eslint-rules.test.cjs :: must still error", "tests/eslint-rules.test.cjs :: pending migration", "tests/eslint-rules.test.cjs :: source-text-is-the-product", diff --git a/scripts/lint-regression-test-names.allowlist.json b/scripts/lint-regression-test-names.allowlist.json index 19487ef53..da5445dd7 100644 --- a/scripts/lint-regression-test-names.allowlist.json +++ b/scripts/lint-regression-test-names.allowlist.json @@ -2,7 +2,6 @@ "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-1891-file-resolution.test.cjs", "bug-1974-context-exhaustion-record.test.cjs", "bug-21-state-md-template-frontmatter.test.cjs", "bug-211-launcher-home-fallback.test.cjs", @@ -22,51 +21,36 @@ "bug-261-worktree-force-add-guard.test.cjs", "bug-2686-review-fix-worktree.test.cjs", "bug-2772-gitmodules-path-intersection.test.cjs", - "bug-2784-update-cache-clear-path.test.cjs", "bug-2808-skill-hyphen-name.test.cjs", "bug-2839-review-fix-transactional-cleanup.test.cjs", "bug-2866-codex-strip-no-trailing-newline.test.cjs", "bug-2876-skill-frontmatter-quote.test.cjs", "bug-2916-handle-branching-default-base.test.cjs", - "bug-2969-verify-reapply-patches.test.cjs", - "bug-2973-profile-user-skills-path.test.cjs", "bug-2990-code-fixer-worktree-branch.test.cjs", - "bug-2992-check-latest-version.test.cjs", - "bug-2994-verify-reapply-patches-installed-path.test.cjs", "bug-2995-post-install-script-paths.test.cjs", "bug-3019-help-passthrough.test.cjs", "bug-3054-stale-gsd-next-references.test.cjs", "bug-3087-planner-directive-language.test.cjs", "bug-3097-3099-executor-worktree-path-safety.test.cjs", - "bug-3129-validate-commit-git-bypass.test.cjs", "bug-3290-intel-updater-layout-block.test.cjs", "bug-33-settings-model-profile-adaptive.test.cjs", "bug-3321-verifier-runs-probes.test.cjs", - "bug-3357-codex-legacy-hooks-json-migration.test.cjs", "bug-3384-secondary-defects.test.cjs", "bug-3430-planner-phase-contract.test.cjs", - "bug-3442-codex-legacy-hooks-json-migration.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-3541-installer-migration-prompt-user-resolution.test.cjs", "bug-3542-executor-git-stash-prohibition.test.cjs", "bug-3588-npm-audit-clean.test.cjs", "bug-3605-stale-research-insert-phase-agent-refs.test.cjs", - "bug-3610-installer-migration-bundled-hooks-classification.test.cjs", - "bug-3628-bundled-hook-classifier-whitelist.test.cjs", - "bug-3657-verify-reapply-patches-pristine-drift.test.cjs", "bug-3668-workflow-runtime-resolution.test.cjs", - "bug-3670-cursor-local-install-migration-lock.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-3735-profiles-core-includes-surface.test.cjs", - "bug-378-update-check-scoped-name.test.cjs", "bug-3810-no-gsd-sdk-runtime-refs.test.cjs", "bug-444-resolver-local-claude-install.test.cjs", "bug-571-doc-writer-fix-mode-edit-only.test.cjs", @@ -78,6 +62,5 @@ "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-947-hermes-gsd-prefix.test.cjs", "bug-969-test-infra-flake-hardening.test.cjs" ] diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 99cb231d2..01e84fd4b 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -86,9 +86,6 @@ }, "verify": { "files": [ - "bug-2969-verify-reapply-patches.test.cjs", - "bug-2994-verify-reapply-patches-installed-path.test.cjs", - "bug-3657-verify-reapply-patches-pristine-drift.test.cjs", "verify-health.test.cjs", "verify-mvp-uat.test.cjs", "verify-npm-publish.test.cjs", diff --git a/tests/bug-1891-file-resolution.test.cjs b/tests/bug-1891-file-resolution.test.cjs deleted file mode 100644 index 331dee6d7..000000000 --- a/tests/bug-1891-file-resolution.test.cjs +++ /dev/null @@ -1,61 +0,0 @@ -// allow-test-rule: structural-implementation-guard -// gsd-tools.cjs @file: resolution is a low-level stdout interception that cannot be -// exercised end-to-end via runGsdTools without a real workflow that emits @file: output. -// These structural tests guard the interception wiring until a behavioral integration -// test suite for the full @file: path is added. - -/** - * Regression tests for bug #1891 - * - * gsd-tools.cjs must transparently resolve @file: references in stdout - * so that workflows never see the @file: prefix. This eliminates the - * bash-specific `if [[ "$INIT" == @file:* ]]` check that breaks on - * PowerShell and other non-bash shells. - */ - -'use strict'; - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const GSD_TOOLS_SRC = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); - -describe('bug #1891: @file: resolution in gsd-tools.cjs', () => { - let src; - - before(() => { - src = fs.readFileSync(GSD_TOOLS_SRC, 'utf-8'); - }); - - test('main() intercepts stdout and resolves @file: references', () => { - // The non-pick path should have @file: resolution, just like the --pick path - assert.ok( - src.includes("captured.startsWith('@file:')") || - src.includes('captured.startsWith(\'@file:\')'), - 'main() should check for @file: prefix in captured output' - ); - }); - - test('@file: resolution reads file content via readFileSync', () => { - // Verify the resolution reads the actual file - assert.ok( - src.includes("readFileSync(captured.slice(6)") || - src.includes('readFileSync(captured.slice(6)'), - '@file: resolution should read file at the path after the prefix' - ); - }); - - test('stdout interception wraps runCommand in the non-pick path', () => { - // The main function should resolve @file: output in BOTH --pick and - // non-pick paths. This can be either two inline checks or a shared helper. - const mainFunc = src.slice(src.indexOf('async function main()')); - const resolveCalls = (mainFunc.match(/resolveAtFileOutput\(/g) || []).length; - const inlineAtFileChecks = (mainFunc.match(/@file:/g) || []).length; - assert.ok( - resolveCalls >= 2 || inlineAtFileChecks >= 2, - 'Both --pick and normal paths should resolve @file: references' - ); - }); -}); diff --git a/tests/bug-2784-update-cache-clear-path.test.cjs b/tests/bug-2784-update-cache-clear-path.test.cjs deleted file mode 100644 index 2ea85d463..000000000 --- a/tests/bug-2784-update-cache-clear-path.test.cjs +++ /dev/null @@ -1,91 +0,0 @@ -// allow-test-rule: structural-regression-guard -// Reads hook .js or bin/install.js source to assert structural invariants -// (search array order, function wiring, path constants) that cannot be -// verified by observing runtime outputs alone. Per CONTRIBUTING.md exception matrix. - -/** - * Regression test for bug #2784 - * - * /gsd-update cache-clear step only cleared per-runtime cache paths - * (e.g. ~/.claude/cache/gsd-update-check.json) but the SessionStart hook - * (hooks/gsd-check-update.js) writes to the shared tool-agnostic path - * ~/.cache/gsd/gsd-update-check.json. After a successful update, the statusline - * kept showing the stale "⬆ /gsd-update" indicator because the actual cache - * file was never deleted. - * - * Fix: add `rm -f "$HOME/.cache/gsd/gsd-update-check.json"` to the - * run_update step's cache-clear block in gsd-core/workflows/update.md. - */ - -'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 REPO_ROOT = path.join(__dirname, '..'); -const UPDATE_WORKFLOW = path.join( - REPO_ROOT, - 'gsd-core', - 'workflows', - 'update.md' -); -const CHECK_UPDATE_HOOK = path.join(REPO_ROOT, 'hooks', 'gsd-check-update.js'); - -describe('bug-2784: update.md cache-clear covers shared cache path', () => { - test('gsd-check-update.js hook constructs cache dir from .cache and gsd path segments', () => { - const hookContent = fs.readFileSync(CHECK_UPDATE_HOOK, 'utf-8'); - // Parse the path.join() call structurally rather than text-grepping. - const m = hookContent.match(/const cacheDir\s*=\s*path\.join\(([^)]+)\)/); - assert.ok( - m !== null, - 'hook must assign cacheDir via path.join() with explicit path segments' - ); - const segments = m[1].split(',').map((a) => a.trim().replace(/^['"]|['"]$/g, '')); - assert.ok( - segments.includes('.cache'), - `hook cacheDir path.join() must include '.cache' segment; got: ${JSON.stringify(segments)}` - ); - assert.ok( - segments.includes('gsd'), - `hook cacheDir path.join() must include 'gsd' segment; got: ${JSON.stringify(segments)}` - ); - }); - - test('update.md run_update bash commands include rm for shared gsd cache file', () => { - const workflowContent = fs.readFileSync(UPDATE_WORKFLOW, 'utf-8'); - // Parse the step block structurally, then extract only bash fenced code lines. - const stepMatch = workflowContent.match(/[\s\S]*?<\/step>/); - assert.ok(stepMatch, 'update.md must have a block'); - const stepContent = stepMatch[0]; - - const bashLines = []; - const fenceRe = /```(?:bash|sh)\r?\n([\s\S]*?)```/g; - let m; - while ((m = fenceRe.exec(stepContent)) !== null) { - for (const line of m[1].split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed) bashLines.push(trimmed); - } - } - - const sharedCacheClearCmds = bashLines.filter( - (line) => /^rm\b/.test(line) && line.includes('.cache/gsd/gsd-update-check') && line.includes('*.json') - ); - assert.ok( - sharedCacheClearCmds.length > 0, - [ - 'run_update step bash blocks must include an `rm` command targeting .cache/gsd/gsd-update-check*.json (glob form clearing legacy + per-package variants).', - `Bash lines found: ${JSON.stringify(bashLines)}`, - ].join('\n') - ); - const hasHomeExpansion = sharedCacheClearCmds.some( - (line) => line.includes('$HOME') || line.includes('~/') - ); - assert.ok( - hasHomeExpansion, - `shared cache rm command must use $HOME or ~/ expansion; found: ${JSON.stringify(sharedCacheClearCmds)}` - ); - }); -}); diff --git a/tests/bug-2969-verify-reapply-patches.test.cjs b/tests/bug-2969-verify-reapply-patches.test.cjs deleted file mode 100644 index 918c147f4..000000000 --- a/tests/bug-2969-verify-reapply-patches.test.cjs +++ /dev/null @@ -1,248 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #2969: /gsd-reapply-patches Step 5 hunk verification gate reports - * success on lost content because the LLM-driven workflow fills in - * "verified: yes" without actually checking content presence. - * - * Fix: deterministic verifier script (scripts/verify-reapply-patches.cjs) - * that the workflow calls. - * - * Per the repo's no-source-grep testing standard (CONTRIBUTING.md): - * tests must assert on TYPED structured fields — not regex/substring - * matching against script output, formatter prose, or file content. - * - * The script's --json mode emits a structured report whose `reason` - * field is a stable enum (exposed as REASON), and whose `missing` field - * is an array of typed strings (exact set membership, not substring). - * Every assertion below is a deepEqual / equal / Array.includes against - * those typed fields. Zero regex, zero String#includes on text. - */ - -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 cp = require('node:child_process'); -const { cleanup } = require('./helpers.cjs'); - -const ROOT = path.join(__dirname, '..'); -// Script lives at gsd-core/bin/ so the installer ships it under -// `${GSD_HOME}/gsd-core/bin/` (issue #2994). The top-level scripts/ -// directory is not copied to user installs. -const SCRIPT = path.join(ROOT, 'gsd-core', 'bin', 'verify-reapply-patches.cjs'); -const { REASON } = require(SCRIPT); - -let tmpRoot; -let patchesDir; -let configDir; -let pristineDir; - -function writeFile(absPath, content) { - fs.mkdirSync(path.dirname(absPath), { recursive: true }); - fs.writeFileSync(absPath, content); -} - -function resetFixture({ withPristine = true } = {}) { - for (const dir of [patchesDir, configDir, pristineDir]) { - cleanup(dir); - } - fs.mkdirSync(patchesDir); - fs.mkdirSync(configDir); - if (withPristine) fs.mkdirSync(pristineDir); -} - -/** Runs the verifier with --json. Returns parsed structured report. */ -function runVerifier({ includePristine = true } = {}) { - const args = [ - SCRIPT, - '--patches-dir', patchesDir, - '--config-dir', configDir, - ...(includePristine ? ['--pristine-dir', pristineDir] : []), - '--json', - ]; - const r = cp.spawnSync(process.execPath, args, { encoding: 'utf8' }); - return { - status: r.status, - report: r.stdout && r.stdout.length ? JSON.parse(r.stdout) : null, - }; -} - -before(() => { - tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2969-')); - patchesDir = path.join(tmpRoot, 'patches'); - configDir = path.join(tmpRoot, 'installed'); - pristineDir = path.join(tmpRoot, 'pristine'); - resetFixture(); -}); - -after(() => { - cleanup(tmpRoot); -}); - -describe('Bug #2969: deterministic Step 5 verification gate', () => { - test('REASON enum exposes the documented set of stable codes', () => { - // Locks the public diagnostic surface — adding a code requires updating - // this assertion, removing one breaks consumers that switch on the enum. - // Bug #3657 added OK_PRISTINE_DRIFT_DETECTED. - // Bug #934 added OK_NO_BASELINE. - assert.deepEqual( - Object.keys(REASON).sort(), - [ - 'FAIL_INSTALLED_MISSING', - 'FAIL_INSTALLED_NOT_REGULAR_FILE', - 'FAIL_READ_ERROR', - 'FAIL_USER_LINES_MISSING', - 'OK_NO_BASELINE', - 'OK_NO_SIGNIFICANT_BACKUP_LINES', - 'OK_NO_USER_LINES_VS_PRISTINE', - 'OK_PRISTINE_DRIFT_DETECTED', - ], - ); - }); - - test('exits 0 with status=ok when every user-added line is present in the merged file', () => { - resetFixture(); - const pristine = 'line one of stock content here\nline two of stock content here\nline three of stock content here\n'; - const userAdded = 'a custom line the user added for behavior X\nanother substantial line that the user inserted\n'; - - writeFile(path.join(pristineDir, 'skills', 'foo', 'SKILL.md'), pristine); - writeFile(path.join(patchesDir, 'skills', 'foo', 'SKILL.md'), pristine + userAdded); - writeFile(path.join(configDir, 'skills', 'foo', 'SKILL.md'), pristine + userAdded); - - const { status, report } = runVerifier(); - assert.equal(status, 0); - assert.equal(report.failures, 0); - assert.equal(report.checked, 1); - assert.equal(report.results[0].status, 'ok'); - assert.deepEqual(report.results[0].missing, []); - }); - - test('reason=FAIL_USER_LINES_MISSING with the exact dropped line in .missing[]', () => { - resetFixture(); - const pristine = 'first stock line in the original file here\nsecond stock line in the original file here\n'; - const lostLine = 'this is the visual companion block that must survive'; - writeFile(path.join(pristineDir, 'skills', 'discuss-phase', 'SKILL.md'), pristine); - writeFile(path.join(patchesDir, 'skills', 'discuss-phase', 'SKILL.md'), `${pristine}${lostLine}\n`); - writeFile(path.join(configDir, 'skills', 'discuss-phase', 'SKILL.md'), pristine); - - const { status, report } = runVerifier(); - assert.equal(status, 1); - assert.equal(report.failures, 1); - const r0 = report.results[0]; - // Normalize separators: on Windows the SUT emits 'skills\discuss-phase\SKILL.md'. - assert.equal(r0.file.replace(/\\/g, '/'), 'skills/discuss-phase/SKILL.md'); - assert.equal(r0.status, 'fail'); - assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING); - assert.ok( - r0.missing.includes(lostLine), - `dropped line should be in .missing[]; got ${JSON.stringify(r0.missing)}`, - ); - }); - - test('reason=FAIL_INSTALLED_NOT_REGULAR_FILE when installed path is a directory', () => { - resetFixture(); - writeFile(path.join(pristineDir, 'a.md'), 'pristine line of substantial content here\n'); - writeFile(path.join(patchesDir, 'a.md'), 'pristine line of substantial content here\nuser added line that is substantial\n'); - fs.mkdirSync(path.join(configDir, 'a.md')); // EISDIR trap - - const { status, report } = runVerifier(); - assert.equal(status, 1); - assert.equal(report.results[0].status, 'fail'); - assert.equal(report.results[0].reason, REASON.FAIL_INSTALLED_NOT_REGULAR_FILE); - }); - - test('reason=FAIL_INSTALLED_MISSING when the merged file has been deleted', () => { - resetFixture(); - const pristine = 'stock line one with substantial content for the test\n'; - writeFile(path.join(pristineDir, 'workflow.md'), pristine); - writeFile(path.join(patchesDir, 'workflow.md'), `${pristine}user line that should survive but does not\n`); - // configDir intentionally missing the file. - - const { status, report } = runVerifier(); - assert.equal(status, 1); - assert.equal(report.results[0].status, 'fail'); - assert.equal(report.results[0].reason, REASON.FAIL_INSTALLED_MISSING); - }); - - test('--json report has the documented shape: { checked, failures, results: [{ file, status, missing, reason }] }', () => { - resetFixture(); - const pristine = 'pristine line that is sufficiently long to be significant\n'; - const userAdded = 'extra line the user wrote for their workflow customisation'; - writeFile(path.join(pristineDir, 'a.md'), pristine); - writeFile(path.join(patchesDir, 'a.md'), `${pristine}${userAdded}\n`); - writeFile(path.join(configDir, 'a.md'), pristine); - - const { status, report } = runVerifier(); - assert.equal(status, 1); - // Bug #3657 (Finding 1): drifted + drifted_files are additive fields added to surface - // pristine-drift skips distinctly from failures. Shape-lock updated to include them. - // Bug #934: no_baseline + no_baseline_files are additive fields for missing-pristine advisory. - assert.deepEqual(Object.keys(report).sort(), ['checked', 'drifted', 'drifted_files', 'failures', 'no_baseline', 'no_baseline_files', 'results']); - const r0 = report.results[0]; - assert.deepEqual(Object.keys(r0).sort(), ['file', 'missing', 'reason', 'status']); - assert.equal(typeof r0.file, 'string'); - assert.equal(typeof r0.status, 'string'); - assert.equal(typeof r0.reason, 'string'); - assert.ok(Array.isArray(r0.missing)); - }); - - test('ignores backup-meta.json — it is metadata, not a patched file', () => { - resetFixture(); - writeFile(path.join(patchesDir, 'backup-meta.json'), JSON.stringify({ files: [] })); - - const { status, report } = runVerifier(); - assert.equal(status, 0); - assert.equal(report.checked, 0); - assert.equal(report.failures, 0); - assert.deepEqual(report.results, []); - }); - - test('without --pristine-dir, treats every significant backup line as required (safe over-broad fallback)', () => { - resetFixture({ withPristine: false }); - const presentLine = 'this is a substantial line of user content here'; - const droppedLine = 'another substantial line that should survive'; - writeFile(path.join(patchesDir, 'b.md'), `${presentLine}\n${droppedLine}\n`); - writeFile(path.join(configDir, 'b.md'), `${presentLine}\n`); - - const { status, report } = runVerifier({ includePristine: false }); - assert.equal(status, 1); - assert.equal(report.results[0].reason, REASON.FAIL_USER_LINES_MISSING); - assert.ok(report.results[0].missing.includes(droppedLine)); - assert.ok(!report.results[0].missing.includes(presentLine)); - }); - - test('treats gsd-hook-version install-time substitution as upstream-owned, not missing user content (#229)', () => { - resetFixture(); - const rel = path.join('hooks', 'gsd-statusline.js'); - const pristine = [ - '// gsd-hook-version: {{GSD_VERSION}}', - 'console.log("statusline hook");', - '', - ].join('\n'); - const backup = [ - '// gsd-hook-version: 1.41.0', - 'console.log("statusline hook");', - '', - ].join('\n'); - const installed = [ - '// gsd-hook-version: 1.42.3', - 'console.log("statusline hook");', - '', - ].join('\n'); - - writeFile(path.join(pristineDir, rel), pristine); - writeFile(path.join(patchesDir, rel), backup); - writeFile(path.join(configDir, rel), installed); - - const { status, report } = runVerifier(); - assert.equal(status, 0, `expected pass for upstream-owned version substitution; report=${JSON.stringify(report)}`); - assert.equal(report.failures, 0); - assert.equal(report.checked, 1); - assert.equal(report.results[0].status, 'ok'); - assert.deepStrictEqual(report.results[0].missing, []); - }); -}); diff --git a/tests/bug-2973-profile-user-skills-path.test.cjs b/tests/bug-2973-profile-user-skills-path.test.cjs deleted file mode 100644 index 00d04c2c6..000000000 --- a/tests/bug-2973-profile-user-skills-path.test.cjs +++ /dev/null @@ -1,248 +0,0 @@ -'use strict'; - -// allow-test-rule: source-text-is-the-product. profile-user.md IS the -// shipped workflow product; the `Display:` line at line 356 IS the -// user-visible artifact-name message. This test parses the markdown's -// structured `Display: "..."` line via a regex (not source-grep) to -// extract the path argument as a typed value, then asserts on the -// typed value. The .includes() at the end is a structural absence-check -// against the legacy path literal — the same shape the bug-2470 -// installer-leak test uses to enforce a known-pattern invariant. - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #2973: /gsd-profile-user --refresh writes dev-preferences.md to the - * legacy commands/gsd subdirectory, contradicting v1.39.0's skills-only - * migration claim that "Legacy commands/gsd directory removed - * (replaced by skills/)". - * - * Root cause: the writer at gsd-core/bin/lib/profile-output.cjs - * fell back to commands/gsd/dev-preferences.md when no --output was passed. - * The /gsd-profile-user workflow does not pass --output, so every refresh - * deterministically re-creates the legacy directory. - * - * Fix: - * 1. profile-output.cjs default targets skills/gsd-dev-preferences/SKILL.md - * 2. profile-user.md confirmation message references the new path - * 3. install.js migrates any existing legacy file into the new skill - * location during install (no-op if SKILL.md already exists) - * - * This test exercises the runtime behavior of the writer (writes to the - * skills path) and the structural shape of the workflow message. No - * source-grep on the .cjs body — assertions go against the writer's - * actual output and the parsed workflow message. - */ - -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 { cleanup } = require('./helpers.cjs'); - -const ROOT = path.join(__dirname, '..'); -const PROFILE_OUTPUT = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'profile-output.cjs'); -const WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'profile-user.md'); - -const installEngine = require('../gsd-core/bin/lib/install-engine.cjs'); - -describe('Bug #2973: dev-preferences default writer path is skills/gsd-dev-preferences/SKILL.md', () => { - test('exercise the writer in a subprocess with HOME pointed at a tmp dir; assert the artifact lands at the skills path', () => { - // Subprocess so fs.writeSync(1, ...) in core.cjs goes to a pipe we can - // capture (the parent process's fd 1 bypasses any in-process stubbing). - const cp = require('node:child_process'); - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-')); - try { - const analysisPath = path.join(tmpHome, 'analysis.json'); - fs.writeFileSync(analysisPath, JSON.stringify({ - data_source: 'questionnaire', - dimensions: { rigor: { score: 7 } }, - })); - const driver = path.join(tmpHome, 'driver.js'); - fs.writeFileSync(driver, ` - const m = require(${JSON.stringify(PROFILE_OUTPUT)}); - m.cmdGenerateDevPreferences(${JSON.stringify(tmpHome)}, { analysis: ${JSON.stringify(analysisPath)} }, false); - `); - const result = cp.spawnSync(process.execPath, [driver], { - env: Object.assign({}, process.env, { HOME: tmpHome, USERPROFILE: tmpHome }), - encoding: 'utf-8', - // Bound the subprocess so a regression that hangs the writer - // (or the dispatcher) cannot deadlock CI (PR #3003 CR feedback). - // 30s is generous for what should complete in <1s; if it trips, - // surface that as a clear test failure rather than CI hanging. - timeout: 30_000, - }); - assert.equal(result.signal, null, - `writer subprocess was killed by signal ${result.signal} (likely timeout): ${result.stderr}`); - assert.equal(result.status, 0, `writer subprocess failed: ${result.stderr}`); - const parsed = JSON.parse(result.stdout); - - const expectedPath = path.join(tmpHome, '.claude', 'skills', 'gsd-dev-preferences', 'SKILL.md'); - assert.equal(parsed.command_path, expectedPath, - `writer emitted ${parsed.command_path}; expected skills path ${expectedPath} (#2973)`); - assert.equal(fs.existsSync(expectedPath), true, - `expected SKILL.md at ${expectedPath} after writer ran`); - const legacyPath = path.join(tmpHome, '.claude', 'commands', 'gsd', 'dev-preferences.md'); - assert.equal(fs.existsSync(legacyPath), false, - `writer must not create ${legacyPath} (#2973)`); - } finally { - cleanup(tmpHome); - } - }); -}); - -describe('Bug #2973: profile-user.md confirmation message references the skills path', () => { - test('the Display message points at $HOME/.claude/skills/gsd-dev-preferences/SKILL.md', () => { - const md = fs.readFileSync(WORKFLOW, 'utf-8'); - // Match the structured Display: line; capture the path value. - const m = md.match(/Display:\s*"[^"]*Generated\s*\/gsd-dev-preferences\s*at\s*([^"]+)"/); - assert.notEqual(m, null, 'expected a Display: "Generated /gsd-dev-preferences at " line'); - const referencedPath = m[1].trim(); - assert.equal(referencedPath, '$HOME/.claude/skills/gsd-dev-preferences/SKILL.md', - `workflow references ${referencedPath}; expected skills path (#2973)`); - }); - - test('no occurrence of the legacy commands/gsd/dev-preferences.md path remains in profile-user.md', () => { - const md = fs.readFileSync(WORKFLOW, 'utf-8'); - assert.equal(md.includes('commands/gsd/dev-preferences.md'), false, - 'profile-user.md still references legacy commands/gsd/dev-preferences.md (#2973)'); - }); -}); - -describe('Bug #2973: installer migrates existing legacy dev-preferences.md to skills/gsd-dev-preferences/SKILL.md', () => { - test('migrateLegacyDevPreferencesToSkill is exported and writes to the skills path', () => { - const inst = installEngine; - // Module exports the migration helper for direct testing. - // Note: this is the structural assertion — the helper exists with the - // documented signature. End-to-end install testing is covered by - // tests/install-*.test.cjs which already exercise legacy preservation. - assert.equal(typeof inst.migrateLegacyDevPreferencesToSkill, 'function', - 'expected migrateLegacyDevPreferencesToSkill in install.js exports (#2973)'); - }); - - test('migration writes to skills/gsd-dev-preferences/SKILL.md when no skill exists yet', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-mig-')); - try { - const inst = installEngine; - const saved = new Map([['dev-preferences.md', '# my legacy preferences\n']]); - const migrated = inst.migrateLegacyDevPreferencesToSkill(tmpDir, saved); - assert.equal(migrated, true, 'expected migration to succeed when no SKILL.md exists'); - const skillFile = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md'); - assert.equal(fs.existsSync(skillFile), true, `expected SKILL.md at ${skillFile}`); - assert.equal(fs.readFileSync(skillFile, 'utf-8'), '# my legacy preferences\n'); - } finally { - cleanup(tmpDir); - } - }); - - test('migration is a no-op when a SKILL.md already exists at the new location (do not clobber user-customized skill content)', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-skip-')); - try { - const inst = installEngine; - const skillDir = path.join(tmpDir, 'skills', 'gsd-dev-preferences'); - const skillFile = path.join(skillDir, 'SKILL.md'); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync(skillFile, '# user-customized skill\n'); - const saved = new Map([['dev-preferences.md', '# legacy content\n']]); - const migrated = inst.migrateLegacyDevPreferencesToSkill(tmpDir, saved); - assert.equal(migrated, false, 'expected migration to skip when SKILL.md exists'); - // Existing content untouched. - assert.equal(fs.readFileSync(skillFile, 'utf-8'), '# user-customized skill\n'); - } finally { - cleanup(tmpDir); - } - }); -}); - -// ─── #3003 CR follow-up: installRuntimeArtifacts preserves user-owned skills ── -// -// Production install() calls installRuntimeArtifacts() without a prior -// uninstallRuntimeArtifacts(). This means _copyStaged overlays new skills -// on top of the existing skills/ directory — it does NOT wipe first. -// As a result, user-owned gsd-dev-preferences/SKILL.md is preserved across -// a plain install because _copyStaged only cpSync's newly staged skill dirs. -// -// NOTE: If callers run uninstallRuntimeArtifacts() before installRuntimeArtifacts() -// (e.g. full reinstall), gsd-dev-preferences IS wiped by uninstall and NOT -// restored by install (#3664 production gap — tracked separately). - -describe('Bug #2973 (#3003 CR): installRuntimeArtifacts preserves user-owned gsd-dev-preferences across install', () => { - test('user-customized skills/gsd-dev-preferences/SKILL.md survives a plain install (no pre-uninstall)', () => { - // Production install() does NOT call uninstallRuntimeArtifacts() first. - // installRuntimeArtifacts → _copyStaged overlays only staged skill dirs; - // gsd-dev-preferences (not in source) is left untouched. - const inst = installEngine; - const { loadSkillsManifest, resolveProfile } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-wipe-')); - try { - const configDir = path.join(tmp, 'config'); - fs.mkdirSync(configDir, { recursive: true }); - - // Set up a minimal source dir (plan-phase only; no dev-preferences). - const srcDir = path.join(tmp, 'src-commands'); - fs.mkdirSync(srcDir, { recursive: true }); - fs.writeFileSync(path.join(srcDir, 'plan-phase.md'), '---\nname: gsd:plan-phase\ndescription: Plan\n---\n\nPlan body.\n'); - fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir + '\n'); - - const skillsDir = path.join(configDir, 'skills'); - const userSkillDir = path.join(skillsDir, 'gsd-dev-preferences'); - fs.mkdirSync(userSkillDir, { recursive: true }); - const userContent = '# my customized dev preferences\n\nstack: rust\n'; - fs.writeFileSync(path.join(userSkillDir, 'SKILL.md'), userContent); - - // Plain install (matching production install() call site). - const manifest = loadSkillsManifest(); - const resolvedProfile = resolveProfile({ modes: [], manifest }); - inst.installRuntimeArtifacts('claude', configDir, 'global', resolvedProfile); - - const skillFile = path.join(userSkillDir, 'SKILL.md'); - assert.equal(fs.existsSync(skillFile), true, - 'gsd-dev-preferences/SKILL.md must survive a plain install (#3003 CR)'); - assert.equal(fs.readFileSync(skillFile, 'utf-8'), userContent, - 'user content must be byte-identical after the install'); - } finally { - cleanup(tmp); - } - }); - - test('non-user-owned gsd-* skills are wiped and recreated via uninstall+install cycle', () => { - // Stale artifacts (e.g. STALE-MARKER.txt left from a previous version) - // are removed when the caller runs uninstallRuntimeArtifacts() before - // installRuntimeArtifacts() — the full uninstall+reinstall cycle. - // uninstallRuntimeArtifacts removes all gsd-* entries; installRuntimeArtifacts - // then writes fresh ones from source. - const inst = installEngine; - const { loadSkillsManifest, resolveProfile } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-wipe-shipped-')); - try { - const configDir = path.join(tmp, 'config'); - fs.mkdirSync(configDir, { recursive: true }); - - const srcDir = path.join(tmp, 'src-commands'); - fs.mkdirSync(srcDir, { recursive: true }); - fs.writeFileSync(path.join(srcDir, 'plan-phase.md'), '---\nname: gsd:plan-phase\ndescription: Plan fresh\n---\n\nFresh body.\n'); - fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir + '\n'); - - const skillsDir = path.join(configDir, 'skills'); - const staleSkillDir = path.join(skillsDir, 'gsd-plan-phase'); - fs.mkdirSync(staleSkillDir, { recursive: true }); - fs.writeFileSync(path.join(staleSkillDir, 'STALE-MARKER.txt'), 'wipe me'); - - const manifest = loadSkillsManifest(); - const resolvedProfile = resolveProfile({ modes: [], manifest }); - - // Full uninstall+install cycle (e.g. --reinstall flow) - inst.uninstallRuntimeArtifacts('claude', configDir, 'global'); - inst.installRuntimeArtifacts('claude', configDir, 'global', resolvedProfile); - - assert.equal(fs.existsSync(path.join(staleSkillDir, 'STALE-MARKER.txt')), false, - 'stale shipped-skill content must be wiped by uninstall (preservation is opt-in by name)'); - assert.equal(fs.existsSync(path.join(staleSkillDir, 'SKILL.md')), true, - 'fresh SKILL.md from source must be installed after wipe'); - } finally { - cleanup(tmp); - } - }); -}); diff --git a/tests/bug-2992-check-latest-version.test.cjs b/tests/bug-2992-check-latest-version.test.cjs deleted file mode 100644 index 51a6dc303..000000000 --- a/tests/bug-2992-check-latest-version.test.cjs +++ /dev/null @@ -1,154 +0,0 @@ -'use strict'; -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const { checkLatestVersion, CHECK_REASON, PACKAGE_NAME } = require( - path.join(ROOT, 'gsd-core', 'bin', 'check-latest-version.cjs'), -); - -// checkLatestVersion is a pure-ish function: it spawns one fixed npm -// command, validates the output, and returns { ok, version | reason }. -// The package name is HARDCODED — not a free choice for the caller. -// Tests use a pluggable spawn so no real npm process is invoked. - -describe('Bug #2992: deterministic latest-version check', () => { - test('PACKAGE_NAME is the constant @opengsd/gsd-core (no callers can override)', () => { - assert.equal(PACKAGE_NAME, '@opengsd/gsd-core'); - }); - - test('CHECK_REASON enum exposes the documented codes', () => { - assert.deepEqual( - Object.keys(CHECK_REASON).sort(), - ['FAIL_INVALID_OUTPUT', 'FAIL_NPM_FAILED', 'OK'].sort(), - ); - }); - - test('returns { ok: true, version } when npm prints a valid semver', () => { - const fakeSpawn = () => ({ status: 0, stdout: '1.39.1\n', stderr: '' }); - const r = checkLatestVersion({ spawn: fakeSpawn }); - assert.deepEqual(r, { ok: true, version: '1.39.1', reason: CHECK_REASON.OK }); - }); -}); - -describe('Bug #2992: error paths', () => { - const { checkLatestVersion, CHECK_REASON } = require(require('node:path').join(__dirname, '..', 'gsd-core', 'bin', 'check-latest-version.cjs')); - - test('FAIL_NPM_FAILED when npm exits non-zero (e.g. offline, 404)', () => { - const r = checkLatestVersion({ - spawn: () => ({ status: 1, stdout: '', stderr: 'npm ERR! 404\n' }), - }); - assert.equal(r.ok, false); - assert.equal(r.reason, CHECK_REASON.FAIL_NPM_FAILED); - assert.equal(r.detail, 'npm ERR! 404', - 'detail should be the trimmed stderr when npm reports a real error'); - }); - - // #2993 CR: distinguish timeout from genuine npm failure in `detail`. - // spawnSync sets status=null and signal='SIGTERM' on timeout; stderr is - // typically empty. Without the signal-first branch, both shape as - // 'npm exited non-zero' and the operator cannot tell timeout from failure. - test('FAIL_NPM_FAILED detail names the signal when spawn times out', () => { - const r = checkLatestVersion({ - spawn: () => ({ status: null, signal: 'SIGTERM', stdout: '', stderr: '' }), - }); - assert.equal(r.ok, false); - assert.equal(r.reason, CHECK_REASON.FAIL_NPM_FAILED); - assert.equal(r.detail, 'npm timed out (signal: SIGTERM)', - 'detail should explicitly name the signal when status is null and signal is set'); - }); - - test('FAIL_NPM_FAILED detail falls back to generic when neither stderr nor signal is present', () => { - const r = checkLatestVersion({ - spawn: () => ({ status: 1, stdout: '', stderr: '' }), - }); - assert.equal(r.detail, 'npm exited non-zero'); - }); - - test('FAIL_INVALID_OUTPUT when npm prints something that is not a semver', () => { - // E.g. if a future npm version changes the output format, or if the - // network returns an HTML error page captured as stdout. - const r = checkLatestVersion({ - spawn: () => ({ status: 0, stdout: 'not a version\n', stderr: '' }), - }); - assert.equal(r.ok, false); - assert.equal(r.reason, CHECK_REASON.FAIL_INVALID_OUTPUT); - }); - - test('FAIL_INVALID_OUTPUT when stdout is empty', () => { - const r = checkLatestVersion({ - spawn: () => ({ status: 0, stdout: '', stderr: '' }), - }); - assert.equal(r.ok, false); - assert.equal(r.reason, CHECK_REASON.FAIL_INVALID_OUTPUT); - }); - - test('accepts pre-release semver (e.g. 1.40.0-rc.1)', () => { - const r = checkLatestVersion({ - spawn: () => ({ status: 0, stdout: '1.40.0-rc.1\n', stderr: '' }), - }); - assert.deepEqual(r, { ok: true, version: '1.40.0-rc.1', reason: CHECK_REASON.OK }); - }); -}); - -describe('Issue #815: --next dist-tag support', () => { - const { buildViewArgs, resolveTag, ALLOWED_TAGS } = require( - path.join(ROOT, 'gsd-core', 'bin', 'check-latest-version.cjs'), - ); - - test('ALLOWED_TAGS is the sanctioned channel allowlist (latest, next)', () => { - assert.deepEqual([...ALLOWED_TAGS].sort(), ['latest', 'next']); - }); - - test('buildViewArgs() defaults to the bare latest spec (byte-for-byte unchanged)', () => { - assert.deepEqual(buildViewArgs(), ['view', '@opengsd/gsd-core', 'version']); - assert.deepEqual(buildViewArgs('latest'), ['view', '@opengsd/gsd-core', 'version']); - }); - - test('buildViewArgs("next") targets the @next dist-tag', () => { - assert.deepEqual(buildViewArgs('next'), ['view', '@opengsd/gsd-core@next', 'version']); - }); - - test('resolveTag defaults to latest when no --tag flag', () => { - assert.equal(resolveTag(['--json']), 'latest'); - }); - - test('resolveTag reads --tag next', () => { - assert.equal(resolveTag(['--json', '--tag', 'next']), 'next'); - }); - - test('resolveTag rejects an unknown tag (typo guard)', () => { - assert.throws(() => resolveTag(['--tag', 'nightly']), /invalid --tag 'nightly'/); - }); - - test('resolveTag rejects --tag with no value', () => { - assert.throws(() => resolveTag(['--tag']), /invalid --tag ''/); - }); - - test('checkLatestVersion accepts an RC under the next tag', () => { - const r = checkLatestVersion({ tag: 'next', spawn: () => ({ status: 0, stdout: '1.4.0-rc.1\n', stderr: '' }) }); - assert.deepEqual(r, { ok: true, version: '1.4.0-rc.1', reason: CHECK_REASON.OK }); - }); - - test('buildViewArgs rejects a tag outside the allowlist (exported-API guard)', () => { - assert.throws(() => buildViewArgs('nightly'), /invalid dist-tag 'nightly'/); - }); - - test('checkLatestVersion rejects an out-of-allowlist tag even with an injected spawn', () => { - assert.throws( - () => checkLatestVersion({ tag: 'nightly', spawn: () => ({ status: 0, stdout: '9.9.9\n', stderr: '' }) }), - /invalid dist-tag 'nightly'/, - ); - }); - - test('resolveTag handles the --tag=next equals form', () => { - assert.equal(resolveTag(['--json', '--tag=next']), 'next'); - }); - - test('resolveTag rejects an unknown --tag=value equals form (no silent fallback)', () => { - assert.throws(() => resolveTag(['--tag=nightly']), /invalid --tag 'nightly'/); - }); -}); diff --git a/tests/bug-2994-verify-reapply-patches-installed-path.test.cjs b/tests/bug-2994-verify-reapply-patches-installed-path.test.cjs deleted file mode 100644 index 74b62ec4c..000000000 --- a/tests/bug-2994-verify-reapply-patches-installed-path.test.cjs +++ /dev/null @@ -1,81 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #2994: scripts/verify-reapply-patches.cjs ships in tarball but is - * not installed at ${GSD_HOME}/scripts/. - * - * Root cause: bin/install.js copies the gsd-core/ source tree to - * ${configDir}/gsd-core/ but does NOT copy the top-level scripts/ - * directory. The verifier script lived under scripts/ so /gsd-reapply-patches - * Step 5 hit `Cannot find module …/scripts/verify-reapply-patches.cjs`. - * - * Fix: move the script to gsd-core/bin/verify-reapply-patches.cjs - * (which IS installed) and update reapply-patches.md to point there. - * - * This test enforces the structural invariant that prevents regression. - */ - -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 RUNTIME_SCRIPT_PATH = path.join(ROOT, 'gsd-core', 'bin', 'verify-reapply-patches.cjs'); -const STALE_SCRIPT_PATH = path.join(ROOT, 'scripts', 'verify-reapply-patches.cjs'); -const REAPPLY_WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'reapply-patches.md'); - -describe('Bug #2994: verify-reapply-patches.cjs lives at the runtime-installed path', () => { - test('the script exists under gsd-core/bin/ (installed by copyWithPathReplacement)', () => { - assert.equal(fs.existsSync(RUNTIME_SCRIPT_PATH), true, - `Expected verifier script at ${RUNTIME_SCRIPT_PATH} -- installer copies gsd-core/ recursively`); - }); - - test('the script does NOT live at the legacy scripts/ path (not installed)', () => { - assert.equal(fs.existsSync(STALE_SCRIPT_PATH), false, - `scripts/ is not copied by installer; verifier must be under gsd-core/bin/ instead`); - }); - - test('the script is requireable (loads without throwing)', () => { - const mod = require(RUNTIME_SCRIPT_PATH); - assert.equal(typeof mod.REASON, 'object'); - assert.notEqual(mod.REASON, null); - }); -}); - -// Parse reapply-patches.md to extract every `node "${GSD_HOME}/...cjs"` -// invocation as structured records. Assertions go against the parsed -// records, not against the markdown text. -function extractScriptInvocations(markdown) { - const invocations = []; - const re = /node\s+"\$\{GSD_HOME\}\/([^"]+\.cjs)"/g; - let match; - while ((match = re.exec(markdown)) !== null) { - invocations.push({ relPath: match[1] }); - } - return invocations; -} - -describe('Bug #2994: reapply-patches workflow references the runtime-installed path', () => { - test('every node ${GSD_HOME}/... invocation in reapply-patches.md uses an installed runtime path', () => { - const md = fs.readFileSync(REAPPLY_WORKFLOW, 'utf-8'); - const invocations = extractScriptInvocations(md); - assert.ok(invocations.length > 0, 'sanity: expected at least one node ${GSD_HOME}/... invocation in reapply-patches.md'); - - const violations = invocations.filter(inv => !inv.relPath.startsWith('gsd-core/')); - assert.deepEqual(violations, [], `invocations under non-installed paths: ${JSON.stringify(violations)}`); - }); - - test('reapply-patches.md references the verifier at gsd-core/bin/verify-reapply-patches.cjs', () => { - const md = fs.readFileSync(REAPPLY_WORKFLOW, 'utf-8'); - const invocations = extractScriptInvocations(md); - const verifierInvocations = invocations.filter(inv => inv.relPath.endsWith('verify-reapply-patches.cjs')); - assert.deepEqual( - verifierInvocations.map(i => i.relPath), - ['gsd-core/bin/verify-reapply-patches.cjs'], - 'workflow must call the runtime-installed verifier path exactly once', - ); - }); -}); diff --git a/tests/bug-3129-validate-commit-git-bypass.test.cjs b/tests/bug-3129-validate-commit-git-bypass.test.cjs deleted file mode 100644 index 0d386d98c..000000000 --- a/tests/bug-3129-validate-commit-git-bypass.test.cjs +++ /dev/null @@ -1,119 +0,0 @@ -'use strict'; -// allow-test-rule: reads hook shell script to verify delegation pattern — structural contract test, not source-grep - -// Regression tests for bug #3129. -// -// gsd-validate-commit.sh used `[[ "$CMD" =~ ^git[[:space:]]+commit ]]` to -// detect git commit invocations. This regex silently bypasses Conventional -// Commits enforcement for three real git commit forms: -// 1. git -C /some/path commit -m "..." (working-directory prefix) -// 2. GIT_AUTHOR_NAME=x git commit "..." (env-var prefix) -// 3. /usr/bin/git commit -m "..." (full path) -// -// Fix: the hook delegates detection to hooks/lib/git-cmd.js isGitSubcommand(), -// a token-walk classifier that correctly handles all four forms. The module -// is the canonical single source of truth for all hooks that gate on git commits. - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); -const fs = require('node:fs'); - -const ROOT = path.join(__dirname, '..'); -const { isGitSubcommand, tokenize } = require(path.join(ROOT, 'hooks', 'lib', 'git-cmd.js')); - -// ── tokenize ───────────────────────────────────────────────────────────────── - -describe('git-cmd.js tokenize', () => { - test('splits bare command', () => { - assert.deepEqual(tokenize('git commit -m "msg"'), ['git', 'commit', '-m', 'msg']); - }); - test('handles single-quoted args', () => { - assert.deepEqual(tokenize("git commit -m 'my message'"), ['git', 'commit', '-m', 'my message']); - }); - test('handles env-prefix assignment', () => { - assert.deepEqual( - tokenize('GIT_AUTHOR_NAME=Alice git commit -m "fix"'), - ['GIT_AUTHOR_NAME=Alice', 'git', 'commit', '-m', 'fix'], - ); - }); - test('handles -C path', () => { - assert.deepEqual( - tokenize('git -C /some/path commit -m "x"'), - ['git', '-C', '/some/path', 'commit', '-m', 'x'], - ); - }); -}); - -// ── isGitSubcommand: must-match cases ──────────────────────────────────────── - -describe('git-cmd.js isGitSubcommand: should match commit', () => { - const cases = [ - ['bare form', 'git commit -m "feat: add thing"'], - ['single-quoted message', "git commit -m 'fix: typo'"], - ['with --no-verify', 'git commit --no-verify -m "wip"'], - ['-C path form (bug #3129)', 'git -C /some/path commit -m "fix: x"'], - ['env-prefix form (bug #3129)', 'GIT_AUTHOR_NAME=Alice git commit -m "fix"'], - ['full-path form (bug #3129)', '/usr/bin/git commit -m "feat: y"'], - ['multiple env vars', 'GIT_AUTHOR_NAME=A GIT_AUTHOR_EMAIL=b@c git commit -m "x"'], - ['--git-dir= flag', 'git --git-dir=.git commit -m "x"'], - ['--git-dir two-token', 'git --git-dir .git commit -m "x"'], - ['--no-pager before subcommand', 'git --no-pager commit -m "x"'], - ['-C + full path', '/usr/bin/git -C /proj commit -m "x"'], - ['-p paginate flag', 'git -p commit -m "x"'], - ]; - for (const [desc, cmd] of cases) { - test(desc, () => { - assert.ok(isGitSubcommand(cmd, 'commit'), `Expected match for: ${cmd}`); - }); - } -}); - -// ── isGitSubcommand: must-not-match cases ──────────────────────────────────── - -describe('git-cmd.js isGitSubcommand: should NOT match commit', () => { - const cases = [ - ['git push', 'git push origin main'], - ['git status', 'git status'], - ['git add', 'git add .'], - ['git log', 'git log --oneline'], - ['not git at all', 'npm install'], - ['empty string', ''], - ['git checkout (not commit)', 'git checkout main'], - ['git -C path push', 'git -C /path push'], - ]; - for (const [desc, cmd] of cases) { - test(desc, () => { - assert.ok(!isGitSubcommand(cmd, 'commit'), `Expected NO match for: ${cmd}`); - }); - } -}); - -// ── gsd-validate-commit.sh source check ────────────────────────────────────── - -describe('gsd-validate-commit.sh delegates to git-cmd.js', () => { - const hookSrc = fs.readFileSync( - path.join(ROOT, 'hooks', 'gsd-validate-commit.sh'), 'utf8', - ); - - test('hook no longer uses the stale ^git\\s+commit bash regex', () => { - assert.ok( - !hookSrc.includes('^git[[:space:]]+commit'), - 'gsd-validate-commit.sh still uses the bypassed regex — fix not applied', - ); - }); - - test('hook delegates to git-cmd.js isGitSubcommand', () => { - assert.ok( - hookSrc.includes('git-cmd.js') && hookSrc.includes('isGitSubcommand'), - 'gsd-validate-commit.sh does not reference git-cmd.js or isGitSubcommand', - ); - }); - - test('hooks/lib/git-cmd.js exists at the expected install path', () => { - assert.ok( - fs.existsSync(path.join(ROOT, 'hooks', 'lib', 'git-cmd.js')), - 'hooks/lib/git-cmd.js does not exist — library file missing', - ); - }); -}); diff --git a/tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs b/tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs deleted file mode 100644 index 33852a3bb..000000000 --- a/tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs +++ /dev/null @@ -1,356 +0,0 @@ -/** - * Regression test for bug #3357. - * - * Older Codex installs carried legacy GSD SessionStart commands in hooks.json. - * Current install keeps the managed SessionStart hook in hooks.json (single - * representation per layer) and strips stale managed entries before writing - * exactly one canonical managed command. - * - * Bug #1348 (addendum): reconcileCodexHooksJsonEvent must always write the - * canonical nested { "hooks": { "": [...] } } shape — never top-level - * event keys — mirroring reconcileCursorHooksJson. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -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 installModule = require('../bin/install.js'); -const { readInstallState } = require('../gsd-core/bin/lib/installer-migrations.cjs'); -const { install, parseTomlToObject, reconcileCodexHooksJsonEvent } = installModule; -const { createTempDir, cleanup } = require('./helpers.cjs'); -const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); -const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); - -function withCodexHome(codexHome, fn) { - const previousCodexHome = process.env.CODEX_HOME; - process.env.CODEX_HOME = codexHome; - try { - return fn(); - } finally { - if (previousCodexHome == null) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = previousCodexHome; - } -} - -function legacyGsdHook(codexHome) { - return { - hooks: [{ - type: 'command', - command: `node "${path.join(codexHome, 'hooks', 'gsd-check-update.js')}"`, - }], - }; -} - -function userHook() { - return { - hooks: [{ - type: 'command', - command: 'node "/Users/example/bin/user-hook.js"', - }], - }; -} - -function tomlGsdHookCount(codexHome) { - const parsed = parseTomlToObject(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8')); - const sessionStart = parsed.hooks?.SessionStart ?? []; - return sessionStart - .flatMap((entry) => Array.isArray(entry.hooks) ? entry.hooks : []) - .filter((hook) => typeof hook.command === 'string' && hook.command.includes('gsd-check-update')) - .length; -} - -describe('#3357 — Codex install removes legacy GSD hooks.json entries', { concurrency: false }, () => { - let tmpRoot; - let codexHome; - - beforeEach(() => { - if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); - } - tmpRoot = createTempDir('gsd-3357-'); - codexHome = path.join(tmpRoot, '.codex'); - fs.mkdirSync(codexHome, { recursive: true }); - }); - - afterEach(() => { - delete installModule.__codexSchemaValidator; - cleanup(tmpRoot); - }); - - test('rewrites hooks.json to one managed SessionStart hook when file only had legacy managed entry', () => { - fs.writeFileSync( - path.join(codexHome, 'hooks.json'), - JSON.stringify({ SessionStart: [legacyGsdHook(codexHome)] }, null, 2), - ); - - withCodexHome(codexHome, () => install(true, 'codex')); - - // #1348: output must be nested { hooks: { SessionStart: [...] } }, not top-level - const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); - assert.ok( - hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks), - 'hooks.json must use nested { hooks: { ... } } shape (bug #1348)', - ); - assert.ok( - !Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'), - 'hooks.json must NOT have a top-level SessionStart key (bug #1348)', - ); - const commands = hooksJson.hooks.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command); - const managed = commands.filter((cmd) => typeof cmd === 'string' && cmd.includes('gsd-check-update')); - assert.equal(managed.length, 1); - assert.equal(tomlGsdHookCount(codexHome), 0); - }); - - test('preserves user hooks.json entries while removing the legacy GSD hook', () => { - const userOwnedSameBasenameHook = { - hooks: [{ - type: 'command', - command: 'node "/Users/example/bin/gsd-check-update.js"', - }], - }; - fs.writeFileSync( - path.join(codexHome, 'hooks.json'), - JSON.stringify({ SessionStart: [legacyGsdHook(codexHome), userHook(), userOwnedSameBasenameHook] }, null, 2), - ); - - withCodexHome(codexHome, () => install(true, 'codex')); - - // #1348: output must be nested { hooks: { SessionStart: [...] } }, not top-level - const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); - assert.ok( - hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks), - 'hooks.json must use nested { hooks: { ... } } shape (bug #1348)', - ); - assert.ok( - !Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'), - 'hooks.json must NOT have a top-level SessionStart key (bug #1348)', - ); - const commands = hooksJson.hooks.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command); - const managed = commands.filter((cmd) => typeof cmd === 'string' && cmd.includes('gsd-check-update')); - assert.equal(commands.includes('node "/Users/example/bin/user-hook.js"'), true); - assert.equal(commands.includes('node "/Users/example/bin/gsd-check-update.js"'), true); - assert.equal(managed.length, 2); - assert.equal(tomlGsdHookCount(codexHome), 0); - }); - - test('restores migrated hooks.json and install state when later Codex validation fails', () => { - const before = JSON.stringify({ SessionStart: [legacyGsdHook(codexHome)] }, null, 2); - fs.writeFileSync(path.join(codexHome, 'hooks.json'), before); - - installModule.__codexSchemaValidator = () => ({ - ok: false, - reason: 'forced migration rollback test', - }); - - assert.throws( - () => withCodexHome(codexHome, () => install(true, 'codex')), - /forced migration rollback test/ - ); - - assert.equal(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'), before); - assert.equal( - readInstallState(codexHome).appliedMigrations.some((entry) => entry.id === '2026-05-11-codex-legacy-hooks-json'), - false - ); - }); -}); - -// --------------------------------------------------------------------------- -// #1348 — reconcileCodexHooksJsonEvent must always write canonical nested shape -// --------------------------------------------------------------------------- - -describe('#1348 — reconcileCodexHooksJsonEvent canonical nested shape', { concurrency: false }, () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-1348-'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - // (a) Fresh/absent hooks.json: register → { "hooks": { "SessionStart": [...] } } - test('(a) fresh/absent hooks.json writes nested { hooks: { SessionStart: [...] } } shape', () => { - const hooksJsonPath = path.join(tmpDir, 'hooks.json'); - const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`; - assert.ok(!fs.existsSync(hooksJsonPath), 'precondition: hooks.json must not exist'); - - reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD }); - - assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must be created'); - const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); - - assert.ok( - hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks), - `Expected nested { hooks: { ... } } shape; got: ${JSON.stringify(hooksJson)}`, - ); - assert.ok( - !Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'), - `hooks.json must NOT have a top-level SessionStart key; got: ${JSON.stringify(hooksJson)}`, - ); - assert.ok( - Array.isArray(hooksJson.hooks.SessionStart) && hooksJson.hooks.SessionStart.length > 0, - `Expected hooks.hooks.SessionStart to be a non-empty array; got: ${JSON.stringify(hooksJson)}`, - ); - }); - - // (b) Legacy migration: seed top-level { "SessionStart": [] }, register → - // nested hooks.SessionStart contains BOTH migrated user entry AND managed entry - test('(b) legacy top-level shape: user entries migrate into hooks.SessionStart alongside managed entry', () => { - const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`; - const userEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/my-hook.js"' }] }; - fs.writeFileSync( - path.join(tmpDir, 'hooks.json'), - JSON.stringify({ SessionStart: [userEntry] }, null, 2), - ); - - reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD }); - - const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8')); - - // Canonical nested shape - assert.ok( - hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks), - `Expected nested { hooks: { ... } } shape; got: ${JSON.stringify(hooksJson)}`, - ); - assert.ok( - !Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'), - `hooks.json must NOT have a top-level SessionStart key; got: ${JSON.stringify(hooksJson)}`, - ); - - // User entry was migrated under hooks.SessionStart (not dropped) - const allCommands = hooksJson.hooks.SessionStart - .flatMap((e) => Array.isArray(e.hooks) ? e.hooks : []) - .map((h) => h.command); - assert.ok( - allCommands.includes('node "/Users/alice/my-hook.js"'), - `User entry must be preserved under hooks.SessionStart; commands: ${JSON.stringify(allCommands)}`, - ); - - // Managed entry is also present - const managedCount = allCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length; - assert.equal(managedCount, 1, 'Exactly one managed entry must be present under hooks.SessionStart'); - }); - - // (c-i) Dedup: re-registering the same managed command does not duplicate it - test('(c-i) re-registering managed command produces exactly one managed entry', () => { - const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`; - reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD }); - reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD }); - - const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8')); - const allCommands = hooksJson.hooks.SessionStart - .flatMap((e) => Array.isArray(e.hooks) ? e.hooks : []) - .map((h) => h.command); - const managedCount = allCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length; - assert.equal(managedCount, 1, 'Re-register must yield exactly one managed entry'); - }); - - // (c-ii) Removal: user entries remain under hooks, managed entry is gone - test('(c-ii) removing managed hook leaves user entry under hooks.SessionStart', () => { - const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`; - const userEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/my-hook.js"' }] }; - // Seed already-nested file with both user + managed - reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD }); - // Now manually seed a user entry into the existing nested file - const seeded = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8')); - seeded.hooks.SessionStart = [userEntry, ...seeded.hooks.SessionStart]; - fs.writeFileSync(path.join(tmpDir, 'hooks.json'), JSON.stringify(seeded, null, 2)); - - // Remove managed - reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: null }); - - const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8')); - // User entry must still be under hooks.SessionStart - const allCommands = hooksJson.hooks.SessionStart - .flatMap((e) => Array.isArray(e.hooks) ? e.hooks : []) - .map((h) => h.command); - assert.ok( - allCommands.includes('node "/Users/alice/my-hook.js"'), - `User entry must remain after managed removal; commands: ${JSON.stringify(allCommands)}`, - ); - // No managed entry - const managedCount = allCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length; - assert.equal(managedCount, 0, 'No managed entry must remain after removal'); - }); - - // (c-iii) Removal from absent file does NOT materialize { "hooks": {} } - test('(c-iii) removing from absent hooks.json does not write a spurious empty { "hooks": {} }', () => { - const hooksJsonPath = path.join(tmpDir, 'hooks.json'); - assert.ok(!fs.existsSync(hooksJsonPath), 'precondition: hooks.json must not exist'); - - reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: null }); - - assert.ok( - !fs.existsSync(hooksJsonPath), - 'hooks.json must NOT be created when removing from absent file (no spurious { "hooks": {} })', - ); - }); - - // (d) Mixed nested + top-level shape: { "hooks": { "PreToolUse": [...] }, "SessionStart": [...] } - // The stray top-level event array must be lifted into hooks and merged; no top-level key survives. - test('(d) mixed nested + top-level shape: stray top-level event array is lifted and merged', () => { - const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`; - const existingNestedEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/pre-tool.js"' }] }; - const userTopLevelEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/session-start.js"' }] }; - - // Seed a mixed-shape file: nested PreToolUse AND top-level SessionStart - fs.writeFileSync( - path.join(tmpDir, 'hooks.json'), - JSON.stringify( - { - hooks: { PreToolUse: [existingNestedEntry] }, - SessionStart: [userTopLevelEntry], - }, - null, - 2, - ), - ); - - reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD }); - - const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8')); - - // No stray top-level SessionStart key - assert.ok( - !Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'), - `hooks.json must NOT have a top-level SessionStart key; got: ${JSON.stringify(hooksJson)}`, - ); - - // hooks.SessionStart contains the migrated user entry AND exactly one managed entry - assert.ok( - Array.isArray(hooksJson.hooks.SessionStart), - `hooks.hooks.SessionStart must be an array; got: ${JSON.stringify(hooksJson)}`, - ); - const sessionCommands = hooksJson.hooks.SessionStart - .flatMap((e) => Array.isArray(e.hooks) ? e.hooks : []) - .map((h) => h.command); - assert.ok( - sessionCommands.includes('node "/Users/alice/session-start.js"'), - `Migrated user entry must be present in hooks.SessionStart; commands: ${JSON.stringify(sessionCommands)}; full: ${JSON.stringify(hooksJson)}`, - ); - const managedCount = sessionCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length; - assert.equal(managedCount, 1, `Exactly one managed entry must be present in hooks.SessionStart; commands: ${JSON.stringify(sessionCommands)}`); - - // hooks.PreToolUse is untouched - assert.ok( - Array.isArray(hooksJson.hooks.PreToolUse) && hooksJson.hooks.PreToolUse.length === 1, - `hooks.hooks.PreToolUse must be preserved with one entry; got: ${JSON.stringify(hooksJson.hooks.PreToolUse)}`, - ); - const preToolCommands = hooksJson.hooks.PreToolUse - .flatMap((e) => Array.isArray(e.hooks) ? e.hooks : []) - .map((h) => h.command); - assert.ok( - preToolCommands.includes('node "/Users/alice/pre-tool.js"'), - `Existing nested PreToolUse entry must be preserved; commands: ${JSON.stringify(preToolCommands)}`, - ); - }); -}); diff --git a/tests/bug-3442-codex-legacy-hooks-json-migration.test.cjs b/tests/bug-3442-codex-legacy-hooks-json-migration.test.cjs deleted file mode 100644 index e94764fe8..000000000 --- a/tests/bug-3442-codex-legacy-hooks-json-migration.test.cjs +++ /dev/null @@ -1,58 +0,0 @@ -'use strict'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const migration = require(path.join( - __dirname, - '..', - 'gsd-core', - 'bin', - 'lib', - 'installer-migrations', - '002-codex-legacy-hooks-json.cjs', -)); - -describe('bug #3442: codex legacy hooks.json migration consumes shared managed-hook policy', () => { - test('plan prunes managed codex hook commands including legacy alias', () => { - const configDir = '/Users/me/.codex'; - const hooksJson = { - hooks: [ - { command: '"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-check-update.js"' }, - { command: '"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-update-check.js"' }, - { command: '"/usr/local/bin/node" "/Users/me/.codex/hooks/custom-hook.js"' }, - ], - }; - - const actions = migration.plan({ - configDir, - readJson: () => ({ exists: true, error: null, value: hooksJson }), - }); - - assert.equal(actions.length, 1); - assert.equal(actions[0].type, 'rewrite-json'); - assert.equal(actions[0].relPath, 'hooks.json'); - assert.deepEqual(actions[0].value, { - hooks: [ - { command: '"/usr/local/bin/node" "/Users/me/.codex/hooks/custom-hook.js"' }, - ], - }); - }); - - test('plan preserves similarly named commands outside the managed hooks directory', () => { - const configDir = '/Users/me/.codex'; - const hooksJson = { - hooks: [ - { command: '"/usr/local/bin/node" "/tmp/other/hooks/gsd-check-update.js"' }, - ], - }; - - const actions = migration.plan({ - configDir, - readJson: () => ({ exists: true, error: null, value: hooksJson }), - }); - - assert.deepEqual(actions, []); - }); -}); diff --git a/tests/bug-3541-installer-migration-prompt-user-resolution.test.cjs b/tests/bug-3541-installer-migration-prompt-user-resolution.test.cjs deleted file mode 100644 index b4f0887a6..000000000 --- a/tests/bug-3541-installer-migration-prompt-user-resolution.test.cjs +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Regression test for #3541: first-time-baseline installer migration - * `prompt-user` actions threw hard with no resolution path, making - * `/gsd-update` unrecoverable when leftover `gsd-*` files were classified - * as `stale-gsd-looking`. - * - * Fix shape (per triage brief): - * A. Classify-and-default for safe categories - stale SDK build - * artifacts default to "remove"; user-facing skills/gsd-asterisk/SKILL.md - * defaults to "keep". Each resolution is logged. - * B. Improved error message when an unresolved prompt-user action - * remains: lists choices, suggests the resolution path, groups - * blocked paths by reason. - * - * Behavioural test — exercises the actual installer migration code paths - * via the public `runInstallerMigrations` + new resolver entry points. - * No source-grep (per CONTEXT.md L98–101 / RULESET.TESTS). - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -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 { - runInstallerMigrations, -} = require('../gsd-core/bin/lib/installer-migrations.cjs'); -const { - assertInstallerMigrationsUnblocked, - resolveInstallerMigrationPromptsForNonTty, -} = require('../gsd-core/bin/lib/installer-migration-report.cjs'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -function writeFile(root, relPath, content) { - const fullPath = path.join(root, relPath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content, 'utf8'); -} - -function writeManifest(root, files) { - fs.writeFileSync( - path.join(root, 'gsd-file-manifest.json'), - JSON.stringify({ - version: '1.41.2', - timestamp: '2026-05-10T00:00:00.000Z', - mode: 'full', - files, - }, null, 2), - 'utf8' - ); -} - -describe('#3541: installer migration prompt-user non-TTY resolution', { concurrency: false }, () => { - let configDir; - - beforeEach(() => { - configDir = createTempDir('gsd-3541-'); - }); - - afterEach(() => { - cleanup(configDir); - }); - - test('Test A: non-TTY default resolution removes stale SDK artifacts and keeps user skills', () => { - // Stale SDK build artifact: replicates the 1.41.2 → 1.42.2 upgrade where - // 24 stale `gsd-core/sdk/{dist,src}/gsd-*` files leaked into the - // baseline because the new manifest no longer classifies them as managed. - writeFile(configDir, 'gsd-core/sdk/dist/gsd-old-bundle.js', 'stale sdk bundle\n'); - // User-facing skill: replicates `skills/gsd-roadmap/SKILL.md` from the - // same incident — user-owned content that must be preserved. - writeFile(configDir, 'skills/gsd-roadmap/SKILL.md', '# Roadmap skill\nuser content\n'); - - // Plant an empty manifest so both files classify as `stale-gsd-looking` - // (they look like GSD artifacts but are not manifest-managed). - writeManifest(configDir, {}); - - const result = runInstallerMigrations({ - configDir, - runtime: 'claude', - scope: 'global', - baselineScan: true, - }); - - // Confirm the migration framework classified both as prompt-user - // blockers — this is the precondition the fix resolves. - const blockedPaths = (result.blocked || []).map((a) => a.relPath).sort(); - assert.deepEqual( - blockedPaths, - ['gsd-core/sdk/dist/gsd-old-bundle.js', 'skills/gsd-roadmap/SKILL.md'], - 'precondition: both stale-looking files should be flagged for explicit user choice' - ); - - // Now run the non-TTY resolver. It must classify-and-default each - // blocked action and return a structured log of resolutions. - const resolved = resolveInstallerMigrationPromptsForNonTty(result, { isTty: false }); - - assert.ok(Array.isArray(resolved.resolutions), 'resolver returns a resolutions log'); - assert.equal( - resolved.resolutions.length, - 2, - 'one resolution entry per blocked action' - ); - - const byPath = new Map(resolved.resolutions.map((r) => [r.relPath, r])); - const sdkResolution = byPath.get('gsd-core/sdk/dist/gsd-old-bundle.js'); - const skillResolution = byPath.get('skills/gsd-roadmap/SKILL.md'); - - assert.ok(sdkResolution, 'SDK artifact resolution logged'); - assert.equal(sdkResolution.choice, 'remove', 'stale SDK build artifact defaults to remove'); - assert.equal(sdkResolution.category, 'stale-sdk-build-artifact'); - - assert.ok(skillResolution, 'user skill resolution logged'); - assert.equal(skillResolution.choice, 'keep', 'user-facing skill defaults to keep'); - assert.equal(skillResolution.category, 'user-facing-skill'); - - // After resolution there must be no blocked actions remaining; the - // assertion gatekeeper must not throw. - assert.equal( - (resolved.result.blocked || []).length, - 0, - 'all prompt-user actions resolved' - ); - assert.doesNotThrow(() => assertInstallerMigrationsUnblocked(resolved.result)); - }); - - test('Test B: error message groups paths by reason and suggests a resolution path', () => { - // Build a synthetic result with two blocked prompt-user actions of - // distinct reasons. The improved error message must (1) list the - // documented choices, (2) suggest the non-interactive resolution - // path, (3) group blocked paths by reason rather than emit each path - // individually. - const blocked = [ - { - type: 'prompt-user', - relPath: 'gsd-core/sdk/dist/gsd-a.js', - reason: 'GSD-looking file is not proven manifest-managed and needs explicit user choice', - classification: 'stale-gsd-looking', - prompt: 'Choose whether to remove this stale-looking GSD artifact or keep it as user-owned.', - choices: ['keep', 'remove'], - }, - { - type: 'prompt-user', - relPath: 'gsd-core/sdk/dist/gsd-b.js', - reason: 'GSD-looking file is not proven manifest-managed and needs explicit user choice', - classification: 'stale-gsd-looking', - prompt: 'Choose whether to remove this stale-looking GSD artifact or keep it as user-owned.', - choices: ['keep', 'remove'], - }, - ]; - - let captured = null; - try { - assertInstallerMigrationsUnblocked({ blocked }); - assert.fail('expected assertInstallerMigrationsUnblocked to throw'); - } catch (err) { - captured = err; - } - - assert.ok(captured instanceof Error); - const message = captured.message; - - // (a) Documented choices listed. - assert.match(message, /keep/, 'error message lists `keep` choice'); - assert.match(message, /remove/, 'error message lists `remove` choice'); - - // (b) Suggests the resolution path. The fix introduces an - // environment variable as the documented non-interactive resolution - // surface — the message must point users at it. - assert.match( - message, - /GSD_INSTALLER_MIGRATION_RESOLVE/, - 'error message suggests the non-interactive resolution env var' - ); - - // (c) Paths grouped by reason — two paths sharing the same reason - // appear under one summary count, not as two separate path lines. - // The message must include a `2 files` (or similar) grouped summary - // and must NOT list each individual relPath in the top-level message. - assert.match( - message, - /2 (files?|paths?|artifacts?)/, - 'error message groups blocked paths into a count summary' - ); - - // Structured surface: the thrown error must carry a `blockedByReason` - // map so callers can render their own report without re-parsing. - assert.ok(captured.blockedByReason, 'error carries blockedByReason data'); - const reasons = Object.keys(captured.blockedByReason); - assert.equal(reasons.length, 1, 'two same-reason paths grouped under one key'); - assert.equal(captured.blockedByReason[reasons[0]].length, 2); - }); - - test('Test C: non-TTY env override resolves otherwise-unclassified prompt-user actions', () => { - const result = { - blocked: [ - { - type: 'prompt-user', - relPath: 'skills/gsd-custom/SKILL.toml', - reason: 'custom skill metadata requires user decision', - choices: ['keep', 'remove'], - }, - ], - plan: { - actions: [], - blocked: [ - { - type: 'prompt-user', - relPath: 'skills/gsd-custom/SKILL.toml', - reason: 'custom skill metadata requires user decision', - choices: ['keep', 'remove'], - }, - ], - }, - }; - - const resolved = resolveInstallerMigrationPromptsForNonTty(result, { - isTty: false, - env: { GSD_INSTALLER_MIGRATION_RESOLVE: 'keep' }, - }); - - assert.equal(resolved.resolutions.length, 1, 'env override resolves prompt-user action'); - assert.equal(resolved.resolutions[0].choice, 'keep'); - assert.equal(resolved.resolutions[0].source, 'GSD_INSTALLER_MIGRATION_RESOLVE'); - assert.equal(resolved.resolutions[0].category, 'operator-override'); - assert.equal((resolved.result.blocked || []).length, 0); - assert.equal((resolved.result.plan.blocked || []).length, 0); - assert.equal((resolved.result.plan.actions || []).length, 1); - assert.equal(resolved.result.plan.actions[0].type, 'baseline-preserve-user'); - }); -}); diff --git a/tests/bug-3610-installer-migration-bundled-hooks-classification.test.cjs b/tests/bug-3610-installer-migration-bundled-hooks-classification.test.cjs deleted file mode 100644 index fb5576104..000000000 --- a/tests/bug-3610-installer-migration-bundled-hooks-classification.test.cjs +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Regression test for #3610: fresh `npx @opengsd/gsd-core@latest --codex` - * hard-aborts when the target ~/.codex/hooks/ contains the bundled GSD - * hook files (`gsd-check-update-worker.js`, `gsd-prompt-guard.js`, …) - * left over from a previous version. The installer-migration report - * classifies them as "GSD-looking file is not proven manifest-managed - * and needs explicit user choice" and `assertInstallerMigrationsUnblocked` - * throws. - * - * The files in question are NOT user-owned — they are the GSD bundled - * hooks shipped under `hooks/gsd-*` in the npm package. The fix adds a - * `bundled-gsd-hook` classification to `classifyPromptUserAction` so the - * resolver removes them (the installer then writes the fresh bundled - * versions in their place). - * - * Because this classification is unambiguous (these are not user files), - * it must apply regardless of whether stdin is a TTY — the reporter's - * `npx ... --codex` run was interactive and the existing non-TTY - * resolver gate at install.js:8069 skipped the safe-default pass. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -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 { - runInstallerMigrations, -} = require('../gsd-core/bin/lib/installer-migrations.cjs'); -const { - assertInstallerMigrationsUnblocked, - resolveInstallerMigrationPromptsForNonTty, - classifyPromptUserAction, -} = require('../gsd-core/bin/lib/installer-migration-report.cjs'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -function writeFile(root, relPath, content) { - const fullPath = path.join(root, relPath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content, 'utf8'); -} - -function writeManifest(root, files) { - fs.writeFileSync( - path.join(root, 'gsd-file-manifest.json'), - JSON.stringify( - { - version: '1.41.2', - timestamp: '2026-05-10T00:00:00.000Z', - mode: 'full', - files, - }, - null, - 2, - ), - 'utf8', - ); -} - -// Reporter's exact list of blocked files from the v1.42.2 → v1.42.0 upgrade -// abort. Each is a real `hooks/gsd-*` file shipped under hooks/ in the npm -// package (verified by `ls hooks/`). -const BUNDLED_HOOK_RELPATHS = [ - 'hooks/gsd-check-update-worker.js', - 'hooks/gsd-check-update.js', - 'hooks/gsd-context-monitor.js', - 'hooks/gsd-phase-boundary.sh', - 'hooks/gsd-prompt-guard.js', - 'hooks/gsd-read-guard.js', - 'hooks/gsd-read-injection-scanner.js', - 'hooks/gsd-session-state.sh', - 'hooks/gsd-statusline.js', - 'hooks/gsd-update-banner.js', - 'hooks/gsd-validate-commit.sh', - 'hooks/gsd-workflow-guard.js', -]; - -describe('bug #3610: classifyPromptUserAction recognizes bundled GSD hooks', () => { - test('classifies hooks/gsd-*.js as bundled-gsd-hook → remove', () => { - const result = classifyPromptUserAction({ - relPath: 'hooks/gsd-prompt-guard.js', - }); - assert.ok(result, 'classifier returned null for a bundled GSD hook (.js)'); - assert.strictEqual(result.category, 'bundled-gsd-hook'); - assert.strictEqual( - result.choice, - 'remove', - 'bundled hook must default to remove so the installer can write the fresh bundled version', - ); - }); - - test('classifies hooks/gsd-*.sh as bundled-gsd-hook → remove', () => { - const result = classifyPromptUserAction({ - relPath: 'hooks/gsd-validate-commit.sh', - }); - assert.ok(result); - assert.strictEqual(result.category, 'bundled-gsd-hook'); - assert.strictEqual(result.choice, 'remove'); - }); - - test('does NOT classify non-gsd hooks (preserves user-owned hook files)', () => { - // A user's custom hook that happens to live under hooks/ must NOT be - // auto-classified as bundled — the existing block-then-choose flow - // continues to apply, preserving the user's control over their files. - const result = classifyPromptUserAction({ - relPath: 'hooks/my-custom-hook.js', - }); - assert.strictEqual( - result, - null, - 'non-gsd-prefixed hook must NOT auto-classify (would clobber user files)', - ); - }); - - test('does NOT classify deeper paths under hooks/gsd-* (e.g. hooks/lib/) as bundled-gsd-hook', () => { - // The bundled GSD distribution has hooks/lib/ (helper modules). Those - // are managed differently — verify the classifier limits itself to - // top-level hooks/gsd-. files, not nested directories. - const result = classifyPromptUserAction({ - relPath: 'hooks/gsd-helpers/index.js', - }); - assert.strictEqual(result, null); - }); -}); - -describe('bug #3610: fresh upgrade with leftover bundled hooks does not throw', () => { - let configDir; - - beforeEach(() => { - configDir = createTempDir('gsd-3610-'); - }); - - afterEach(() => { - cleanup(configDir); - }); - - test('end-to-end: 12 leftover bundled hooks + empty manifest → resolver clears all blockers', () => { - // Recreate the reporter's environment: 12 bundled `gsd-*` hook files - // present at target, but the manifest has not yet seeded their baseline - // entries (first-time-baseline scan). - for (const rel of BUNDLED_HOOK_RELPATHS) { - writeFile(configDir, rel, '#!/usr/bin/env node\n// stale 1.42.0 hook\n'); - } - writeManifest(configDir, {}); - - const result = runInstallerMigrations({ - configDir, - runtime: 'codex', - scope: 'global', - baselineScan: true, - }); - - // Precondition: all 12 leftover hooks classify as prompt-user blockers. - const blockedPaths = (result.blocked || []).map((a) => a.relPath).sort(); - assert.deepStrictEqual( - blockedPaths, - [...BUNDLED_HOOK_RELPATHS].sort(), - 'precondition: every leftover hooks/gsd-* should be a prompt-user blocker', - ); - - // Resolve through the safe-default classifier (passing isTty=false to - // exercise the same code path the bundled-hook classification will hit - // regardless of TTY once the fix removes the gate). - const resolved = resolveInstallerMigrationPromptsForNonTty(result, { isTty: false }); - - assert.strictEqual( - resolved.resolutions.length, - BUNDLED_HOOK_RELPATHS.length, - 'every bundled hook should produce a safe-default resolution entry', - ); - - for (const entry of resolved.resolutions) { - assert.strictEqual(entry.category, 'bundled-gsd-hook'); - assert.strictEqual(entry.choice, 'remove'); - assert.strictEqual(entry.resolvedActionType, 'backup-and-remove'); - } - - assert.strictEqual( - (resolved.result.blocked || []).length, - 0, - 'no blockers should remain after bundled-hook classification fires', - ); - assert.doesNotThrow(() => assertInstallerMigrationsUnblocked(resolved.result)); - }); -}); diff --git a/tests/bug-3628-bundled-hook-classifier-whitelist.test.cjs b/tests/bug-3628-bundled-hook-classifier-whitelist.test.cjs deleted file mode 100644 index 5ede2d0c8..000000000 --- a/tests/bug-3628-bundled-hook-classifier-whitelist.test.cjs +++ /dev/null @@ -1,140 +0,0 @@ -// allow-test-rule: architectural-invariant -// classifyPromptUserAction returns a typed result object; this test asserts -// on that typed surface (category + choice fields) for both the positive -// (shipped) and negative (user-owned / retired) cases. There is no rendered -// text or stdout under test — the classifier's structured return value IS -// the contract. - -/** - * Bug #3628: `bundled-gsd-hook` classifier (added in #3610) uses a shape - * regex (`/^hooks\/gsd-[^/]+\.(?:js|sh|cjs|mjs)$/`) that matches ANY file - * named `hooks/gsd-.{js,sh,cjs,mjs}`, not only the 13 hook files - * actually shipped in the npm distribution. The permissive shape regex - * silently auto-classifies — and on first-time-baseline scan auto-removes: - * - * - User-authored custom hooks (e.g. `hooks/gsd-personal-experiment.js`) - * - Retired bundled hooks from prior GSD versions - * - * Fix: the classifier must whitelist the explicit set of shipped hook - * filenames sourced from a single point of truth (`BUNDLED_GSD_HOOK_FILES` - * exported from the classifier module). Any `hooks/gsd-` file NOT in - * that set must fall through to the existing block-or-prompt flow so the - * user retains control. - */ - -'use strict'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); - -const { - classifyPromptUserAction, - BUNDLED_GSD_HOOK_FILES, -} = require('../gsd-core/bin/lib/installer-migration-report.cjs'); -const path = require('node:path'); -const fs = require('node:fs'); - -describe('bug #3628: BUNDLED_GSD_HOOK_FILES is an explicit whitelist', () => { - test('exports a Set of shipped hook filenames', () => { - assert.ok( - BUNDLED_GSD_HOOK_FILES instanceof Set, - 'BUNDLED_GSD_HOOK_FILES must be exported as a Set so callers can probe membership', - ); - assert.ok( - BUNDLED_GSD_HOOK_FILES.size > 0, - 'BUNDLED_GSD_HOOK_FILES must enumerate at least one shipped hook', - ); - }); - - test('every entry is a hooks/-prefixed posix path', () => { - for (const relPath of BUNDLED_GSD_HOOK_FILES) { - assert.ok( - relPath.startsWith('hooks/'), - `entry ${JSON.stringify(relPath)} must be prefixed with "hooks/"`, - ); - assert.ok( - !relPath.includes('\\'), - `entry ${JSON.stringify(relPath)} must use POSIX slashes`, - ); - assert.ok( - relPath.includes('gsd-'), - `entry ${JSON.stringify(relPath)} must contain the "gsd-" prefix`, - ); - } - }); - - test('every BUNDLED_GSD_HOOK_FILES entry corresponds to a real file in hooks/', () => { - // Sourcing the whitelist from a frozen constant is only durable if the - // constant stays aligned with the on-disk distribution. This guard - // fails the day someone removes a hook file but forgets to update the - // whitelist (or vice-versa). - const hooksDir = path.join(__dirname, '..', 'hooks'); - for (const relPath of BUNDLED_GSD_HOOK_FILES) { - const fullPath = path.join(hooksDir, relPath.slice('hooks/'.length)); - assert.ok( - fs.existsSync(fullPath), - `whitelisted ${relPath} is missing from hooks/ on disk — whitelist drifted`, - ); - } - }); - - test('every gsd-*.{js,sh,cjs,mjs} file in hooks/ is in BUNDLED_GSD_HOOK_FILES (no shipping drift)', () => { - const hooksDir = path.join(__dirname, '..', 'hooks'); - const onDisk = fs - .readdirSync(hooksDir, { withFileTypes: true }) - .filter((e) => e.isFile() && /^gsd-[^/]+\.(?:js|sh|cjs|mjs)$/.test(e.name)) - .map((e) => `hooks/${e.name}`); - for (const relPath of onDisk) { - assert.ok( - BUNDLED_GSD_HOOK_FILES.has(relPath), - `${relPath} ships in hooks/ but is missing from BUNDLED_GSD_HOOK_FILES — whitelist drifted`, - ); - } - }); -}); - -describe('bug #3628: classifyPromptUserAction whitelists shipped bundled hooks', () => { - test('classifies every entry in BUNDLED_GSD_HOOK_FILES as bundled-gsd-hook → remove', () => { - for (const relPath of BUNDLED_GSD_HOOK_FILES) { - const result = classifyPromptUserAction({ relPath }); - assert.deepStrictEqual( - result, - { category: 'bundled-gsd-hook', choice: 'remove' }, - `${relPath} should classify as bundled-gsd-hook`, - ); - } - }); - - const USER_OWNED_OR_RETIRED = [ - 'hooks/gsd-personal-experiment.js', - 'hooks/gsd-my-custom-guard.sh', - 'hooks/gsd-team-policy.cjs', - 'hooks/gsd-retired-hook.js', - 'hooks/gsd-old-statusline.js', - 'hooks/gsd-experimental.mjs', - ]; - - for (const relPath of USER_OWNED_OR_RETIRED) { - test(`does NOT classify ${relPath} (user-owned / retired)`, () => { - assert.strictEqual( - classifyPromptUserAction({ relPath }), - null, - `${relPath} must NOT auto-classify — falls through to block-or-prompt`, - ); - }); - } - - test('still does NOT classify nested gsd-* directories (existing #3610 boundary preserved)', () => { - assert.strictEqual( - classifyPromptUserAction({ relPath: 'hooks/gsd-helpers/index.js' }), - null, - ); - }); - - test('still does NOT classify non-gsd hooks (existing boundary preserved)', () => { - assert.strictEqual( - classifyPromptUserAction({ relPath: 'hooks/my-custom-hook.js' }), - null, - ); - }); -}); diff --git a/tests/bug-3657-verify-reapply-patches-pristine-drift.test.cjs b/tests/bug-3657-verify-reapply-patches-pristine-drift.test.cjs deleted file mode 100644 index 463f3d6a8..000000000 --- a/tests/bug-3657-verify-reapply-patches-pristine-drift.test.cjs +++ /dev/null @@ -1,681 +0,0 @@ -// allow-test-rule: source-text-is-the-product — Finding 2 reads reapply-patches.md to -// assert structural presence of the Step 5a drift-check block; the .md file is the -// product (workflow instructions consumed by AI agents), not a source .cjs file. -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #3657: verify-reapply-patches false-fails when gsd-pristine/ snapshot is - * newer than backup-meta baseline. - * - * Root cause: the verifier computes user-added lines as - * diff(backup, pristine_on_disk) - * but pristine_on_disk is from a LATER GSD version than the one captured in - * backup-meta.json.pristine_hashes. Lines present in the backup but removed by - * the upstream update appear as "user-added lines that must survive", causing - * FAIL_USER_LINES_MISSING false positives even when the user's real - * customisation survived the merge. - * - * Fix: when backup-meta.json contains `pristine_hashes` and the on-disk - * pristine file's SHA-256 does NOT match the recorded hash, the verifier must - * skip the stale pristine and fall back to the over-broad mode (treating every - * significant backup line as required) rather than computing a diff against the - * wrong baseline. Over-broad mode still passes if all backup lines are present - * in the installed file — it never false-fails for a DIFFERENT reason. - * - * Per CONTRIBUTING.md testing standard: assert on typed structured fields from - * the --json report and the REASON frozen enum. Zero regex / String#includes on - * formatter prose. - */ - -const { test, describe, before, after } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const crypto = require('node:crypto'); -const os = require('node:os'); -const path = require('node:path'); -const cp = require('node:child_process'); -const { cleanup } = require('./helpers.cjs'); - -const ROOT = path.join(__dirname, '..'); -const SCRIPT = path.join(ROOT, 'gsd-core', 'bin', 'verify-reapply-patches.cjs'); -const { REASON } = require(SCRIPT); - -// --------------------------------------------------------------------------- -// Fixture helpers -// --------------------------------------------------------------------------- - -let tmpRoot; -let patchesDir; -let configDir; -let pristineDir; - -function sha256(content) { - return crypto.createHash('sha256').update(content).digest('hex'); -} - -function writeFile(absPath, content) { - fs.mkdirSync(path.dirname(absPath), { recursive: true }); - fs.writeFileSync(absPath, content); -} - -function writeBackupMeta(overrides = {}) { - const meta = { pristine_hashes: {}, ...overrides }; - writeFile(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2)); -} - -function resetFixture() { - for (const dir of [patchesDir, configDir, pristineDir]) { - cleanup(dir); - } - fs.mkdirSync(patchesDir); - fs.mkdirSync(configDir); - fs.mkdirSync(pristineDir); -} - -/** Runs the verifier with --json. Returns { status, report }. */ -function runVerifier({ pristine = true } = {}) { - const args = [ - SCRIPT, - '--patches-dir', patchesDir, - '--config-dir', configDir, - ...(pristine ? ['--pristine-dir', pristineDir] : []), - '--json', - ]; - const r = cp.spawnSync(process.execPath, args, { encoding: 'utf8' }); - return { - status: r.status, - report: r.stdout && r.stdout.length ? JSON.parse(r.stdout) : null, - }; -} - -before(() => { - tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3657-')); - patchesDir = path.join(tmpRoot, 'patches'); - configDir = path.join(tmpRoot, 'installed'); - pristineDir = path.join(tmpRoot, 'pristine'); - resetFixture(); -}); - -after(() => { - cleanup(tmpRoot); -}); - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('Bug #3657: pristine-drift does not produce false FAIL_USER_LINES_MISSING', () => { - - /** - * Core regression: the user has one real customisation line. The pristine - * snapshot on disk is a NEWER version that removed a line that was in the - * backup (v_old pristine). Without the fix, the removed-upstream line - * appears as a "user-added line" that is missing from the installed file, - * causing a spurious failure. With the fix, the verifier detects hash - * mismatch and skips the stale pristine, so only the real user line is - * checked — which IS present — and the run exits 0. - */ - test('exits 0 with reason=OK_PRISTINE_DRIFT_DETECTED when on-disk pristine hash does not match recorded hash', () => { - resetFixture(); - - const FILE = 'agents/gsd-executor.md'; - - // v_old pristine: the file as it existed when the backup was made. - const oldPristineContent = - 'line present in old pristine and also in backup\n' + - 'another stock line that was present in old pristine\n'; - - // The user added one customisation line on top of v_old pristine. - const backupContent = - oldPristineContent + - 'model: sonnet in frontmatter — the user customisation to preserve\n'; - - // The installer later refreshed gsd-pristine/ to v_new. - // The upstream update removed the second stock line entirely. - const newPristineContent = - 'line present in old pristine and also in backup\n' + - 'brand-new upstream line added in the newer version here\n'; - - // After reapply-patches, the installed file has the new upstream content - // PLUS the user's real customisation. - const installedContent = - newPristineContent + - 'model: sonnet in frontmatter — the user customisation to preserve\n'; - - // backup-meta.json records the SHA-256 of the OLD pristine content. - writeBackupMeta({ pristine_hashes: { [FILE]: sha256(oldPristineContent) } }); - writeFile(path.join(patchesDir, FILE), backupContent); - writeFile(path.join(configDir, FILE), installedContent); - // The pristine dir has the NEW (mismatched) version. - writeFile(path.join(pristineDir, FILE), newPristineContent); - - const { status, report } = runVerifier(); - - // Must exit 0: drift detected, file skipped with diagnostic code rather - // than false-failing. The user's real line cannot be verified without the - // correct baseline, but the gate must not halt on a false alarm. - assert.equal(status, 0, `expected exit 0 (no failures); got ${status}; report=${JSON.stringify(report)}`); - assert.equal(report.failures, 0, `expected 0 failures; got ${report.failures}`); - const r0 = report.results[0]; - assert.equal(r0.status, 'ok'); - assert.equal(r0.reason, REASON.OK_PRISTINE_DRIFT_DETECTED, - `expected OK_PRISTINE_DRIFT_DETECTED; got ${r0.reason}`); - assert.deepEqual(r0.missing, []); - }); - - /** - * Counter-test (anti-false-positive): when pristine on-disk MATCHES the - * recorded hash (no drift), a real user-added line that was dropped from - * the installed file must still be caught as FAIL_USER_LINES_MISSING. - * The hash-mismatch guard must not suppress legitimate failures. - */ - test('still catches FAIL_USER_LINES_MISSING when pristine matches recorded hash', () => { - resetFixture(); - - const FILE = 'agents/gsd-executor.md'; - - const pristineContent = - 'stock line one that is long enough to be significant\n' + - 'stock line two that is also long enough to matter\n'; - - const droppedLine = 'model: sonnet in frontmatter — the user customisation that was lost'; - const backupContent = pristineContent + droppedLine + '\n'; - - // Installed file is missing the user's line — a real failure. - const installedContent = pristineContent; - - // backup-meta records hash of the SAME pristine currently on disk (no drift). - writeBackupMeta({ pristine_hashes: { [FILE]: sha256(pristineContent) } }); - writeFile(path.join(patchesDir, FILE), backupContent); - writeFile(path.join(configDir, FILE), installedContent); - writeFile(path.join(pristineDir, FILE), pristineContent); - - const { status, report } = runVerifier(); - - assert.equal(status, 1, 'expected exit 1 (real failure should be caught)'); - assert.equal(report.failures, 1); - const r0 = report.results[0]; - assert.equal(r0.status, 'fail'); - assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING); - assert.ok( - r0.missing.includes(droppedLine), - `dropped user line must appear in .missing[]; got ${JSON.stringify(r0.missing)}`, - ); - }); - - /** - * Counter-test (pristine present but no backup-meta.json): behaviour must - * be unchanged from the pre-fix code — use whatever pristine is on disk - * without hash validation (backup-meta is absent so no recorded hash). - */ - test('uses on-disk pristine normally when backup-meta.json is absent (no hash to check)', () => { - resetFixture(); - // No backup-meta.json written — simulate older installer that never recorded hashes. - - const FILE = 'workflow.md'; - const pristineContent = 'stock line that is long enough to be significant in the file\n'; - const droppedLine = 'user line that was added but dropped from the merged install'; - const backupContent = pristineContent + droppedLine + '\n'; - const installedContent = pristineContent; // user line was dropped - - writeFile(path.join(patchesDir, FILE), backupContent); - writeFile(path.join(configDir, FILE), installedContent); - writeFile(path.join(pristineDir, FILE), pristineContent); - - const { status, report } = runVerifier(); - - // Should still catch the dropped user line via normal pristine diff. - assert.equal(status, 1); - assert.equal(report.failures, 1); - assert.equal(report.results[0].reason, REASON.FAIL_USER_LINES_MISSING); - assert.ok(report.results[0].missing.includes(droppedLine)); - }); - - /** - * Counter-test (pristine matches AND user line present): clean run must - * report 0 failures — no false positives even with hash-validation active. - */ - test('reports 0 failures when pristine matches recorded hash and user line is present', () => { - resetFixture(); - - const FILE = 'skills/custom/SKILL.md'; - const pristineContent = 'stock line one with sufficient length to be significant\n'; - const userLine = 'user custom instruction that the user intentionally added here'; - const backupContent = pristineContent + userLine + '\n'; - const installedContent = backupContent; // user line survived - - writeBackupMeta({ pristine_hashes: { [FILE]: sha256(pristineContent) } }); - writeFile(path.join(patchesDir, FILE), backupContent); - writeFile(path.join(configDir, FILE), installedContent); - writeFile(path.join(pristineDir, FILE), pristineContent); - - const { status, report } = runVerifier(); - - assert.equal(status, 0); - assert.equal(report.failures, 0); - assert.equal(report.results[0].status, 'ok'); - }); - - /** - * Multi-file regression: two files; one with hash drift (should not false-fail), - * one with no drift but a real dropped line (should catch it). - * Verifies that per-file hash checking is independent. - */ - test('handles mixed drift + real-failure across multiple files independently', () => { - resetFixture(); - - const DRIFT_FILE = 'agents/gsd-executor.md'; - const CLEAN_FILE = 'workflows/update.md'; - - const driftOldPristine = 'old upstream line that was removed in newer pristine version\n'; - const driftNewPristine = 'brand-new upstream replacement line in the refreshed snapshot\n'; - const driftUserLine = 'model: sonnet — the user customisation that survived reapply'; - const driftBackup = driftOldPristine + driftUserLine + '\n'; - const driftInstalled = driftNewPristine + driftUserLine + '\n'; - - const cleanPristine = 'stock workflow line long enough to pass significance threshold\n'; - const cleanDroppedLine = 'user workflow customisation that was lost in the merge operation'; - const cleanBackup = cleanPristine + cleanDroppedLine + '\n'; - const cleanInstalled = cleanPristine; // dropped - - writeBackupMeta({ - pristine_hashes: { - [DRIFT_FILE]: sha256(driftOldPristine), - [CLEAN_FILE]: sha256(cleanPristine), - }, - }); - - writeFile(path.join(patchesDir, DRIFT_FILE), driftBackup); - writeFile(path.join(configDir, DRIFT_FILE), driftInstalled); - writeFile(path.join(pristineDir, DRIFT_FILE), driftNewPristine); // hash mismatch - - writeFile(path.join(patchesDir, CLEAN_FILE), cleanBackup); - writeFile(path.join(configDir, CLEAN_FILE), cleanInstalled); - writeFile(path.join(pristineDir, CLEAN_FILE), cleanPristine); // hash matches - - const { status, report } = runVerifier(); - - // Exactly 1 failure (the clean file with the genuinely dropped line). - assert.equal(report.failures, 1, `expected 1 failure; got ${report.failures}; report=${JSON.stringify(report, null, 2)}`); - assert.equal(status, 1); - - const driftResult = report.results.find( - (r) => r.file.replace(/\\/g, '/') === DRIFT_FILE, - ); - const cleanResult = report.results.find( - (r) => r.file.replace(/\\/g, '/') === CLEAN_FILE, - ); - - assert.ok(driftResult, 'drift file result must be present in report'); - assert.ok(cleanResult, 'clean file result must be present in report'); - - assert.equal(driftResult.status, 'ok', 'drift file must not false-fail'); - assert.equal(driftResult.reason, REASON.OK_PRISTINE_DRIFT_DETECTED, - `drift file must report OK_PRISTINE_DRIFT_DETECTED; got ${driftResult.reason}`); - assert.equal(cleanResult.status, 'fail', 'clean file with dropped line must fail'); - assert.equal(cleanResult.reason, REASON.FAIL_USER_LINES_MISSING); - assert.ok(cleanResult.missing.includes(cleanDroppedLine)); - }); - - /** - * REASON enum shape-lock: the #3657 fix adds OK_PRISTINE_DRIFT_DETECTED. - * This assertion locks the updated documented set of stable codes. - * Any further additions require updating this assertion. - */ - test('REASON enum includes OK_PRISTINE_DRIFT_DETECTED added by the #3657 fix', () => { - assert.deepEqual( - Object.keys(REASON).sort(), - [ - 'FAIL_INSTALLED_MISSING', - 'FAIL_INSTALLED_NOT_REGULAR_FILE', - 'FAIL_READ_ERROR', - 'FAIL_USER_LINES_MISSING', - 'OK_NO_BASELINE', - 'OK_NO_SIGNIFICANT_BACKUP_LINES', - 'OK_NO_USER_LINES_VS_PRISTINE', - 'OK_PRISTINE_DRIFT_DETECTED', - ], - ); - }); - - // --------------------------------------------------------------------------- - // Finding 1 (BLOCKER) — drifted_files report shape - // Asserts that the JSON report top-level carries `drifted` count + - // `drifted_files` array so that workflow Step 5a has structured data to gate - // on. Per-file shape is unchanged (backward compat). - // --------------------------------------------------------------------------- - - /** - * Single drifted file: the top-level `drifted` count must be 1 and - * `drifted_files` must contain the relative path of the drifted file. - * The `failures` count must remain 0 (drift ≠ failure). - */ - test('Finding 1: JSON report includes top-level drifted count and drifted_files when drift is detected', () => { - resetFixture(); - - const FILE = 'agents/gsd-executor.md'; - const oldPristineContent = 'old pristine line that was present when backup was captured\n'; - const newPristineContent = 'new upstream line in the refreshed pristine snapshot version\n'; - const userLine = 'user customisation line that should be preserved across updates'; - const backupContent = oldPristineContent + userLine + '\n'; - const installedContent = newPristineContent + userLine + '\n'; - - writeBackupMeta({ pristine_hashes: { [FILE]: sha256(oldPristineContent) } }); - writeFile(path.join(patchesDir, FILE), backupContent); - writeFile(path.join(configDir, FILE), installedContent); - writeFile(path.join(pristineDir, FILE), newPristineContent); // hash mismatch → drift - - const { status, report } = runVerifier(); - - // Script exits 0 — drift is not a failure. - assert.equal(status, 0, `expected exit 0; got ${status}`); - assert.equal(report.failures, 0, 'failures must be 0 — drift is not a failure'); - - // Finding 1: top-level drifted fields must be present and accurate. - assert.equal(typeof report.drifted, 'number', 'report.drifted must be a number'); - assert.equal(report.drifted, 1, `expected drifted=1; got ${report.drifted}`); - assert.ok(Array.isArray(report.drifted_files), 'report.drifted_files must be an array'); - assert.equal(report.drifted_files.length, 1, `expected 1 drifted_files entry; got ${report.drifted_files.length}`); - // Normalize path separator so test passes on Windows worktrees too. - assert.equal( - report.drifted_files[0].replace(/\\/g, '/'), - FILE, - `drifted_files[0] must equal the drifted file path; got ${report.drifted_files[0]}`, - ); - - // Per-file shape is unchanged for backward compat. - const r0 = report.results.find((r) => r.file.replace(/\\/g, '/') === FILE); - assert.ok(r0, 'per-file result must be present'); - assert.equal(r0.status, 'ok'); - assert.equal(r0.reason, REASON.OK_PRISTINE_DRIFT_DETECTED); - }); - - /** - * Multi-file drift: two files drifted, one clean pass. Asserts that the - * `drifted` count is 2 and `drifted_files` lists both relative paths. - * Confirms `failures` stays at 0. - */ - test('Finding 1: drifted count and drifted_files aggregate correctly across multiple drifted files', () => { - resetFixture(); - - const FILE_A = 'agents/gsd-executor.md'; - const FILE_B = 'workflows/update.md'; - const FILE_C = 'skills/custom/SKILL.md'; - - const oldPristineA = 'old pristine content for file A that was captured at backup time\n'; - const newPristineA = 'refreshed upstream content for file A in the newer GSD snapshot\n'; - const oldPristineB = 'old pristine content for file B that was captured at backup time\n'; - const newPristineB = 'refreshed upstream content for file B in the newer GSD snapshot\n'; - const pristineC = 'stable pristine for file C — this one did not drift between versions\n'; - const userLineC = 'user customisation for file C that survived the merge successfully'; - - writeBackupMeta({ - pristine_hashes: { - [FILE_A]: sha256(oldPristineA), - [FILE_B]: sha256(oldPristineB), - [FILE_C]: sha256(pristineC), - }, - }); - - // FILE_A: drifted (hash mismatch) - writeFile(path.join(patchesDir, FILE_A), oldPristineA + 'user line A\n'); - writeFile(path.join(configDir, FILE_A), newPristineA + 'user line A\n'); - writeFile(path.join(pristineDir, FILE_A), newPristineA); // mismatch - - // FILE_B: drifted (hash mismatch) - writeFile(path.join(patchesDir, FILE_B), oldPristineB + 'user line B\n'); - writeFile(path.join(configDir, FILE_B), newPristineB + 'user line B\n'); - writeFile(path.join(pristineDir, FILE_B), newPristineB); // mismatch - - // FILE_C: clean (hash matches, user line present) - writeFile(path.join(patchesDir, FILE_C), pristineC + userLineC + '\n'); - writeFile(path.join(configDir, FILE_C), pristineC + userLineC + '\n'); - writeFile(path.join(pristineDir, FILE_C), pristineC); // matches - - const { status, report } = runVerifier(); - - assert.equal(status, 0, `expected exit 0; got ${status}`); - assert.equal(report.failures, 0, 'failures must be 0'); - assert.equal(report.drifted, 2, `expected drifted=2; got ${report.drifted}`); - assert.ok(Array.isArray(report.drifted_files), 'drifted_files must be an array'); - assert.equal(report.drifted_files.length, 2); - const normalised = report.drifted_files.map((f) => f.replace(/\\/g, '/')); - assert.ok(normalised.includes(FILE_A), `drifted_files must include ${FILE_A}`); - assert.ok(normalised.includes(FILE_B), `drifted_files must include ${FILE_B}`); - assert.ok(!normalised.includes(FILE_C), `drifted_files must NOT include the clean file ${FILE_C}`); - }); - - /** - * No-drift baseline: when no files have hash mismatch, the top-level - * `drifted` field must be 0 and `drifted_files` must be an empty array. - * Verifies the additive fields are always present (not omitted on clean runs). - */ - test('Finding 1: drifted=0 and drifted_files=[] when no files have pristine drift', () => { - resetFixture(); - - const FILE = 'skills/custom/SKILL.md'; - const pristineContent = 'stable pristine content that did not change between versions\n'; - const userLine = 'user customisation that survived correctly into the merged file'; - const backupContent = pristineContent + userLine + '\n'; - const installedContent = backupContent; // user line survived - - writeBackupMeta({ pristine_hashes: { [FILE]: sha256(pristineContent) } }); - writeFile(path.join(patchesDir, FILE), backupContent); - writeFile(path.join(configDir, FILE), installedContent); - writeFile(path.join(pristineDir, FILE), pristineContent); - - const { status, report } = runVerifier(); - - assert.equal(status, 0); - assert.equal(report.failures, 0); - assert.equal(report.drifted, 0, `expected drifted=0 on clean run; got ${report.drifted}`); - assert.ok(Array.isArray(report.drifted_files), 'drifted_files must always be an array'); - assert.equal(report.drifted_files.length, 0, 'drifted_files must be empty on clean run'); - }); - - // --------------------------------------------------------------------------- - // Finding 2 (WARNING) — workflow Step 5a drift-check structural test - // Asserts that the workflow markdown source now contains the drift-check - // section that gates on `DRIFTED_COUNT > 0`. Treating the .md source as - // the product per allow-test-rule:source-text-is-the-product. - // --------------------------------------------------------------------------- - - /** - * Structural assertion: the workflow source must now contain the drift-check - * block that Step 5a uses to halt on drifted files. This guarantees that the - * workflow consumer gate exists and uses the structured `drifted` / `drifted_files` - * fields that Finding 1 added to the JSON report. - */ - test('Finding 2: workflow Step 5a source contains drift-check section for DRIFTED_COUNT gate', () => { - const workflowPath = path.join(ROOT, 'gsd-core', 'workflows', 'reapply-patches.md'); - const workflowSource = fs.readFileSync(workflowPath, 'utf8'); - - // The drift-check block must be present in Step 5a. - assert.ok( - workflowSource.includes('Step 5a: drift check'), - 'workflow must contain "Step 5a: drift check" heading', - ); - - // Must gate on the drifted count field from the JSON report. - assert.ok( - workflowSource.includes('DRIFTED_COUNT'), - 'workflow must reference DRIFTED_COUNT so it gates on the structured drifted field', - ); - - // Must reference drifted_files so the halt message names each drifted path. - assert.ok( - workflowSource.includes('drifted_files'), - 'workflow must reference drifted_files to name each drifted path in the halt message', - ); - - // Must instruct the user to resolve drift before re-running. - assert.ok( - workflowSource.includes('DRIFT_DETECTED'), - 'workflow must set DRIFT_DETECTED flag when drift is found (signals halt to subsequent steps)', - ); - - // The drift check must appear BEFORE the VERIFY_STATUS non-zero check. - // (Drift can be present even when exit code is 0.) - const driftCheckPos = workflowSource.indexOf('Step 5a: drift check'); - const verifyStatusPos = workflowSource.indexOf('If `VERIFY_STATUS` is non-zero'); - assert.ok( - driftCheckPos < verifyStatusPos, - 'drift-check block must appear before the VERIFY_STATUS non-zero check in Step 5a', - ); - }); -}); - -// --------------------------------------------------------------------------- -// Bug #934: OK_NO_BASELINE — pristine dir provided, hash recorded, but file absent -// --------------------------------------------------------------------------- - -describe('Bug #934: OK_NO_BASELINE when recordedHash present but pristine file absent', () => { - - /** - * Core regression: backup-meta.json has a pristine_hash for the file but - * the gsd-pristine/ snapshot is absent from disk (the installer's - * saveLocalPatches discarded the only candidate because its hash did not - * match the old-release hash — the file changed upstream between releases). - * Without the fix the verifier falls to over-broad mode and treats every - * upstream-removed line as a "user-added line that must survive", producing - * FAIL_USER_LINES_MISSING false positives. - * With the fix the verifier returns OK_NO_BASELINE (non-blocking, advisory). - */ - test('exits 0 with reason=OK_NO_BASELINE when recordedHash present but pristine absent', () => { - resetFixture(); - - const FILE = 'gsd-core/workflows/execute-phase.md'; - - // The backup contains both the old upstream content and the user's line. - const backupContent = - 'upstream line that was present in 1.4.0 but removed in 1.4.2 release\n' + - 'another upstream line removed upstream between gsd-core releases here\n' + - 'model: sonnet in frontmatter — this is the real user customisation line\n'; - - // The installed file has the new upstream content + the user's real line. - const installedContent = - 'brand-new upstream line that replaced the old content in gsd-core 1.4.2\n' + - 'model: sonnet in frontmatter — this is the real user customisation line\n'; - - // backup-meta.json records a hash (modern installer) but gsd-pristine/ is absent. - writeBackupMeta({ pristine_hashes: { [FILE]: 'sha256:deadbeef00000000000000000000000000000000000000000000000000000001' } }); - writeFile(path.join(patchesDir, FILE), backupContent); - writeFile(path.join(configDir, FILE), installedContent); - // Deliberately do NOT write a pristine file — this is the gap-1 scenario. - - const { status, report } = runVerifier(); - - // Must exit 0: cannot reason without baseline → non-blocking advisory. - assert.equal(status, 0, `expected exit 0; got ${status}; report=${JSON.stringify(report)}`); - assert.equal(report.failures, 0, `expected 0 failures; got ${report.failures}`); - const r0 = report.results[0]; - assert.equal(r0.status, 'ok', `expected status ok; got ${r0.status}`); - assert.equal(r0.reason, REASON.OK_NO_BASELINE, - `expected OK_NO_BASELINE; got ${r0.reason}`); - assert.deepEqual(r0.missing, []); - }); - - /** - * Counter-test: when pristine is absent but NO recordedHash is present - * (pre-fix installer that never wrote backup-meta.json), the verifier must - * still fall to over-broad mode — the old behaviour for untracked backups. - * OK_NO_BASELINE must NOT fire in this case. - */ - test('falls through to over-broad mode when pristine absent AND no recordedHash', () => { - resetFixture(); - - const FILE = 'gsd-core/workflows/plan-phase.md'; - const droppedLine = 'user-added instruction that was dropped from the install output'; - const backupContent = - 'stock upstream line long enough to be significant in the file\n' + - droppedLine + '\n'; - const installedContent = 'stock upstream line long enough to be significant in the file\n'; - - // No backup-meta.json — simulates pre-fix installer with no hash records. - writeFile(path.join(patchesDir, FILE), backupContent); - writeFile(path.join(configDir, FILE), installedContent); - // No pristine file. - - const { status, report } = runVerifier(); - - // Over-broad mode catches the genuinely dropped user line. - assert.equal(status, 1, 'over-broad mode should catch the dropped user line'); - assert.equal(report.failures, 1); - const r0 = report.results[0]; - assert.equal(r0.status, 'fail'); - assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING); - assert.ok(r0.missing.includes(droppedLine), - `dropped line must appear in .missing[]; got ${JSON.stringify(r0.missing)}`); - // Must NOT be OK_NO_BASELINE — that only fires when a hash WAS recorded. - assert.notEqual(r0.reason, REASON.OK_NO_BASELINE); - }); - - /** - * Presence check: when pristine IS present AND hash matches, the normal - * flow must proceed (not short-circuit to OK_NO_BASELINE). - * A real dropped user line must still be caught. - */ - test('does not short-circuit to OK_NO_BASELINE when pristine exists and hash matches', () => { - resetFixture(); - - const FILE = 'gsd-core/workflows/plan-phase.md'; - const pristineContent = 'stock upstream line long enough to be significant content\n'; - const droppedLine = 'user customisation that was genuinely dropped from the merged output'; - const backupContent = pristineContent + droppedLine + '\n'; - const installedContent = pristineContent; // user line dropped — real failure - - writeBackupMeta({ pristine_hashes: { [FILE]: sha256(pristineContent) } }); - writeFile(path.join(patchesDir, FILE), backupContent); - writeFile(path.join(configDir, FILE), installedContent); - writeFile(path.join(pristineDir, FILE), pristineContent); - - const { status, report } = runVerifier(); - - assert.equal(status, 1, 'real dropped user line must be caught'); - assert.equal(report.failures, 1); - const r0 = report.results[0]; - assert.equal(r0.status, 'fail'); - assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING); - assert.notEqual(r0.reason, REASON.OK_NO_BASELINE); - assert.ok(r0.missing.includes(droppedLine)); - }); - - /** - * When --pristine-dir is NOT provided at all (old CLI invocation without the - * flag), the OK_NO_BASELINE path must never fire — there is no pristine dir - * context to consult and the old over-broad behaviour must be preserved. - */ - test('does not return OK_NO_BASELINE when --pristine-dir is not provided', () => { - resetFixture(); - - const FILE = 'gsd-core/workflows/execute-phase.md'; - const backupContent = - 'upstream line removed in newer version but present in backup\n' + - 'model: sonnet — user customisation line in the backup file\n'; - const installedContent = - 'replacement upstream line in the newer release version\n' + - 'model: sonnet — user customisation line in the backup file\n'; - - // Record a hash — but no pristine dir will be passed to the verifier. - writeBackupMeta({ pristine_hashes: { [FILE]: 'sha256:deadbeef00000000000000000000000000000000000000000000000000000001' } }); - writeFile(path.join(patchesDir, FILE), backupContent); - writeFile(path.join(configDir, FILE), installedContent); - - // Run without --pristine-dir flag. - const { status, report } = runVerifier({ pristine: false }); - - // Over-broad mode: every significant backup line is required. - // "upstream line removed in newer version but present in backup" is NOT in - // the installed content → over-broad mode FAILS this file (exit 1). - // OK_NO_BASELINE must NOT fire — there was no pristine dir to consult. - assert.equal(status, 1, `over-broad mode should fail (upstream-removed line absent); got ${status}`); - const r0 = report.results[0]; - assert.equal(r0.status, 'fail', `expected fail status; got ${r0.status}`); - assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING, - `expected FAIL_USER_LINES_MISSING from over-broad mode; got ${r0.reason}`); - assert.notEqual(r0.reason, REASON.OK_NO_BASELINE, - `OK_NO_BASELINE must not fire when --pristine-dir is not provided`); - }); -}); diff --git a/tests/bug-3670-cursor-local-install-migration-lock.test.cjs b/tests/bug-3670-cursor-local-install-migration-lock.test.cjs deleted file mode 100644 index 860661aa9..000000000 --- a/tests/bug-3670-cursor-local-install-migration-lock.test.cjs +++ /dev/null @@ -1,313 +0,0 @@ -/** - * Regression tests for issue #3670: --cursor --local install self-deadlocks - * on gsd-install-migration.lock. - * - * Root cause: On Windows, `fs.rmSync(lockPath, { force: true })` in the lock - * release closure silently swallows EPERM errors that NTFS returns when a - * recently-closed file descriptor's handle has not yet been fully released by - * the OS. The lock file is left on disk. The next `runInstallerMigrations` - * call in the same install() invocation hits EEXIST, spins for - * DEFAULT_LOCK_TIMEOUT_MS (30 s), then throws "installer migration lock is - * held". There is also no stale-PID reclamation: if the lock names the - * current process's PID, the helper should reclaim rather than spin. - * - * Windows wall-clock deadlock repro depends on Docker matrix Windows runners. - * These tests reproduce the failure modes via mock-injected fs faults on any - * platform (macOS/Linux/Windows). They fail deterministically WITHOUT the fix - * and pass WITH it. - * - * Test plan: - * T1 (same-process re-entry / stale-PID reclamation — primary regression) - * Pre-seed the lock file with {pid: process.pid, ...}. Verify that a - * runInstallerMigrations call reclaims the lock and succeeds rather than - * spinning 30 s and throwing. - * - * T2 (dead-PID reclamation — cross-invocation stale lock) - * Pre-seed the lock file with a PID known to be dead. Verify that acquire - * reclaims rather than throws. - * - * T3 (silent rmSync swallow / Windows EPERM simulation) - * Inject a fault that makes fs.rmSync throw EPERM for the lock file only - * (simulating Windows NTFS delete-pending). Verify that the lock file IS - * removed by an alternative path (or that the error propagates) — i.e. - * verify that the fix does not silently leave the lock on disk. - * - * T4 (counter-test: normal single acquire/release round-trip still works) - * No pre-seeded lock. One runInstallerMigrations call. Must succeed and - * leave no lock file behind. - * - * T5 (counter-test: genuinely-held live lock still surfaces an error) - * Pre-seed lock with a live PID (process.pid) AND simulate a lock that - * has been "truly acquired" (fd still open). With lockTimeoutMs: 0 and a - * truly un-reclaimable lock, must still throw with a useful message naming - * the holder PID. (This guards against over-reclamation.) - * - * @see https://github.com/open-gsd/gsd-core/issues/3670 - */ - -'use strict'; - -const { test, mock } = 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 { - INSTALL_MIGRATION_LOCK_NAME, - runInstallerMigrations, -} = require('../gsd-core/bin/lib/installer-migrations.cjs'); -const { cleanup } = require('./helpers.cjs'); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function createTempDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3670-')); -} - -function lockPath(dir) { - return path.join(dir, INSTALL_MIGRATION_LOCK_NAME); -} - -function writeLockFile(dir, pid, acquiredAt) { - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - lockPath(dir), - JSON.stringify({ pid, acquiredAt: acquiredAt || new Date().toISOString() }) + '\n', - 'utf8' - ); -} - -/** - * Find a PID that is guaranteed to be dead on this host. - * We probe a set of high candidate PIDs (far outside the running set) and - * pick the first one for which process.kill(pid, 0) throws ESRCH. - * Falls back to 99999 if the probe loop exhausts (extremely unlikely). - */ -function findDeadPid() { - // Avoid process.pid ± small numbers — those could be live siblings. - for (let candidate = 600000; candidate < 700000; candidate += 1000) { - try { - process.kill(candidate, 0); - // Still alive (or permission denied but exists) — try next - } catch (err) { - if (err.code === 'ESRCH') return candidate; - } - } - return 99999; // fallback: extremely unlikely to be a live PID -} - -// --------------------------------------------------------------------------- -// T1: Same-process re-entry — stale lock with current process.pid reclaimed -// --------------------------------------------------------------------------- -test('T1: reclaims stale lock that names the current process PID (same-process re-entry)', (t) => { - const configDir = createTempDir(); - t.after(() => cleanup(configDir)); - - // Pre-seed lock file with the CURRENT process's PID — exactly what happens - // on Windows when rmSync swallows EPERM after the first runInstallerMigrations - // call releases (or fails to release) the lock. - writeLockFile(configDir, process.pid); - - // Without the fix: this would spin for lockTimeoutMs then throw. - // With the fix: detects own PID → reclaims → succeeds. - // lockTimeoutMs: 200 (fail fast so the test doesn't hang for 30 s without fix) - const result = runInstallerMigrations({ - configDir, - migrations: [], - lockTimeoutMs: 200, - }); - - assert.ok(result, 'runInstallerMigrations must return a result object'); - // Lock file must be removed after the call completes. - assert.equal( - fs.existsSync(lockPath(configDir)), - false, - 'lock file must not remain on disk after successful runInstallerMigrations' - ); -}); - -// --------------------------------------------------------------------------- -// T2: Dead-PID reclamation — cross-invocation stale lock -// --------------------------------------------------------------------------- -test('T2: reclaims stale lock whose PID is no longer alive', (t) => { - const configDir = createTempDir(); - t.after(() => cleanup(configDir)); - - const deadPid = findDeadPid(); - writeLockFile(configDir, deadPid); - - const result = runInstallerMigrations({ - configDir, - migrations: [], - lockTimeoutMs: 200, - }); - - assert.ok(result, 'runInstallerMigrations must return a result object'); - assert.equal( - fs.existsSync(lockPath(configDir)), - false, - 'lock file must not remain on disk after stale-PID reclamation' - ); -}); - -// --------------------------------------------------------------------------- -// T3: Windows EPERM simulation — unlinkSync failure surfaces (not silently swallowed) -// --------------------------------------------------------------------------- -test('T3: lock release does not silently leave lock file on disk when unlink fails (Windows EPERM simulation)', (t) => { - const configDir = createTempDir(); - const originalUnlinkSync = fs.unlinkSync; - - t.after(() => { - fs.unlinkSync = originalUnlinkSync; - cleanup(configDir); - }); - - // The fix uses fs.unlinkSync (not fs.rmSync with { force: true }) in the - // release closure. Inject EPERM on the lock file to simulate the Windows - // NTFS condition where the recently-closed handle has not been fully - // released by the OS. - // - // The fix's contract: EPERM must NOT be silently swallowed. - // Either (a) the error propagates as a releaseError, or (b) some alternative - // deletion path succeeds. Silent-swallow (no error + file still exists) is - // the failure condition we guard against. - let unlinkCallCount = 0; - fs.unlinkSync = function faultInjectUnlinkSync(targetPath) { - const isLock = path.basename(String(targetPath)) === INSTALL_MIGRATION_LOCK_NAME; - if (isLock) { - unlinkCallCount++; - // Simulate Windows EPERM (file handle not fully released by OS) - const err = Object.assign( - new Error('EPERM: operation not permitted, unlink ' + targetPath), - { code: 'EPERM' } - ); - throw err; - } - return originalUnlinkSync.call(fs, targetPath); - }; - - // With the fix: unlinkSync throws EPERM → releaseError is thrown by the - // release closure → runInstallerMigrations throws releaseError. - // With the buggy code (rmSync + force:true): EPERM was swallowed silently, - // no error thrown, lock file left on disk. - // - // Assert: if the call succeeds (no throw), the lock file must be gone. - // If the call throws, the error message must reference the lock. - let threw = false; - let thrownError = null; - try { - runInstallerMigrations({ - configDir, - migrations: [], - lockTimeoutMs: 500, - }); - } catch (err) { - threw = true; - thrownError = err; - } - - if (threw) { - // Acceptable: error surfaced. Verify it's lock-related (not a bug elsewhere). - assert.match( - thrownError.message, - /lock/i, - 'thrown error must reference the lock file' - ); - } else { - // If no error was thrown, the lock file must have been removed by some - // alternative path (not left silently on disk). - assert.equal( - fs.existsSync(lockPath(configDir)), - false, - 'if unlinkSync EPERM is encountered but no error thrown, lock file must still be removed' - ); - } - - // Sanity: the fault injection was actually triggered. - assert.ok(unlinkCallCount > 0, 'unlinkSync must have been called for the lock file at least once'); -}); - -// --------------------------------------------------------------------------- -// T4: Counter-test — normal single acquire/release round-trip still works -// --------------------------------------------------------------------------- -test('T4: normal (non-recursive) runInstallerMigrations acquires and releases lock correctly', (t) => { - const configDir = createTempDir(); - t.after(() => cleanup(configDir)); - - // No pre-seeded lock. Standard happy path. - const result = runInstallerMigrations({ - configDir, - migrations: [], - }); - - assert.ok(result, 'runInstallerMigrations must return a result'); - assert.equal( - fs.existsSync(lockPath(configDir)), - false, - 'lock file must be cleaned up after normal completion' - ); -}); - -// --------------------------------------------------------------------------- -// T5: Counter-test — unreclaimable live lock must surface a bounded error -// --------------------------------------------------------------------------- -// This test guards against over-reclamation: if the reclaim-unlink fails -// (e.g. Windows EPERM on a live open handle), the fix must NOT spin -// indefinitely — it must fall through to the timeout path and throw. -// -// Conditions forced by this test: -// 1. Lock file contains the CURRENT process.pid (triggers isSameProcess branch). -// 2. fs.unlinkSync is mocked to throw EPERM for the lock file (reclaim fails). -// 3. lockTimeoutMs: 200 — timeout must fire within a short wall-clock window. -// -// Expected outcome: throws with /installer migration lock is held/ within -// ~200ms. SUCCESS (no throw) is NOT acceptable here — that would mean the fix -// over-reclaimed a lock that it couldn't actually remove. -test('T5: unreclaimable same-PID lock throws bounded error (reclaim-unlink failure falls through to timeout)', (t) => { - const configDir = createTempDir(); - const originalUnlinkSync = fs.unlinkSync; - - t.after(() => { - mock.restoreAll(); - fs.unlinkSync = originalUnlinkSync; - cleanup(configDir); - }); - - // Pre-seed lock file with the CURRENT process's PID. - // This triggers the isSameProcess reclamation path inside acquireInstallerMigrationLock. - writeLockFile(configDir, process.pid); - - // Mock unlinkSync to throw EPERM for the lock file only. - // This simulates Windows NTFS refusing to delete a file with an open handle. - // With the fix: reclaim-unlink fails → reclaimed=false → falls through to - // the timeout check → throws "installer migration lock is held" after ≤200ms. - // Without the fix (original code): unlink throws but continue runs anyway → - // spins indefinitely, never reaches the timeout check → deadlock. - mock.method(fs, 'unlinkSync', function faultInjectUnlinkSync(targetPath) { - const isLock = path.basename(String(targetPath)) === INSTALL_MIGRATION_LOCK_NAME; - if (isLock) { - const err = Object.assign( - new Error('EPERM: operation not permitted, unlink ' + targetPath), - { code: 'EPERM' } - ); - throw err; - } - return originalUnlinkSync.call(fs, targetPath); - }); - - assert.throws( - () => runInstallerMigrations({ - configDir, - migrations: [], - lockTimeoutMs: 200, - }), - (err) => { - assert.match(err.message, /installer migration lock is held/, 'error must name the held lock'); - return true; - }, - 'must throw "installer migration lock is held" when reclaim-unlink fails — not spin indefinitely' - ); -}); diff --git a/tests/bug-3735-profiles-core-includes-surface.test.cjs b/tests/bug-3735-profiles-core-includes-surface.test.cjs deleted file mode 100644 index 6f810a6af..000000000 --- a/tests/bug-3735-profiles-core-includes-surface.test.cjs +++ /dev/null @@ -1,48 +0,0 @@ -'use strict'; -/** - * Regression test for #3735: PROFILES.core must include 'surface' in its - * resolved closure so that --profile=core users can expand via - * /gsd:surface enable — the advertised use-case from ADR-0011. - * - * Stage 2 (RED): This test must fail before the fix is applied. - * Stage 3 (GREEN): This test must pass after 'surface' is added to PROFILES.core. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('path'); - -const { - resolveProfile, - loadSkillsManifest, -} = require('../gsd-core/bin/lib/install-profiles.cjs'); - -const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); - -describe('PROFILES.core — ADR-0011 expand contract', () => { - test("PROFILES.core includes 'surface' so users can expand via /gsd:surface enable", () => { - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const result = resolveProfile({ modes: ['core'], manifest }); - - assert.ok(result.skills instanceof Set, - 'resolveProfile must return a skills Set for core profile'); - - // The primary assertion: surface must be in the resolved closure. - // ADR-0011 documents that --profile=core users expand via /gsd:surface enable . - // That sub-command is only available if surface.md is staged — which requires it to be - // in the resolved set for the core profile. - assert.ok(result.skills.has('surface'), - `PROFILES.core resolved closure must include 'surface'; got: [${[...result.skills].sort().join(', ')}]`); - }); - - // Counter-test: 'forensics' is NOT in core — proves the assertion above is selective, - // not vacuously true for all skills. - test("PROFILES.core does NOT include 'forensics' (selective assertion counter-check)", () => { - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const result = resolveProfile({ modes: ['core'], manifest }); - - assert.ok(result.skills instanceof Set); - assert.ok(!result.skills.has('forensics'), - `'forensics' should NOT be in core closure — it is a specialist skill, not a core loop skill`); - }); -}); diff --git a/tests/bug-378-update-check-scoped-name.test.cjs b/tests/bug-378-update-check-scoped-name.test.cjs deleted file mode 100644 index d73c6ba05..000000000 --- a/tests/bug-378-update-check-scoped-name.test.cjs +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Regression test for #378 / #498: the SessionStart update worker must end up - * querying the SCOPED package name (@opengsd/gsd-core) when it asks - * npm for the latest version. - * - * Background (#378): the worker once hardcoded the unscoped 'gsd-core', - * which 404s from the registry, leaving update_available permanently false. - * - * Original #378 fix derived the name from `require('../package.json').name`. - * That is broken at runtime (#498): the installed tree carries only a synthetic - * `{"type":"commonjs"}` package.json (no `.name`), so post-install the worker - * queried `npm view undefined version` → latest stayed null → update_available - * permanently false. The old structural test passed only because it grepped the - * DEV tree, where package.json still has a name. - * - * New contract (#498): the worker no longer resolves the package name itself. - * It delegates the latest-version lookup to check-latest-version.cjs's - * `checkLatestVersion()`, whose `PACKAGE_NAME` is sourced from the baked Package - * Identity seam (`gsd-core/bin/lib/package-identity.cjs`). The seam's value - * is a build-time constant, correct in every install layout, so the - * undefined-at-runtime failure cannot recur. This test locks that contract: - * - * 1. Structural: worker must NOT contain the bare unscoped literal. - * 2. Structural: worker must NOT use `require(...package.json...).name` - * (the runtime-broken path). - * 3. Structural: worker delegates to check-latest-version's - * `checkLatestVersion` rather than calling `npm view` itself. - * 4. Single-source: check-latest-version's PACKAGE_NAME === the seam's - * packageName === the scoped '@opengsd/gsd-core'. - * - * Source-grep policy: this test reads hook source via readFileSync. The repo's - * lint-no-source-grep rule targets bin/lib/gsd-core — hooks/ is out of - * scope. The behavior (correct name → no E404) only manifests at runtime - * against the live registry; structural assertions are the minimum-cost - * contract for the worker, the same rationale #378 carried. - */ - -// allow-test-rule: structural assertion on hook delegation; the behavior being -// tested (correct package name → no E404) only manifests at runtime against the -// live npm registry, which CI does not call. - -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const WORKER_PATH = path.join(__dirname, '..', 'hooks', 'gsd-check-update-worker.js'); -const PKG_PATH = path.join(__dirname, '..', 'package.json'); -const SEAM = require('../gsd-core/bin/lib/package-identity.cjs'); -const { PACKAGE_NAME } = require('../gsd-core/bin/check-latest-version.cjs'); - -function workerCodeOnly() { - const src = fs.readFileSync(WORKER_PATH, 'utf8'); - return src - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/(^|[^:])\/\/[^\r\n]*/g, '$1'); -} - -describe('bug #378 / #498: update worker queries the scoped name via the seam', () => { - test('worker file exists', () => { - assert.ok(fs.existsSync(WORKER_PATH), `worker not found at ${WORKER_PATH}`); - }); - - test('package.json name is the scoped @opengsd/gsd-core', () => { - const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf8')); - assert.equal(pkg.name, '@opengsd/gsd-core'); - }); - - test('worker does NOT hardcode the unscoped gsd-core as a string literal', () => { - assert.doesNotMatch( - workerCodeOnly(), - /['"]gsd-core['"]/, - "Worker must not pass the unscoped 'gsd-core' to npm — it 404s.", - ); - }); - - test('worker does NOT resolve the name via require(package.json).name (broken at runtime)', () => { - assert.doesNotMatch( - workerCodeOnly(), - /require\s*\(\s*['"][^'"]*package\.json['"]\s*\)\s*\.name/, - [ - 'require(package.json).name resolves to undefined in the installed tree', - '(only a {"type":"commonjs"} marker ships). The worker must delegate to', - 'checkLatestVersion(), which sources the name from the baked seam.', - ].join(' '), - ); - }); - - test('worker delegates the latest-version lookup to checkLatestVersion', () => { - const code = workerCodeOnly(); - assert.match( - code, - /check-latest-version/, - 'Worker must require check-latest-version.cjs and call checkLatestVersion().', - ); - assert.match(code, /checkLatestVersion\s*\(/); - }); - - test('check-latest-version PACKAGE_NAME is single-sourced from the seam', () => { - assert.equal(PACKAGE_NAME, SEAM.packageName); - assert.equal(SEAM.packageName, '@opengsd/gsd-core'); - }); -}); diff --git a/tests/bug-947-hermes-gsd-prefix.test.cjs b/tests/bug-947-hermes-gsd-prefix.test.cjs deleted file mode 100644 index 458b096dc..000000000 --- a/tests/bug-947-hermes-gsd-prefix.test.cjs +++ /dev/null @@ -1,454 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Reads installed .md product artefacts from a real install run — -// testing their on-disk layout + frontmatter tests the deployed contract. - -/** - * Regression test: #947 — Hermes skills must install with canonical gsd- prefix. - * - * Prior to this fix, Hermes installed skills at skills/gsd//SKILL.md - * with frontmatter `name: ` (e.g. name: quick), causing invocation as - * /quick instead of /gsd-quick. This file asserts the corrected behaviour: - * - Fresh install → skills/gsd/gsd-/SKILL.md, name: gsd- - * - The skills/gsd/ category bucket and its DESCRIPTION.md are retained - * - Migration: prior bare-stem dirs (skills/gsd//) are removed - * on reinstall; no orphaned bare-stem directories remain. - * - * Runtime: node:test, node:assert/strict. No Jest. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -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 os = require('node:os'); - -const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); -const { parseFrontmatter, cleanup } = require('./helpers.cjs'); -const { - loadSkillsManifest, - resolveProfile, -} = require('../gsd-core/bin/lib/install-profiles.cjs'); - -// --------------------------------------------------------------------------- -// Shared fixture: a minimal commands/gsd/ source with two skills -// --------------------------------------------------------------------------- - -/** - * Write a minimal commands/gsd/ source tree with the given stem names. - * Returns the path to the commands/gsd directory (used as .gsd-source value). - */ -function writeMinimalSourceTree(baseDir, stems) { - const srcDir = path.join(baseDir, 'src', 'commands', 'gsd'); - fs.mkdirSync(srcDir, { recursive: true }); - for (const stem of stems) { - fs.writeFileSync(path.join(srcDir, `${stem}.md`), [ - '---', - `name: gsd:${stem}`, - `description: ${stem} task description`, - 'allowed-tools:', - ' - Read', - ' - Bash', - '---', - '', - `${stem} body`, - ].join('\n')); - } - return srcDir; -} - -const MANIFEST = loadSkillsManifest(); -const RESOLVED_FULL = resolveProfile({ modes: [], manifest: MANIFEST }); - -// --------------------------------------------------------------------------- -// #947 regression: fresh install produces prefixed layout -// --------------------------------------------------------------------------- - -describe('#947 Hermes: fresh install → gsd- prefixed layout', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-947-fresh-')); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('skill lands at skills/gsd/gsd-/SKILL.md (NOT skills/gsd//SKILL.md)', () => { - const srcDir = writeMinimalSourceTree(tmpDir, ['quick']); - const configDir = path.join(tmpDir, 'dest'); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); - - installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); - - // Correct (post-fix) path: skills/gsd/gsd-quick/SKILL.md - const correctPath = path.join(configDir, 'skills', 'gsd', 'gsd-quick', 'SKILL.md'); - assert.ok(fs.existsSync(correctPath), - 'skills/gsd/gsd-quick/SKILL.md must exist (canonical gsd- prefix)'); - - // Old (bare-stem) path must NOT exist - const bareStemPath = path.join(configDir, 'skills', 'gsd', 'quick', 'SKILL.md'); - assert.ok(!fs.existsSync(bareStemPath), - 'skills/gsd/quick/SKILL.md must NOT exist (bare-stem path is wrong)'); - }); - - test('SKILL.md frontmatter name is gsd- (NOT bare )', () => { - const srcDir = writeMinimalSourceTree(tmpDir, ['plan']); - const configDir = path.join(tmpDir, 'dest'); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); - - installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); - - const skillPath = path.join(configDir, 'skills', 'gsd', 'gsd-plan', 'SKILL.md'); - assert.ok(fs.existsSync(skillPath), 'skills/gsd/gsd-plan/SKILL.md must exist'); - - const content = fs.readFileSync(skillPath, 'utf8'); - const fm = parseFrontmatter(content); - assert.strictEqual(fm.name, 'gsd-plan', - `frontmatter name must be 'gsd-plan', got '${fm.name}'`); - }); - - test('gsd- identifier satisfies Hermes name rule ^[a-z][a-z0-9_-]*$', () => { - const srcDir = writeMinimalSourceTree(tmpDir, ['plan-phase', 'code-review']); - const configDir = path.join(tmpDir, 'dest'); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); - - installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); - - const HERMES_NAME_RE = /^[a-z][a-z0-9_-]*$/; - for (const stem of ['plan-phase', 'code-review']) { - const skillPath = path.join(configDir, 'skills', 'gsd', `gsd-${stem}`, 'SKILL.md'); - assert.ok(fs.existsSync(skillPath), `skills/gsd/gsd-${stem}/SKILL.md must exist`); - const content = fs.readFileSync(skillPath, 'utf8'); - const fm = parseFrontmatter(content); - assert.ok(HERMES_NAME_RE.test(fm.name), - `name '${fm.name}' must satisfy Hermes identifier rule ${HERMES_NAME_RE}`); - assert.strictEqual(fm.name, `gsd-${stem}`, - `name must be 'gsd-${stem}', got '${fm.name}'`); - } - }); - - test('skills/gsd/ category bucket is retained (not flattened to top-level skills/)', () => { - const srcDir = writeMinimalSourceTree(tmpDir, ['quick']); - const configDir = path.join(tmpDir, 'dest'); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); - - installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); - - // Skill must be INSIDE skills/gsd/ — not at skills/gsd-quick/ directly - const categoryBucket = path.join(configDir, 'skills', 'gsd'); - assert.ok(fs.existsSync(categoryBucket), - 'skills/gsd/ category directory must be retained'); - - // Flat (non-categorised) path must NOT exist - const flatPath = path.join(configDir, 'skills', 'gsd-quick'); - assert.ok(!fs.existsSync(flatPath), - 'skills/gsd-quick/ (flat, non-categorised) must NOT exist for Hermes'); - }); - - test('skills/gsd/ category directory exists (bucket retained after install)', () => { - // Note: DESCRIPTION.md is written by writeHermesCategoryDescription which is - // called from the top-level installGsd flow (not inside installRuntimeArtifacts). - // This test confirms the category bucket itself is present post-install. - const srcDir = writeMinimalSourceTree(tmpDir, ['quick']); - const configDir = path.join(tmpDir, 'dest'); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); - - installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); - - const categoryBucket = path.join(configDir, 'skills', 'gsd'); - assert.ok(fs.existsSync(categoryBucket), - 'skills/gsd/ category directory must exist after Hermes install'); - assert.ok(fs.statSync(categoryBucket).isDirectory(), - 'skills/gsd/ must be a directory, not a file'); - }); - - test('multiple skills all get gsd- prefix', () => { - const srcDir = writeMinimalSourceTree(tmpDir, ['quick', 'plan', 'review']); - const configDir = path.join(tmpDir, 'dest'); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); - - installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); - - for (const stem of ['quick', 'plan', 'review']) { - const correctPath = path.join(configDir, 'skills', 'gsd', `gsd-${stem}`, 'SKILL.md'); - assert.ok(fs.existsSync(correctPath), - `skills/gsd/gsd-${stem}/SKILL.md must exist`); - const bareStem = path.join(configDir, 'skills', 'gsd', stem, 'SKILL.md'); - assert.ok(!fs.existsSync(bareStem), - `bare-stem path skills/gsd/${stem}/SKILL.md must NOT exist`); - } - }); -}); - -// --------------------------------------------------------------------------- -// #947 regression: migration from prior bare-stem install -// --------------------------------------------------------------------------- - -describe('#947 Hermes: migration from prior bare-stem install', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-947-migrate-')); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('bare-stem dirs from prior install are removed on reinstall', () => { - const srcDir = writeMinimalSourceTree(tmpDir, ['quick']); - const configDir = path.join(tmpDir, 'dest'); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); - - // Seed a prior bare-stem install: skills/gsd/quick/SKILL.md - const legacySkillDir = path.join(configDir, 'skills', 'gsd', 'quick'); - fs.mkdirSync(legacySkillDir, { recursive: true }); - fs.writeFileSync(path.join(legacySkillDir, 'SKILL.md'), [ - '---', - 'name: quick', - 'description: Quick task (legacy bare-stem)', - '---', - '', - 'Legacy body.', - ].join('\n')); - - installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); - - // Bare-stem dir must be gone (migrated) - assert.ok(!fs.existsSync(legacySkillDir), - 'skills/gsd/quick/ (bare-stem legacy dir) must be removed on reinstall'); - - // Prefixed dir must exist - const newPath = path.join(configDir, 'skills', 'gsd', 'gsd-quick', 'SKILL.md'); - assert.ok(fs.existsSync(newPath), - 'skills/gsd/gsd-quick/SKILL.md must exist after migration'); - }); - - test('reinstall over bare-stem install leaves NO orphaned bare-stem dirs', () => { - const srcDir = writeMinimalSourceTree(tmpDir, ['quick', 'plan']); - const configDir = path.join(tmpDir, 'dest'); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); - - // Seed two bare-stem dirs - for (const stem of ['quick', 'plan']) { - const dir = path.join(configDir, 'skills', 'gsd', stem); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${stem}\ndescription: ${stem}\n---\n`); - } - - installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); - - const gsdCategoryDir = path.join(configDir, 'skills', 'gsd'); - const entries = fs.readdirSync(gsdCategoryDir, { withFileTypes: true }); - - // Check NO bare-stem dirs remain - for (const entry of entries) { - if (!entry.isDirectory()) continue; - // Bare-stem dirs: name does NOT start with 'gsd-' and is not a known exception - // (DESCRIPTION.md is a file so it won't appear in isDirectory check) - assert.ok( - entry.name.startsWith('gsd-'), - `All dirs under skills/gsd/ must start with 'gsd-'. Found bare-stem: '${entry.name}'`, - ); - } - }); - - test('pre-#2841 flat skills/gsd-/ dirs are still removed (existing migration path)', () => { - const srcDir = writeMinimalSourceTree(tmpDir, ['quick']); - const configDir = path.join(tmpDir, 'dest'); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); - - // Seed a pre-#2841 flat skill dir: skills/gsd-quick/SKILL.md - const flatSkillDir = path.join(configDir, 'skills', 'gsd-quick'); - fs.mkdirSync(flatSkillDir, { recursive: true }); - fs.writeFileSync(path.join(flatSkillDir, 'SKILL.md'), '---\nname: gsd-quick\n---\nOld flat.'); - - installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); - - // The pre-#2841 flat dir must still be cleaned up - assert.ok(!fs.existsSync(flatSkillDir), - 'Pre-#2841 flat skills/gsd-quick/ dir must be removed (existing migration)'); - - // The correct post-fix dir must exist - assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd', 'gsd-quick', 'SKILL.md')), - 'skills/gsd/gsd-quick/SKILL.md must exist after install'); - }); -}); - -// --------------------------------------------------------------------------- -// #947 adversarial-review: bare-stem cleanup derived from installed set -// (not readGsdCommandNames) — covers skills missing from the commands dir -// --------------------------------------------------------------------------- - -describe('#947 Hermes: adversarial-review bare-stem cleanup (installed-set derivation)', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-947-adv-')); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('bare skills/gsd/dev-preferences/ is removed when gsd-dev-preferences/ is installed this run', () => { - // Seed a source tree that includes a 'dev-preferences' skill (e.g. the user's - // commands/gsd/dev-preferences.md, or any skill whose stem is NOT normally in - // the shipped readGsdCommandNames() set). The old cleanup (readGsdCommandNames- - // based) would MISS this bare dir because readGsdCommandNames() reads GSD's - // shipped source, not the user's actual install state. - const srcDir = writeMinimalSourceTree(tmpDir, ['quick', 'dev-preferences']); - const configDir = path.join(tmpDir, 'dest'); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); - - // Seed the legacy bare-stem dir: skills/gsd/dev-preferences/ (pre-#947 install) - const bareLegacyDir = path.join(configDir, 'skills', 'gsd', 'dev-preferences'); - fs.mkdirSync(bareLegacyDir, { recursive: true }); - fs.writeFileSync(path.join(bareLegacyDir, 'SKILL.md'), [ - '---', - 'name: dev-preferences', - 'description: My dev preferences (legacy bare-stem)', - '---', - '', - 'Legacy body.', - ].join('\n')); - - installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); - - // gsd-dev-preferences/ must be installed (new prefixed form) - const newPath = path.join(configDir, 'skills', 'gsd', 'gsd-dev-preferences', 'SKILL.md'); - assert.ok(fs.existsSync(newPath), - 'skills/gsd/gsd-dev-preferences/SKILL.md must exist after install'); - - // Bare-stem dir must be gone — even though 'dev-preferences' is NOT in the - // shipped readGsdCommandNames() set (it was user-sourced). The fix derives - // the removal set from gsd-/ dirs installed this run. - assert.ok(!fs.existsSync(bareLegacyDir), - 'skills/gsd/dev-preferences/ (bare-stem) must be removed when gsd-dev-preferences/ was installed'); - }); - - test('user-owned bare dir with no gsd- counterpart is preserved (no over-deletion)', () => { - // A user has a dir 'skills/gsd/my-custom-workflow/' that is NOT a GSD shipped - // skill — GSD never installs 'gsd-my-custom-workflow/'. This dir must survive. - const srcDir = writeMinimalSourceTree(tmpDir, ['quick']); - const configDir = path.join(tmpDir, 'dest'); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); - - // Seed user-owned bare dir: no corresponding gsd-my-custom-workflow/ will be installed - const userOwnedDir = path.join(configDir, 'skills', 'gsd', 'my-custom-workflow'); - fs.mkdirSync(userOwnedDir, { recursive: true }); - fs.writeFileSync(path.join(userOwnedDir, 'SKILL.md'), [ - '---', - 'name: my-custom-workflow', - 'description: My personal workflow', - '---', - '', - 'Custom body.', - ].join('\n')); - - installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); - - // User-owned dir must survive — no gsd-my-custom-workflow/ was installed, - // so the removal rule (only remove / when gsd-/ exists) protects it. - assert.ok(fs.existsSync(userOwnedDir), - 'User-owned skills/gsd/my-custom-workflow/ must be preserved (no gsd-my-custom-workflow/ installed)'); - assert.ok(fs.existsSync(path.join(userOwnedDir, 'SKILL.md')), - 'User-owned SKILL.md inside the dir must be preserved'); - }); -}); - -// --------------------------------------------------------------------------- -// #947 regression: manifest/listing prefix -// --------------------------------------------------------------------------- - -describe('#947 Hermes: manifest and skill-listing use gsd- prefix', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-947-manifest-')); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('gsd-manifest.json skill entries use skills/gsd/gsd-/ paths', () => { - const srcDir = writeMinimalSourceTree(tmpDir, ['quick']); - const configDir = path.join(tmpDir, 'dest'); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); - - installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); - - // The manifest file lives at gsd-core/gsd-manifest.json inside configDir - const manifestPath = path.join(configDir, 'gsd-core', 'gsd-manifest.json'); - if (!fs.existsSync(manifestPath)) return; // manifest optional in test mode - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - const keys = Object.keys(manifest.files || {}); - // Any key for the quick skill must use gsd-quick not bare quick - const bareKey = keys.find(k => k.includes('skills/gsd/quick/')); - assert.ok(!bareKey, - `manifest must not contain bare-stem key 'skills/gsd/quick/', found: ${bareKey}`); - const prefixedKey = keys.find(k => k.includes('skills/gsd/gsd-quick/')); - assert.ok(prefixedKey, - 'manifest must contain prefixed key containing skills/gsd/gsd-quick/'); - }); -}); - -// --------------------------------------------------------------------------- -// #947 regression: non-Hermes runtimes unaffected -// --------------------------------------------------------------------------- - -describe('#947 Non-Hermes runtimes: unaffected by this change', () => { - // Spot-check claude (global/flat) and cline (global/nested) to confirm - // they are not disturbed by the Hermes prefix fix. - - test('claude global install still produces flat skills/gsd-/ layout', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-947-claude-')); - try { - installRuntimeArtifacts('claude', tmpDir, 'global', RESOLVED_FULL); - const skillsDir = path.join(tmpDir, 'skills'); - assert.ok(fs.existsSync(skillsDir), 'skills/ must exist for claude global'); - const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); - const gsdEntries = entries.filter(e => e.isDirectory() && e.name.startsWith('gsd-')); - assert.ok(gsdEntries.length >= 10, - `claude must still emit >= 10 gsd-* skill dirs, got ${gsdEntries.length}`); - // No skills/gsd/ category bucket (that is Hermes-specific) - assert.ok(!fs.existsSync(path.join(skillsDir, 'gsd')), - 'claude must NOT have a skills/gsd/ category bucket (that is Hermes-only)'); - } finally { - cleanup(tmpDir); - } - }); - - test('cline global install still produces skills/ with gsd- prefix nested layout', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-947-cline-')); - try { - installRuntimeArtifacts('cline', tmpDir, 'global', RESOLVED_FULL); - const skillsDir = path.join(tmpDir, 'skills'); - assert.ok(fs.existsSync(skillsDir), 'skills/ must exist for cline global'); - const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); - const routerDirs = entries.filter(e => e.isDirectory() && e.name.startsWith('gsd-ns-')); - assert.ok(routerDirs.length > 0, - 'cline must still emit gsd-ns-* router dirs with gsd- prefix'); - } finally { - cleanup(tmpDir); - } - }); -}); diff --git a/tests/enh-191-retire-sdk-package.test.cjs b/tests/enh-191-retire-sdk-package.test.cjs deleted file mode 100644 index 8432c3227..000000000 --- a/tests/enh-191-retire-sdk-package.test.cjs +++ /dev/null @@ -1,62 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const ROOT = path.resolve(__dirname, '..'); -const PKG_PATH = path.join(ROOT, 'package.json'); -const INSTALL_PATH = path.join(ROOT, 'bin', 'install.js'); -const ACTIVE_GUIDANCE_PATHS = [ - 'docs/contributing/bootstrap.md', -]; - -function readPackageJson() { - return JSON.parse(fs.readFileSync(PKG_PATH, 'utf8')); -} - -test('enhancement #191: sdk package artifacts are removed from repository layout', () => { - const sdkDir = path.join(ROOT, 'sdk'); - const shimPath = path.join(ROOT, 'bin', 'gsd-sdk.js'); - - assert.equal(fs.existsSync(sdkDir), false, 'sdk/ directory must be deleted'); - assert.equal(fs.existsSync(shimPath), false, 'bin/gsd-sdk.js must be deleted'); -}); - -test('enhancement #191: published package no longer exposes gsd-sdk artifacts', () => { - const pkg = readPackageJson(); - - assert.equal(Object.prototype.hasOwnProperty.call(pkg.bin || {}, 'gsd-sdk'), false, - 'package.json bin must not expose gsd-sdk'); - assert.equal(pkg.bin && pkg.bin['gsd-tools'], 'gsd-core/bin/gsd-tools.cjs', - 'package.json bin.gsd-tools must point to gsd-core/bin/gsd-tools.cjs'); - - const publishedFiles = Array.isArray(pkg.files) ? pkg.files : []; - const hasSdkPublishedPaths = publishedFiles.some((entry) => String(entry).startsWith('sdk')); - assert.equal(hasSdkPublishedPaths, false, - 'package.json files must not include sdk artifacts'); -}); - -test('enhancement #191: installer does not maintain gsd-sdk shim compatibility path', () => { - const installJs = fs.readFileSync(INSTALL_PATH, 'utf8'); - - assert.equal(/\b--sdk\b/.test(installJs), false, - 'bin/install.js must not expose --sdk flag'); - assert.equal(/\b--no-sdk\b/.test(installJs), false, - 'bin/install.js must not expose --no-sdk flag'); - assert.equal(/installSdkIfNeeded\(\{/.test(installJs), false, - 'bin/install.js must not run installSdkIfNeeded during installation'); -}); - -test('enhancement #191: active contributor guidance does not reference retired SDK build steps', () => { - for (const relPath of ACTIVE_GUIDANCE_PATHS) { - const body = fs.readFileSync(path.join(ROOT, relPath), 'utf8'); - - assert.equal( - /\bbuild:sdk\b|\bcd sdk\b|\bsdk\/dist\b|\bsdk\/src\b/.test(body), - false, - `${relPath} must not direct contributors or agents to use the retired SDK package workflow`, - ); - } -}); diff --git a/tests/enh-2538-statusline-last-command.test.cjs b/tests/enh-2538-statusline-last-command.test.cjs deleted file mode 100644 index 1b408e4d7..000000000 --- a/tests/enh-2538-statusline-last-command.test.cjs +++ /dev/null @@ -1,127 +0,0 @@ -'use strict'; - -/** - * Enhancement #2538 — statusline `last: /cmd` suffix. - * - * Asserts that: - * - default (flag absent) output does NOT include "last:" text - * - with statusline.show_last_command=true AND a transcript containing - * /gsd-plan-phase, output includes "last: /gsd-plan-phase" - * - a missing transcript_path does not throw and produces no "last:" suffix - * - an existing transcript with no slash commands produces no "last:" suffix - * - the config key is registered in the schema so /gsd-settings can surface it - */ - -const { 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 { cleanup } = require('./helpers.cjs'); - -const statusline = require('../hooks/gsd-statusline.js'); -const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs'); - -function makeProject({ flag, transcript }) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'enh-2538-')); - fs.mkdirSync(path.join(dir, '.planning'), { recursive: true }); - if (flag !== undefined) { - fs.writeFileSync( - path.join(dir, '.planning', 'config.json'), - JSON.stringify({ statusline: { show_last_command: flag } }), - ); - } - let transcriptPath = null; - if (transcript !== undefined) { - transcriptPath = path.join(dir, 'transcript.jsonl'); - fs.writeFileSync(transcriptPath, transcript); - } - return { dir, transcriptPath, cleanup: () => cleanup(dir) }; -} - -function buildInput(dir, transcriptPath) { - return { - model: { display_name: 'Claude' }, - workspace: { current_dir: dir }, - session_id: 'test-session', - transcript_path: transcriptPath, - }; -} - -test('config schema registers statusline.show_last_command', () => { - assert.ok( - VALID_CONFIG_KEYS.has('statusline.show_last_command'), - 'statusline.show_last_command must be in VALID_CONFIG_KEYS', - ); -}); - -test('default (flag absent) output has no "last:" suffix', () => { - const transcript = - JSON.stringify({ type: 'user', message: { content: '/gsd-plan-phase' } }) + '\n'; - const { dir, transcriptPath, cleanup } = makeProject({ transcript }); - try { - const out = statusline.renderStatusline(buildInput(dir, transcriptPath)); - assert.ok(!out.includes('last:'), `expected no "last:" in output; got: ${out}`); - } finally { - cleanup(); - } -}); - -test('flag=true with recorded command yields "last: /"', () => { - const transcript = - JSON.stringify({ type: 'user', message: { content: '/gsd-plan-phase' } }) + '\n' + - JSON.stringify({ type: 'assistant', message: { content: 'ok' } }) + '\n'; - const { dir, transcriptPath, cleanup } = makeProject({ flag: true, transcript }); - try { - const out = statusline.renderStatusline(buildInput(dir, transcriptPath)); - assert.ok(out.includes('last: /gsd-plan-phase'), `expected "last: /gsd-plan-phase" in output; got: ${out}`); - } finally { - cleanup(); - } -}); - -test('flag=true picks the MOST RECENT command when multiple are present', () => { - const transcript = - JSON.stringify({ type: 'user', message: { content: '/gsd-discuss-phase' } }) + '\n' + - JSON.stringify({ type: 'user', message: { content: '/gsd-plan-phase' } }) + '\n' + - JSON.stringify({ type: 'user', message: { content: '/gsd-execute-phase' } }) + '\n'; - const { dir, transcriptPath, cleanup } = makeProject({ flag: true, transcript }); - try { - const out = statusline.renderStatusline(buildInput(dir, transcriptPath)); - assert.ok(out.includes('last: /gsd-execute-phase'), `expected most-recent "gsd-execute-phase"; got: ${out}`); - assert.ok(!out.includes('last: /gsd-discuss-phase'), `should not show stale command; got: ${out}`); - } finally { - cleanup(); - } -}); - -test('flag=true with missing transcript_path does not throw and omits suffix', () => { - const { dir, cleanup } = makeProject({ flag: true }); - try { - let out; - assert.doesNotThrow(() => { - out = statusline.renderStatusline(buildInput(dir, undefined)); - }); - assert.ok(!out.includes('last:'), `expected no "last:" suffix when transcript missing; got: ${out}`); - } finally { - cleanup(); - } -}); - -test('flag=true with transcript lacking command tags omits suffix', () => { - const transcript = - JSON.stringify({ type: 'user', message: { content: 'just a plain prompt' } }) + '\n'; - const { dir, transcriptPath, cleanup } = makeProject({ flag: true, transcript }); - try { - const out = statusline.renderStatusline(buildInput(dir, transcriptPath)); - assert.ok(!out.includes('last:'), `expected no "last:" suffix with no commands; got: ${out}`); - } finally { - cleanup(); - } -}); - -test('readLastSlashCommand returns null for nonexistent paths', () => { - assert.strictEqual(statusline.readLastSlashCommand('/nonexistent/path.jsonl'), null); - assert.strictEqual(statusline.readLastSlashCommand(null), null); - assert.strictEqual(statusline.readLastSlashCommand(undefined), null); -}); diff --git a/tests/enh-2833-phase-lifecycle-statusline.test.cjs b/tests/enh-2833-phase-lifecycle-statusline.test.cjs deleted file mode 100644 index 4b96c6a5f..000000000 --- a/tests/enh-2833-phase-lifecycle-statusline.test.cjs +++ /dev/null @@ -1,302 +0,0 @@ -/** - * Tests for issue #2833 — phase-lifecycle status-line. - * - * Covers the additions made by the two preceding feat commits: - * - * 1. parseStateMd reads four new STATE.md frontmatter fields - * - active_phase - * - next_action - * - next_phases (YAML flow array) - * - progress (nested block: completed_phases / total_phases / percent) - * - * 2. formatGsdState renders three new scenes when those fields are populated - * - Scene 1: active_phase set → "Phase X.Y " - * - Scene 2: idle + next_action set → "next " - * - Scene 3: percent 100 / all done → "milestone complete" - * - Scene 4: default fallback → unchanged " · " - * - * 3. renderProgressBar() helper for the opt-in milestone bar. - * - * 4. Backward compatibility — existing STATE.md files (without any of the - * new fields) render byte-for-byte identically to v1.38.x. - */ - -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); - -const { - parseStateMd, - formatGsdState, -} = require('../hooks/gsd-statusline.js'); - -// ─── parseStateMd: new lifecycle fields ───────────────────────────────────── - -describe('parseStateMd #2833 lifecycle fields', () => { - test('reads active_phase from frontmatter', () => { - const content = [ - '---', - 'milestone: v2.0', - 'status: executing', - 'active_phase: "4.5"', - '---', - ].join('\n'); - const s = parseStateMd(content); - assert.equal(s.activePhase, '4.5'); - }); - - test('reads next_action from frontmatter', () => { - const content = [ - '---', - 'milestone: v2.0', - 'next_action: execute-phase', - '---', - ].join('\n'); - const s = parseStateMd(content); - assert.equal(s.nextAction, 'execute-phase'); - }); - - test('treats "null" literal as null for active_phase and next_action', () => { - const content = [ - '---', - 'active_phase: null', - 'next_action: null', - '---', - ].join('\n'); - const s = parseStateMd(content); - assert.equal(s.activePhase, null); - assert.equal(s.nextAction, null); - }); - - test('parses next_phases YAML flow array (single item)', () => { - const content = [ - '---', - 'next_phases: ["4.5"]', - '---', - ].join('\n'); - const s = parseStateMd(content); - assert.deepEqual(s.nextPhases, ['4.5']); - }); - - test('parses next_phases YAML flow array (multiple items)', () => { - const content = [ - '---', - 'next_phases: ["4.5", "4.6", "5"]', - '---', - ].join('\n'); - const s = parseStateMd(content); - assert.deepEqual(s.nextPhases, ['4.5', '4.6', '5']); - }); - - test('parses progress nested block — all three fields', () => { - const content = [ - '---', - 'progress:', - ' total_phases: 17', - ' completed_phases: 10', - ' percent: 59', - '---', - ].join('\n'); - const s = parseStateMd(content); - assert.equal(s.totalPhases, '17'); - assert.equal(s.completedPhases, '10'); - assert.equal(s.percent, '59'); - }); - - test('returns undefined for absent lifecycle fields', () => { - const content = [ - '---', - 'milestone: v1.9', - 'status: executing', - '---', - ].join('\n'); - const s = parseStateMd(content); - assert.equal(s.activePhase, undefined); - assert.equal(s.nextAction, undefined); - assert.equal(s.nextPhases, undefined); - assert.equal(s.percent, undefined); - }); -}); - -// ─── formatGsdState: new scenes ───────────────────────────────────────────── - -describe('formatGsdState #2833 lifecycle scenes', () => { - test('Scene 1 — active_phase set renders "Phase X.Y "', () => { - const out = formatGsdState({ - milestone: 'v2.0', - status: 'executing', - activePhase: '4.5', - percent: '59', - }); - assert.equal(out, 'v2.0 [█████░░░░░] 59% · Phase 4.5 executing'); - }); - - test('Scene 1 — active_phase without status renders "Phase X.Y"', () => { - const out = formatGsdState({ - milestone: 'v2.0', - activePhase: '4.5', - }); - assert.equal(out, 'v2.0 · Phase 4.5'); - }); - - test('Scene 2 — idle + next_action renders "next "', () => { - const out = formatGsdState({ - milestone: 'v2.0', - activePhase: null, - nextAction: 'execute-phase', - nextPhases: ['4.5'], - percent: '59', - }); - assert.equal(out, 'v2.0 [█████░░░░░] 59% · next execute-phase 4.5'); - }); - - test('Scene 2 — multiple next_phases joined with /', () => { - const out = formatGsdState({ - milestone: 'v2.0', - nextAction: 'discuss-phase', - nextPhases: ['4.7', '6.5'], - }); - assert.equal(out, 'v2.0 · next discuss-phase 4.7/6.5'); - }); - - test('Scene 3 — percent=100 renders "milestone complete"', () => { - const out = formatGsdState({ - milestone: 'v2.0', - percent: '100', - }); - assert.equal(out, 'v2.0 [██████████] 100% · milestone complete'); - }); - - test('Scene 3 — completed_phases equals total_phases also triggers complete', () => { - const out = formatGsdState({ - milestone: 'v2.0', - completedPhases: '17', - totalPhases: '17', - }); - assert.equal(out, 'v2.0 · milestone complete'); - }); -}); - -// ─── Backward compatibility — CRITICAL: existing STATE.md unchanged ───────── - -describe('formatGsdState #2833 backward compatibility', () => { - test('legacy STATE.md (only status + milestone + phase) renders unchanged', () => { - // Identical to the format documented in #1989 (the foundation issue). - // No new lifecycle fields populated → must render exactly as v1.38.x did. - const out = formatGsdState({ - status: 'executing', - milestone: 'v1.9', - milestoneName: 'Code Quality', - phaseNum: '1', - phaseTotal: '5', - phaseName: 'fix-graphiti-deployment', - }); - assert.equal(out, 'v1.9 Code Quality · executing · fix-graphiti-deployment (1/5)'); - }); - - test('only status set (no phase, no lifecycle fields) renders just " · "', () => { - const out = formatGsdState({ - milestone: 'v1.9', - status: 'executing', - }); - assert.equal(out, 'v1.9 · executing'); - }); - - test('empty state renders empty string', () => { - const out = formatGsdState({}); - assert.equal(out, ''); - }); - - test('progress.percent is opt-in — absent percent leaves milestone segment unchanged', () => { - const out = formatGsdState({ - milestone: 'v1.9', - milestoneName: 'Code Quality', - status: 'executing', - }); - // No bar rendered when percent is absent. - assert.equal(out, 'v1.9 Code Quality · executing'); - }); -}); - -// ─── renderProgressBar (exported indirectly via formatGsdState behavior) ──── - -describe('progress bar rendering', () => { - test('0% renders 10 empty segments', () => { - // percent=0 doesn't trigger Scene 3 (only percent='100' does), so - // Scene 4 fallback fires with no extra parts — just milestone + bar. - const out = formatGsdState({ milestone: 'v2.0', percent: '0' }); - assert.ok(out.includes('[░░░░░░░░░░] 0%')); - }); - - test('50% renders 5 filled + 5 empty', () => { - const out = formatGsdState({ milestone: 'v2.0', percent: '50' }); - assert.ok(out.includes('[█████░░░░░] 50%')); - }); - - test('100% renders 10 filled (and triggers Scene 3)', () => { - const out = formatGsdState({ milestone: 'v2.0', percent: '100' }); - assert.equal(out, 'v2.0 [██████████] 100% · milestone complete'); - }); - - test('percent absent → no bar rendered (opt-in)', () => { - const out = formatGsdState({ milestone: 'v2.0', status: 'executing' }); - assert.ok(!out.includes('[')); - assert.ok(!out.includes('░')); - assert.ok(!out.includes('█')); - }); - - test('percent over 100 clamps to 100', () => { - const out = formatGsdState({ milestone: 'v2.0', percent: '150' }); - assert.ok(out.includes('[██████████] 100%')); - }); - - test('percent below 0 clamps to 0', () => { - const out = formatGsdState({ milestone: 'v2.0', percent: '-10' }); - assert.ok(out.includes('[░░░░░░░░░░] 0%')); - }); -}); - -// ─── Scene priority — first-match-wins guarantee ──────────────────────────── - -describe('formatGsdState #2833 scene priority', () => { - test('active_phase wins over next_action when both populated', () => { - // active_phase populated should win — orchestrator is in flight, - // any "next" recommendation would be misleading. - const out = formatGsdState({ - milestone: 'v2.0', - status: 'executing', - activePhase: '4.5', - nextAction: 'execute-phase', - nextPhases: ['4.5'], - }); - assert.ok(out.includes('Phase 4.5 executing')); - assert.ok(!out.includes('next execute-phase')); - }); - - test('next_action wins over Scene 4 fallback when active_phase null', () => { - const out = formatGsdState({ - milestone: 'v2.0', - status: 'in_progress', // would be Scene 4 fallback alone - activePhase: null, - nextAction: 'execute-phase', - nextPhases: ['4.5'], - phaseNum: '1', - phaseTotal: '5', - }); - assert.ok(out.includes('next execute-phase 4.5')); - assert.ok(!out.includes('in_progress')); - assert.ok(!out.includes('1/5')); - }); - - test('percent=100 wins over Scene 4 even with phase set', () => { - const out = formatGsdState({ - milestone: 'v2.0', - percent: '100', - phaseNum: '1', - phaseTotal: '5', - }); - assert.ok(out.includes('milestone complete')); - assert.ok(!out.includes('1/5')); - }); -}); diff --git a/tests/enh-2937-statusline-context-position.test.cjs b/tests/enh-2937-statusline-context-position.test.cjs deleted file mode 100644 index 0921e1cb1..000000000 --- a/tests/enh-2937-statusline-context-position.test.cjs +++ /dev/null @@ -1,149 +0,0 @@ -'use strict'; - -/** - * Enhancement #2937 — statusline opt-in `context_position` config. - * - * Asserts that: - * - VALID_CONFIG_KEYS registers statusline.context_position (parity guard) - * - Default (no config) renders ctx at tail — "end" layout - * - Explicit "end" is byte-identical to default (regression guard) - * - Explicit "front" puts ctx after model, before first " │ " - * - Empty ctx with "front" leaves no stray separator - * - Invalid value (e.g. "middle") silently falls back to "end" at runtime - * - gsdUpdate warning stays leftmost in both "front" and "end" modes - */ - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); - -const { composeStatusline } = require('../hooks/gsd-statusline.js'); -const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); - -// ── Parity guard ───────────────────────────────────────────────────────────── - -test('config schema registers statusline.context_position', () => { - assert.ok( - VALID_CONFIG_KEYS.has('statusline.context_position'), - 'statusline.context_position must be in VALID_CONFIG_KEYS', - ); -}); - -// ── Default / "end" layout ─────────────────────────────────────────────────── - -test('default (no position arg) renders ctx at tail — end layout', () => { - const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; - const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx }); - // ctx should appear after dirname, not before first │ - const dirIdx = out.indexOf('myproject'); - const ctxIdx = out.indexOf(ctx); - assert.ok(ctxIdx > dirIdx, `ctx should be after dirname; got: ${out}`); -}); - -test('explicit "end" is byte-identical to default', () => { - const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; - const args = { model: 'Claude', dirname: 'myproject', ctx }; - const defaultOut = composeStatusline(args); - const endOut = composeStatusline({ ...args, position: 'end' }); - assert.strictEqual(endOut, defaultOut, 'explicit "end" must equal default output'); -}); - -test('"end" with middle segment places ctx after dirname', () => { - const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; - const out = composeStatusline({ model: 'Claude', ctx, middle: 'doing work', dirname: 'proj', position: 'end' }); - const dirIdx = out.indexOf('proj'); - const ctxIdx = out.indexOf(ctx); - assert.ok(ctxIdx > dirIdx, `ctx should be after dirname in end mode; got: ${out}`); -}); - -// ── "front" layout ─────────────────────────────────────────────────────────── - -test('"front" puts ctx after model name, before first │', () => { - const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; - const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx, position: 'front' }); - const firstPipe = out.indexOf(' │ '); - const ctxIdx = out.indexOf(ctx); - assert.ok(ctxIdx !== -1, `ctx should appear in output; got: ${out}`); - assert.ok(ctxIdx < firstPipe, `ctx should come before first │ in front mode; got: ${out}`); -}); - -test('"front" with middle segment: ctx after model, before first │', () => { - const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; - const out = composeStatusline({ model: 'Claude', ctx, middle: 'doing work', dirname: 'proj', position: 'front' }); - const firstPipe = out.indexOf(' │ '); - const ctxIdx = out.indexOf(ctx); - assert.ok(ctxIdx < firstPipe, `ctx must precede first │; got: ${out}`); -}); - -// ── Empty ctx ──────────────────────────────────────────────────────────────── - -test('empty ctx + "front" renders no stray separator', () => { - const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx: '', position: 'front' }); - // Should not have double-separator or leading │ - assert.ok(!out.includes(' │ │ '), `stray separator found; got: ${out}`); - // Should still contain the single separator between model area and dirname - assert.ok(out.includes(' │ '), `expected at least one separator; got: ${out}`); -}); - -test('empty ctx + "end" renders no stray separator', () => { - const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx: '', position: 'end' }); - assert.ok(!out.includes(' │ │ '), `stray separator found; got: ${out}`); -}); - -// ── Invalid value fallback ─────────────────────────────────────────────────── - -test('invalid position value silently falls back to "end" layout', () => { - const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; - const invalid = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx, position: 'middle' }); - const end = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx, position: 'end' }); - assert.strictEqual(invalid, end, `invalid position should produce same output as "end"; got: ${invalid}`); -}); - -test('invalid position "banana" silently falls back to "end"', () => { - const ctx = ' \x1b[33m██████░░░░ 60%\x1b[0m'; - const invalid = composeStatusline({ model: 'Claude', dirname: 'proj', ctx, position: 'banana' }); - const end = composeStatusline({ model: 'Claude', dirname: 'proj', ctx, position: 'end' }); - assert.strictEqual(invalid, end, `invalid "banana" should fall back to "end"; got: ${invalid}`); -}); - -// ── gsdUpdate leftmost invariant ───────────────────────────────────────────── - -test('gsdUpdate warning is leftmost in "end" mode', () => { - const gsdUpdate = '\x1b[33m⬆ /gsd:update\x1b[0m │ '; - const out = composeStatusline({ gsdUpdate, model: 'Claude', dirname: 'proj', position: 'end' }); - assert.ok(out.startsWith(gsdUpdate), `gsdUpdate should be leftmost in end mode; got: ${out}`); -}); - -test('gsdUpdate warning is leftmost in "front" mode', () => { - const gsdUpdate = '\x1b[33m⬆ /gsd:update\x1b[0m │ '; - const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; - const out = composeStatusline({ gsdUpdate, model: 'Claude', dirname: 'proj', ctx, position: 'front' }); - assert.ok(out.startsWith(gsdUpdate), `gsdUpdate should be leftmost in front mode; got: ${out}`); -}); - -// ── CLI write-path enforcement (config-set rejects invalid enum) ───────────── -// Locked design: hard reject at config-set time AND silent fallback at runtime. -// The runtime fallback is covered by the "Invalid position value silently falls -// back" tests above. This test covers the other half — that the CLI write path -// actually refuses to persist an invalid value in the first place. - -test('config-set rejects invalid statusline.context_position', () => { - const tmpDir = createTempProject(); - try { - const r = runGsdTools( - ['config-set', 'statusline.context_position', 'middle'], - tmpDir, - ); - assert.equal( - r.success, - false, - `config-set should exit non-zero on invalid enum; got success=${r.success}, output=${r.output}`, - ); - assert.ok( - /statusline\.context_position|Invalid/i.test(r.error), - `stderr must reference key or "Invalid"; got: ${r.error}`, - ); - } finally { - cleanup(tmpDir); - } -}); diff --git a/tests/enh-770-claude-hook-events.test.cjs b/tests/enh-770-claude-hook-events.test.cjs deleted file mode 100644 index 6352bfa98..000000000 --- a/tests/enh-770-claude-hook-events.test.cjs +++ /dev/null @@ -1,379 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Enhancement #770: Register Claude Code lifecycle hooks (SubagentStop / Stop / - * PreCompact / FileChanged). - * - * Claude Code now supports the same SubagentStop, Stop, and PreCompact events - * that were wired for Qwen Code in #788. This suite asserts: - * - * 1. Claude Code installs register SubagentStop, Stop, and PreCompact, each - * wired to gsd-context-monitor.js (same as Qwen). - * 2. Claude Code installs register a FileChanged hook for .planning/config.json - * wired to gsd-config-reload.js (new hook; hot-reloads gsd config). - * 3. All four registrations are idempotent (reinstall does not duplicate). - * 4. Uninstall removes all four event registrations. - * 5. The gsd-config-reload.js hook script exists in hooks/ and has the - * expected structure (reads on stdin, emits additionalContext or exits 0). - * 6. The hooks/hooks.json plugin manifest includes the new events. - * - * Source: https://code.claude.com/docs/en/hooks - */ - -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 { install, uninstall, validateHookFields } = require('../bin/install.js'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -/** Extract all hook commands registered under `eventName` from settings. */ -function hooksForEvent(settings, eventName) { - if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; - return settings.hooks[eventName].flatMap(entry => - (entry && Array.isArray(entry.hooks) ? entry.hooks : []) - .map(h => h && h.command) - .filter(Boolean) - ); -} - -/** Extract all matchers registered under `eventName` from settings. */ -function matchersForEvent(settings, eventName) { - if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; - return settings.hooks[eventName] - .map(entry => entry && entry.matcher) - .filter(Boolean); -} - -const HOOKS_SRC = path.join(__dirname, '..', 'hooks'); -// Hooks the installer existsSync-checks before registering; must be present -// in targetDir/hooks/ so the registration guards pass. -const STUB_HOOKS = [ - 'gsd-context-monitor.js', - 'gsd-prompt-guard.js', - 'gsd-check-update.js', - 'gsd-config-reload.js', -]; - -/** - * Pre-populate targetDir/hooks/ with stub hook files so the installer's - * fs.existsSync guards pass even when hooks/dist/ is absent (e.g. CI without - * a build step). Each test suite passes its own per-test tmpDir/.claude path - * so stubs are isolated to that test's temp directory — no shared filesystem - * state, no cross-test races. - * - * When hooks/dist/ DOES exist (local dev with npm run build:hooks), the - * installer copies real files over these stubs during install() — that is - * fine and correct. - */ -function stubHooksIntoTarget(targetDir) { - const hooksDest = path.join(targetDir, 'hooks'); - fs.mkdirSync(hooksDest, { recursive: true }); - for (const hookFile of STUB_HOOKS) { - const src = path.join(HOOKS_SRC, hookFile); - const dest = path.join(hooksDest, hookFile); - if (fs.existsSync(src)) { - fs.copyFileSync(src, dest); - } else { - // Minimal stub so existsSync passes - fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); - } - try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } - } -} - -function persistSettings(settingsPath, settings) { - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8'); -} - -// ─── Suite 1: Claude — new context monitor events are registered ────────────── - -describe('enh-770: Claude install registers SubagentStop / Stop / PreCompact context hooks', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-770-claude-ctx-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - stubHooksIntoTarget(path.join(tmpDir, '.claude')); - - const result = install(false, 'claude', { installerMigrations: [] }); - settings = result && result.settings; - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('install returns a settings object (not null)', () => { - assert.ok(settings !== null && typeof settings === 'object', - 'Claude install must return a non-null settings object'); - }); - - test('SubagentStop event is registered with at least one hook', () => { - const cmds = hooksForEvent(settings, 'SubagentStop'); - assert.ok(cmds.length > 0, - `Expected SubagentStop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); - }); - - test('Stop event is registered with at least one hook', () => { - const cmds = hooksForEvent(settings, 'Stop'); - assert.ok(cmds.length > 0, - `Expected Stop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); - }); - - test('PreCompact event is registered with at least one hook', () => { - const cmds = hooksForEvent(settings, 'PreCompact'); - assert.ok(cmds.length > 0, - `Expected PreCompact hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); - }); - - test('SubagentStop / Stop / PreCompact all use gsd-context-monitor', () => { - for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { - const cmds = hooksForEvent(settings, event); - assert.ok( - cmds.some(c => c.includes('gsd-context-monitor')), - `Event ${event} should use gsd-context-monitor; got commands: ${JSON.stringify(cmds)}` - ); - } - }); -}); - -// ─── Suite 2: Claude — FileChanged hook for config hot-reload ───────────────── - -describe('enh-770: Claude install registers FileChanged hook for .planning/config.json', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-770-filechanged-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - stubHooksIntoTarget(path.join(tmpDir, '.claude')); - - const result = install(false, 'claude', { installerMigrations: [] }); - settings = result && result.settings; - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('FileChanged event is registered with at least one hook', () => { - const cmds = hooksForEvent(settings, 'FileChanged'); - assert.ok(cmds.length > 0, - `Expected FileChanged hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); - }); - - test('FileChanged hook uses gsd-config-reload', () => { - const cmds = hooksForEvent(settings, 'FileChanged'); - assert.ok( - cmds.some(c => c.includes('gsd-config-reload')), - `FileChanged should use gsd-config-reload; got commands: ${JSON.stringify(cmds)}` - ); - }); - - test('FileChanged hook has a matcher targeting .planning/config.json', () => { - const matchers = matchersForEvent(settings, 'FileChanged'); - assert.ok( - matchers.some(m => m && m.includes('config.json')), - `FileChanged matcher should target config.json; got matchers: ${JSON.stringify(matchers)}` - ); - }); -}); - -// ─── Suite 3: Idempotency ───────────────────────────────────────────────────── - -describe('enh-770: Claude install is idempotent for the new hook events', () => { - let tmpDir; - let previousCwd; - - beforeEach(() => { - tmpDir = createTempDir('gsd-770-idem-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - stubHooksIntoTarget(path.join(tmpDir, '.claude')); - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('re-running after persisted first install does not duplicate context monitor hooks', () => { - const result1 = install(false, 'claude', { installerMigrations: [] }); - persistSettings(result1.settingsPath, result1.settings); - - process.chdir(tmpDir); - const result2 = install(false, 'claude', { installerMigrations: [] }); - const s2 = result2.settings; - - for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { - const cmds = hooksForEvent(s2, event); - assert.strictEqual(cmds.length, 1, - `Event ${event} should have exactly 1 hook after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); - } - }); - - test('re-running after persisted first install does not duplicate FileChanged hook', () => { - const result1 = install(false, 'claude', { installerMigrations: [] }); - persistSettings(result1.settingsPath, result1.settings); - - process.chdir(tmpDir); - const result2 = install(false, 'claude', { installerMigrations: [] }); - const s2 = result2.settings; - - const cmds = hooksForEvent(s2, 'FileChanged'); - assert.strictEqual(cmds.length, 1, - `FileChanged should have exactly 1 hook after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); - }); -}); - -// ─── Suite 4: Uninstall removes registrations ───────────────────────────────── - -describe('enh-770: Uninstall removes new hook event entries', () => { - let tmpDir; - let previousCwd; - - beforeEach(() => { - tmpDir = createTempDir('gsd-770-uninstall-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - stubHooksIntoTarget(path.join(tmpDir, '.claude')); - - const result = install(false, 'claude', { installerMigrations: [] }); - persistSettings(result.settingsPath, result.settings); - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('settings.json hook entries are removed on uninstall', () => { - uninstall(false, 'claude', { installerMigrations: [] }); - const settingsPath = path.join(tmpDir, '.claude', 'settings.json'); - if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - for (const event of ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged']) { - const cmds = hooksForEvent(settings, event); - assert.strictEqual(cmds.length, 0, - `After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`); - } - }); -}); - -// ─── Suite 5: gsd-config-reload.js hook script exists and has correct shape ─── - -describe('enh-770: gsd-config-reload.js hook script', () => { - const reloadScript = path.join(__dirname, '..', 'hooks', 'gsd-config-reload.js'); - - test('gsd-config-reload.js exists in hooks/', () => { - assert.ok(fs.existsSync(reloadScript), - `gsd-config-reload.js must exist at ${reloadScript}`); - }); - - test('gsd-config-reload.js contains the gsd-hook-version stamp', () => { - // allow-test-rule: runtime-contract-is-the-product — the stamp template token - // IS the product surface that the installer must find and replace with the - // real version at copy time; asserting its presence is required. - const content = fs.readFileSync(reloadScript, 'utf8'); - assert.ok( - content.includes('gsd-hook-version'), - 'gsd-config-reload.js must contain the gsd-hook-version stamp for installer stamping' - ); - }); - - test('gsd-config-reload.js reads from stdin and emits JSON output', () => { - // allow-test-rule: runtime-contract-is-the-product — the stdin-read and - // JSON-emit pattern IS the hook contract; asserting its presence is required. - const content = fs.readFileSync(reloadScript, 'utf8'); - assert.ok( - content.includes('process.stdin') && content.includes('JSON.stringify'), - 'gsd-config-reload.js must read stdin and emit JSON output per hook protocol' - ); - }); - - test('gsd-config-reload.js targets the FileChanged hook event', () => { - // allow-test-rule: runtime-contract-is-the-product — the hookEventName is - // the protocol surface; asserting its presence verifies the contract. - const content = fs.readFileSync(reloadScript, 'utf8'); - assert.ok( - content.includes('FileChanged'), - 'gsd-config-reload.js must reference FileChanged in its hookSpecificOutput' - ); - }); -}); - -// ─── Suite 6: hooks.json plugin manifest includes new events ────────────────── - -describe('enh-770: hooks/hooks.json plugin manifest includes new hook events', () => { - const hooksJsonPath = path.join(__dirname, '..', 'hooks', 'hooks.json'); - - test('hooks.json exists', () => { - assert.ok(fs.existsSync(hooksJsonPath), `hooks.json must exist at ${hooksJsonPath}`); - }); - - test('hooks.json contains SubagentStop event', () => { - // allow-test-rule: runtime-contract-is-the-product — hooks.json IS the - // plugin manifest surface that Claude Code reads at plugin load time. - const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); - assert.ok( - content.hooks && content.hooks.SubagentStop, - 'hooks.json must contain SubagentStop' - ); - }); - - test('hooks.json contains Stop event', () => { - // allow-test-rule: runtime-contract-is-the-product — hooks.json IS the - // plugin manifest surface that Claude Code reads at plugin load time. - const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); - assert.ok( - content.hooks && content.hooks.Stop, - 'hooks.json must contain Stop' - ); - }); - - test('hooks.json contains PreCompact event', () => { - // allow-test-rule: runtime-contract-is-the-product — hooks.json IS the - // plugin manifest surface that Claude Code reads at plugin load time. - const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); - assert.ok( - content.hooks && content.hooks.PreCompact, - 'hooks.json must contain PreCompact' - ); - }); - - test('hooks.json contains FileChanged event', () => { - // allow-test-rule: runtime-contract-is-the-product — hooks.json IS the - // plugin manifest surface that Claude Code reads at plugin load time. - const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); - assert.ok( - content.hooks && content.hooks.FileChanged, - 'hooks.json must contain FileChanged' - ); - }); -}); - -// ─── Suite 7: managed-hooks-registry includes gsd-config-reload.js ─────────── - -describe('enh-770: managed-hooks-registry includes gsd-config-reload.js', () => { - test('MANAGED_HOOKS array includes gsd-config-reload.js', () => { - const { MANAGED_HOOKS } = require('../hooks/managed-hooks-registry.cjs'); - assert.ok( - MANAGED_HOOKS.includes('gsd-config-reload.js'), - `MANAGED_HOOKS must include gsd-config-reload.js; got: ${JSON.stringify(MANAGED_HOOKS)}` - ); - }); -}); diff --git a/tests/enh-790-augment-commands.test.cjs b/tests/enh-790-augment-commands.test.cjs deleted file mode 100644 index 8ab26b342..000000000 --- a/tests/enh-790-augment-commands.test.cjs +++ /dev/null @@ -1,212 +0,0 @@ -'use strict'; -/** - * Regression guard — enh(#790): Augment commands/ emitted alongside skills/. - * - * Verifies that a global Augment install writes: - * - commands/gsd-.md (slash command definitions) - * - skills/gsd-/SKILL.md (existing skill definitions) - * - * mcpServers in settings.json is explicitly excluded: gsd ships no MCP server - * and registering third-party servers is out of scope for the installer. - * - * Ref: https://docs.augmentcode.com/cli/reference — ~/.augment/commands/.md - */ - -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 { createTempDir, cleanup } = require('./helpers.cjs'); - -const { installRuntimeArtifacts, uninstallRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); -const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); -const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); - -const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); -const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); -const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); - -// ─── Layout contract ───────────────────────────────────────────────────────── - -describe('enh-790 — augment layout has commands + skills + agents kinds', () => { - test('resolveRuntimeArtifactLayout augment returns 3 kinds', () => { - const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir'); - assert.strictEqual(layout.kinds.length, 3, 'augment must have exactly 3 artifact kinds'); - const kindNames = layout.kinds.map(k => k.kind).sort(); - assert.deepStrictEqual(kindNames, ['agents', 'commands', 'skills']); - }); - - test('augment commands kind targets commands/ with gsd- prefix', () => { - const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir'); - const commandsKind = layout.kinds.find(k => k.kind === 'commands'); - assert.ok(commandsKind, 'must have commands kind'); - assert.strictEqual(commandsKind.destSubpath, 'commands'); - assert.strictEqual(commandsKind.prefix, 'gsd-'); - }); - - test('augment skills kind targets skills/ with gsd- prefix', () => { - const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir'); - const skillsKind = layout.kinds.find(k => k.kind === 'skills'); - assert.ok(skillsKind, 'must have skills kind'); - assert.strictEqual(skillsKind.destSubpath, 'skills'); - assert.strictEqual(skillsKind.prefix, 'gsd-'); - }); -}); - -// ─── Install contract ──────────────────────────────────────────────────────── - -describe('enh-790 — installRuntimeArtifacts augment emits both commands and skills', () => { - test('global augment install: commands/gsd-help.md and skills/gsd-help/SKILL.md exist', (t) => { - const configDir = createTempDir('gsd-enh790-augment-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); - - // Commands dir - const commandsDir = path.join(configDir, 'commands'); - assert.ok(fs.existsSync(commandsDir), 'commands/ dir must exist'); - const cmdFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); - assert.ok(cmdFiles.length > 0, 'at least one gsd-*.md command file must be installed'); - assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'commands/gsd-help.md must exist'); - - // Skills dir (pre-existing behavior preserved) - const skillsDir = path.join(configDir, 'skills'); - assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist'); - assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist'); - }); - - test('commands/gsd-help.md has Augment-compatible content (no raw ~/.claude/ refs)', (t) => { - const configDir = createTempDir('gsd-enh790-content-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); - - const helpCmd = path.join(configDir, 'commands', 'gsd-help.md'); - assert.ok(fs.existsSync(helpCmd), 'gsd-help.md must exist'); - const content = fs.readFileSync(helpCmd, 'utf8'); - // Should not have raw ~/.claude/ references after path rewrite - assert.ok(!content.includes('~/.claude/'), 'commands must not contain raw ~/.claude/ refs'); - }); - - test('command count matches skill count (profile parity)', (t) => { - const configDir = createTempDir('gsd-enh790-parity-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); - - const commandsDir = path.join(configDir, 'commands'); - const skillsDir = path.join(configDir, 'skills'); - const cmdCount = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; - const skillCount = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; - assert.strictEqual(cmdCount, skillCount, 'command count must equal skill count for same profile'); - }); - - test('full profile install does NOT mutate source commands/gsd/ files', (t) => { - // Regression guard: stageSkillsForProfile returns the real source dir on full profile - // (skills === '*'). applyRuntimeContentRewritesForCommandsInPlace must copy to temp - // before rewriting — it must NEVER write back to the source tree. - const { resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); - const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); - assert.strictEqual(RESOLVED_FULL.skills, '*', 'full profile must have skills === "*"'); - - const configDir = createTempDir('gsd-enh790-full-'); - t.after(() => cleanup(configDir)); - - // Record source file content before install - const srcHelpPath = path.join(__dirname, '..', 'commands', 'gsd', 'help.md'); - const srcContentBefore = fs.readFileSync(srcHelpPath, 'utf8'); - - installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL); - - // Source file must be identical after install - const srcContentAfter = fs.readFileSync(srcHelpPath, 'utf8'); - assert.strictEqual(srcContentBefore, srcContentAfter, - 'source commands/gsd/help.md must not be mutated by the install'); - - // Installed command file must have rewrites applied (Augment path substitution) - const installedHelp = path.join(configDir, 'commands', 'gsd-help.md'); - assert.ok(fs.existsSync(installedHelp), 'installed gsd-help.md must exist'); - const installedContent = fs.readFileSync(installedHelp, 'utf8'); - assert.ok(!installedContent.includes('~/.claude/'), 'installed command must not have raw ~/.claude/ refs'); - }); -}); - -describe('enh-790 — installRuntimeArtifacts does not leak temp dirs', () => { - test('install cleans up gsd-cmd-rewrites-* temp dirs (no leak) — #856', (t) => { - const { resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); - const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); - - // Isolate os.tmpdir() to a private root so parallel test processes can't race - // on the shared system temp dir. os.tmpdir() resolves $TMPDIR/$TEMP/$TMP per call. - const isolatedTmp = createTempDir('gsd-enh790-tmproot-'); - const prev = { TMPDIR: process.env.TMPDIR, TEMP: process.env.TEMP, TMP: process.env.TMP }; - process.env.TMPDIR = isolatedTmp; - process.env.TEMP = isolatedTmp; - process.env.TMP = isolatedTmp; - - const configDir = createTempDir('gsd-enh790-leak-'); - t.after(() => { - for (const k of ['TMPDIR', 'TEMP', 'TMP']) { - if (prev[k] === undefined) delete process.env[k]; - else process.env[k] = prev[k]; - } - cleanup(configDir); - cleanup(isolatedTmp); - }); - - installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL); - - // The install creates its gsd-cmd-rewrites-* temp dirs under the isolated root; - // after the fix none must remain. - const leaked = fs.readdirSync(isolatedTmp).filter(n => n.startsWith('gsd-cmd-rewrites-')); - assert.ok( - leaked.length === 0, - `installer must not leak gsd-cmd-rewrites-* temp dirs; leaked: ${leaked.join(', ')}` - ); - }); -}); - -// ─── Uninstall contract ────────────────────────────────────────────────────── - -describe('enh-790 — uninstallRuntimeArtifacts removes augment commands', () => { - test('uninstall removes gsd-* commands but preserves user commands', (t) => { - const configDir = createTempDir('gsd-enh790-uninstall-'); - t.after(() => cleanup(configDir)); - - // uninstallRuntimeArtifacts is imported from install-engine.cjs at the top of this file - - // Pre-create: a GSD command + a user-owned command - const commandsDir = path.join(configDir, 'commands'); - fs.mkdirSync(commandsDir, { recursive: true }); - fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), '# help\n'); - fs.writeFileSync(path.join(commandsDir, 'user-custom.md'), '# user\n'); - - uninstallRuntimeArtifacts('augment', configDir, 'global'); - - assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'gsd-help.md must be removed'); - assert.ok(fs.existsSync(path.join(commandsDir, 'user-custom.md')), 'user-custom.md must be preserved'); - }); -}); - -// ─── mcpServers exclusion ──────────────────────────────────────────────────── - -describe('enh-790 — mcpServers excluded (gsd ships no MCP server)', () => { - test('augment install does not write settings.json mcpServers', (t) => { - const configDir = createTempDir('gsd-enh790-mcp-excluded-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); - - // No settings.json with mcpServers should be written by the layout - const settingsPath = path.join(configDir, 'settings.json'); - if (fs.existsSync(settingsPath)) { - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - assert.ok(!settings.mcpServers, 'settings.json must not contain mcpServers (gsd ships no MCP server)'); - } - // If no settings.json at all, that is also correct - }); -}); diff --git a/tests/feat-2795-update-banner.test.cjs b/tests/feat-2795-update-banner.test.cjs deleted file mode 100644 index b1c49e148..000000000 --- a/tests/feat-2795-update-banner.test.cjs +++ /dev/null @@ -1,365 +0,0 @@ -/** - * Tests for gsd-update-banner.js (#2795). - * - * The banner hook is an opt-in SessionStart consumer of the update cache that - * gsd-check-update-worker.js writes. When a user declines GSD's statusline, - * install.js may register this hook so update availability still surfaces in - * runtimes that use a non-GSD statusline. - * - * Tests follow the typed-IR convention (CONTRIBUTING.md "Prohibited: Raw Text - * Matching on Test Outputs"): assert on parsed JSON envelopes, not on raw - * stdout substrings. - */ - -'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 { spawnSync } = require('node:child_process'); -const { cleanup } = require('./helpers.cjs'); - -const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-update-banner.js'); -const { - buildBannerOutput, - shouldSuppressFailureWarning, - RATE_LIMIT_SECONDS, -} = require('../hooks/gsd-update-banner.js'); -const { updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs'); - -// ─── Pure function: buildBannerOutput ─────────────────────────────────────── - -describe('buildBannerOutput', () => { - test('returns null when cache is missing', () => { - const out = buildBannerOutput({ - cache: null, - parseError: false, - suppressFailureWarning: false, - }); - assert.equal(out, null); - }); - - test('returns null when update_available is false', () => { - const out = buildBannerOutput({ - cache: { update_available: false, installed: '1.40.0', latest: '1.40.0' }, - parseError: false, - suppressFailureWarning: false, - }); - assert.equal(out, null); - }); - - test('returns banner envelope when update_available is true', () => { - const out = buildBannerOutput({ - cache: { update_available: true, installed: '1.39.0', latest: '1.40.0', package_name: '@opengsd/gsd-core' }, - parseError: false, - suppressFailureWarning: false, - }); - assert.ok(out, 'expected banner envelope'); - assert.equal(typeof out.systemMessage, 'string'); - assert.ok( - out.systemMessage.includes('1.39.0'), - 'banner should name installed version' - ); - assert.ok( - out.systemMessage.includes('1.40.0'), - 'banner should name latest version' - ); - assert.ok( - out.systemMessage.includes('/gsd:update'), - 'banner should reference /gsd:update command' - ); - }); - - test('returns failure diagnostic on parseError when not suppressed', () => { - const out = buildBannerOutput({ - cache: null, - parseError: true, - suppressFailureWarning: false, - }); - assert.ok(out, 'expected diagnostic envelope'); - assert.equal(typeof out.systemMessage, 'string'); - assert.ok( - /check failed/i.test(out.systemMessage), - 'diagnostic should describe a failed check' - ); - }); - - test('returns null on parseError when suppressed by rate limit', () => { - const out = buildBannerOutput({ - cache: null, - parseError: true, - suppressFailureWarning: true, - }); - assert.equal(out, null); - }); - - test('falls back to "unknown" when installed/latest missing', () => { - const out = buildBannerOutput({ - cache: { update_available: true, package_name: '@opengsd/gsd-core' }, - parseError: false, - suppressFailureWarning: false, - }); - assert.ok(out); - assert.ok( - out.systemMessage.includes('unknown'), - 'banner should degrade gracefully when versions are absent' - ); - }); -}); - -// ─── Pure function: shouldSuppressFailureWarning ──────────────────────────── - -describe('shouldSuppressFailureWarning', () => { - function tmpDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-banner-supp-')); - } - - test('returns false when sentinel file is missing', () => { - const dir = tmpDir(); - try { - const result = shouldSuppressFailureWarning( - path.join(dir, 'no-such-file'), - 100 - ); - assert.equal(result, false); - } finally { - cleanup(dir); - } - }); - - test('returns true within rate-limit window', () => { - const dir = tmpDir(); - try { - const f = path.join(dir, 'sentinel'); - fs.writeFileSync(f, '1000'); - const result = shouldSuppressFailureWarning(f, 1000 + RATE_LIMIT_SECONDS - 1); - assert.equal(result, true); - } finally { - cleanup(dir); - } - }); - - test('returns false outside rate-limit window', () => { - const dir = tmpDir(); - try { - const f = path.join(dir, 'sentinel'); - fs.writeFileSync(f, '1000'); - const result = shouldSuppressFailureWarning(f, 1000 + RATE_LIMIT_SECONDS + 1); - assert.equal(result, false); - } finally { - cleanup(dir); - } - }); - - test('returns false when sentinel content is non-numeric', () => { - const dir = tmpDir(); - try { - const f = path.join(dir, 'sentinel'); - fs.writeFileSync(f, 'garbage-not-a-number'); - const result = shouldSuppressFailureWarning(f, 100); - assert.equal(result, false); - } finally { - cleanup(dir); - } - }); -}); - -// ─── End-to-end: spawn the hook against fixture cache states ──────────────── - -describe('gsd-update-banner.js end-to-end', () => { - function setupHome() { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-banner-home-')); - fs.mkdirSync(path.join(home, '.cache', 'gsd'), { recursive: true }); - return home; - } - - function runHook(home) { - return spawnSync(process.execPath, [HOOK_PATH], { - env: { ...process.env, HOME: home, USERPROFILE: home }, - encoding: 'utf8', - }); - } - - function writeCache(home, contents) { - fs.writeFileSync( - path.join(home, '.cache', 'gsd', updateCacheFileName), - typeof contents === 'string' ? contents : JSON.stringify(contents) - ); - } - - test('exits 0 with empty stdout when cache file missing', () => { - const home = setupHome(); - try { - const r = runHook(home); - assert.equal(r.status, 0, `expected exit 0, got ${r.status} stderr=${r.stderr}`); - assert.equal(r.stdout.trim(), ''); - } finally { - cleanup(home); - } - }); - - test('emits valid SessionStart JSON when update_available=true', () => { - const home = setupHome(); - try { - writeCache(home, { - update_available: true, - installed: '1.39.0', - latest: '1.40.0', - package_name: '@opengsd/gsd-core', - }); - const r = runHook(home); - assert.equal(r.status, 0); - const parsed = JSON.parse(r.stdout); - assert.equal(typeof parsed.systemMessage, 'string'); - assert.ok(parsed.systemMessage.includes('1.40.0')); - assert.ok(parsed.systemMessage.includes('/gsd:update')); - } finally { - cleanup(home); - } - }); - - test('exits silent when update_available=false', () => { - const home = setupHome(); - try { - writeCache(home, { - update_available: false, - installed: '1.40.0', - latest: '1.40.0', - }); - const r = runHook(home); - assert.equal(r.status, 0); - assert.equal(r.stdout.trim(), ''); - } finally { - cleanup(home); - } - }); - - test('emits failure diagnostic when cache JSON is malformed', () => { - const home = setupHome(); - try { - writeCache(home, 'not json {{{{'); - const r = runHook(home); - assert.equal(r.status, 0); - const parsed = JSON.parse(r.stdout); - assert.equal(typeof parsed.systemMessage, 'string'); - assert.ok(/check failed/i.test(parsed.systemMessage)); - } finally { - cleanup(home); - } - }); - - test('suppresses repeat failure diagnostic within 24h via sentinel', () => { - const home = setupHome(); - try { - writeCache(home, 'not json'); - const r1 = runHook(home); - assert.equal( - r1.status, - 0, - `expected exit 0, got ${r1.status} stderr=${r1.stderr}` - ); - const parsed1 = JSON.parse(r1.stdout); - assert.ok(/check failed/i.test(parsed1.systemMessage)); - - // Sentinel should now exist so the next run is silent - const sentinel = path.join(home, '.cache', 'gsd', 'banner-failure-warned-at'); - assert.ok(fs.existsSync(sentinel), 'first run must record the warning sentinel'); - - const r2 = runHook(home); - assert.equal(r2.status, 0); - assert.equal( - r2.stdout.trim(), - '', - 'subsequent run within rate-limit window must stay silent' - ); - } finally { - cleanup(home); - } - }); - - test('handles cache present but update_available field absent (older cache schema)', () => { - const home = setupHome(); - try { - writeCache(home, { installed: '1.40.0', latest: '1.40.0' }); - const r = runHook(home); - assert.equal(r.status, 0); - assert.equal(r.stdout.trim(), ''); - } finally { - cleanup(home); - } - }); -}); - -// ─── Install.js wiring: prompt + SessionStart entry registration ──────────── -// -// These tests load bin/install.js as a module via GSD_TEST_MODE and assert on -// pure exported helpers. The shape mirrors how runtime-prompt-builder / -// statusline tests interact with install.js. - -describe('install.js update-banner wiring', () => { - process.env.GSD_TEST_MODE = '1'; - // Re-require fresh so test-mode exports are populated. - const installPath = path.join(__dirname, '..', 'bin', 'install.js'); - delete require.cache[installPath]; - const installExports = require(installPath); - - test('exports buildUpdateBannerPromptText for structural prompt assertions', () => { - assert.equal( - typeof installExports.buildUpdateBannerPromptText, - 'function', - 'install.js must export buildUpdateBannerPromptText so tests can assert without grepping source' - ); - const text = installExports.buildUpdateBannerPromptText(); - assert.equal(typeof text, 'string'); - assert.ok(text.length > 0); - // Strip ANSI color escapes before structural assertions — the choice - // digits are wrapped in color codes so word-boundary regex against the - // raw text would miss them. - // eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output - const stripped = text.replace(/\x1b\[[0-9;]*m/g, ''); - // Prompt must offer at least two choices (default + opt-in). - assert.match(stripped, /\b1\b/); - assert.match(stripped, /\b2\b/); - }); - - test('parseUpdateBannerInput defaults to false on empty / "1"', () => { - assert.equal(typeof installExports.parseUpdateBannerInput, 'function'); - assert.equal(installExports.parseUpdateBannerInput(''), false); - assert.equal(installExports.parseUpdateBannerInput(' '), false); - assert.equal(installExports.parseUpdateBannerInput('1'), false); - }); - - test('parseUpdateBannerInput returns true on "2"', () => { - assert.equal(installExports.parseUpdateBannerInput('2'), true); - assert.equal(installExports.parseUpdateBannerInput('2 '), true); - }); - - test('parseUpdateBannerInput accepts "y" / "yes" affirmative shortcuts', () => { - assert.equal(installExports.parseUpdateBannerInput('y'), true); - assert.equal(installExports.parseUpdateBannerInput('Y'), true); - assert.equal(installExports.parseUpdateBannerInput('yes'), true); - assert.equal(installExports.parseUpdateBannerInput('YES'), true); - }); - - test('buildUpdateBannerHookEntry produces a SessionStart hook entry', () => { - assert.equal(typeof installExports.buildUpdateBannerHookEntry, 'function'); - const entry = installExports.buildUpdateBannerHookEntry( - '"/usr/local/bin/node" "/home/u/.claude/hooks/gsd-update-banner.js"' - ); - assert.ok(entry, 'expected hook entry object'); - assert.ok(Array.isArray(entry.hooks), 'entry.hooks must be an array'); - assert.equal(entry.hooks.length, 1); - assert.equal(entry.hooks[0].type, 'command'); - assert.ok( - entry.hooks[0].command.includes('gsd-update-banner.js'), - 'command must reference the banner hook' - ); - }); - - test('buildUpdateBannerHookEntry returns null on null command', () => { - assert.equal(installExports.buildUpdateBannerHookEntry(null), null); - assert.equal(installExports.buildUpdateBannerHookEntry(''), null); - }); -}); diff --git a/tests/feat-443-effort-defaults-drift.test.cjs b/tests/feat-443-effort-defaults-drift.test.cjs deleted file mode 100644 index 0033953da..000000000 --- a/tests/feat-443-effort-defaults-drift.test.cjs +++ /dev/null @@ -1,83 +0,0 @@ -'use strict'; -/** - * feat-443-effort-defaults-drift.test.cjs - * - * Drift-guard: asserts that install.js's resolved baseline effort defaults - * equal config-defaults.manifest.json's effort block. Any future divergence - * (someone edits the manifest without updating install.js or vice-versa) fails - * CI immediately rather than silently injecting stale effort values. - * - * Real assertions on runtime values — no source-grep. - */ - -// MUST be set before require('bin/install.js') so the main install block -// (guarded by !GSD_TEST_MODE) does not execute and perform a real global -// install into $HOME/.claude/ — which would leak gsd-tools.cjs into the -// ambient HOME and break runtime-launcher-parity test (D) in the same -// node --test run (all unit tests share the same HOME on CI). -process.env.GSD_TEST_MODE = '1'; - -const assert = require('assert'); -const path = require('path'); - -const { test } = require('node:test'); - -// Load the manifest directly (JSON, not a .cjs source file — allowed by lint rule) -const manifestPath = path.join( - __dirname, - '..', - 'gsd-core', - 'bin', - 'shared', - 'config-defaults.manifest.json' -); -const manifest = require(manifestPath); - -// Load install.js exported values (executes the module, not text inspection) -const installPath = path.join(__dirname, '..', 'bin', 'install.js'); -const { - _GSD_EFFORT_MANIFEST_TIER_DEFAULTS, - _GSD_EFFORT_MANIFEST_DEFAULT, -} = require(installPath); - -test('install.js _GSD_EFFORT_MANIFEST_TIER_DEFAULTS.light matches manifest effort.routing_tier_defaults.light', () => { - assert.strictEqual( - _GSD_EFFORT_MANIFEST_TIER_DEFAULTS.light, - manifest.effort.routing_tier_defaults.light, - `install.js tier default for "light" (${_GSD_EFFORT_MANIFEST_TIER_DEFAULTS.light}) differs from manifest (${manifest.effort.routing_tier_defaults.light})` - ); -}); - -test('install.js _GSD_EFFORT_MANIFEST_TIER_DEFAULTS.standard matches manifest effort.routing_tier_defaults.standard', () => { - assert.strictEqual( - _GSD_EFFORT_MANIFEST_TIER_DEFAULTS.standard, - manifest.effort.routing_tier_defaults.standard, - `install.js tier default for "standard" (${_GSD_EFFORT_MANIFEST_TIER_DEFAULTS.standard}) differs from manifest (${manifest.effort.routing_tier_defaults.standard})` - ); -}); - -test('install.js _GSD_EFFORT_MANIFEST_TIER_DEFAULTS.heavy matches manifest effort.routing_tier_defaults.heavy', () => { - assert.strictEqual( - _GSD_EFFORT_MANIFEST_TIER_DEFAULTS.heavy, - manifest.effort.routing_tier_defaults.heavy, - `install.js tier default for "heavy" (${_GSD_EFFORT_MANIFEST_TIER_DEFAULTS.heavy}) differs from manifest (${manifest.effort.routing_tier_defaults.heavy})` - ); -}); - -test('install.js _GSD_EFFORT_MANIFEST_DEFAULT matches manifest effort.default', () => { - assert.strictEqual( - _GSD_EFFORT_MANIFEST_DEFAULT, - manifest.effort.default, - `install.js effort default (${_GSD_EFFORT_MANIFEST_DEFAULT}) differs from manifest (${manifest.effort.default})` - ); -}); - -test('install.js tier-defaults object has exactly the same keys as manifest effort.routing_tier_defaults', () => { - const installKeys = Object.keys(_GSD_EFFORT_MANIFEST_TIER_DEFAULTS).sort(); - const manifestKeys = Object.keys(manifest.effort.routing_tier_defaults).sort(); - assert.deepStrictEqual( - installKeys, - manifestKeys, - `Key mismatch — install.js: [${installKeys.join(', ')}], manifest: [${manifestKeys.join(', ')}]` - ); -}); diff --git a/tests/fix-1679-destsubpath-confinement.test.cjs b/tests/fix-1679-destsubpath-confinement.test.cjs deleted file mode 100644 index 078428302..000000000 --- a/tests/fix-1679-destsubpath-confinement.test.cjs +++ /dev/null @@ -1,789 +0,0 @@ -'use strict'; - -/** - * Tests for ADR-1239 Phase B: destSubpath write-confinement security gate. - * - * Verifies that assertDestWithinConfigHome rejects escaping destSubpath values - * and that createRuntimeArtifactInstallPlan and createRuntimeArtifactUninstallPlan - * both reject them at plan-build time. - * - * Also covers: - * F3 - assertDestWithinConfigHome rejects destSubpath === configHome itself - * F4 - migrateLegacyDevPreferencesToSkill routes through the confinement gate - * F2 - write sites (installOpencodeFamilySkills) reject symlink-escaping destDir - */ - -const { test, describe, beforeEach, afterEach } = 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 { - assertDestWithinConfigHome, - createRuntimeArtifactInstallPlan, - createRuntimeArtifactUninstallPlan, -} = require('../gsd-core/bin/lib/runtime-artifact-install-plan.cjs'); - -const { - migrateLegacyDevPreferencesToSkill, - installOpencodeFamilySkills, - installRuntimeArtifacts, - _copyStaged, -} = require('../gsd-core/bin/lib/install-engine.cjs'); - -const { createTempDir, cleanup } = require('./helpers.cjs'); - -// --------------------------------------------------------------------------- -// Unit tests for assertDestWithinConfigHome -// --------------------------------------------------------------------------- - -describe('assertDestWithinConfigHome', () => { - let configDir; - - beforeEach(() => { - configDir = createTempDir('gsd-confine-test-'); - }); - - afterEach(() => { - cleanup(configDir); - }); - - // --- Rejection cases --- - - test('rejects destSubpath "../../etc" that escapes configDir', () => { - assert.throws( - () => assertDestWithinConfigHome(configDir, '../../etc'), - (err) => { - assert.ok(err instanceof Error, 'must be an Error'); - assert.ok( - err.message.includes('escapes configHome'), - `expected "escapes configHome" in: ${err.message}`, - ); - return true; - }, - ); - }); - - test('rejects destSubpath "../foo" that escapes configDir', () => { - assert.throws( - () => assertDestWithinConfigHome(configDir, '../foo'), - /escapes configHome/, - ); - }); - - test('rejects destSubpath "a/../../b" that escapes configDir', () => { - assert.throws( - () => assertDestWithinConfigHome(configDir, 'a/../../b'), - /escapes configHome/, - ); - }); - - test('rejects destSubpath containing a NUL byte', () => { - assert.throws( - () => assertDestWithinConfigHome(configDir, 'skills\0evil'), - (err) => { - assert.ok(err instanceof Error, 'must be an Error'); - assert.ok( - err.message.includes('NUL'), - `expected "NUL" in: ${err.message}`, - ); - return true; - }, - ); - }); - - // --- F3: reject destSubpath that resolves to configHome itself --- - - test('F3: rejects destSubpath "." that resolves to configHome itself', () => { - assert.throws( - () => assertDestWithinConfigHome(configDir, '.'), - (err) => { - assert.ok(err instanceof Error, 'must be an Error'); - assert.ok( - err.message.includes('not configHome itself') || err.message.includes('escapes configHome'), - `expected confinement error in: ${err.message}`, - ); - return true; - }, - ); - }); - - test('F3: rejects destSubpath "a/.." that resolves to configHome itself', () => { - assert.throws( - () => assertDestWithinConfigHome(configDir, 'a/..'), - (err) => { - assert.ok(err instanceof Error, 'must be an Error'); - assert.ok( - err.message.includes('not configHome itself') || err.message.includes('escapes configHome'), - `expected confinement error in: ${err.message}`, - ); - return true; - }, - ); - }); - - test('F3: rejects destSubpath "skills/../.." that resolves to configHome parent', () => { - assert.throws( - () => assertDestWithinConfigHome(configDir, 'skills/../..'), - (err) => { - assert.ok(err instanceof Error, 'must be an Error'); - assert.ok( - err.message.includes('not configHome itself') || err.message.includes('escapes configHome'), - `expected confinement error in: ${err.message}`, - ); - return true; - }, - ); - }); - - // --- Accepted cases --- - - test('accepts "skills" and returns path under configDir', () => { - const result = assertDestWithinConfigHome(configDir, 'skills'); - assert.ok( - result.startsWith(path.resolve(configDir)), - `expected result to start with configDir (${path.resolve(configDir)}), got: ${result}`, - ); - assert.strictEqual(result, path.join(path.resolve(configDir), 'skills')); - }); - - test('accepts "commands/gsd" and returns path under configDir', () => { - const result = assertDestWithinConfigHome(configDir, 'commands/gsd'); - assert.ok(result.startsWith(path.resolve(configDir))); - assert.strictEqual(result, path.join(path.resolve(configDir), 'commands', 'gsd')); - }); - - test('accepts "./skills" and returns resolved path under configDir', () => { - const result = assertDestWithinConfigHome(configDir, './skills'); - assert.ok(result.startsWith(path.resolve(configDir))); - assert.strictEqual(result, path.join(path.resolve(configDir), 'skills')); - }); - - test('does not match a sibling directory with a shared prefix', () => { - // configDir = /tmp/gsd-foo; a sibling like /tmp/gsd-foobar must NOT be accepted. - // The path.sep guard in the implementation prevents a startsWith match - // from crossing directory boundaries. We verify the happy-path: a valid - // nested subpath resolves to a path strictly under configDir (includes sep). - const result = assertDestWithinConfigHome(configDir, 'subdir/nested'); - assert.ok(result.startsWith(path.resolve(configDir) + path.sep)); - }); -}); - -// --------------------------------------------------------------------------- -// Integration tests for createRuntimeArtifactInstallPlan -// --------------------------------------------------------------------------- - -describe('createRuntimeArtifactInstallPlan destSubpath confinement', () => { - let configDir; - - beforeEach(() => { - configDir = createTempDir('gsd-plan-confine-'); - }); - - afterEach(() => { - cleanup(configDir); - }); - - function noopStage() { - return '/tmp/staged-noop'; - } - - function makeLayout(destSubpath) { - return { - runtime: 'claude', - configDir, - scope: 'global', - kinds: [ - { - kind: 'skills', - destSubpath, - prefix: 'gsd-', - stage: noopStage, - }, - ], - }; - } - - test('rejects an escaping destSubpath ("../../escape") at plan-build time', () => { - const layout = makeLayout('../../escape'); - assert.throws( - () => createRuntimeArtifactInstallPlan({ - layout, - resolvedProfile: { name: 'core' }, - deps: { - rewriteStagedSkillBodies: () => undefined, - rewriteStagedCommandBodies: () => undefined, - }, - }), - (err) => { - assert.ok(err instanceof Error); - assert.ok( - err.message.includes('escapes'), - `expected "escapes" in: ${err.message}`, - ); - return true; - }, - ); - }); - - test('normal destSubpath produces plan with destDir under configDir', () => { - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-staged-')); - try { - const layout = { - runtime: 'claude', - configDir, - scope: 'global', - kinds: [ - { - kind: 'skills', - destSubpath: 'skills', - prefix: 'gsd-', - stage: () => stagedDir, - }, - ], - }; - - const result = createRuntimeArtifactInstallPlan({ - layout, - resolvedProfile: { name: 'core' }, - deps: { - rewriteStagedSkillBodies: () => undefined, - rewriteStagedCommandBodies: () => undefined, - }, - }); - - assert.strictEqual(result.ok, true, 'plan must succeed for normal destSubpath'); - assert.strictEqual(result.plan.items.length, 1); - const destDir = result.plan.items[0].destDir; - assert.ok( - destDir.startsWith(path.resolve(configDir)), - `destDir (${destDir}) must be under configDir (${configDir})`, - ); - } finally { - cleanup(stagedDir); - } - }); -}); - -// --------------------------------------------------------------------------- -// Integration tests for createRuntimeArtifactUninstallPlan -// --------------------------------------------------------------------------- - -describe('createRuntimeArtifactUninstallPlan destSubpath confinement', () => { - let configDir; - - beforeEach(() => { - configDir = createTempDir('gsd-uninstall-confine-'); - }); - - afterEach(() => { - cleanup(configDir); - }); - - function makeUninstallLayout(destSubpath) { - return { - runtime: 'claude', - configDir, - kinds: [ - { - kind: 'skills', - destSubpath, - prefix: 'gsd-', - stage: () => '/tmp/staged-noop', - }, - ], - }; - } - - test('rejects an escaping destSubpath ("../../escape") at uninstall-plan-build time', () => { - const layout = makeUninstallLayout('../../escape'); - assert.throws( - () => createRuntimeArtifactUninstallPlan(layout), - (err) => { - assert.ok(err instanceof Error); - assert.ok( - err.message.includes('escapes'), - `expected "escapes" in: ${err.message}`, - ); - return true; - }, - ); - }); - - test('rejects destSubpath "../outside" at uninstall-plan-build time', () => { - const layout = makeUninstallLayout('../outside'); - assert.throws( - () => createRuntimeArtifactUninstallPlan(layout), - /escapes/, - ); - }); - - test('normal destSubpath produces uninstall plan with destDir under configDir', () => { - const layout = makeUninstallLayout('skills'); - const plan = createRuntimeArtifactUninstallPlan(layout); - assert.strictEqual(plan.items.length, 1); - const destDir = plan.items[0].destDir; - assert.ok( - destDir.startsWith(path.resolve(configDir)), - `destDir (${destDir}) must be under configDir (${configDir})`, - ); - assert.strictEqual(destDir, path.join(path.resolve(configDir), 'skills')); - }); - - test('normal nested destSubpath ("commands/gsd") produces uninstall plan with destDir under configDir', () => { - const layout = makeUninstallLayout('commands/gsd'); - const plan = createRuntimeArtifactUninstallPlan(layout); - assert.strictEqual(plan.items.length, 1); - const destDir = plan.items[0].destDir; - assert.ok( - destDir.startsWith(path.resolve(configDir)), - `destDir (${destDir}) must be under configDir (${configDir})`, - ); - assert.strictEqual(destDir, path.join(path.resolve(configDir), 'commands', 'gsd')); - }); -}); - -// --------------------------------------------------------------------------- -// F4: migrateLegacyDevPreferencesToSkill must route through the confinement gate -// --------------------------------------------------------------------------- - -describe('F4: migrateLegacyDevPreferencesToSkill confinement', () => { - let configDir; - let outsideDir; - - beforeEach(() => { - configDir = createTempDir('gsd-f4-confine-'); - outsideDir = createTempDir('gsd-f4-outside-'); - }); - - afterEach(() => { - cleanup(configDir); - cleanup(outsideDir); - }); - - test('F4: migrateLegacyDevPreferencesToSkill throws when destSubpath resolves to configHome itself (via mocked layout with "." destSubpath)', () => { - // We cannot easily inject a bad destSubpath through the real layout resolver - // (it resolves to a real valid path). Instead we validate that the function - // uses assertDestWithinConfigHome by passing a runtime whose layout's - // skillsKindEntry.destSubpath, when joined with configDir, would escape — but - // since real layouts are always safe, we test the guard on a deliberately - // crafted saved map calling the real function and observing the path written - // is always within configDir for a real runtime. - // - // Real-layout sanity: verify 'opencode' produces a write inside configDir. - const savedLegacy = new Map([['dev-preferences.md', '# dev prefs\n']]); - // Real opencode layout — should succeed without throwing - assert.doesNotThrow(() => { - migrateLegacyDevPreferencesToSkill(configDir, savedLegacy, 'opencode', 'global'); - }, 'migrateLegacyDevPreferencesToSkill with real opencode layout must not throw'); - - // Verify the written file is inside configDir - const written = []; - function findMd(dir) { - if (!fs.existsSync(dir)) return; - for (const e of fs.readdirSync(dir, { withFileTypes: true })) { - if (e.isDirectory()) findMd(path.join(dir, e.name)); - else if (e.name.endsWith('.md')) written.push(path.join(dir, e.name)); - } - } - findMd(configDir); - assert.ok(written.length > 0, 'at least one .md must have been written'); - for (const f of written) { - assert.ok( - f.startsWith(path.resolve(configDir) + path.sep), - `written file ${f} must be inside configDir ${configDir}`, - ); - } - }); - - test('F4: migrateLegacyDevPreferencesToSkill uses assertDestWithinConfigHome — path.join on configDir+destSubpath cannot escape via symlink in destSubpath string', () => { - // Validate that the guard (assertDestWithinConfigHome) would have caught a - // manipulated destSubpath value. We simulate by calling assertDestWithinConfigHome - // directly with a "."-equivalent subpath (F3 guard) to prove F4 now relies on it. - assert.throws( - () => assertDestWithinConfigHome(configDir, '.'), - (err) => { - assert.ok(err instanceof Error); - return true; - }, - 'assertDestWithinConfigHome must reject "." (used by F4 guard)', - ); - }); -}); - -// --------------------------------------------------------------------------- -// F2: write sites reject a symlink-escaping destDir -// --------------------------------------------------------------------------- - -describe('F2: installOpencodeFamilySkills rejects symlink-escaping destDir', () => { - let configDir; - let outsideDir; - let symlinkTarget; - - beforeEach(() => { - configDir = createTempDir('gsd-f2-config-'); - outsideDir = createTempDir('gsd-f2-outside-'); - // Create a symlink inside configDir pointing outside - symlinkTarget = path.join(configDir, 'skills'); - fs.symlinkSync(outsideDir, symlinkTarget); - }); - - afterEach(() => { - // Remove symlink before cleanup to avoid errors - try { fs.unlinkSync(symlinkTarget); } catch { /* already gone */ } - cleanup(configDir); - cleanup(outsideDir); - }); - - test('F2: installOpencodeFamilySkills throws when skills/ is a symlink pointing outside configDir', () => { - // Create a minimal rawCommandsDir with one .md file - const rawDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-f2-raw-')); - try { - fs.writeFileSync(path.join(rawDir, 'help.md'), '# help\n', 'utf8'); - - assert.throws( - () => installOpencodeFamilySkills('opencode', configDir, rawDir, '~/.opencode/'), - (err) => { - assert.ok(err instanceof Error, 'must be an Error'); - assert.ok( - err.message.toLowerCase().includes('symlink') || - err.message.toLowerCase().includes('escap') || - err.message.toLowerCase().includes('outside') || - err.message.toLowerCase().includes('confinement'), - `expected symlink/escape error in: ${err.message}`, - ); - return true; - }, - ); - - // Verify nothing was written to outsideDir - const outsideFiles = fs.readdirSync(outsideDir); - assert.strictEqual(outsideFiles.length, 0, 'must not have written anything outside configDir'); - } finally { - cleanup(rawDir); - } - }); -}); - -// --------------------------------------------------------------------------- -// M1: _copyStaged defense-in-depth must also reject dest === configRoot -// --------------------------------------------------------------------------- - -describe('M1: _copyStaged rejects dest equal to configRoot', () => { - let configDir; - let stagedDir; - - beforeEach(() => { - configDir = createTempDir('gsd-m1-config-'); - stagedDir = createTempDir('gsd-m1-staged-'); - // Write a dummy file into stagedDir so _copyStaged has something to copy - fs.writeFileSync(path.join(stagedDir, 'help.md'), '# help\n', 'utf8'); - }); - - afterEach(() => { - cleanup(configDir); - cleanup(stagedDir); - }); - - test('M1: _copyStaged throws when destDir equals configRoot (was silently accepted before fix)', () => { - // dest === configRoot: the canonical gate (assertDestWithinConfigHome) rejects - // resolved === root with "escapes configHome" / "not configHome itself". - assert.throws( - () => _copyStaged(stagedDir, configDir, { kind: 'commands', destSubpath: '.', prefix: 'gsd-' }, configDir), - (err) => { - assert.ok(err instanceof Error, 'must be an Error'); - assert.ok( - err.message.includes('escapes configHome') || - err.message.includes('not configHome itself') || - err.message.includes('outside') || - err.message.includes('inside'), - `expected confinement error in: ${err.message}`, - ); - return true; - }, - ); - }); - - test('M1: _copyStaged throws when destDir is outside configRoot', () => { - const outsideDir = createTempDir('gsd-m1-outside-'); - try { - assert.throws( - () => _copyStaged(stagedDir, outsideDir, { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, configDir), - (err) => { - assert.ok(err instanceof Error, 'must be an Error'); - assert.ok( - // After EDIT 1, _copyStaged delegates to assertDestWithinConfigHome which - // emits "escapes configHome"; the old "_copyStaged" prefix is no longer present. - err.message.includes('escapes configHome') || - err.message.includes('strict subpath') || - err.message.includes('refusing'), - `expected confinement error in: ${err.message}`, - ); - return true; - }, - ); - } finally { - cleanup(outsideDir); - } - }); - - test('M1: _copyStaged accepts destDir strictly under configRoot', () => { - const destDir = path.join(configDir, 'commands', 'gsd'); - fs.mkdirSync(destDir, { recursive: true }); - // Should not throw — just copies (stagedDir has help.md, kind=commands) - assert.doesNotThrow( - () => _copyStaged(stagedDir, destDir, { kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' }, configDir), - ); - }); -}); - -// --------------------------------------------------------------------------- -// L2: symlink guard BEFORE mkdirSync in installRuntimeArtifacts -// --------------------------------------------------------------------------- - -describe('L2: installRuntimeArtifacts rejects symlink-escaping dest before mkdirSync', () => { - let configDir; - let outsideDir; - - beforeEach(() => { - configDir = createTempDir('gsd-l2-config-'); - outsideDir = createTempDir('gsd-l2-outside-'); - // Create configDir/skills as a symlink pointing outside - fs.symlinkSync(outsideDir, path.join(configDir, 'skills')); - }); - - afterEach(() => { - // Remove symlink before cleanup to avoid crossing dir boundaries - try { fs.unlinkSync(path.join(configDir, 'skills')); } catch { /* already gone */ } - cleanup(configDir); - cleanup(outsideDir); - }); - - test('L2: installRuntimeArtifacts throws before creating dirs when skills/ is a symlink pointing outside', () => { - // Use the full profile shape (skills: '*') so staging short-circuits early - // and the symlink guard is the first thing that fires. - assert.throws( - () => installRuntimeArtifacts('opencode', configDir, 'global', { name: 'full', skills: '*', agents: new Set() }), - (err) => { - assert.ok(err instanceof Error, 'must be an Error'); - assert.ok( - err.message.toLowerCase().includes('symlink') || - err.message.toLowerCase().includes('escap') || - err.message.toLowerCase().includes('outside') || - err.message.toLowerCase().includes('confinement') || - err.message.toLowerCase().includes('install root'), - `expected symlink/escape error in: ${err.message}`, - ); - return true; - }, - ); - - // The symlink itself still exists but no new entries were created in outsideDir - const outsideEntries = fs.readdirSync(outsideDir); - assert.strictEqual(outsideEntries.length, 0, 'must not have created any dirs/files outside configDir'); - }); -}); - -// --------------------------------------------------------------------------- -// L1: symlink guard in migrateLegacyDevPreferencesToSkill -// --------------------------------------------------------------------------- - -describe('L1: migrateLegacyDevPreferencesToSkill rejects symlink-escaping skillDir', () => { - let configDir; - let outsideDir; - - beforeEach(() => { - configDir = createTempDir('gsd-l1-config-'); - outsideDir = createTempDir('gsd-l1-outside-'); - // Create configDir/skills as a symlink pointing outside - fs.symlinkSync(outsideDir, path.join(configDir, 'skills')); - }); - - afterEach(() => { - try { fs.unlinkSync(path.join(configDir, 'skills')); } catch { /* already gone */ } - cleanup(configDir); - cleanup(outsideDir); - }); - - test('L1: migrateLegacyDevPreferencesToSkill throws when skills/ is a symlink pointing outside', () => { - const saved = new Map([['dev-preferences.md', '# dev prefs\n']]); - assert.throws( - () => migrateLegacyDevPreferencesToSkill(configDir, saved, 'opencode', 'global'), - (err) => { - assert.ok(err instanceof Error, 'must be an Error'); - assert.ok( - err.message.toLowerCase().includes('symlink') || - err.message.toLowerCase().includes('escap') || - err.message.toLowerCase().includes('outside') || - err.message.toLowerCase().includes('install root'), - `expected symlink/escape error in: ${err.message}`, - ); - return true; - }, - ); - - // Nothing must have been written outside - const outsideFiles = fs.readdirSync(outsideDir); - assert.strictEqual(outsideFiles.length, 0, 'must not have written anything outside configDir'); - }); -}); - -// --------------------------------------------------------------------------- -// L3: relative configDir support -// --------------------------------------------------------------------------- - -describe('L3: assertDestWithinConfigHome handles relative configDir', () => { - test('L3: throws when relative configDir + escaping destSubpath resolves outside', () => { - // path.resolve handles relative roots; '../../etc' from '.' would escape - assert.throws( - () => assertDestWithinConfigHome('.', '../../etc'), - (err) => { - assert.ok(err instanceof Error, 'must be an Error'); - assert.ok( - err.message.includes('escapes configHome') || err.message.includes('outside'), - `expected escape error in: ${err.message}`, - ); - return true; - }, - ); - }); - - test('L3: throws when "." destSubpath resolves to the relative configDir itself', () => { - // '.' resolves to the same directory as the configDir — must be rejected (F3) - assert.throws( - () => assertDestWithinConfigHome('.', '.'), - /escapes configHome|not configHome itself/, - ); - }); - - test('L3: accepts "skills" under relative "./somedir" and returns absolute path', () => { - const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-l3-')); - const relDir = path.relative(process.cwd(), tmpBase); - try { - const result = assertDestWithinConfigHome(relDir, 'skills'); - const expectedBase = path.resolve(relDir); - assert.ok( - result.startsWith(expectedBase + path.sep), - `result (${result}) must be under resolved relDir (${expectedBase})`, - ); - assert.strictEqual(result, path.join(expectedBase, 'skills')); - } finally { - fs.rmdirSync(tmpBase); - } - }); -}); - -// --------------------------------------------------------------------------- -// N1: sibling-prefix NEGATIVE assertion -// --------------------------------------------------------------------------- - -describe('N1: sibling directory with shared prefix is rejected', () => { - test('N1: rejects sibling path sharing a prefix with configDir', () => { - // /tmp/gsd-foobar is NOT inside /tmp/gsd-foo — must throw despite the - // startsWith prefix overlap at the string level (the sep-check prevents it). - assert.throws( - () => assertDestWithinConfigHome('/tmp/gsd-foo', '../gsd-foobar'), - (err) => { - assert.ok(err instanceof Error, 'must be an Error'); - assert.ok( - err.message.includes('escapes configHome') || err.message.includes('outside'), - `expected confinement error in: ${err.message}`, - ); - return true; - }, - ); - }); - - test('N1: accepts a true child subpath inside configDir', () => { - // 'bar' appended INSIDE /tmp/gsd-foo => the child path — accepted. - // Compute expected via path.resolve (the same primitive the helper uses) so - // the assertion is platform-portable: on Windows path.resolve prepends the - // cwd drive (C:\...) and uses backslashes, which a hardcoded posix literal / - // path.join (no drive) would not match (#1679 Windows-CI portability). - const root = path.resolve('/tmp/gsd-foo'); - const result = assertDestWithinConfigHome('/tmp/gsd-foo', 'bar'); - assert.strictEqual(result, path.resolve('/tmp/gsd-foo', 'bar')); - assert.ok(result.startsWith(root + path.sep)); - }); - - test('N1: the accepted child does not imply the sibling is accepted', () => { - // Double-check: 'bar' inside is fine, but '../gsd-foobar' (the sibling) is not. - // 'bar' resolves to /tmp/gsd-foo/bar ✓ - assert.doesNotThrow(() => assertDestWithinConfigHome('/tmp/gsd-foo', 'bar')); - // '../gsd-foobar' resolves to /tmp/gsd-foobar — NOT inside /tmp/gsd-foo - assert.throws( - () => assertDestWithinConfigHome('/tmp/gsd-foo', '../gsd-foobar'), - /escapes configHome/, - ); - }); -}); - -// --------------------------------------------------------------------------- -// N3: Windows-separator coverage (structural guard using path.win32) -// --------------------------------------------------------------------------- - -describe('N3: Windows-separator confinement logic (path.win32 semantics)', () => { - /** - * Replicate the assertDestWithinConfigHome predicate using path.win32 - * so we can test the sep-guard logic on any platform. - * - * This mirrors the implementation in runtime-artifact-install-plan.cjs - * but forces win32 path semantics. - */ - function assertDestWithinConfigHomeWin32(configDir, destSubpath) { - if (destSubpath.includes('\0')) { - throw new Error(`destSubpath "${destSubpath}" contains a NUL byte and is not valid`); - } - const root = path.win32.resolve(configDir); - const resolved = path.win32.resolve(configDir, destSubpath); - if (resolved === root || !resolved.startsWith(root + path.win32.sep)) { - throw new Error( - `destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`, - ); - } - return resolved; - } - - const winRoot = 'C:\\Users\\me\\.claude'; - - test('N3: rejects ..\\..\\Windows (Windows backslash traversal)', () => { - assert.throws( - () => assertDestWithinConfigHomeWin32(winRoot, '..\\..\\Windows'), - /escapes configHome/, - ); - }); - - test('N3: rejects mixed ../..\\x traversal', () => { - assert.throws( - () => assertDestWithinConfigHomeWin32(winRoot, '../..\\x'), - /escapes configHome/, - ); - }); - - test('N3: rejects "." that resolves to configHome itself', () => { - assert.throws( - () => assertDestWithinConfigHomeWin32(winRoot, '.'), - /escapes configHome/, - ); - }); - - test('N3: accepts "skills" under Windows root', () => { - const result = assertDestWithinConfigHomeWin32(winRoot, 'skills'); - assert.strictEqual(result, path.win32.join(winRoot, 'skills')); - assert.ok(result.startsWith(winRoot + path.win32.sep)); - }); - - test('N3: accepts "commands\\gsd" (Windows nested path) under Windows root', () => { - const result = assertDestWithinConfigHomeWin32(winRoot, 'commands\\gsd'); - assert.strictEqual(result, path.win32.join(winRoot, 'commands', 'gsd')); - assert.ok(result.startsWith(winRoot + path.win32.sep)); - }); - - test('N3: rejects sibling C:\\Users\\me\\.claude-extra under win32 semantics', () => { - assert.throws( - () => assertDestWithinConfigHomeWin32(winRoot, '..\\.claude-extra'), - /escapes configHome/, - ); - }); -}); diff --git a/tests/gsd-check-update-worker-platform-gate.test.cjs b/tests/gsd-check-update-worker-platform-gate.test.cjs index 7547ba6df..24651a247 100644 --- a/tests/gsd-check-update-worker-platform-gate.test.cjs +++ b/tests/gsd-check-update-worker-platform-gate.test.cjs @@ -84,3 +84,758 @@ describe('worker delegates the npm spawn (does not re-open the gate, #498)', () assert.match(codeOnly(WORKER_PATH), /check-latest-version/); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2992-check-latest-version.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2992-check-latest-version (consolidation epic #1969 B5 #1974)", () => { +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const { checkLatestVersion, CHECK_REASON, PACKAGE_NAME } = require( + path.join(ROOT, 'gsd-core', 'bin', 'check-latest-version.cjs'), +); + +// checkLatestVersion is a pure-ish function: it spawns one fixed npm +// command, validates the output, and returns { ok, version | reason }. +// The package name is HARDCODED — not a free choice for the caller. +// Tests use a pluggable spawn so no real npm process is invoked. + +describe('Bug #2992: deterministic latest-version check', () => { + test('PACKAGE_NAME is the constant @opengsd/gsd-core (no callers can override)', () => { + assert.equal(PACKAGE_NAME, '@opengsd/gsd-core'); + }); + + test('CHECK_REASON enum exposes the documented codes', () => { + assert.deepEqual( + Object.keys(CHECK_REASON).sort(), + ['FAIL_INVALID_OUTPUT', 'FAIL_NPM_FAILED', 'OK'].sort(), + ); + }); + + test('returns { ok: true, version } when npm prints a valid semver', () => { + const fakeSpawn = () => ({ status: 0, stdout: '1.39.1\n', stderr: '' }); + const r = checkLatestVersion({ spawn: fakeSpawn }); + assert.deepEqual(r, { ok: true, version: '1.39.1', reason: CHECK_REASON.OK }); + }); +}); + +describe('Bug #2992: error paths', () => { + const { checkLatestVersion, CHECK_REASON } = require(require('node:path').join(__dirname, '..', 'gsd-core', 'bin', 'check-latest-version.cjs')); + + test('FAIL_NPM_FAILED when npm exits non-zero (e.g. offline, 404)', () => { + const r = checkLatestVersion({ + spawn: () => ({ status: 1, stdout: '', stderr: 'npm ERR! 404\n' }), + }); + assert.equal(r.ok, false); + assert.equal(r.reason, CHECK_REASON.FAIL_NPM_FAILED); + assert.equal(r.detail, 'npm ERR! 404', + 'detail should be the trimmed stderr when npm reports a real error'); + }); + + // #2993 CR: distinguish timeout from genuine npm failure in `detail`. + // spawnSync sets status=null and signal='SIGTERM' on timeout; stderr is + // typically empty. Without the signal-first branch, both shape as + // 'npm exited non-zero' and the operator cannot tell timeout from failure. + test('FAIL_NPM_FAILED detail names the signal when spawn times out', () => { + const r = checkLatestVersion({ + spawn: () => ({ status: null, signal: 'SIGTERM', stdout: '', stderr: '' }), + }); + assert.equal(r.ok, false); + assert.equal(r.reason, CHECK_REASON.FAIL_NPM_FAILED); + assert.equal(r.detail, 'npm timed out (signal: SIGTERM)', + 'detail should explicitly name the signal when status is null and signal is set'); + }); + + test('FAIL_NPM_FAILED detail falls back to generic when neither stderr nor signal is present', () => { + const r = checkLatestVersion({ + spawn: () => ({ status: 1, stdout: '', stderr: '' }), + }); + assert.equal(r.detail, 'npm exited non-zero'); + }); + + test('FAIL_INVALID_OUTPUT when npm prints something that is not a semver', () => { + // E.g. if a future npm version changes the output format, or if the + // network returns an HTML error page captured as stdout. + const r = checkLatestVersion({ + spawn: () => ({ status: 0, stdout: 'not a version\n', stderr: '' }), + }); + assert.equal(r.ok, false); + assert.equal(r.reason, CHECK_REASON.FAIL_INVALID_OUTPUT); + }); + + test('FAIL_INVALID_OUTPUT when stdout is empty', () => { + const r = checkLatestVersion({ + spawn: () => ({ status: 0, stdout: '', stderr: '' }), + }); + assert.equal(r.ok, false); + assert.equal(r.reason, CHECK_REASON.FAIL_INVALID_OUTPUT); + }); + + test('accepts pre-release semver (e.g. 1.40.0-rc.1)', () => { + const r = checkLatestVersion({ + spawn: () => ({ status: 0, stdout: '1.40.0-rc.1\n', stderr: '' }), + }); + assert.deepEqual(r, { ok: true, version: '1.40.0-rc.1', reason: CHECK_REASON.OK }); + }); +}); + +describe('Issue #815: --next dist-tag support', () => { + const { buildViewArgs, resolveTag, ALLOWED_TAGS } = require( + path.join(ROOT, 'gsd-core', 'bin', 'check-latest-version.cjs'), + ); + + test('ALLOWED_TAGS is the sanctioned channel allowlist (latest, next)', () => { + assert.deepEqual([...ALLOWED_TAGS].sort(), ['latest', 'next']); + }); + + test('buildViewArgs() defaults to the bare latest spec (byte-for-byte unchanged)', () => { + assert.deepEqual(buildViewArgs(), ['view', '@opengsd/gsd-core', 'version']); + assert.deepEqual(buildViewArgs('latest'), ['view', '@opengsd/gsd-core', 'version']); + }); + + test('buildViewArgs("next") targets the @next dist-tag', () => { + assert.deepEqual(buildViewArgs('next'), ['view', '@opengsd/gsd-core@next', 'version']); + }); + + test('resolveTag defaults to latest when no --tag flag', () => { + assert.equal(resolveTag(['--json']), 'latest'); + }); + + test('resolveTag reads --tag next', () => { + assert.equal(resolveTag(['--json', '--tag', 'next']), 'next'); + }); + + test('resolveTag rejects an unknown tag (typo guard)', () => { + assert.throws(() => resolveTag(['--tag', 'nightly']), /invalid --tag 'nightly'/); + }); + + test('resolveTag rejects --tag with no value', () => { + assert.throws(() => resolveTag(['--tag']), /invalid --tag ''/); + }); + + test('checkLatestVersion accepts an RC under the next tag', () => { + const r = checkLatestVersion({ tag: 'next', spawn: () => ({ status: 0, stdout: '1.4.0-rc.1\n', stderr: '' }) }); + assert.deepEqual(r, { ok: true, version: '1.4.0-rc.1', reason: CHECK_REASON.OK }); + }); + + test('buildViewArgs rejects a tag outside the allowlist (exported-API guard)', () => { + assert.throws(() => buildViewArgs('nightly'), /invalid dist-tag 'nightly'/); + }); + + test('checkLatestVersion rejects an out-of-allowlist tag even with an injected spawn', () => { + assert.throws( + () => checkLatestVersion({ tag: 'nightly', spawn: () => ({ status: 0, stdout: '9.9.9\n', stderr: '' }) }), + /invalid dist-tag 'nightly'/, + ); + }); + + test('resolveTag handles the --tag=next equals form', () => { + assert.equal(resolveTag(['--json', '--tag=next']), 'next'); + }); + + test('resolveTag rejects an unknown --tag=value equals form (no silent fallback)', () => { + assert.throws(() => resolveTag(['--tag=nightly']), /invalid --tag 'nightly'/); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-378-update-check-scoped-name.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-378-update-check-scoped-name (consolidation epic #1969 B5 #1974)", () => { +/** + * Regression test for #378 / #498: the SessionStart update worker must end up + * querying the SCOPED package name (@opengsd/gsd-core) when it asks + * npm for the latest version. + * + * Background (#378): the worker once hardcoded the unscoped 'gsd-core', + * which 404s from the registry, leaving update_available permanently false. + * + * Original #378 fix derived the name from `require('../package.json').name`. + * That is broken at runtime (#498): the installed tree carries only a synthetic + * `{"type":"commonjs"}` package.json (no `.name`), so post-install the worker + * queried `npm view undefined version` → latest stayed null → update_available + * permanently false. The old structural test passed only because it grepped the + * DEV tree, where package.json still has a name. + * + * New contract (#498): the worker no longer resolves the package name itself. + * It delegates the latest-version lookup to check-latest-version.cjs's + * `checkLatestVersion()`, whose `PACKAGE_NAME` is sourced from the baked Package + * Identity seam (`gsd-core/bin/lib/package-identity.cjs`). The seam's value + * is a build-time constant, correct in every install layout, so the + * undefined-at-runtime failure cannot recur. This test locks that contract: + * + * 1. Structural: worker must NOT contain the bare unscoped literal. + * 2. Structural: worker must NOT use `require(...package.json...).name` + * (the runtime-broken path). + * 3. Structural: worker delegates to check-latest-version's + * `checkLatestVersion` rather than calling `npm view` itself. + * 4. Single-source: check-latest-version's PACKAGE_NAME === the seam's + * packageName === the scoped '@opengsd/gsd-core'. + * + * Source-grep policy: this test reads hook source via readFileSync. The repo's + * lint-no-source-grep rule targets bin/lib/gsd-core — hooks/ is out of + * scope. The behavior (correct name → no E404) only manifests at runtime + * against the live registry; structural assertions are the minimum-cost + * contract for the worker, the same rationale #378 carried. + */ + +// allow-test-rule: structural assertion on hook delegation; the behavior being (see #378) +// tested (correct package name → no E404) only manifests at runtime against the +// live npm registry, which CI does not call. + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const WORKER_PATH = path.join(__dirname, '..', 'hooks', 'gsd-check-update-worker.js'); +const PKG_PATH = path.join(__dirname, '..', 'package.json'); +const SEAM = require('../gsd-core/bin/lib/package-identity.cjs'); +const { PACKAGE_NAME } = require('../gsd-core/bin/check-latest-version.cjs'); + +function workerCodeOnly() { + const src = fs.readFileSync(WORKER_PATH, 'utf8'); + return src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/[^\r\n]*/g, '$1'); +} + +describe('bug #378 / #498: update worker queries the scoped name via the seam', () => { + test('worker file exists', () => { + assert.ok(fs.existsSync(WORKER_PATH), `worker not found at ${WORKER_PATH}`); + }); + + test('package.json name is the scoped @opengsd/gsd-core', () => { + const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf8')); + assert.equal(pkg.name, '@opengsd/gsd-core'); + }); + + test('worker does NOT hardcode the unscoped gsd-core as a string literal', () => { + assert.doesNotMatch( + workerCodeOnly(), + /['"]gsd-core['"]/, + "Worker must not pass the unscoped 'gsd-core' to npm — it 404s.", + ); + }); + + test('worker does NOT resolve the name via require(package.json).name (broken at runtime)', () => { + assert.doesNotMatch( + workerCodeOnly(), + /require\s*\(\s*['"][^'"]*package\.json['"]\s*\)\s*\.name/, + [ + 'require(package.json).name resolves to undefined in the installed tree', + '(only a {"type":"commonjs"} marker ships). The worker must delegate to', + 'checkLatestVersion(), which sources the name from the baked seam.', + ].join(' '), + ); + }); + + test('worker delegates the latest-version lookup to checkLatestVersion', () => { + const code = workerCodeOnly(); + assert.match( + code, + /check-latest-version/, + 'Worker must require check-latest-version.cjs and call checkLatestVersion().', + ); + assert.match(code, /checkLatestVersion\s*\(/); + }); + + test('check-latest-version PACKAGE_NAME is single-sourced from the seam', () => { + assert.equal(PACKAGE_NAME, SEAM.packageName); + assert.equal(SEAM.packageName, '@opengsd/gsd-core'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2784-update-cache-clear-path.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2784-update-cache-clear-path (consolidation epic #1969 B5 #1974)", () => { +// allow-test-rule: structural-regression-guard (see #2784) +// Reads hook .js or bin/install.js source to assert structural invariants +// (search array order, function wiring, path constants) that cannot be +// verified by observing runtime outputs alone. Per CONTRIBUTING.md exception matrix. + +/** + * Regression test for bug #2784 + * + * /gsd-update cache-clear step only cleared per-runtime cache paths + * (e.g. ~/.claude/cache/gsd-update-check.json) but the SessionStart hook + * (hooks/gsd-check-update.js) writes to the shared tool-agnostic path + * ~/.cache/gsd/gsd-update-check.json. After a successful update, the statusline + * kept showing the stale "⬆ /gsd-update" indicator because the actual cache + * file was never deleted. + * + * Fix: add `rm -f "$HOME/.cache/gsd/gsd-update-check.json"` to the + * run_update step's cache-clear block in gsd-core/workflows/update.md. + */ + +'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 REPO_ROOT = path.join(__dirname, '..'); +const UPDATE_WORKFLOW = path.join( + REPO_ROOT, + 'gsd-core', + 'workflows', + 'update.md' +); +const CHECK_UPDATE_HOOK = path.join(REPO_ROOT, 'hooks', 'gsd-check-update.js'); + +describe('bug-2784: update.md cache-clear covers shared cache path', () => { + test('gsd-check-update.js hook constructs cache dir from .cache and gsd path segments', () => { + const hookContent = fs.readFileSync(CHECK_UPDATE_HOOK, 'utf-8'); + // Parse the path.join() call structurally rather than text-grepping. + const m = hookContent.match(/const cacheDir\s*=\s*path\.join\(([^)]+)\)/); + assert.ok( + m !== null, + 'hook must assign cacheDir via path.join() with explicit path segments' + ); + const segments = m[1].split(',').map((a) => a.trim().replace(/^['"]|['"]$/g, '')); + assert.ok( + segments.includes('.cache'), + `hook cacheDir path.join() must include '.cache' segment; got: ${JSON.stringify(segments)}` + ); + assert.ok( + segments.includes('gsd'), + `hook cacheDir path.join() must include 'gsd' segment; got: ${JSON.stringify(segments)}` + ); + }); + + test('update.md run_update bash commands include rm for shared gsd cache file', () => { + const workflowContent = fs.readFileSync(UPDATE_WORKFLOW, 'utf-8'); + // Parse the step block structurally, then extract only bash fenced code lines. + const stepMatch = workflowContent.match(/[\s\S]*?<\/step>/); + assert.ok(stepMatch, 'update.md must have a block'); + const stepContent = stepMatch[0]; + + const bashLines = []; + const fenceRe = /```(?:bash|sh)\r?\n([\s\S]*?)```/g; + let m; + while ((m = fenceRe.exec(stepContent)) !== null) { + for (const line of m[1].split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed) bashLines.push(trimmed); + } + } + + const sharedCacheClearCmds = bashLines.filter( + (line) => /^rm\b/.test(line) && line.includes('.cache/gsd/gsd-update-check') && line.includes('*.json') + ); + assert.ok( + sharedCacheClearCmds.length > 0, + [ + 'run_update step bash blocks must include an `rm` command targeting .cache/gsd/gsd-update-check*.json (glob form clearing legacy + per-package variants).', + `Bash lines found: ${JSON.stringify(bashLines)}`, + ].join('\n') + ); + const hasHomeExpansion = sharedCacheClearCmds.some( + (line) => line.includes('$HOME') || line.includes('~/') + ); + assert.ok( + hasHomeExpansion, + `shared cache rm command must use $HOME or ~/ expansion; found: ${JSON.stringify(sharedCacheClearCmds)}` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-2795-update-banner.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-2795-update-banner (consolidation epic #1969 B5 #1974)", () => { +/** + * Tests for gsd-update-banner.js (#2795). + * + * The banner hook is an opt-in SessionStart consumer of the update cache that + * gsd-check-update-worker.js writes. When a user declines GSD's statusline, + * install.js may register this hook so update availability still surfaces in + * runtimes that use a non-GSD statusline. + * + * Tests follow the typed-IR convention (CONTRIBUTING.md "Prohibited: Raw Text + * Matching on Test Outputs"): assert on parsed JSON envelopes, not on raw + * stdout substrings. + */ + +'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 { spawnSync } = require('node:child_process'); +const { cleanup } = require('./helpers.cjs'); + +const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-update-banner.js'); +const { + buildBannerOutput, + shouldSuppressFailureWarning, + RATE_LIMIT_SECONDS, +} = require('../hooks/gsd-update-banner.js'); +const { updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs'); + +// ─── Pure function: buildBannerOutput ─────────────────────────────────────── + +describe('buildBannerOutput', () => { + test('returns null when cache is missing', () => { + const out = buildBannerOutput({ + cache: null, + parseError: false, + suppressFailureWarning: false, + }); + assert.equal(out, null); + }); + + test('returns null when update_available is false', () => { + const out = buildBannerOutput({ + cache: { update_available: false, installed: '1.40.0', latest: '1.40.0' }, + parseError: false, + suppressFailureWarning: false, + }); + assert.equal(out, null); + }); + + test('returns banner envelope when update_available is true', () => { + const out = buildBannerOutput({ + cache: { update_available: true, installed: '1.39.0', latest: '1.40.0', package_name: '@opengsd/gsd-core' }, + parseError: false, + suppressFailureWarning: false, + }); + assert.ok(out, 'expected banner envelope'); + assert.equal(typeof out.systemMessage, 'string'); + assert.ok( + out.systemMessage.includes('1.39.0'), + 'banner should name installed version' + ); + assert.ok( + out.systemMessage.includes('1.40.0'), + 'banner should name latest version' + ); + assert.ok( + out.systemMessage.includes('/gsd:update'), + 'banner should reference /gsd:update command' + ); + }); + + test('returns failure diagnostic on parseError when not suppressed', () => { + const out = buildBannerOutput({ + cache: null, + parseError: true, + suppressFailureWarning: false, + }); + assert.ok(out, 'expected diagnostic envelope'); + assert.equal(typeof out.systemMessage, 'string'); + assert.ok( + /check failed/i.test(out.systemMessage), + 'diagnostic should describe a failed check' + ); + }); + + test('returns null on parseError when suppressed by rate limit', () => { + const out = buildBannerOutput({ + cache: null, + parseError: true, + suppressFailureWarning: true, + }); + assert.equal(out, null); + }); + + test('falls back to "unknown" when installed/latest missing', () => { + const out = buildBannerOutput({ + cache: { update_available: true, package_name: '@opengsd/gsd-core' }, + parseError: false, + suppressFailureWarning: false, + }); + assert.ok(out); + assert.ok( + out.systemMessage.includes('unknown'), + 'banner should degrade gracefully when versions are absent' + ); + }); +}); + +// ─── Pure function: shouldSuppressFailureWarning ──────────────────────────── + +describe('shouldSuppressFailureWarning', () => { + function tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-banner-supp-')); + } + + test('returns false when sentinel file is missing', () => { + const dir = tmpDir(); + try { + const result = shouldSuppressFailureWarning( + path.join(dir, 'no-such-file'), + 100 + ); + assert.equal(result, false); + } finally { + cleanup(dir); + } + }); + + test('returns true within rate-limit window', () => { + const dir = tmpDir(); + try { + const f = path.join(dir, 'sentinel'); + fs.writeFileSync(f, '1000'); + const result = shouldSuppressFailureWarning(f, 1000 + RATE_LIMIT_SECONDS - 1); + assert.equal(result, true); + } finally { + cleanup(dir); + } + }); + + test('returns false outside rate-limit window', () => { + const dir = tmpDir(); + try { + const f = path.join(dir, 'sentinel'); + fs.writeFileSync(f, '1000'); + const result = shouldSuppressFailureWarning(f, 1000 + RATE_LIMIT_SECONDS + 1); + assert.equal(result, false); + } finally { + cleanup(dir); + } + }); + + test('returns false when sentinel content is non-numeric', () => { + const dir = tmpDir(); + try { + const f = path.join(dir, 'sentinel'); + fs.writeFileSync(f, 'garbage-not-a-number'); + const result = shouldSuppressFailureWarning(f, 100); + assert.equal(result, false); + } finally { + cleanup(dir); + } + }); +}); + +// ─── End-to-end: spawn the hook against fixture cache states ──────────────── + +describe('gsd-update-banner.js end-to-end', () => { + function setupHome() { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-banner-home-')); + fs.mkdirSync(path.join(home, '.cache', 'gsd'), { recursive: true }); + return home; + } + + function runHook(home) { + return spawnSync(process.execPath, [HOOK_PATH], { + env: { ...process.env, HOME: home, USERPROFILE: home }, + encoding: 'utf8', + }); + } + + function writeCache(home, contents) { + fs.writeFileSync( + path.join(home, '.cache', 'gsd', updateCacheFileName), + typeof contents === 'string' ? contents : JSON.stringify(contents) + ); + } + + test('exits 0 with empty stdout when cache file missing', () => { + const home = setupHome(); + try { + const r = runHook(home); + assert.equal(r.status, 0, `expected exit 0, got ${r.status} stderr=${r.stderr}`); + assert.equal(r.stdout.trim(), ''); + } finally { + cleanup(home); + } + }); + + test('emits valid SessionStart JSON when update_available=true', () => { + const home = setupHome(); + try { + writeCache(home, { + update_available: true, + installed: '1.39.0', + latest: '1.40.0', + package_name: '@opengsd/gsd-core', + }); + const r = runHook(home); + assert.equal(r.status, 0); + const parsed = JSON.parse(r.stdout); + assert.equal(typeof parsed.systemMessage, 'string'); + assert.ok(parsed.systemMessage.includes('1.40.0')); + assert.ok(parsed.systemMessage.includes('/gsd:update')); + } finally { + cleanup(home); + } + }); + + test('exits silent when update_available=false', () => { + const home = setupHome(); + try { + writeCache(home, { + update_available: false, + installed: '1.40.0', + latest: '1.40.0', + }); + const r = runHook(home); + assert.equal(r.status, 0); + assert.equal(r.stdout.trim(), ''); + } finally { + cleanup(home); + } + }); + + test('emits failure diagnostic when cache JSON is malformed', () => { + const home = setupHome(); + try { + writeCache(home, 'not json {{{{'); + const r = runHook(home); + assert.equal(r.status, 0); + const parsed = JSON.parse(r.stdout); + assert.equal(typeof parsed.systemMessage, 'string'); + assert.ok(/check failed/i.test(parsed.systemMessage)); + } finally { + cleanup(home); + } + }); + + test('suppresses repeat failure diagnostic within 24h via sentinel', () => { + const home = setupHome(); + try { + writeCache(home, 'not json'); + const r1 = runHook(home); + assert.equal( + r1.status, + 0, + `expected exit 0, got ${r1.status} stderr=${r1.stderr}` + ); + const parsed1 = JSON.parse(r1.stdout); + assert.ok(/check failed/i.test(parsed1.systemMessage)); + + // Sentinel should now exist so the next run is silent + const sentinel = path.join(home, '.cache', 'gsd', 'banner-failure-warned-at'); + assert.ok(fs.existsSync(sentinel), 'first run must record the warning sentinel'); + + const r2 = runHook(home); + assert.equal(r2.status, 0); + assert.equal( + r2.stdout.trim(), + '', + 'subsequent run within rate-limit window must stay silent' + ); + } finally { + cleanup(home); + } + }); + + test('handles cache present but update_available field absent (older cache schema)', () => { + const home = setupHome(); + try { + writeCache(home, { installed: '1.40.0', latest: '1.40.0' }); + const r = runHook(home); + assert.equal(r.status, 0); + assert.equal(r.stdout.trim(), ''); + } finally { + cleanup(home); + } + }); +}); + +// ─── Install.js wiring: prompt + SessionStart entry registration ──────────── +// +// These tests load bin/install.js as a module via GSD_TEST_MODE and assert on +// pure exported helpers. The shape mirrors how runtime-prompt-builder / +// statusline tests interact with install.js. + +describe('install.js update-banner wiring', () => { + process.env.GSD_TEST_MODE = '1'; + // Re-require fresh so test-mode exports are populated. + const installPath = path.join(__dirname, '..', 'bin', 'install.js'); + delete require.cache[installPath]; + const installExports = require(installPath); + + test('exports buildUpdateBannerPromptText for structural prompt assertions', () => { + assert.equal( + typeof installExports.buildUpdateBannerPromptText, + 'function', + 'install.js must export buildUpdateBannerPromptText so tests can assert without grepping source' + ); + const text = installExports.buildUpdateBannerPromptText(); + assert.equal(typeof text, 'string'); + assert.ok(text.length > 0); + // Strip ANSI color escapes before structural assertions — the choice + // digits are wrapped in color codes so word-boundary regex against the + // raw text would miss them. + // eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output + const stripped = text.replace(/\x1b\[[0-9;]*m/g, ''); + // Prompt must offer at least two choices (default + opt-in). + assert.match(stripped, /\b1\b/); + assert.match(stripped, /\b2\b/); + }); + + test('parseUpdateBannerInput defaults to false on empty / "1"', () => { + assert.equal(typeof installExports.parseUpdateBannerInput, 'function'); + assert.equal(installExports.parseUpdateBannerInput(''), false); + assert.equal(installExports.parseUpdateBannerInput(' '), false); + assert.equal(installExports.parseUpdateBannerInput('1'), false); + }); + + test('parseUpdateBannerInput returns true on "2"', () => { + assert.equal(installExports.parseUpdateBannerInput('2'), true); + assert.equal(installExports.parseUpdateBannerInput('2 '), true); + }); + + test('parseUpdateBannerInput accepts "y" / "yes" affirmative shortcuts', () => { + assert.equal(installExports.parseUpdateBannerInput('y'), true); + assert.equal(installExports.parseUpdateBannerInput('Y'), true); + assert.equal(installExports.parseUpdateBannerInput('yes'), true); + assert.equal(installExports.parseUpdateBannerInput('YES'), true); + }); + + test('buildUpdateBannerHookEntry produces a SessionStart hook entry', () => { + assert.equal(typeof installExports.buildUpdateBannerHookEntry, 'function'); + const entry = installExports.buildUpdateBannerHookEntry( + '"/usr/local/bin/node" "/home/u/.claude/hooks/gsd-update-banner.js"' + ); + assert.ok(entry, 'expected hook entry object'); + assert.ok(Array.isArray(entry.hooks), 'entry.hooks must be an array'); + assert.equal(entry.hooks.length, 1); + assert.equal(entry.hooks[0].type, 'command'); + assert.ok( + entry.hooks[0].command.includes('gsd-update-banner.js'), + 'command must reference the banner hook' + ); + }); + + test('buildUpdateBannerHookEntry returns null on null command', () => { + assert.equal(installExports.buildUpdateBannerHookEntry(null), null); + assert.equal(installExports.buildUpdateBannerHookEntry(''), null); + }); +}); + }); +} diff --git a/tests/gsd-statusline.test.cjs b/tests/gsd-statusline.test.cjs index 8e89ac108..0ac03eb01 100644 --- a/tests/gsd-statusline.test.cjs +++ b/tests/gsd-statusline.test.cjs @@ -584,3 +584,611 @@ describe('todo-resolution: resolves in_progress task from the newest matching to `stdout must NOT contain "NOT AGENT 305", got: ${stdout}`); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-2538-statusline-last-command.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-2538-statusline-last-command (consolidation epic #1969 B5 #1974)", () => { +'use strict'; + +/** + * Enhancement #2538 — statusline `last: /cmd` suffix. + * + * Asserts that: + * - default (flag absent) output does NOT include "last:" text + * - with statusline.show_last_command=true AND a transcript containing + * /gsd-plan-phase, output includes "last: /gsd-plan-phase" + * - a missing transcript_path does not throw and produces no "last:" suffix + * - an existing transcript with no slash commands produces no "last:" suffix + * - the config key is registered in the schema so /gsd-settings can surface it + */ + +const { 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 { cleanup } = require('./helpers.cjs'); + +const statusline = require('../hooks/gsd-statusline.js'); +const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs'); + +function makeProject({ flag, transcript }) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'enh-2538-')); + fs.mkdirSync(path.join(dir, '.planning'), { recursive: true }); + if (flag !== undefined) { + fs.writeFileSync( + path.join(dir, '.planning', 'config.json'), + JSON.stringify({ statusline: { show_last_command: flag } }), + ); + } + let transcriptPath = null; + if (transcript !== undefined) { + transcriptPath = path.join(dir, 'transcript.jsonl'); + fs.writeFileSync(transcriptPath, transcript); + } + return { dir, transcriptPath, cleanup: () => cleanup(dir) }; +} + +function buildInput(dir, transcriptPath) { + return { + model: { display_name: 'Claude' }, + workspace: { current_dir: dir }, + session_id: 'test-session', + transcript_path: transcriptPath, + }; +} + +test('config schema registers statusline.show_last_command', () => { + assert.ok( + VALID_CONFIG_KEYS.has('statusline.show_last_command'), + 'statusline.show_last_command must be in VALID_CONFIG_KEYS', + ); +}); + +test('default (flag absent) output has no "last:" suffix', () => { + const transcript = + JSON.stringify({ type: 'user', message: { content: '/gsd-plan-phase' } }) + '\n'; + const { dir, transcriptPath, cleanup } = makeProject({ transcript }); + try { + const out = statusline.renderStatusline(buildInput(dir, transcriptPath)); + assert.ok(!out.includes('last:'), `expected no "last:" in output; got: ${out}`); + } finally { + cleanup(); + } +}); + +test('flag=true with recorded command yields "last: /"', () => { + const transcript = + JSON.stringify({ type: 'user', message: { content: '/gsd-plan-phase' } }) + '\n' + + JSON.stringify({ type: 'assistant', message: { content: 'ok' } }) + '\n'; + const { dir, transcriptPath, cleanup } = makeProject({ flag: true, transcript }); + try { + const out = statusline.renderStatusline(buildInput(dir, transcriptPath)); + assert.ok(out.includes('last: /gsd-plan-phase'), `expected "last: /gsd-plan-phase" in output; got: ${out}`); + } finally { + cleanup(); + } +}); + +test('flag=true picks the MOST RECENT command when multiple are present', () => { + const transcript = + JSON.stringify({ type: 'user', message: { content: '/gsd-discuss-phase' } }) + '\n' + + JSON.stringify({ type: 'user', message: { content: '/gsd-plan-phase' } }) + '\n' + + JSON.stringify({ type: 'user', message: { content: '/gsd-execute-phase' } }) + '\n'; + const { dir, transcriptPath, cleanup } = makeProject({ flag: true, transcript }); + try { + const out = statusline.renderStatusline(buildInput(dir, transcriptPath)); + assert.ok(out.includes('last: /gsd-execute-phase'), `expected most-recent "gsd-execute-phase"; got: ${out}`); + assert.ok(!out.includes('last: /gsd-discuss-phase'), `should not show stale command; got: ${out}`); + } finally { + cleanup(); + } +}); + +test('flag=true with missing transcript_path does not throw and omits suffix', () => { + const { dir, cleanup } = makeProject({ flag: true }); + try { + let out; + assert.doesNotThrow(() => { + out = statusline.renderStatusline(buildInput(dir, undefined)); + }); + assert.ok(!out.includes('last:'), `expected no "last:" suffix when transcript missing; got: ${out}`); + } finally { + cleanup(); + } +}); + +test('flag=true with transcript lacking command tags omits suffix', () => { + const transcript = + JSON.stringify({ type: 'user', message: { content: 'just a plain prompt' } }) + '\n'; + const { dir, transcriptPath, cleanup } = makeProject({ flag: true, transcript }); + try { + const out = statusline.renderStatusline(buildInput(dir, transcriptPath)); + assert.ok(!out.includes('last:'), `expected no "last:" suffix with no commands; got: ${out}`); + } finally { + cleanup(); + } +}); + +test('readLastSlashCommand returns null for nonexistent paths', () => { + assert.strictEqual(statusline.readLastSlashCommand('/nonexistent/path.jsonl'), null); + assert.strictEqual(statusline.readLastSlashCommand(null), null); + assert.strictEqual(statusline.readLastSlashCommand(undefined), null); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-2833-phase-lifecycle-statusline.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-2833-phase-lifecycle-statusline (consolidation epic #1969 B5 #1974)", () => { +/** + * Tests for issue #2833 — phase-lifecycle status-line. + * + * Covers the additions made by the two preceding feat commits: + * + * 1. parseStateMd reads four new STATE.md frontmatter fields + * - active_phase + * - next_action + * - next_phases (YAML flow array) + * - progress (nested block: completed_phases / total_phases / percent) + * + * 2. formatGsdState renders three new scenes when those fields are populated + * - Scene 1: active_phase set → "Phase X.Y " + * - Scene 2: idle + next_action set → "next " + * - Scene 3: percent 100 / all done → "milestone complete" + * - Scene 4: default fallback → unchanged " · " + * + * 3. renderProgressBar() helper for the opt-in milestone bar. + * + * 4. Backward compatibility — existing STATE.md files (without any of the + * new fields) render byte-for-byte identically to v1.38.x. + */ + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + parseStateMd, + formatGsdState, +} = require('../hooks/gsd-statusline.js'); + +// ─── parseStateMd: new lifecycle fields ───────────────────────────────────── + +describe('parseStateMd #2833 lifecycle fields', () => { + test('reads active_phase from frontmatter', () => { + const content = [ + '---', + 'milestone: v2.0', + 'status: executing', + 'active_phase: "4.5"', + '---', + ].join('\n'); + const s = parseStateMd(content); + assert.equal(s.activePhase, '4.5'); + }); + + test('reads next_action from frontmatter', () => { + const content = [ + '---', + 'milestone: v2.0', + 'next_action: execute-phase', + '---', + ].join('\n'); + const s = parseStateMd(content); + assert.equal(s.nextAction, 'execute-phase'); + }); + + test('treats "null" literal as null for active_phase and next_action', () => { + const content = [ + '---', + 'active_phase: null', + 'next_action: null', + '---', + ].join('\n'); + const s = parseStateMd(content); + assert.equal(s.activePhase, null); + assert.equal(s.nextAction, null); + }); + + test('parses next_phases YAML flow array (single item)', () => { + const content = [ + '---', + 'next_phases: ["4.5"]', + '---', + ].join('\n'); + const s = parseStateMd(content); + assert.deepEqual(s.nextPhases, ['4.5']); + }); + + test('parses next_phases YAML flow array (multiple items)', () => { + const content = [ + '---', + 'next_phases: ["4.5", "4.6", "5"]', + '---', + ].join('\n'); + const s = parseStateMd(content); + assert.deepEqual(s.nextPhases, ['4.5', '4.6', '5']); + }); + + test('parses progress nested block — all three fields', () => { + const content = [ + '---', + 'progress:', + ' total_phases: 17', + ' completed_phases: 10', + ' percent: 59', + '---', + ].join('\n'); + const s = parseStateMd(content); + assert.equal(s.totalPhases, '17'); + assert.equal(s.completedPhases, '10'); + assert.equal(s.percent, '59'); + }); + + test('returns undefined for absent lifecycle fields', () => { + const content = [ + '---', + 'milestone: v1.9', + 'status: executing', + '---', + ].join('\n'); + const s = parseStateMd(content); + assert.equal(s.activePhase, undefined); + assert.equal(s.nextAction, undefined); + assert.equal(s.nextPhases, undefined); + assert.equal(s.percent, undefined); + }); +}); + +// ─── formatGsdState: new scenes ───────────────────────────────────────────── + +describe('formatGsdState #2833 lifecycle scenes', () => { + test('Scene 1 — active_phase set renders "Phase X.Y "', () => { + const out = formatGsdState({ + milestone: 'v2.0', + status: 'executing', + activePhase: '4.5', + percent: '59', + }); + assert.equal(out, 'v2.0 [█████░░░░░] 59% · Phase 4.5 executing'); + }); + + test('Scene 1 — active_phase without status renders "Phase X.Y"', () => { + const out = formatGsdState({ + milestone: 'v2.0', + activePhase: '4.5', + }); + assert.equal(out, 'v2.0 · Phase 4.5'); + }); + + test('Scene 2 — idle + next_action renders "next "', () => { + const out = formatGsdState({ + milestone: 'v2.0', + activePhase: null, + nextAction: 'execute-phase', + nextPhases: ['4.5'], + percent: '59', + }); + assert.equal(out, 'v2.0 [█████░░░░░] 59% · next execute-phase 4.5'); + }); + + test('Scene 2 — multiple next_phases joined with /', () => { + const out = formatGsdState({ + milestone: 'v2.0', + nextAction: 'discuss-phase', + nextPhases: ['4.7', '6.5'], + }); + assert.equal(out, 'v2.0 · next discuss-phase 4.7/6.5'); + }); + + test('Scene 3 — percent=100 renders "milestone complete"', () => { + const out = formatGsdState({ + milestone: 'v2.0', + percent: '100', + }); + assert.equal(out, 'v2.0 [██████████] 100% · milestone complete'); + }); + + test('Scene 3 — completed_phases equals total_phases also triggers complete', () => { + const out = formatGsdState({ + milestone: 'v2.0', + completedPhases: '17', + totalPhases: '17', + }); + assert.equal(out, 'v2.0 · milestone complete'); + }); +}); + +// ─── Backward compatibility — CRITICAL: existing STATE.md unchanged ───────── + +describe('formatGsdState #2833 backward compatibility', () => { + test('legacy STATE.md (only status + milestone + phase) renders unchanged', () => { + // Identical to the format documented in #1989 (the foundation issue). + // No new lifecycle fields populated → must render exactly as v1.38.x did. + const out = formatGsdState({ + status: 'executing', + milestone: 'v1.9', + milestoneName: 'Code Quality', + phaseNum: '1', + phaseTotal: '5', + phaseName: 'fix-graphiti-deployment', + }); + assert.equal(out, 'v1.9 Code Quality · executing · fix-graphiti-deployment (1/5)'); + }); + + test('only status set (no phase, no lifecycle fields) renders just " · "', () => { + const out = formatGsdState({ + milestone: 'v1.9', + status: 'executing', + }); + assert.equal(out, 'v1.9 · executing'); + }); + + test('empty state renders empty string', () => { + const out = formatGsdState({}); + assert.equal(out, ''); + }); + + test('progress.percent is opt-in — absent percent leaves milestone segment unchanged', () => { + const out = formatGsdState({ + milestone: 'v1.9', + milestoneName: 'Code Quality', + status: 'executing', + }); + // No bar rendered when percent is absent. + assert.equal(out, 'v1.9 Code Quality · executing'); + }); +}); + +// ─── renderProgressBar (exported indirectly via formatGsdState behavior) ──── + +describe('progress bar rendering', () => { + test('0% renders 10 empty segments', () => { + // percent=0 doesn't trigger Scene 3 (only percent='100' does), so + // Scene 4 fallback fires with no extra parts — just milestone + bar. + const out = formatGsdState({ milestone: 'v2.0', percent: '0' }); + assert.ok(out.includes('[░░░░░░░░░░] 0%')); + }); + + test('50% renders 5 filled + 5 empty', () => { + const out = formatGsdState({ milestone: 'v2.0', percent: '50' }); + assert.ok(out.includes('[█████░░░░░] 50%')); + }); + + test('100% renders 10 filled (and triggers Scene 3)', () => { + const out = formatGsdState({ milestone: 'v2.0', percent: '100' }); + assert.equal(out, 'v2.0 [██████████] 100% · milestone complete'); + }); + + test('percent absent → no bar rendered (opt-in)', () => { + const out = formatGsdState({ milestone: 'v2.0', status: 'executing' }); + assert.ok(!out.includes('[')); + assert.ok(!out.includes('░')); + assert.ok(!out.includes('█')); + }); + + test('percent over 100 clamps to 100', () => { + const out = formatGsdState({ milestone: 'v2.0', percent: '150' }); + assert.ok(out.includes('[██████████] 100%')); + }); + + test('percent below 0 clamps to 0', () => { + const out = formatGsdState({ milestone: 'v2.0', percent: '-10' }); + assert.ok(out.includes('[░░░░░░░░░░] 0%')); + }); +}); + +// ─── Scene priority — first-match-wins guarantee ──────────────────────────── + +describe('formatGsdState #2833 scene priority', () => { + test('active_phase wins over next_action when both populated', () => { + // active_phase populated should win — orchestrator is in flight, + // any "next" recommendation would be misleading. + const out = formatGsdState({ + milestone: 'v2.0', + status: 'executing', + activePhase: '4.5', + nextAction: 'execute-phase', + nextPhases: ['4.5'], + }); + assert.ok(out.includes('Phase 4.5 executing')); + assert.ok(!out.includes('next execute-phase')); + }); + + test('next_action wins over Scene 4 fallback when active_phase null', () => { + const out = formatGsdState({ + milestone: 'v2.0', + status: 'in_progress', // would be Scene 4 fallback alone + activePhase: null, + nextAction: 'execute-phase', + nextPhases: ['4.5'], + phaseNum: '1', + phaseTotal: '5', + }); + assert.ok(out.includes('next execute-phase 4.5')); + assert.ok(!out.includes('in_progress')); + assert.ok(!out.includes('1/5')); + }); + + test('percent=100 wins over Scene 4 even with phase set', () => { + const out = formatGsdState({ + milestone: 'v2.0', + percent: '100', + phaseNum: '1', + phaseTotal: '5', + }); + assert.ok(out.includes('milestone complete')); + assert.ok(!out.includes('1/5')); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-2937-statusline-context-position.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-2937-statusline-context-position (consolidation epic #1969 B5 #1974)", () => { +'use strict'; + +/** + * Enhancement #2937 — statusline opt-in `context_position` config. + * + * Asserts that: + * - VALID_CONFIG_KEYS registers statusline.context_position (parity guard) + * - Default (no config) renders ctx at tail — "end" layout + * - Explicit "end" is byte-identical to default (regression guard) + * - Explicit "front" puts ctx after model, before first " │ " + * - Empty ctx with "front" leaves no stray separator + * - Invalid value (e.g. "middle") silently falls back to "end" at runtime + * - gsdUpdate warning stays leftmost in both "front" and "end" modes + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { composeStatusline } = require('../hooks/gsd-statusline.js'); +const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +// ── Parity guard ───────────────────────────────────────────────────────────── + +test('config schema registers statusline.context_position', () => { + assert.ok( + VALID_CONFIG_KEYS.has('statusline.context_position'), + 'statusline.context_position must be in VALID_CONFIG_KEYS', + ); +}); + +// ── Default / "end" layout ─────────────────────────────────────────────────── + +test('default (no position arg) renders ctx at tail — end layout', () => { + const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; + const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx }); + // ctx should appear after dirname, not before first │ + const dirIdx = out.indexOf('myproject'); + const ctxIdx = out.indexOf(ctx); + assert.ok(ctxIdx > dirIdx, `ctx should be after dirname; got: ${out}`); +}); + +test('explicit "end" is byte-identical to default', () => { + const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; + const args = { model: 'Claude', dirname: 'myproject', ctx }; + const defaultOut = composeStatusline(args); + const endOut = composeStatusline({ ...args, position: 'end' }); + assert.strictEqual(endOut, defaultOut, 'explicit "end" must equal default output'); +}); + +test('"end" with middle segment places ctx after dirname', () => { + const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; + const out = composeStatusline({ model: 'Claude', ctx, middle: 'doing work', dirname: 'proj', position: 'end' }); + const dirIdx = out.indexOf('proj'); + const ctxIdx = out.indexOf(ctx); + assert.ok(ctxIdx > dirIdx, `ctx should be after dirname in end mode; got: ${out}`); +}); + +// ── "front" layout ─────────────────────────────────────────────────────────── + +test('"front" puts ctx after model name, before first │', () => { + const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; + const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx, position: 'front' }); + const firstPipe = out.indexOf(' │ '); + const ctxIdx = out.indexOf(ctx); + assert.ok(ctxIdx !== -1, `ctx should appear in output; got: ${out}`); + assert.ok(ctxIdx < firstPipe, `ctx should come before first │ in front mode; got: ${out}`); +}); + +test('"front" with middle segment: ctx after model, before first │', () => { + const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; + const out = composeStatusline({ model: 'Claude', ctx, middle: 'doing work', dirname: 'proj', position: 'front' }); + const firstPipe = out.indexOf(' │ '); + const ctxIdx = out.indexOf(ctx); + assert.ok(ctxIdx < firstPipe, `ctx must precede first │; got: ${out}`); +}); + +// ── Empty ctx ──────────────────────────────────────────────────────────────── + +test('empty ctx + "front" renders no stray separator', () => { + const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx: '', position: 'front' }); + // Should not have double-separator or leading │ + assert.ok(!out.includes(' │ │ '), `stray separator found; got: ${out}`); + // Should still contain the single separator between model area and dirname + assert.ok(out.includes(' │ '), `expected at least one separator; got: ${out}`); +}); + +test('empty ctx + "end" renders no stray separator', () => { + const out = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx: '', position: 'end' }); + assert.ok(!out.includes(' │ │ '), `stray separator found; got: ${out}`); +}); + +// ── Invalid value fallback ─────────────────────────────────────────────────── + +test('invalid position value silently falls back to "end" layout', () => { + const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; + const invalid = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx, position: 'middle' }); + const end = composeStatusline({ model: 'Claude', dirname: 'myproject', ctx, position: 'end' }); + assert.strictEqual(invalid, end, `invalid position should produce same output as "end"; got: ${invalid}`); +}); + +test('invalid position "banana" silently falls back to "end"', () => { + const ctx = ' \x1b[33m██████░░░░ 60%\x1b[0m'; + const invalid = composeStatusline({ model: 'Claude', dirname: 'proj', ctx, position: 'banana' }); + const end = composeStatusline({ model: 'Claude', dirname: 'proj', ctx, position: 'end' }); + assert.strictEqual(invalid, end, `invalid "banana" should fall back to "end"; got: ${invalid}`); +}); + +// ── gsdUpdate leftmost invariant ───────────────────────────────────────────── + +test('gsdUpdate warning is leftmost in "end" mode', () => { + const gsdUpdate = '\x1b[33m⬆ /gsd:update\x1b[0m │ '; + const out = composeStatusline({ gsdUpdate, model: 'Claude', dirname: 'proj', position: 'end' }); + assert.ok(out.startsWith(gsdUpdate), `gsdUpdate should be leftmost in end mode; got: ${out}`); +}); + +test('gsdUpdate warning is leftmost in "front" mode', () => { + const gsdUpdate = '\x1b[33m⬆ /gsd:update\x1b[0m │ '; + const ctx = ' \x1b[32m████░░░░░░ 40%\x1b[0m'; + const out = composeStatusline({ gsdUpdate, model: 'Claude', dirname: 'proj', ctx, position: 'front' }); + assert.ok(out.startsWith(gsdUpdate), `gsdUpdate should be leftmost in front mode; got: ${out}`); +}); + +// ── CLI write-path enforcement (config-set rejects invalid enum) ───────────── +// Locked design: hard reject at config-set time AND silent fallback at runtime. +// The runtime fallback is covered by the "Invalid position value silently falls +// back" tests above. This test covers the other half — that the CLI write path +// actually refuses to persist an invalid value in the first place. + +test('config-set rejects invalid statusline.context_position', () => { + const tmpDir = createTempProject(); + try { + const r = runGsdTools( + ['config-set', 'statusline.context_position', 'middle'], + tmpDir, + ); + assert.equal( + r.success, + false, + `config-set should exit non-zero on invalid enum; got success=${r.success}, output=${r.output}`, + ); + assert.ok( + /statusline\.context_position|Invalid/i.test(r.error), + `stderr must reference key or "Invalid"; got: ${r.error}`, + ); + } finally { + cleanup(tmpDir); + } +}); + }); +} diff --git a/tests/install-minimal-hooks.test.cjs b/tests/install-minimal-hooks.test.cjs index c14fdd5e6..c886ba6cb 100644 --- a/tests/install-minimal-hooks.test.cjs +++ b/tests/install-minimal-hooks.test.cjs @@ -2599,3 +2599,450 @@ describe('enh-788: Qwen uninstall removes new hook event entries', () => { }); }); } + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3735-profiles-core-includes-surface.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3735-profiles-core-includes-surface (consolidation epic #1969 B5 #1974)", () => { +'use strict'; +/** + * Regression test for #3735: PROFILES.core must include 'surface' in its + * resolved closure so that --profile=core users can expand via + * /gsd:surface enable — the advertised use-case from ADR-0011. + * + * Stage 2 (RED): This test must fail before the fix is applied. + * Stage 3 (GREEN): This test must pass after 'surface' is added to PROFILES.core. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); + +const { + resolveProfile, + loadSkillsManifest, +} = require('../gsd-core/bin/lib/install-profiles.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); + +describe('PROFILES.core — ADR-0011 expand contract', () => { + test("PROFILES.core includes 'surface' so users can expand via /gsd:surface enable", () => { + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const result = resolveProfile({ modes: ['core'], manifest }); + + assert.ok(result.skills instanceof Set, + 'resolveProfile must return a skills Set for core profile'); + + // The primary assertion: surface must be in the resolved closure. + // ADR-0011 documents that --profile=core users expand via /gsd:surface enable . + // That sub-command is only available if surface.md is staged — which requires it to be + // in the resolved set for the core profile. + assert.ok(result.skills.has('surface'), + `PROFILES.core resolved closure must include 'surface'; got: [${[...result.skills].sort().join(', ')}]`); + }); + + // Counter-test: 'forensics' is NOT in core — proves the assertion above is selective, + // not vacuously true for all skills. + test("PROFILES.core does NOT include 'forensics' (selective assertion counter-check)", () => { + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const result = resolveProfile({ modes: ['core'], manifest }); + + assert.ok(result.skills instanceof Set); + assert.ok(!result.skills.has('forensics'), + `'forensics' should NOT be in core closure — it is a specialist skill, not a core loop skill`); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-770-claude-hook-events.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-770-claude-hook-events (consolidation epic #1969 B5 #1974)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Enhancement #770: Register Claude Code lifecycle hooks (SubagentStop / Stop / + * PreCompact / FileChanged). + * + * Claude Code now supports the same SubagentStop, Stop, and PreCompact events + * that were wired for Qwen Code in #788. This suite asserts: + * + * 1. Claude Code installs register SubagentStop, Stop, and PreCompact, each + * wired to gsd-context-monitor.js (same as Qwen). + * 2. Claude Code installs register a FileChanged hook for .planning/config.json + * wired to gsd-config-reload.js (new hook; hot-reloads gsd config). + * 3. All four registrations are idempotent (reinstall does not duplicate). + * 4. Uninstall removes all four event registrations. + * 5. The gsd-config-reload.js hook script exists in hooks/ and has the + * expected structure (reads on stdin, emits additionalContext or exits 0). + * 6. The hooks/hooks.json plugin manifest includes the new events. + * + * Source: https://code.claude.com/docs/en/hooks + */ + +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 { install, uninstall, validateHookFields } = require('../bin/install.js'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Extract all hook commands registered under `eventName` from settings. */ +function hooksForEvent(settings, eventName) { + if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; + return settings.hooks[eventName].flatMap(entry => + (entry && Array.isArray(entry.hooks) ? entry.hooks : []) + .map(h => h && h.command) + .filter(Boolean) + ); +} + +/** Extract all matchers registered under `eventName` from settings. */ +function matchersForEvent(settings, eventName) { + if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; + return settings.hooks[eventName] + .map(entry => entry && entry.matcher) + .filter(Boolean); +} + +const HOOKS_SRC = path.join(__dirname, '..', 'hooks'); +// Hooks the installer existsSync-checks before registering; must be present +// in targetDir/hooks/ so the registration guards pass. +const STUB_HOOKS = [ + 'gsd-context-monitor.js', + 'gsd-prompt-guard.js', + 'gsd-check-update.js', + 'gsd-config-reload.js', +]; + +/** + * Pre-populate targetDir/hooks/ with stub hook files so the installer's + * fs.existsSync guards pass even when hooks/dist/ is absent (e.g. CI without + * a build step). Each test suite passes its own per-test tmpDir/.claude path + * so stubs are isolated to that test's temp directory — no shared filesystem + * state, no cross-test races. + * + * When hooks/dist/ DOES exist (local dev with npm run build:hooks), the + * installer copies real files over these stubs during install() — that is + * fine and correct. + */ +function stubHooksIntoTarget(targetDir) { + const hooksDest = path.join(targetDir, 'hooks'); + fs.mkdirSync(hooksDest, { recursive: true }); + for (const hookFile of STUB_HOOKS) { + const src = path.join(HOOKS_SRC, hookFile); + const dest = path.join(hooksDest, hookFile); + if (fs.existsSync(src)) { + fs.copyFileSync(src, dest); + } else { + // Minimal stub so existsSync passes + fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); + } + try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } + } +} + +function persistSettings(settingsPath, settings) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8'); +} + +// ─── Suite 1: Claude — new context monitor events are registered ────────────── + +describe('enh-770: Claude install registers SubagentStop / Stop / PreCompact context hooks', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-770-claude-ctx-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + stubHooksIntoTarget(path.join(tmpDir, '.claude')); + + const result = install(false, 'claude', { installerMigrations: [] }); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('install returns a settings object (not null)', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'Claude install must return a non-null settings object'); + }); + + test('SubagentStop event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'SubagentStop'); + assert.ok(cmds.length > 0, + `Expected SubagentStop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('Stop event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'Stop'); + assert.ok(cmds.length > 0, + `Expected Stop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('PreCompact event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'PreCompact'); + assert.ok(cmds.length > 0, + `Expected PreCompact hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('SubagentStop / Stop / PreCompact all use gsd-context-monitor', () => { + for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + const cmds = hooksForEvent(settings, event); + assert.ok( + cmds.some(c => c.includes('gsd-context-monitor')), + `Event ${event} should use gsd-context-monitor; got commands: ${JSON.stringify(cmds)}` + ); + } + }); +}); + +// ─── Suite 2: Claude — FileChanged hook for config hot-reload ───────────────── + +describe('enh-770: Claude install registers FileChanged hook for .planning/config.json', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-770-filechanged-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + stubHooksIntoTarget(path.join(tmpDir, '.claude')); + + const result = install(false, 'claude', { installerMigrations: [] }); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('FileChanged event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'FileChanged'); + assert.ok(cmds.length > 0, + `Expected FileChanged hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('FileChanged hook uses gsd-config-reload', () => { + const cmds = hooksForEvent(settings, 'FileChanged'); + assert.ok( + cmds.some(c => c.includes('gsd-config-reload')), + `FileChanged should use gsd-config-reload; got commands: ${JSON.stringify(cmds)}` + ); + }); + + test('FileChanged hook has a matcher targeting .planning/config.json', () => { + const matchers = matchersForEvent(settings, 'FileChanged'); + assert.ok( + matchers.some(m => m && m.includes('config.json')), + `FileChanged matcher should target config.json; got matchers: ${JSON.stringify(matchers)}` + ); + }); +}); + +// ─── Suite 3: Idempotency ───────────────────────────────────────────────────── + +describe('enh-770: Claude install is idempotent for the new hook events', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-770-idem-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + stubHooksIntoTarget(path.join(tmpDir, '.claude')); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('re-running after persisted first install does not duplicate context monitor hooks', () => { + const result1 = install(false, 'claude', { installerMigrations: [] }); + persistSettings(result1.settingsPath, result1.settings); + + process.chdir(tmpDir); + const result2 = install(false, 'claude', { installerMigrations: [] }); + const s2 = result2.settings; + + for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + const cmds = hooksForEvent(s2, event); + assert.strictEqual(cmds.length, 1, + `Event ${event} should have exactly 1 hook after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); + } + }); + + test('re-running after persisted first install does not duplicate FileChanged hook', () => { + const result1 = install(false, 'claude', { installerMigrations: [] }); + persistSettings(result1.settingsPath, result1.settings); + + process.chdir(tmpDir); + const result2 = install(false, 'claude', { installerMigrations: [] }); + const s2 = result2.settings; + + const cmds = hooksForEvent(s2, 'FileChanged'); + assert.strictEqual(cmds.length, 1, + `FileChanged should have exactly 1 hook after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); + }); +}); + +// ─── Suite 4: Uninstall removes registrations ───────────────────────────────── + +describe('enh-770: Uninstall removes new hook event entries', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-770-uninstall-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + stubHooksIntoTarget(path.join(tmpDir, '.claude')); + + const result = install(false, 'claude', { installerMigrations: [] }); + persistSettings(result.settingsPath, result.settings); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('settings.json hook entries are removed on uninstall', () => { + uninstall(false, 'claude', { installerMigrations: [] }); + const settingsPath = path.join(tmpDir, '.claude', 'settings.json'); + if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + for (const event of ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged']) { + const cmds = hooksForEvent(settings, event); + assert.strictEqual(cmds.length, 0, + `After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`); + } + }); +}); + +// ─── Suite 5: gsd-config-reload.js hook script exists and has correct shape ─── + +describe('enh-770: gsd-config-reload.js hook script', () => { + const reloadScript = path.join(__dirname, '..', 'hooks', 'gsd-config-reload.js'); + + test('gsd-config-reload.js exists in hooks/', () => { + assert.ok(fs.existsSync(reloadScript), + `gsd-config-reload.js must exist at ${reloadScript}`); + }); + + test('gsd-config-reload.js contains the gsd-hook-version stamp', () => { + // allow-test-rule: runtime-contract-is-the-product — the stamp template token (see #770) + // IS the product surface that the installer must find and replace with the + // real version at copy time; asserting its presence is required. + const content = fs.readFileSync(reloadScript, 'utf8'); + assert.ok( + content.includes('gsd-hook-version'), + 'gsd-config-reload.js must contain the gsd-hook-version stamp for installer stamping' + ); + }); + + test('gsd-config-reload.js reads from stdin and emits JSON output', () => { + // allow-test-rule: runtime-contract-is-the-product — the stdin-read and (see #770) + // JSON-emit pattern IS the hook contract; asserting its presence is required. + const content = fs.readFileSync(reloadScript, 'utf8'); + assert.ok( + content.includes('process.stdin') && content.includes('JSON.stringify'), + 'gsd-config-reload.js must read stdin and emit JSON output per hook protocol' + ); + }); + + test('gsd-config-reload.js targets the FileChanged hook event', () => { + // allow-test-rule: runtime-contract-is-the-product — the hookEventName is (see #770) + // the protocol surface; asserting its presence verifies the contract. + const content = fs.readFileSync(reloadScript, 'utf8'); + assert.ok( + content.includes('FileChanged'), + 'gsd-config-reload.js must reference FileChanged in its hookSpecificOutput' + ); + }); +}); + +// ─── Suite 6: hooks.json plugin manifest includes new events ────────────────── + +describe('enh-770: hooks/hooks.json plugin manifest includes new hook events', () => { + const hooksJsonPath = path.join(__dirname, '..', 'hooks', 'hooks.json'); + + test('hooks.json exists', () => { + assert.ok(fs.existsSync(hooksJsonPath), `hooks.json must exist at ${hooksJsonPath}`); + }); + + test('hooks.json contains SubagentStop event', () => { + // allow-test-rule: runtime-contract-is-the-product — hooks.json IS the (see #770) + // plugin manifest surface that Claude Code reads at plugin load time. + const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + assert.ok( + content.hooks && content.hooks.SubagentStop, + 'hooks.json must contain SubagentStop' + ); + }); + + test('hooks.json contains Stop event', () => { + // allow-test-rule: runtime-contract-is-the-product — hooks.json IS the (see #770) + // plugin manifest surface that Claude Code reads at plugin load time. + const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + assert.ok( + content.hooks && content.hooks.Stop, + 'hooks.json must contain Stop' + ); + }); + + test('hooks.json contains PreCompact event', () => { + // allow-test-rule: runtime-contract-is-the-product — hooks.json IS the (see #770) + // plugin manifest surface that Claude Code reads at plugin load time. + const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + assert.ok( + content.hooks && content.hooks.PreCompact, + 'hooks.json must contain PreCompact' + ); + }); + + test('hooks.json contains FileChanged event', () => { + // allow-test-rule: runtime-contract-is-the-product — hooks.json IS the (see #770) + // plugin manifest surface that Claude Code reads at plugin load time. + const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + assert.ok( + content.hooks && content.hooks.FileChanged, + 'hooks.json must contain FileChanged' + ); + }); +}); + +// ─── Suite 7: managed-hooks-registry includes gsd-config-reload.js ─────────── + +describe('enh-770: managed-hooks-registry includes gsd-config-reload.js', () => { + test('MANAGED_HOOKS array includes gsd-config-reload.js', () => { + const { MANAGED_HOOKS } = require('../hooks/managed-hooks-registry.cjs'); + assert.ok( + MANAGED_HOOKS.includes('gsd-config-reload.js'), + `MANAGED_HOOKS must include gsd-config-reload.js; got: ${JSON.stringify(MANAGED_HOOKS)}` + ); + }); +}); + }); +} diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 5c6e25069..489ef05d7 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -4548,3 +4548,4700 @@ test('bridge collapse removes cjs-sdk-bridge and runtime-bridge-sync seam', () = }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2418-antigravity-bare-path.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2418-antigravity-bare-path (consolidation epic #1969 B1 #1970)", () => { +/** + * Bug #2418: Found unreplaced .claude path reference(s) in Antigravity install + * + * The Antigravity path converter handles ~/.claude/ (with trailing slash) but + * misses bare ~/.claude (without trailing slash), leaving unreplaced references + * that cause the installer to warn about leaked paths. + * + * Files affected: agents/gsd-debugger.md (configDir = ~/.claude) and + * gsd-core/workflows/update.md (comment with e.g. ~/.claude). + */ + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { convertClaudeToAntigravityContent } = require('../bin/install.js'); + +describe('convertClaudeToAntigravityContent bare path replacement (#2418)', () => { + describe('global install', () => { + test('replaces ~/.claude (bare, no trailing slash) with ~/.gemini/antigravity', () => { + const input = 'configDir = ~/.claude'; + const result = convertClaudeToAntigravityContent(input, true); + assert.ok( + result.includes('~/.gemini/antigravity'), + `Expected ~/.gemini/antigravity in output, got: ${result}` + ); + assert.ok( + !result.includes('~/.claude'), + `Expected ~/ .claude to be replaced, got: ${result}` + ); + }); + + test('replaces $HOME/.claude (bare, no trailing slash) with $HOME/.gemini/antigravity', () => { + const input = 'export DIR=$HOME/.claude'; + const result = convertClaudeToAntigravityContent(input, true); + assert.ok( + result.includes('$HOME/.gemini/antigravity'), + `Expected $HOME/.gemini/antigravity in output, got: ${result}` + ); + assert.ok( + !result.includes('$HOME/.claude'), + `Expected $HOME/.claude to be replaced, got: ${result}` + ); + }); + + test('handles bare ~/.claude followed by comma (comment context)', () => { + const input = '# e.g. ~/.claude, ~/.config/opencode'; + const result = convertClaudeToAntigravityContent(input, true); + assert.ok( + !result.includes('~/.claude'), + `Expected ~/ .claude to be replaced in comment context, got: ${result}` + ); + }); + + test('still replaces ~/.claude/ (with trailing slash) correctly', () => { + const input = 'See ~/.claude/gsd-core/workflows/'; + const result = convertClaudeToAntigravityContent(input, true); + assert.ok( + result.includes('~/.gemini/antigravity/gsd-core/workflows/'), + `Expected path with trailing slash to be replaced, got: ${result}` + ); + assert.ok(!result.includes('~/.claude/'), `Expected ~/ .claude/ to be fully replaced, got: ${result}`); + }); + + test('does not double-replace ~/.claude/ paths', () => { + const input = 'See ~/.claude/gsd-core/'; + const result = convertClaudeToAntigravityContent(input, true); + // Result should contain exactly one occurrence of the replacement path + const count = (result.match(/~\/.gemini\/antigravity\//g) || []).length; + assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`); + }); + }); + + describe('local install', () => { + test('replaces ~/.claude (bare, no trailing slash) with .agents', () => { + const input = 'configDir = ~/.claude'; + const result = convertClaudeToAntigravityContent(input, false); + assert.ok( + result.includes('.agents'), + `Expected .agents in output, got: ${result}` + ); + assert.ok( + !result.includes('~/.claude'), + `Expected ~/ .claude to be replaced, got: ${result}` + ); + }); + + test('replaces $HOME/.claude (bare, no trailing slash) with .agents', () => { + const input = 'export DIR=$HOME/.claude'; + const result = convertClaudeToAntigravityContent(input, false); + assert.ok( + result.includes('.agents'), + `Expected .agents in output, got: ${result}` + ); + assert.ok( + !result.includes('$HOME/.claude'), + `Expected $HOME/.claude to be replaced, got: ${result}` + ); + }); + + test('does not double-replace ~/.claude/ paths', () => { + const input = 'See ~/.claude/gsd-core/'; + const result = convertClaudeToAntigravityContent(input, false); + // .agents/ should appear exactly once + const count = (result.match(/\.agents\//g) || []).length; + assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`); + }); + }); + + describe('installed files contain no bare ~/.claude references after conversion', () => { + const fs = require('fs'); + const path = require('path'); + const repoRoot = path.join(__dirname, '..'); + + // The scanner regex used by the installer to detect leaked paths + const leakedPathRegex = /(?:~|\$HOME)\/\.claude\b/g; + + function convertFile(filePath, isGlobal) { + const content = fs.readFileSync(filePath, 'utf8'); + return convertClaudeToAntigravityContent(content, isGlobal); + } + + test('gsd-debugger.md has no leaked ~/.claude after global Antigravity conversion', () => { + const debuggerPath = path.join(repoRoot, 'agents', 'gsd-debugger.md'); + if (!fs.existsSync(debuggerPath)) return; // skip if file doesn't exist + const converted = convertFile(debuggerPath, true); + const matches = converted.match(leakedPathRegex); + assert.strictEqual( + matches, null, + `gsd-debugger.md still contains leaked .claude paths after Antigravity conversion: ${matches}` + ); + }); + + test('update.md has no leaked ~/.claude after global Antigravity conversion', () => { + const updatePath = path.join(repoRoot, 'gsd-core', 'workflows', 'update.md'); + if (!fs.existsSync(updatePath)) return; // skip if file doesn't exist + const converted = convertFile(updatePath, true); + const matches = converted.match(leakedPathRegex); + assert.strictEqual( + matches, null, + `update.md still contains leaked .claude paths after Antigravity conversion: ${matches}` + ); + }); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2545-copilot-unreplaced-paths.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2545-copilot-unreplaced-paths (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for issue #2545. + * + * The Copilot content converter's `~/.claude/` and `$HOME/.claude/` replacements + * only matched when a literal slash followed, so bare `~/.claude` references + * (end of line, quotes, punctuation) were left unreplaced. Those leaks then + * triggered the installer's "Found N unreplaced .claude path reference(s)" + * warning, which scans for `(?:~|$HOME)/\.claude\b`. + * + * Fix: replace with a word-boundary pattern so both forms are caught in a + * single pass, matching the approach already used by the Antigravity, OpenCode, + * Kilo, and Codex converters. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { convertClaudeToCopilotContent } = require('../bin/install.js'); + +describe('convertClaudeToCopilotContent — bare ~/.claude (issue #2545)', () => { + test('global install replaces bare ~/.claude at end of line', () => { + const input = 'configDir = ~/.claude\n'; + const out = convertClaudeToCopilotContent(input, /* isGlobal */ true); + assert.ok( + !/(?:~|\$HOME)\/\.claude\b/.test(out), + `expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`, + ); + assert.match(out, /~\/\.copilot\b/); + }); + + test('global install replaces bare $HOME/.claude at end of line', () => { + const input = 'configDir = $HOME/.claude\n'; + const out = convertClaudeToCopilotContent(input, /* isGlobal */ true); + assert.ok( + !/(?:~|\$HOME)\/\.claude\b/.test(out), + `expected no leaked $HOME/.claude reference, got: ${JSON.stringify(out)}`, + ); + assert.match(out, /\$HOME\/\.copilot\b/); + }); + + test('global install replaces bare ~/.claude before punctuation', () => { + const input = 'paths include `~/.claude`, `~/.copilot`'; + const out = convertClaudeToCopilotContent(input, true); + assert.ok(!/(?:~|\$HOME)\/\.claude\b/.test(out)); + }); + + test('local install replaces bare ~/.claude', () => { + const input = 'configDir = ~/.claude\n'; + const out = convertClaudeToCopilotContent(input, /* isGlobal */ false); + assert.ok( + !/(?:~|\$HOME)\/\.claude\b/.test(out), + `expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`, + ); + }); + + test('does not double-replace trailing-slash form', () => { + const input = '@~/.claude/gsd-core/foo.md\n'; + const out = convertClaudeToCopilotContent(input, true); + assert.match(out, /~\/\.copilot\/gsd-core\/foo\.md/); + assert.ok(!/\.copilot\/\.copilot/.test(out)); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-983-trae-windsurf-claude-path-leak.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-983-trae-windsurf-claude-path-leak (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #983) +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Regression tests for issue #983 — Trae and Windsurf converters leak + * unreplaced bare `~/.claude` / `$HOME/.claude` references. + * + * Both converters rewrote only trailing-slash `.claude/` forms, so bare + * home-path references (configDir = ~/.claude, $HOME/.claude) survived + * conversion and pointed users at the wrong config dir. + * + * Fix: add bare word-boundary replacements mirroring Cline (#782) and + * Codex (#570) precedent, with a negative lookahead to preserve `.claude-plugin`. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + convertClaudeToWindsurfMarkdown, + convertClaudeToTraeMarkdown, + _applyRuntimeRewrites, +} = require('../bin/install.js'); + +// ─── Windsurf converter bare-form tests ───────────────────────────────────── + +describe('convertClaudeToWindsurfMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (#983)', () => { + test('bare ~/.claude rewritten to ~/.windsurf (#1615: workspace dir is now .windsurf)', () => { + const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; + const result = convertClaudeToWindsurfMarkdown(input); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + `bare ~/.claude must be rewritten; got: ${result}`, + ); + assert.ok(result.includes('~/.windsurf'), 'must rewrite to ~/.windsurf'); + }); + + test('$HOME/.claude rewritten to $HOME/.windsurf (#1615: workspace dir is now .windsurf)', () => { + const input = 'RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'; + const result = convertClaudeToWindsurfMarkdown(input); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + `bare $HOME/.claude must be rewritten; got: ${result}`, + ); + assert.ok(result.includes('$HOME/.windsurf'), 'must rewrite to $HOME/.windsurf'); + }); + + test('CLAUDE_CONFIG_DIR rewritten to WINDSURF_CONFIG_DIR', () => { + const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; + const result = convertClaudeToWindsurfMarkdown(input); + assert.ok( + result.includes('WINDSURF_CONFIG_DIR'), + 'CLAUDE_CONFIG_DIR must become WINDSURF_CONFIG_DIR', + ); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'CLAUDE_CONFIG_DIR must be gone', + ); + }); + + test('.claude-plugin is NOT corrupted (preserved as-is)', () => { + const input = 'The .claude-plugin/plugin.json manifest enables plugin install.'; + const result = convertClaudeToWindsurfMarkdown(input); + assert.ok( + result.includes('.claude-plugin'), + `.claude-plugin must be preserved; got: ${result}`, + ); + assert.ok( + !result.includes('.windsurf-plugin'), + `.windsurf-plugin must not appear; got: ${result}`, + ); + }); + + test('no bare ~/.claude in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToWindsurfMarkdown(raw); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + 'converted surface.md must not contain bare ~/.claude', + ); + }); + + test('no $HOME/.claude in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToWindsurfMarkdown(raw); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + 'converted surface.md must not contain bare $HOME/.claude', + ); + }); + + test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToWindsurfMarkdown(raw); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'converted surface.md must not contain CLAUDE_CONFIG_DIR', + ); + }); +}); + +// ─── Trae converter bare-form tests ───────────────────────────────────────── + +describe('convertClaudeToTraeMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (#983)', () => { + test('bare ~/.claude rewritten to ~/.trae', () => { + const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; + const result = convertClaudeToTraeMarkdown(input); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + `bare ~/.claude must be rewritten; got: ${result}`, + ); + assert.ok(result.includes('~/.trae'), 'must rewrite to ~/.trae'); + }); + + test('$HOME/.claude rewritten to $HOME/.trae', () => { + const input = 'RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'; + const result = convertClaudeToTraeMarkdown(input); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + `bare $HOME/.claude must be rewritten; got: ${result}`, + ); + assert.ok(result.includes('$HOME/.trae'), 'must rewrite to $HOME/.trae'); + }); + + test('CLAUDE_CONFIG_DIR rewritten to TRAE_CONFIG_DIR', () => { + const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; + const result = convertClaudeToTraeMarkdown(input); + assert.ok( + result.includes('TRAE_CONFIG_DIR'), + 'CLAUDE_CONFIG_DIR must become TRAE_CONFIG_DIR', + ); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'CLAUDE_CONFIG_DIR must be gone', + ); + }); + + test('.claude-plugin is NOT corrupted (preserved as-is)', () => { + const input = 'The .claude-plugin/plugin.json manifest enables plugin install.'; + const result = convertClaudeToTraeMarkdown(input); + assert.ok( + result.includes('.claude-plugin'), + `.claude-plugin must be preserved; got: ${result}`, + ); + assert.ok( + !result.includes('.trae-plugin'), + `.trae-plugin must not appear; got: ${result}`, + ); + }); + + test('no bare ~/.claude in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToTraeMarkdown(raw); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + 'converted surface.md must not contain bare ~/.claude', + ); + }); + + test('no $HOME/.claude in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToTraeMarkdown(raw); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + 'converted surface.md must not contain bare $HOME/.claude', + ); + }); + + test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToTraeMarkdown(raw); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'converted surface.md must not contain CLAUDE_CONFIG_DIR', + ); + }); +}); + +// ─── _applyRuntimeRewrites install-path tests (windsurf) ──────────────────── +// +// These tests exercise the ACTUAL install path that causes the user-facing leak. +// The converter functions are called at stage time to produce a Windsurf-branded +// copy, but _applyRuntimeRewrites is the path that runs at INSTALL time and +// rewrites any surviving ~/.claude / $HOME/.claude refs in the staged files. +// +// FAIL-BEFORE proof: prior to this PR, windsurf used /~\/\.claude\b/ which +// fires on "~/.claude-plugin" because \b matches between 'e' and '-'. Running +// the test below against the old regex (`\b`) would: +// - let bare $HOME/.claude survive (it used only /~\/\.claude\b/, missing $HOME form), AND +// - corrupt "~/.claude-plugin" → "~/.windsurf-plugin". +// Both assertions in the test below would fail on the old code. +// +// PASS-AFTER: the fix changes to (?![\w-]) so: +// - bare ~/.claude / $HOME/.claude (not followed by word-char or hyphen) → rewritten +// - ~/.claude-plugin preserved (the '-' after 'e' is in [\w-]) +// +// NOTE on pathPrefix choice: we use '~/.windsurf/' (a simple home-relative +// prefix) rather than '$HOME/.codeium/windsurf/' so that the corruption of +// '~/.claude-plugin' → '~/.windsurf-plugin' is directly detectable via +// result.includes('.windsurf-plugin'). +describe('_applyRuntimeRewrites(windsurf) — install-path bare-form + .claude-plugin (#983)', () => { + // Use ~/ prefix (local-style) so that the .windsurf-plugin corruption is + // directly detectable as a substring of the result. + const WINDSURF_PATH_PREFIX = '~/.windsurf/'; + + // Compound content: covers every form the fix must handle. + // IMPORTANT: we use ~/.claude-plugin (home-relative form) to exercise the + // corruption that the old \b regex caused. The \b fires between 'e' and '-', + // so ~/.claude-plugin → ~/.windsurf-plugin under the old code. That would + // break the preservation assertion below. The (?![\w-]) fix prevents this. + const COMPOUND_INPUT = [ + 'Config dir: ~/.claude', + 'Also: $HOME/.claude', + 'Slash form: ~/.claude/skills/foo.md', + 'Plugin installed at: ~/.claude-plugin/plugin.json', + 'Env var: CLAUDE_CONFIG_DIR', + ].join('\n'); + + test('bare ~/.claude rewritten to ~/.windsurf (no trailing slash)', () => { + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + `bare ~/.claude must be gone; got:\n${result}`, + ); + assert.ok( + result.includes('~/.windsurf'), + `must contain normalized pathPrefix; got:\n${result}`, + ); + }); + + test('bare $HOME/.claude rewritten to ~/.windsurf (install-path normalizes both home forms)', () => { + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + `bare $HOME/.claude must be gone; got:\n${result}`, + ); + }); + + test('zero surviving bare ~/.claude or $HOME/.claude refs in compound input', () => { + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + const bareClaudePattern = /(?:~|\$HOME)\/\.claude(?![\w-])/; + assert.ok( + !bareClaudePattern.test(result), + `no bare ~/.claude / $HOME/.claude must survive; got:\n${result}`, + ); + }); + + test('~/.claude-plugin is NOT corrupted to ~/.windsurf-plugin — was the \\b corruption', () => { + // FAIL-BEFORE: old /~\/\.claude\b/ rewrote ~/.claude-plugin → ~/.windsurf-plugin + // because \b fires between 'e' and '-'. + // PASS-AFTER: (?![\w-]) sees '-' and skips the match, preserving ~/.claude-plugin. + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + assert.ok( + result.includes('~/.claude-plugin'), + `~/.claude-plugin must be preserved; got:\n${result}`, + ); + assert.ok( + !result.includes('~/.windsurf-plugin'), + `~/.windsurf-plugin must NOT appear (was the \\b corruption); got:\n${result}`, + ); + }); + + test('slash form ~/.claude/ is also rewritten (pre-existing coverage)', () => { + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + assert.ok( + !result.includes('~/.claude/'), + `slash form ~/.claude/ must be gone; got:\n${result}`, + ); + }); + + test('CLAUDE_CONFIG_DIR is NOT rewritten by _applyRuntimeRewrites (converter responsibility)', () => { + // _applyRuntimeRewrites does NOT handle CLAUDE_CONFIG_DIR for windsurf; + // that rewrite is done by convertClaudeToWindsurfMarkdown at stage time. + // This test documents the boundary and guards against scope creep. + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + assert.ok( + result.includes('CLAUDE_CONFIG_DIR'), + 'CLAUDE_CONFIG_DIR is not rewritten by _applyRuntimeRewrites — that is converter scope', + ); + }); +}); + +// ─── _applyRuntimeRewrites install-path tests (trae) ──────────────────────── +// +// Trae had bare-form handling before this PR (via \b) and the converter uses +// (?![\w-]). The pre-existing \b in _applyRuntimeRewrites DOES corrupt +// .claude-plugin → .trae-plugin (known limitation, out of scope for #983). +// We document this here but do NOT assert preservation for trae, and we do NOT +// fix the pre-existing trae \b lines (that would be a separate concern). +// +// What we DO assert: trae bare ~/.claude / $HOME/.claude refs are rewritten +// (the install path cleans them), which is the core #983 fix for trae. +describe('_applyRuntimeRewrites(trae) — install-path bare-form (#983)', () => { + const TRAE_PATH_PREFIX = '$HOME/.trae/'; + + const TRAE_INPUT = [ + 'Config dir: ~/.claude', + 'Also: $HOME/.claude', + 'Slash form: ~/.claude/skills/foo.md', + // Note: .claude-plugin is intentionally omitted from assertions here because + // the pre-existing trae case uses \b which corrupts it (known limitation, + // out of scope for #983 — do not fix here). + ].join('\n'); + + test('bare ~/.claude rewritten to $HOME/.trae (trae install path)', () => { + const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + `bare ~/.claude must be gone; got:\n${result}`, + ); + }); + + test('bare $HOME/.claude rewritten to $HOME/.trae (trae install path)', () => { + const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + `bare $HOME/.claude must be gone; got:\n${result}`, + ); + }); + + test('slash form ~/.claude/ also rewritten (trae install path)', () => { + const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); + assert.ok( + !result.includes('~/.claude/'), + `slash form ~/.claude/ must be gone; got:\n${result}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-782-cline-skills-emission.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-782-cline-skills-emission (consolidation epic #1969 B1 #1970)", () => { +'use strict'; +/** + * Regression tests for bug #782 — Cline skills emission. + * + * gsd now emits skills to ~/.cline/skills//SKILL.md for Cline >= v3.48. + * Skills discovery: https://docs.cline.bot/customization/skills + * + * (a) Converter unit test: convertClaudeCommandToClineSkill + * (b) Integration test: installRuntimeArtifacts for cline writes SKILL.md files + * (c) .clinerules/gsd.md still written by the install path (#787 dir form) + * (d) Idempotency: running install twice leaves skills + .clinerules/ intact + * (e) Full install() global: both skills AND .clinerules/gsd.md are written + */ + +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 { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); + +const { + convertClaudeCommandToClineSkill, + convertClaudeToCliineMarkdown, + install, + _applyRuntimeRewrites, +} = require('../bin/install.js'); + +const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); + +const { + resolveRuntimeArtifactLayout, +} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + +const { + loadSkillsManifest, + resolveProfile, +} = require('../gsd-core/bin/lib/install-profiles.cjs'); + +const { nestedSkillPath } = require('./helpers/nested-layout.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); +const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); +const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); + +// ─── (a) Converter unit test ───────────────────────────────────────────────── + +const SAMPLE_COMMAND = `--- +name: gsd:execute-phase +description: Execute all tasks in the current phase using Cline tools. +allowed-tools: + - Read + - Write + - Bash +--- + +## Objective + +Run all tasks in the current phase. + +See ~/.claude/skills/gsd-help/SKILL.md for reference. +Use \`/gsd-help\` or Claude Code for details. +`; + +// A command that exercises all three Claude-specific frontmatter fields that +// must NOT leak into the emitted Cline SKILL.md. +const RICH_COMMAND = `--- +name: gsd:validate-phase +description: Retroactively audit and fill Nyquist validation gaps for a completed phase +argument-hint: "[phase number]" +agent: researcher +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep + - Agent + - AskUserQuestion +--- + +## Objective + +Audit Nyquist validation coverage. See ~/.claude/skills/gsd-help/SKILL.md for reference. +Use Claude Code for details. +`; + +/** + * Extract frontmatter block (between --- delimiters) from output. + * Returns the raw text between the first --- and the closing ---. + * Uses \r?\n to handle both LF and CRLF line endings (Windows parity). + */ +function parseFrontmatter(text) { + const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + return m ? m[1] : null; +} + +describe('convertClaudeCommandToClineSkill — unit', () => { + test('emits frontmatter with name: gsd-', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + const nameMatch = result.match(/^name:\s*(.+)$/m); + assert.ok(nameMatch, 'frontmatter must contain name field'); + assert.ok(nameMatch[1].includes('gsd-execute-phase'), 'name must start with gsd-execute-phase'); + }); + + test('emits non-empty description in frontmatter', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'frontmatter must contain description field'); + assert.ok(descMatch[1].trim().length > 0, 'description must not be empty'); + }); + + test('body uses .cline/ paths not .claude/', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + // The body reference to ~/.claude/ should be rewritten to ~/.cline/ + assert.ok(!result.includes('~/.claude/skills'), 'body must not contain ~/.claude/skills'); + assert.ok(result.includes('.cline/skills'), 'body must contain .cline/skills'); + }); + + test('body replaces "Claude Code" with "Cline"', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + assert.ok(!result.includes('Claude Code'), 'Claude Code must be replaced with Cline'); + assert.ok(result.includes('Cline'), 'result must contain Cline branding'); + }); + + test('no stray .claude/ paths in frontmatter or body', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + // Should not contain .claude/ anywhere (except inside CLAUDE.md→.clinerules rewrites + // but those are already handled by convertClaudeToCliineMarkdown) + assert.ok(!result.includes('/.claude/'), 'no /.claude/ paths in output'); + }); + + // ── Fix 1 (code-review): frontmatter must be ONLY name + description ────── + + test('frontmatter emits ONLY name and description — no allowed-tools (SAMPLE_COMMAND)', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + const fm = parseFrontmatter(result); + assert.ok(fm !== null, 'result must have YAML frontmatter'); + assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools'); + assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint'); + assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:'); + }); + + test('frontmatter emits ONLY name and description — no allowed-tools/argument-hint/agent (RICH_COMMAND)', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + const fm = parseFrontmatter(result); + assert.ok(fm !== null, 'result must have YAML frontmatter'); + assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools'); + assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint'); + assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:'); + }); + + test('name == gsd-validate-phase for RICH_COMMAND', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + const nameMatch = result.match(/^name:\s*(.+)$/m); + assert.ok(nameMatch, 'must have name field'); + // yamlIdentifier may quote the value; strip surrounding quotes for comparison + const nameVal = nameMatch[1].replace(/^['"]|['"]$/g, '').trim(); + assert.strictEqual(nameVal, 'gsd-validate-phase', `name must be gsd-validate-phase, got: ${nameVal}`); + }); + + test('description is non-empty and <= 1024 chars for RICH_COMMAND', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'must have description field'); + const desc = descMatch[1].replace(/^['"]|['"]$/g, '').trim(); + assert.ok(desc.length > 0, 'description must be non-empty'); + assert.ok(desc.length <= 1024, `description must be <= 1024 chars, got ${desc.length}`); + }); + + test('description truncated to <=1024 chars when source description is very long', () => { + const longDesc = 'A'.repeat(2000); + const longDescCommand = `---\nname: gsd:test\ndescription: ${longDesc}\n---\n\nBody text.\n`; + const result = convertClaudeCommandToClineSkill(longDescCommand, 'gsd-test'); + const descMatch = result.match(/^description:\s*'?(.*?)'?$/m); + assert.ok(descMatch, 'must have description field'); + // The raw description value (unquoted) should be <=1024 chars + // The result string after the --- block will have the quoted form; check raw length + // by checking the whole result doesn't have the full 2000-char string + assert.ok(!result.includes('A'.repeat(1025)), 'description must be truncated to 1024 chars'); + }); + + test('returns content unchanged when source has no frontmatter', () => { + const noFm = 'Just a body, no frontmatter here.\n'; + const result = convertClaudeCommandToClineSkill(noFm, 'gsd-test'); + assert.strictEqual(result, noFm, 'content without frontmatter must be returned unchanged'); + }); + + test('RICH_COMMAND body uses .cline/ paths and Cline branding', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + assert.ok(!result.includes('~/.claude/'), 'body must not contain ~/.claude/'); + assert.ok(result.includes('.cline/'), 'body must contain .cline/ paths'); + assert.ok(!result.includes('Claude Code'), 'body must not contain "Claude Code"'); + assert.ok(result.includes('Cline'), 'body must reference Cline'); + }); +}); + +// ─── (b) + (c) + (d) Integration tests ──────────────────────────────────────── + +describe('installRuntimeArtifacts — cline skills emission', () => { + test('cline global: writes gsd-prefixed skill dirs under skills/', (t) => { + const configDir = createTempDir('gsd-cline-skills-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const layout = resolveRuntimeArtifactLayout('cline', configDir, 'global'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'cline must have a skills kind after #782'); + + const skillsDir = path.join(configDir, skillsKind.destSubpath); + assert.ok(fs.existsSync(skillsDir), 'skills/ directory must be created'); + + const helpSkillDir = path.join(skillsDir, `${skillsKind.prefix}help`); + assert.ok( + fs.existsSync(path.join(helpSkillDir, 'SKILL.md')), + `gsd-help/SKILL.md must exist under ${skillsKind.destSubpath}/` + ); + }); + + test('cline global: SKILL.md has valid cline frontmatter (name + description)', (t) => { + const configDir = createTempDir('gsd-cline-fm-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + const helpSkill = path.join(skillsDir, 'gsd-help', 'SKILL.md'); + assert.ok(fs.existsSync(helpSkill), 'gsd-help/SKILL.md must exist'); + + const content = fs.readFileSync(helpSkill, 'utf8'); + // Must have YAML frontmatter + assert.ok(content.startsWith('---'), 'SKILL.md must start with YAML frontmatter'); + assert.ok(content.includes('name:'), 'frontmatter must have name field'); + assert.ok(content.includes('description:'), 'frontmatter must have description field'); + // name must be gsd-help + const nameMatch = content.match(/^name:\s*(.+)$/m); + assert.ok(nameMatch, 'must have name field'); + assert.ok(nameMatch[1].includes('gsd-help'), `name must include gsd-help, got: ${nameMatch[1]}`); + }); + + test('cline global: SKILL.md uses .cline/ paths not .claude/', (t) => { + const configDir = createTempDir('gsd-cline-paths-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + // Check all installed skill files for stray .claude/ references + const skills = fs.readdirSync(skillsDir).filter(n => n.startsWith('gsd-')); + assert.ok(skills.length > 0, 'at least one gsd- skill must be installed'); + + for (const skillName of skills) { + const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); + if (!fs.existsSync(skillFile)) continue; + const content = fs.readFileSync(skillFile, 'utf8'); + assert.ok( + !content.includes('~/.claude/'), + `${skillName}/SKILL.md must not contain ~/.claude/ — found stray path` + ); + assert.ok( + !content.includes('/.claude/'), + `${skillName}/SKILL.md must not contain /.claude/ — found stray path` + ); + } + }); + + test('cline global: skill count matches resolved profile', (t) => { + const configDir = createTempDir('gsd-cline-count-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + const count = fs.readdirSync(skillsDir) + .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) + .length; + + if (RESOLVED_CORE.skills !== '*') { + assert.strictEqual(count, RESOLVED_CORE.skills.size, + `installed skill count (${count}) must match profile size (${RESOLVED_CORE.skills.size})`); + } else { + assert.ok(count > 0, 'must install at least 1 skill'); + } + }); +}); + +describe('installRuntimeArtifacts — cline idempotency', () => { + test('cline: running install twice leaves skills intact (idempotency)', (t) => { + const configDir = createTempDir('gsd-cline-idempotent-'); + t.after(() => cleanup(configDir)); + + // First install + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + const countAfterFirst = fs.readdirSync(skillsDir) + .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) + .length; + + // Second install (upgrade over existing) + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const countAfterSecond = fs.readdirSync(skillsDir) + .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) + .length; + + assert.strictEqual(countAfterFirst, countAfterSecond, + `skill count must be stable across installs: first=${countAfterFirst} second=${countAfterSecond}`); + }); +}); + +// ─── (e) Full install() global — coexistence regression ─────────────────────── +// +// Issue #782 explicitly requires that a global Cline install writes BOTH: +// - skills//SKILL.md (skills for Cline >= v3.48) +// - .clinerules/gsd.md (rules dir form introduced by #787) +// +// installRuntimeArtifacts() tests cover skills in isolation; this test exercises +// the FULL install() code path to ensure neither artifact is silently dropped. + +describe('install() global cline — coexistence: skills AND .clinerules', () => { + let tmpGlobalDir; + let originalClineConfigDir; + + beforeEach(() => { + originalClineConfigDir = process.env.CLINE_CONFIG_DIR; + tmpGlobalDir = createTempDir('gsd-cline-global-'); + // Redirect CLINE_CONFIG_DIR to the temp dir so install() never touches ~/.cline + process.env.CLINE_CONFIG_DIR = tmpGlobalDir; + }); + + afterEach(() => { + if (originalClineConfigDir !== undefined) { + process.env.CLINE_CONFIG_DIR = originalClineConfigDir; + } else { + delete process.env.CLINE_CONFIG_DIR; + } + cleanup(tmpGlobalDir); + }); + + test('global cline install writes at least one gsd-* SKILL.md under skills/', () => { + captureConsole(() => install(true, 'cline')); + + const skillsDir = path.join(tmpGlobalDir, 'skills'); + assert.ok( + fs.existsSync(skillsDir), + `skills/ directory must exist under ${tmpGlobalDir} after global cline install` + ); + + // full profile: gsd-help is nested under gsd-ns-manage/skills/help/SKILL.md + const helpSkillFile = nestedSkillPath(skillsDir, 'gsd-', 'help'); + assert.ok( + fs.existsSync(helpSkillFile), + `${path.relative(tmpGlobalDir, helpSkillFile)} must exist under ${tmpGlobalDir} — skills emission broken for global cline` + ); + }); + + test('global cline install writes .clinerules/gsd.md to the global config dir', () => { + captureConsole(() => install(true, 'cline')); + + // For a global Cline install, targetDir = getGlobalDir('cline') = CLINE_CONFIG_DIR. + // The cline-rules surface (#787) writes the .clinerules/ DIRECTORY form: + // .clinerules/gsd.md (rule file) + // .clinerules/hooks/PreToolUse (lifecycle hook) + const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md'); + assert.ok( + fs.existsSync(clinerulesMd), + `.clinerules/gsd.md must exist at ${clinerulesMd} — coexistence with skills broken for global cline (#782+#787)` + ); + }); + + test('global cline .clinerules/gsd.md contains GSD instructions', () => { + captureConsole(() => install(true, 'cline')); + + // #787 dir form: rule content lives in .clinerules/gsd.md, not a flat .clinerules file + const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md'); + assert.ok(fs.existsSync(clinerulesMd), '.clinerules/gsd.md must exist'); + const content = fs.readFileSync(clinerulesMd, 'utf8'); + assert.ok( + content.includes('GSD') || content.includes('gsd'), + '.clinerules/gsd.md must reference GSD' + ); + }); +}); + +// ─── Fix 3 regression: converter rewrites bare ~/.claude and CLAUDE_CONFIG_DIR ── +// +// convertClaudeToCliineMarkdown must also handle bare ~/.claude (no trailing +// slash) and the CLAUDE_CONFIG_DIR env-var name. surface.md contains these; +// the emitted Cline SKILL.md must contain no such stale Claude refs. + +describe('convertClaudeToCliineMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (Fix 3)', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + + test('no bare ~/.claude in converted surface.md', () => { + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToCliineMarkdown(raw); + // ~/.claude followed by a word-boundary (not a /) must be gone + assert.ok( + !/~\/\.claude\b/.test(result), + 'converted surface.md must not contain bare ~/.claude' + ); + }); + + test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToCliineMarkdown(raw); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'converted surface.md must not contain CLAUDE_CONFIG_DIR' + ); + }); + + test('CLAUDE_CONFIG_DIR rewritten to CLINE_CONFIG_DIR', () => { + const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; + const result = convertClaudeToCliineMarkdown(input); + assert.ok(result.includes('CLINE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must become CLINE_CONFIG_DIR'); + assert.ok(!result.includes('CLAUDE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must be gone'); + }); + + test('bare ~/.claude rewritten to ~/.cline', () => { + const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; + const result = convertClaudeToCliineMarkdown(input); + assert.ok(!result.includes('~/.claude'), 'bare ~/.claude must be rewritten'); + assert.ok(result.includes('~/.cline'), 'must rewrite to ~/.cline'); + }); + + test('installRuntimeArtifacts cline global: gsd-surface SKILL.md has no bare ~/.claude or CLAUDE_CONFIG_DIR', (t) => { + const configDir = createTempDir('gsd-cline-surface-fix3-'); + t.after(() => cleanup(configDir)); + + const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest( + path.join(__dirname, '..', 'commands', 'gsd') + ); + const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({ + modes: ['full'], manifest: MANIFEST_FULL, + }); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL); + + // full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md + const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface'); + assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist for full profile`); + + const content = fs.readFileSync(surfaceSkill, 'utf8'); + assert.ok( + !/~\/\.claude\b/.test(content), + 'gsd-surface SKILL.md must not contain bare ~/.claude (Fix 3)' + ); + assert.ok( + !content.includes('CLAUDE_CONFIG_DIR'), + 'gsd-surface SKILL.md must not contain CLAUDE_CONFIG_DIR (Fix 3)' + ); + }); +}); + +// ─── Fix 1 regression: custom CLINE_CONFIG_DIR → embedded paths use custom dir ── +// +// _applyRuntimeRewrites for cline must rewrite ~/.cline/ → pathPrefix. +// For default global installs, pathPrefix = "$HOME/.cline/" (unchanged). +// For custom installs (CLINE_CONFIG_DIR=/custom), pathPrefix = "/custom/" and +// all embedded ~/.cline/ refs in SKILL.md must become /custom/... + +describe('_applyRuntimeRewrites — cline custom-dir embedded path (Fix 1)', () => { + test('default pathPrefix ($HOME/.cline/) leaves ~/.cline refs as $HOME/.cline', () => { + const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n'; + const result = _applyRuntimeRewrites(content, 'cline', '$HOME/.cline/'); + assert.ok(result.includes('$HOME/.cline/'), 'default prefix must map ~/.cline/ to $HOME/.cline/'); + assert.ok(!result.includes('~/.cline'), 'no tilde form should remain after rewrite'); + }); + + test('custom pathPrefix rewrites ~/.cline/ → custom path in SKILL.md body', () => { + const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n'; + const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/'); + assert.ok(result.includes('/custom/cline-dir/'), 'custom prefix must appear in output'); + assert.ok(!result.includes('~/.cline'), 'no tilde cline form should remain after custom rewrite'); + }); + + test('custom pathPrefix rewrites residual ~/.claude/ safety net', () => { + const content = 'Residual: ~/.claude/skills\n'; + const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/'); + assert.ok(result.includes('/custom/cline-dir/'), 'safety-net ~/.claude/ also rewritten to custom prefix'); + assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ should remain'); + }); + + test('installRuntimeArtifacts cline with CLINE_CONFIG_DIR custom: SKILL.md embeds custom path', (t) => { + const configDir = createTempDir('gsd-cline-custom-dir-'); + t.after(() => cleanup(configDir)); + + const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest( + path.join(__dirname, '..', 'commands', 'gsd') + ); + const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({ + modes: ['full'], manifest: MANIFEST_FULL, + }); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL); + + // gsd-surface SKILL.md references config paths; with a custom configDir + // (not under $HOME), pathPrefix will be the absolute custom path. + // full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md + const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface'); + assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist`); + + const content = fs.readFileSync(surfaceSkill, 'utf8'); + // With a custom dir (path under /tmp, not ~/.cline), the output must NOT + // contain ~/.cline/ or $HOME/.cline/ — it must embed the actual configDir path. + assert.ok( + !content.includes('~/.cline/'), + `gsd-surface SKILL.md must not contain ~/.cline/ when configDir=${configDir} (Fix 1)` + ); + // The custom path must appear somewhere in the file + // (configDir is a /tmp/... path so pathPrefix = configDir+'/'). + // Production normalizes backslashes to forward slashes via + // path.resolve(configDir).replace(/\\/g, '/'), so compare against that + // form — otherwise this assertion fails on Windows where mkdtempSync + // returns a backslash path (e.g. C:\Users\...) but the emitted content + // already has forward slashes (C:/Users/...). + const expectedPath = path.resolve(configDir).replace(/\\/g, '/'); + assert.ok( + content.includes(expectedPath), + `gsd-surface SKILL.md must embed custom configDir path ${expectedPath} (Fix 1)` + ); + }); +}); + +// ─── Fix 4 regression: description truncation is code-point-aware ──────────── +// +// Naive UTF-16 slicing (`str.slice(0, 1021)`) can split a surrogate pair when +// the cut falls between the high and low surrogate of a multibyte character +// (e.g. emoji U+1F600, which is encoded as two UTF-16 code units). The fix +// uses Array.from() to split by code point, guaranteeing that the truncated +// value never contains a lone surrogate. + +describe('convertClaudeCommandToClineSkill — code-point-aware truncation (Fix 4)', () => { + /** + * Build a frontmatter+body command string whose description is: + * - exactly `prefixLen` ASCII chars + * - followed by `emojiCount` repetitions of '😀' (U+1F600, 2 UTF-16 units) + * - total UTF-16 length is prefixLen + emojiCount * 2 + */ + function makeEmojiCommand(prefixLen, emojiCount) { + const desc = 'A'.repeat(prefixLen) + '😀'.repeat(emojiCount); + return `---\nname: gsd:emoji-test\ndescription: ${desc}\n---\n\nBody.\n`; + } + + test('emitted description is <= 1024 code points when source overflows', () => { + // 1020 ASCII chars + 4 emoji = 1020 + 8 UTF-16 units = 1028 UTF-16 units > 1024. + // Code-point count = 1020 + 4 = 1024 — exactly at the boundary BEFORE adding '...'. + // After truncation to 1021 code points + '...' → 1024 code points total. + const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + // Extract raw description value (strip surrounding YAML quotes if present) + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + const codePoints = Array.from(rawDesc); + assert.ok( + codePoints.length <= 1024, + `emitted description must be <= 1024 code points, got ${codePoints.length}` + ); + }); + + test('emitted description ends with "..." when truncated', () => { + const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + assert.ok(rawDesc.endsWith('...'), `truncated description must end with "...", got: ${rawDesc.slice(-10)}`); + }); + + test('emitted description has no lone surrogate (no split emoji)', () => { + // Place emojis exactly at positions 1021–1025 (code points) so that a naive + // UTF-16 slice at 1021 code units would cut inside the second emoji's surrogate pair. + // 1019 ASCII chars + 6 emoji = 1025 code points (>1024, triggers truncation). + // UTF-16 length = 1019 + 12 = 1031. Naive slice(0,1021) yields 1019 ASCII + + // the HIGH surrogate of emoji[0] — a lone surrogate. + const cmd = makeEmojiCommand(1019, 6); + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + // Verify no lone surrogate: every char's code point must be outside [0xD800, 0xDFFF]. + const hasLoneSurrogate = [...rawDesc].some(c => { + const cp = c.codePointAt(0); + return cp >= 0xD800 && cp <= 0xDFFF; + }); + assert.ok(!hasLoneSurrogate, 'emitted description must not contain a lone surrogate'); + + // Also round-trip through Buffer to confirm the string is valid UTF-8 encodable. + assert.doesNotThrow( + () => Buffer.from(rawDesc, 'utf8').toString('utf8'), + 'emitted description must round-trip through Buffer without error' + ); + }); + + test('short description (<= 1024 code points) is not truncated', () => { + // 10 ASCII + 5 emoji = 15 code points — well under the limit. + const cmd = makeEmojiCommand(10, 5); + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + assert.ok(!rawDesc.endsWith('...'), 'short description must NOT be truncated with "..."'); + // Must contain the original emoji characters intact + assert.ok(rawDesc.includes('😀'), 'short description must preserve emoji characters'); + }); +}); + +// ─── Fix 2 regression: cline local scope emits no skills ───────────────────── +// +// resolveRuntimeArtifactLayout('cline', dir, 'local') must return 0 kinds. +// installRuntimeArtifacts('cline', dir, 'local') must not write any skills. + +describe('resolveRuntimeArtifactLayout — cline scope-aware (Fix 2)', () => { + test('cline local: kinds.length === 0 (no skills for local scope)', () => { + const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'local'); + assert.strictEqual(layout.kinds.length, 0, 'cline local must have 0 kinds'); + }); + + test('cline global: kinds.length === 1 (skills kind)', () => { + const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'global'); + assert.strictEqual(layout.kinds.length, 1, 'cline global must have 1 skills kind'); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + }); + + test('installRuntimeArtifacts cline local: no skills/ dir created', (t) => { + const configDir = createTempDir('gsd-cline-local-noskills-'); + t.after(() => cleanup(configDir)); + + assert.doesNotThrow(() => installRuntimeArtifacts('cline', configDir, 'local', RESOLVED_CORE)); + const skillsDir = path.join(configDir, 'skills'); + assert.ok( + !fs.existsSync(skillsDir), + `skills/ must NOT be created for cline local install (Fix 2), but found ${skillsDir}` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3037-gemini-duplicate-commands.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3037-gemini-duplicate-commands (consolidation epic #1969 B1 #1970)", () => { +/** + * Bug #3037: Gemini global+local install creates duplicate /gsd:* commands + * across user (HOME/.gemini/) and workspace (PROJECT/.gemini/) scopes. + * + * Reproduction (from issue body): + * 1. install --gemini --global with HOME=tmpHome + * 2. cd tmpProject; install --gemini --local + * → both ~/.gemini/commands/gsd/ and PROJECT/.gemini/commands/gsd/ contain + * 65 overlapping command filenames. + * → Gemini conflict detection renames every overlapping command to + * /workspace.gsd:* and /user.gsd:*, breaking the documented /gsd:* + * namespace. + * + * Fix: when the local Gemini install detects the user-scope GSD command + * directory already exists with managed-shape content, skip the local copy + * and emit a clear warning explaining the conflict avoidance. + * + * Tests assert on the post-install filesystem shape and capture the skip + * warning so the full test log remains warning-clean. + */ + +'use strict'; + +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 { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); + +const { install } = require('../bin/install.js'); + +describe('bug #3037: Gemini global+local install must not create duplicate command scopes', () => { + let tmpHome; + let tmpProject; + let originalHome; + let originalUserprofile; + let originalCwd; + + beforeEach(() => { + tmpHome = createTempDir('gsd-3037-home-'); + tmpProject = createTempDir('gsd-3037-work-'); + originalHome = process.env.HOME; + originalUserprofile = process.env.USERPROFILE; + originalCwd = process.cwd(); + // Point HOME at the temp dir so install(true, 'gemini') writes to + // tmpHome/.gemini, not the developer's real home. + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + }); + + afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + // CR #3041: also restore USERPROFILE so the temp HOME doesn't leak + // into later tests and create order-dependent failures on Windows + // or any code path that reads USERPROFILE. + if (originalUserprofile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserprofile; + process.chdir(originalCwd); + cleanup(tmpHome); + cleanup(tmpProject); + }); + + function listCommandFiles(geminiCommandsRoot) { + if (!fs.existsSync(geminiCommandsRoot)) return []; + const out = []; + 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()) out.push(path.relative(geminiCommandsRoot, full)); + } + } + walk(geminiCommandsRoot); + return out.sort(); + } + + function runInstall(...args) { + return captureConsole(() => install(...args)); + } + + test('global install populates HOME/.gemini/commands/gsd', () => { + runInstall(true, 'gemini'); + const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd'); + const files = listCommandFiles(globalCmds); + assert.ok( + files.length > 0, + 'global install must populate HOME/.gemini/commands/gsd' + ); + }); + + test('local install after global does NOT populate PROJECT/.gemini/commands/gsd (avoids /gsd:* namespace conflict)', () => { + // Step 1: global install + runInstall(true, 'gemini'); + const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd'); + const globalFiles = listCommandFiles(globalCmds); + assert.ok(globalFiles.length > 0, 'precondition: global install must succeed'); + + // Step 2: local install in a temp project + process.chdir(tmpProject); + const { stdout } = runInstall(false, 'gemini'); + assert.match( + stdout, + /Skipping commands\/gsd\/ for local install/, + 'local install must explain why it skips duplicate Gemini commands' + ); + + // Assertion: the local commands/gsd/ directory must NOT exist (or must + // be empty) so Gemini's conflict detection has nothing to rename. The + // fix may either skip the directory entirely (preferred — no leftover + // file system noise) or create an empty directory (acceptable but odd). + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.equal( + localFiles.length, + 0, + `local install must skip commands/gsd/ when global already exists; ` + + `found ${localFiles.length} duplicate command file(s) at ${localCmds}` + ); + }); + + test('local install with NO existing global GSD does still populate PROJECT/.gemini/commands/gsd', () => { + // No global install first — local should proceed normally so users who + // only ever run --local still get GSD commands in their project. + process.chdir(tmpProject); + runInstall(false, 'gemini'); + + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.ok( + localFiles.length > 0, + `local-only install must populate PROJECT/.gemini/commands/gsd; ` + + `found ${localFiles.length} files at ${localCmds}` + ); + }); + + test('local install when HOME has hand-dropped overrides UNDER commands/gsd/ (but no full GSD) still populates locally', () => { + // CR #3041 regression: the previous detection was + // `fs.readdirSync(homeGeminiGsd).length > 0` which would skip the + // local install for a user who manually dropped a single override + // command at ~/.gemini/commands/gsd/.toml without ever + // running --gemini --global. The fix narrows detection to require + // at least 3 canonical GSD command files (help.toml, progress.toml, + // new-project.toml) — a marker that's structurally impossible to + // produce by accident. + const homeGsdDir = path.join(tmpHome, '.gemini', 'commands', 'gsd'); + fs.mkdirSync(homeGsdDir, { recursive: true }); + fs.writeFileSync( + path.join(homeGsdDir, 'my-override.toml'), + 'description = "user override"\nprompt = "..."\n' + ); + + process.chdir(tmpProject); + runInstall(false, 'gemini'); + + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.ok( + localFiles.length > 0, + `local install must proceed when HOME/.gemini/commands/gsd contains ` + + `only user overrides (not the full GSD canary set); ` + + `found ${localFiles.length} files at ${localCmds}` + ); + }); + + test('local install when HOME/.gemini exists but commands/gsd is absent (non-GSD Gemini user) still populates locally', () => { + // Simulate a user who has Gemini configured but never installed GSD + // globally. ~/.gemini/ exists with unrelated content; ~/.gemini/commands/ + // may or may not exist with non-gsd subdirectories. Local install must + // still proceed because no GSD-managed user-scope directory is present. + fs.mkdirSync(path.join(tmpHome, '.gemini', 'commands', 'someone-else'), { + recursive: true, + }); + fs.writeFileSync( + path.join(tmpHome, '.gemini', 'commands', 'someone-else', 'foo.toml'), + 'description = "user command"\nprompt = "..."\n' + ); + + process.chdir(tmpProject); + runInstall(false, 'gemini'); + + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.ok( + localFiles.length > 0, + `local install must proceed when no GSD-managed user-scope directory ` + + `exists, even if other Gemini commands are present at the user scope` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-789-codebuddy-commands.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-789-codebuddy-commands (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #789) +// Workflow .md / command .md / SKILL.md files — their text IS what the runtime +// loads. Testing emitted text tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Regression guard — enh(#789): elevate CodeBuddy slash-command surface. + * + * CodeBuddy (Tencent, @tencent-ai/codebuddy-code) reads user-level surfaces + * (https://www.codebuddy.ai/docs/cli/slash-commands, /skills): + * - commands/gsd-.md — slash commands shown in the '/' menu + * - skills/gsd-/SKILL.md — model-invocable skills + * + * Before #789 gsd emitted only skills/. Because CodeBuddy skills default to + * user-invocable:true (appear in '/'), emitting a commands/ surface AND leaving + * skills user-invocable would duplicate every /gsd-* entry. #789 therefore: + * 1. emits commands/gsd-.md (the '/' surface, peer-consistent with + * Cursor #785 and Augment #790), + * 2. marks skills user-invocable:false so they become model-invocable + * background knowledge and the commands/ surface is the sole '/' surface. + * + * Subagents are already emitted via the generic agents block + convertClaude + * AgentToCodebuddyAgent (~/.codebuddy/agents/), so #789 adds no agents change. + * + * mcp.json is intentionally NOT written: gsd ships no MCP server, and CodeBuddy's + * mcp.json holds an `mcpServers` map of *external* servers to connect to — + * there is nothing for gsd to register. Same exclusion as #784/#785/#790. + */ +'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 { createTempDir, cleanup } = require('./helpers.cjs'); + +const { + convertClaudeCommandToCodebuddyCommand, + convertClaudeCommandToCodebuddySkill, +} = require('../bin/install.js'); + +const { + installRuntimeArtifacts, + uninstallRuntimeArtifacts, +} = require('../gsd-core/bin/lib/install-engine.cjs'); +const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); +const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); +const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); +const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); + +// ─── Layout contract ───────────────────────────────────────────────────────── + +describe('enh-789 — codebuddy layout has commands + skills kinds', () => { + test('resolveRuntimeArtifactLayout codebuddy returns 3 kinds (ADR-1235 §1 agents cutover)', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); + assert.strictEqual(layout.kinds.length, 3, 'codebuddy must have exactly 3 artifact kinds (commands + skills + agents)'); + const kindNames = layout.kinds.map(k => k.kind).sort(); + assert.deepStrictEqual(kindNames, ['agents', 'commands', 'skills']); + }); + + test('codebuddy commands kind targets commands/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'must have commands kind'); + assert.strictEqual(commandsKind.destSubpath, 'commands'); + assert.strictEqual(commandsKind.prefix, 'gsd-'); + assert.strictEqual(typeof commandsKind.stage, 'function'); + }); + + test('codebuddy skills kind targets skills/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'must have skills kind'); + assert.strictEqual(skillsKind.destSubpath, 'skills'); + assert.strictEqual(skillsKind.prefix, 'gsd-'); + }); +}); + +// ─── Command converter contract ────────────────────────────────────────────── + +describe('enh-789 — convertClaudeCommandToCodebuddyCommand', () => { + const SRC = [ + '---', + 'name: gsd:new-project', + 'description: Initialize a project', + 'argument-hint: "[name]"', + 'allowed-tools:', + ' - Read', + '---', + '', + 'Use .claude/skills/ and run /gsd:help. Claude Code reads CLAUDE.md.', + '', + ].join('\n'); + + test('emits a description-only frontmatter (no Claude-specific name: gsd:)', () => { + const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); + assert.ok(out.startsWith('---\n'), 'must begin with frontmatter'); + assert.ok(/^description:/m.test(out), 'must carry a description field'); + assert.ok(!out.includes('name: gsd:new-project'), 'must drop Claude colon-form name field'); + }); + + test('preserves a present argument-hint (CodeBuddy supports it)', () => { + const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); + assert.ok(/^argument-hint:\s*["']?\[name\]["']?\s*$/m.test(out), + `argument-hint must be carried through when present in source. Got:\n${out}`); + }); + + test('converts body Claude-isms to CodeBuddy equivalents', () => { + const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); + assert.ok(out.includes('.codebuddy/skills/'), out); + assert.ok(out.includes('/gsd-help'), out); + assert.ok(out.includes('CODEBUDDY.md'), out); + assert.ok(!/\bClaude Code\b/.test(out), 'must rebrand "Claude Code"'); + }); +}); + +describe('enh-789 — skills marked user-invocable:false', () => { + test('convertClaudeCommandToCodebuddySkill emits user-invocable: false', () => { + const src = [ + '---', + 'name: gsd:help', + 'description: Show help', + '---', + '', + '# body', + '', + ].join('\n'); + const out = convertClaudeCommandToCodebuddySkill(src, 'gsd-help'); + assert.ok(/^user-invocable:\s*false\s*$/m.test(out), + `SKILL.md frontmatter must hide skill from '/' menu (user-invocable: false). Got:\n${out}`); + }); +}); + +// ─── Install contract ──────────────────────────────────────────────────────── + +describe('enh-789 — installRuntimeArtifacts codebuddy emits commands and skills', () => { + test('global codebuddy install: commands/gsd-help.md and skills/gsd-help/SKILL.md exist', (t) => { + const configDir = createTempDir('gsd-enh789-codebuddy-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const commandsDir = path.join(configDir, 'commands'); + assert.ok(fs.existsSync(commandsDir), 'commands/ dir must exist'); + const cmdFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok(cmdFiles.length > 0, 'at least one gsd-*.md command file must be installed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'commands/gsd-help.md must exist'); + + const skillsDir = path.join(configDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist'); + assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist'); + }); + + test('installed commands/gsd-help.md is CodeBuddy-compatible (no raw ~/.claude/, rebranded)', (t) => { + const configDir = createTempDir('gsd-enh789-content-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const helpCmd = path.join(configDir, 'commands', 'gsd-help.md'); + const content = fs.readFileSync(helpCmd, 'utf8'); + assert.ok(!content.includes('~/.claude/'), 'commands must not contain raw ~/.claude/ refs'); + assert.ok(content.startsWith('---'), 'commands must carry frontmatter'); + }); + + test('installed skills/gsd-help/SKILL.md is hidden from the / menu', (t) => { + const configDir = createTempDir('gsd-enh789-skillhide-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const skill = fs.readFileSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'), 'utf8'); + assert.ok(/^user-invocable:\s*false\s*$/m.test(skill), + 'installed SKILL.md must set user-invocable: false'); + }); + + test('command count matches skill count (profile parity)', (t) => { + const configDir = createTempDir('gsd-enh789-parity-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const cmdCount = fs.readdirSync(path.join(configDir, 'commands')) + .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; + const skillCount = fs.readdirSync(path.join(configDir, 'skills'), { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; + assert.strictEqual(cmdCount, skillCount, 'command count must equal skill count for same profile'); + }); + + test('full profile install: no $HOME/.codebuddy or ~/.codebuddy leak in any command', (t) => { + // The codebuddy converter rewrites `.claude/` → `.codebuddy/`, so source + // refs like `@$HOME/.claude/gsd-core/...` (e.g. plan-review-convergence.md) + // must be normalized to the install target — not left as $HOME/.codebuddy. + const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + const configDir = createTempDir('gsd-enh789-noleak-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); + + const commandsDir = path.join(configDir, 'commands'); + for (const f of fs.readdirSync(commandsDir).filter(n => n.endsWith('.md'))) { + const content = fs.readFileSync(path.join(commandsDir, f), 'utf8'); + assert.ok(!content.includes('$HOME/.codebuddy'), `${f} must not leak $HOME/.codebuddy`); + assert.ok(!content.includes('~/.codebuddy'), `${f} must not leak ~/.codebuddy`); + assert.ok(!content.includes('.claude/'), `${f} must not retain raw .claude/ refs`); + } + }); + + test('full profile install does NOT mutate source commands/gsd/ files', (t) => { + const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + assert.strictEqual(RESOLVED_FULL.skills, '*', 'full profile must have skills === "*"'); + + const configDir = createTempDir('gsd-enh789-full-'); + t.after(() => cleanup(configDir)); + + const srcHelpPath = path.join(REAL_COMMANDS_DIR, 'help.md'); + const before = fs.readFileSync(srcHelpPath, 'utf8'); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); + + const after = fs.readFileSync(srcHelpPath, 'utf8'); + assert.strictEqual(before, after, 'source commands/gsd/help.md must not be mutated by the install'); + }); +}); + +// ─── Uninstall contract ────────────────────────────────────────────────────── + +describe('enh-789 — uninstallRuntimeArtifacts removes codebuddy commands', () => { + test('uninstall removes gsd-* commands but preserves user commands', (t) => { + const configDir = createTempDir('gsd-enh789-uninstall-'); + t.after(() => cleanup(configDir)); + + const commandsDir = path.join(configDir, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), '# help\n'); + fs.writeFileSync(path.join(commandsDir, 'user-custom.md'), '# user\n'); + + uninstallRuntimeArtifacts('codebuddy', configDir, 'global'); + + assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'gsd-help.md must be removed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'user-custom.md')), 'user-custom.md must be preserved'); + }); +}); + +// ─── mcp.json exclusion ────────────────────────────────────────────────────── + +describe('enh-789 — mcp.json excluded (gsd ships no MCP server)', () => { + test('codebuddy install does not write mcp.json / .mcp.json', (t) => { + const configDir = createTempDir('gsd-enh789-mcp-excluded-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + assert.ok(!fs.existsSync(path.join(configDir, 'mcp.json')), 'must not write mcp.json'); + assert.ok(!fs.existsSync(path.join(configDir, '.mcp.json')), 'must not write .mcp.json'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2794-opencode-model-profile-overrides.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2794-opencode-model-profile-overrides (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for bug #2794 + * + * OpenCode generated agents ignored `model_profile_overrides.opencode.*`. + * The agent install path called `readGsdEffectiveModelOverrides` (explicit + * per-agent overrides) but never called `readGsdRuntimeProfileResolver` + * (tier-based profile overrides). When a user configured: + * + * { runtime: "opencode", model_profile_overrides: { opencode: { sonnet: "..." } } } + * + * generated `.opencode/agents/gsd-*.md` files contained no `model:` frontmatter. + * + * The fix adds a tier-resolver fallback in the OpenCode agent conversion block: + * explicit `model_overrides[agent]` > `model_profile_overrides.opencode.` > omit. + * + * This test exercises: + * 1. `readGsdRuntimeProfileResolver` correctly resolves OpenCode tier overrides. + * 2. The agent install code path embeds the resolved model into OpenCode frontmatter. + * 3. Explicit `model_overrides` still wins over tier-based resolution. + * 4. Missing overrides produce no `model:` field (no regression on omit behavior). + */ + +process.env.GSD_TEST_MODE = '1'; + +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 { + readGsdRuntimeProfileResolver, + install, +} = require('../bin/install.js'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const makeTmp = (prefix) => createTempDir(`gsd-2794-${prefix}-`); + +function writeJson(p, obj) { + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(obj, null, 2), 'utf-8'); +} + + +describe('bug-2794: readGsdRuntimeProfileResolver resolves opencode tier overrides', () => { + let projectDir; + let homeDir; + let origHome; + let origUP; + + beforeEach(() => { + projectDir = makeTmp('proj'); + homeDir = makeTmp('home'); + origHome = process.env.HOME; + origUP = process.env.USERPROFILE; + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + }); + + afterEach(() => { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + if (origUP === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = origUP; + cleanup(projectDir); + cleanup(homeDir); + }); + + test('resolves opencode sonnet tier to user-supplied model ID', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + runtime: 'opencode', + model_profile: 'balanced', + model_profile_overrides: { + opencode: { + sonnet: 'anthropic/claude-sonnet-4-7', + }, + }, + }); + + const resolver = readGsdRuntimeProfileResolver(projectDir); + assert.ok(resolver !== null, 'expected a resolver for opencode runtime'); + + // gsd-roadmapper balanced tier = sonnet — should resolve to override + const entry = resolver.resolve('gsd-roadmapper'); + assert.ok(entry !== null, 'expected entry for gsd-roadmapper'); + assert.strictEqual(entry.model, 'anthropic/claude-sonnet-4-7', 'sonnet override applied'); + }); + + test('returns null resolver when runtime is not set', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + model_profile: 'balanced', + model_profile_overrides: { opencode: { sonnet: 'x' } }, + }); + const resolver = readGsdRuntimeProfileResolver(projectDir); + assert.strictEqual(resolver, null, 'no resolver without runtime field'); + }); + + test('resolver returns null for agent not in MODEL_PROFILES', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + runtime: 'opencode', + model_profile: 'balanced', + model_profile_overrides: { opencode: { sonnet: 'x' } }, + }); + const resolver = readGsdRuntimeProfileResolver(projectDir); + assert.ok(resolver !== null); + const entry = resolver.resolve('gsd-nonexistent-agent'); + assert.strictEqual(entry, null, 'unknown agent name yields null'); + }); +}); + +describe('bug-2794: OpenCode agent install embeds model_profile_overrides model', () => { + let projectDir; + let homeDir; + let origHome; + let origUP; + let origCwd; + + beforeEach(() => { + projectDir = makeTmp('proj'); + homeDir = makeTmp('home'); + origHome = process.env.HOME; + origUP = process.env.USERPROFILE; + origCwd = process.cwd(); + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + process.chdir(projectDir); + }); + + afterEach(() => { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + if (origUP === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = origUP; + process.chdir(origCwd); + cleanup(projectDir); + cleanup(homeDir); + }); + + test('generated OpenCode agent frontmatter includes model from model_profile_overrides', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + runtime: 'opencode', + model_profile: 'balanced', + model_profile_overrides: { + opencode: { + sonnet: 'anthropic/claude-sonnet-4-7', + opus: 'anthropic/claude-opus-4-7', + haiku: 'anthropic/claude-haiku-4-5', + }, + }, + }); + + const oldLog = console.log; + console.log = () => {}; + try { + install(false, 'opencode'); + } finally { + console.log = oldLog; + } + + const agentsDir = path.join(projectDir, '.opencode', 'agents'); + assert.ok(fs.existsSync(agentsDir), 'agents directory should be created'); + + // gsd-roadmapper is balanced -> sonnet tier + const roadmapperPath = path.join(agentsDir, 'gsd-roadmapper.md'); + assert.ok(fs.existsSync(roadmapperPath), 'gsd-roadmapper.md should exist'); + const roadmapperContent = fs.readFileSync(roadmapperPath, 'utf-8'); + assert.match( + roadmapperContent, + /^model: anthropic\/claude-sonnet-4-7$/m, + 'gsd-roadmapper should have sonnet model from model_profile_overrides' + ); + + // gsd-planner is balanced -> opus tier + const plannerPath = path.join(agentsDir, 'gsd-planner.md'); + assert.ok(fs.existsSync(plannerPath), 'gsd-planner.md should exist'); + const plannerContent = fs.readFileSync(plannerPath, 'utf-8'); + assert.match( + plannerContent, + /^model: anthropic\/claude-opus-4-7$/m, + 'gsd-planner should have opus model from model_profile_overrides' + ); + }); + + test('explicit model_overrides[agent] wins over model_profile_overrides tier', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + runtime: 'opencode', + model_profile: 'balanced', + model_overrides: { + 'gsd-roadmapper': 'explicit-winner-model', + }, + model_profile_overrides: { + opencode: { + sonnet: 'tier-model-that-should-lose', + }, + }, + }); + + const oldLog = console.log; + console.log = () => {}; + try { + install(false, 'opencode'); + } finally { + console.log = oldLog; + } + + const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md'); + assert.ok(fs.existsSync(roadmapperPath)); + const content = fs.readFileSync(roadmapperPath, 'utf-8'); + assert.match( + content, + /^model: explicit-winner-model$/m, + 'explicit model_overrides must win over model_profile_overrides tier' + ); + assert.doesNotMatch( + content, + /tier-model-that-should-lose/, + 'tier model must not appear when explicit override is present' + ); + }); + + test('no model field when neither model_overrides nor model_profile_overrides is set', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + runtime: 'opencode', + model_profile: 'balanced', + }); + + const oldLog = console.log; + console.log = () => {}; + try { + install(false, 'opencode'); + } finally { + console.log = oldLog; + } + + const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md'); + if (fs.existsSync(roadmapperPath)) { + const content = fs.readFileSync(roadmapperPath, 'utf-8'); + // When no overrides, model field should either be absent or use built-in default + // The key invariant: no model field if there are no user-supplied overrides + // AND no built-in opencode defaults for this tier + // (gsd-roadmapper balanced = sonnet; opencode has built-in sonnet defaults) + // So we only assert no crash and no tier-model-not-provided entries + assert.ok(typeof content === 'string', 'agent file should be a string'); + } + // Key: no exception thrown (test passes = no crash on missing overrides) + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2643-skill-frontmatter-name.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2643-skill-frontmatter-name (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #2643 / #2808: skill frontmatter name parity. + * + * Original (#2643): workflows emitted Skill(skill="gsd:") and the + * installer registered colon form in SKILL.md name: to match. + * + * Updated (#2808): workflows now use Skill(skill="gsd-") (hyphen), + * and the installer emits name: gsd- (hyphen). Claude Code autocomplete + * now shows the canonical hyphen form instead of the deprecated colon form. + * The directory name (gsd-) is unchanged. + */ + +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 { + convertClaudeCommandToClaudeSkill, + skillFrontmatterName, +} = require(path.join(ROOT, 'bin', 'install.js')); + +const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows'); +const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); + +function collectFiles(dir, results) { + if (!results) 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()) collectFiles(full, results); + else if (e.name.endsWith('.md')) results.push(full); + } + return results; +} + +/** + * Extract every `Skill(skill="")` invocation as a structured record. + * + * Per project test rigor (`feedback_no_source_grep_tests.md`), this parses + * each call as a unit instead of leaning on a single regex over raw bytes. + * The flow is: + * + * 1. Strip HTML comments so commented-out examples don't count as drift. + * 2. Walk the content for `Skill(` openers; for each, find the matching + * `)` closer (Skill bodies are simple kwarg lists, no nesting). + * 3. Parse the call body for the `skill = "..."` keyword argument. + * Permissive whitespace around the keyword and `=`, permissive + * single/double quoting (with optional `\` escapes from string- + * embedded examples), permissive name body — so malformed drift like + * `Skill(skill="gsd:extract_learnings")` is surfaced rather than + * silently skipped by an over-strict character class. + * + * Returns `[{ name, raw }]` per call. Filtering by namespace (gsd- vs gsd:) + * happens at the call site so the extractor stays neutral. + */ +function extractSkillCalls(content) { + // regex-free HTML-comment stripper (CodeQL: avoid incomplete-multi-character-sanitization) + let stripped = ''; + { + let rest = content; + let idx; + while ((idx = rest.indexOf('', idx + 4); + if (end === -1) { rest = ''; break; } + rest = rest.slice(end + 3); + } + stripped += rest; + } + const calls = []; + // Body class excludes backslash so the extractor doesn't include an + // escape character that precedes the closing quote in embedded examples + // (e.g. `Skill(skill=\"gsd-plan-phase\", …)` written inside a string + // context). A trailing `\` is permitted on the closing-quote side via the + // optional `\\?` so both `\"` and `"` close the value cleanly. + const argRe = /^\s*skill\s*=\s*\\?(['"])([^'"\\]+)\\?\1/i; + let i = 0; + while (i < stripped.length) { + const open = stripped.indexOf('Skill(', i); + if (open === -1) break; + const close = stripped.indexOf(')', open); + if (close === -1) break; + const body = stripped.slice(open + 'Skill('.length, close); + const match = body.match(argRe); + if (match) calls.push({ name: match[2], raw: stripped.slice(open, close + 1) }); + i = close + 1; + } + return calls; +} + +function extractSkillNamesHyphen(content) { + return new Set( + extractSkillCalls(content) + .map((c) => c.name) + .filter((n) => n.startsWith('gsd-')), + ); +} + +function extractSkillNamesColon(content) { + return new Set( + extractSkillCalls(content) + .map((c) => c.name) + .filter((n) => n.startsWith('gsd:')), + ); +} + +describe('skill frontmatter name parity (#2643 / #2808)', () => { + test('skillFrontmatterName helper emits hyphen form (#2808)', () => { + assert.strictEqual(typeof skillFrontmatterName, 'function'); + assert.strictEqual(skillFrontmatterName('gsd-execute-phase'), 'gsd-execute-phase'); + assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd-plan-phase'); + assert.strictEqual(skillFrontmatterName('gsd-next'), 'gsd-next'); + }); + + test('convertClaudeCommandToClaudeSkill emits name: gsd- (hyphen)', () => { + const input = '---\nname: old\ndescription: test\n---\n\nBody.'; + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-execute-phase'); + // Parse the frontmatter block structurally: extract the name: field value. + const frontmatterMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/); + assert.ok(frontmatterMatch, 'output must have a frontmatter block delimited by ---'); + const frontmatterLines = frontmatterMatch[1].split(/\r?\n/); + const nameEntry = frontmatterLines.find((l) => l.startsWith('name:')); + assert.ok(nameEntry, 'frontmatter must contain a name: field'); + const nameValue = nameEntry.replace(/^name:\s*/, '').trim(); + assert.strictEqual( + nameValue, + 'gsd-execute-phase', + `frontmatter name: must be 'gsd-execute-phase' (hyphen form), got '${nameValue}'` + ); + }); + + test('no workflow uses deprecated Skill(skill="gsd:") colon form', () => { + const workflowFiles = collectFiles(WORKFLOWS_DIR); + const colonRefs = []; + for (const f of workflowFiles) { + const src = fs.readFileSync(f, 'utf-8'); + for (const n of extractSkillNamesColon(src)) { + colonRefs.push(path.basename(f) + ': ' + n); + } + } + assert.deepStrictEqual( + colonRefs, + [], + 'deprecated colon-form Skill() calls found (update to hyphen): ' + colonRefs.join(', ') + ); + }); + + test('every workflow Skill(skill="gsd-") resolves to an emitted skill name', () => { + const workflowFiles = collectFiles(WORKFLOWS_DIR); + const referenced = new Set(); + const templatedSkipped = []; + for (const f of workflowFiles) { + const src = fs.readFileSync(f, 'utf-8'); + for (const n of extractSkillNamesHyphen(src)) { + // Skip template expressions (e.g. `gsd-${ref.skill}`): these are + // capability-dispatched — the skill stem is resolved at runtime from + // the `loop render-hooks` registry output (ADR-857 phase 6), so there + // is no single literal skill file to validate against here. + // The capability registry's own validateStep gate (gen-capability-registry.cjs) + // is responsible for ensuring each `steps[].ref.skill` corresponds to a + // real skill declared in the capability's `skills` array. + if (n.includes('${')) { + templatedSkipped.push(path.basename(f) + ': ' + n); + } else { + referenced.add(n); + } + } + } + assert.ok( + referenced.size > 0, + `expected at least one literal Skill(skill="gsd-") reference in workflows under ${WORKFLOWS_DIR}` + ); + + const emitted = new Set(); + const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md')); + 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 out = convertClaudeCommandToClaudeSkill(src, skillDirName); + const m = out.match(/^---\r?\nname:\s*(.+)$/m); + if (m) emitted.add(m[1].trim()); + } + + const missing = []; + for (const r of referenced) if (!emitted.has(r)) missing.push(r); + assert.deepStrictEqual( + missing, + [], + 'workflow refs not emitted as skill names: ' + missing.join(', '), + ); + // Informational: report how many templated dispatches were intentionally skipped. + // (Templated names are validated by the capability registry, not statically here.) + if (templatedSkipped.length > 0) { + // Not a failure — just a note for test output transparency. + // Use a diagnostic comment: node:test does not have a skip-within-test API. + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-778-cross-runtime-command-enrichment.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-778-cross-runtime-command-enrichment (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #778) +// Reads .md/SKILL.md/.toml product files whose deployed text IS what the +// runtime loads — testing text content tests the deployed contract. + +/** + * GSD Tools Tests — #778 cross-runtime command enrichment. + * + * Two independently-verified, additive sub-features: + * (b) Qwen Code skills: numeric `priority` field (higher sorts earlier in the + * /skills TUI listing per the Qwen skills spec). Scoped to runtime='qwen'. + * (c) Gemini custom-command TOML: $ARGUMENTS → {{args}} interpolation, and a + * fixed `!{cat .planning/STATE.md}` live-state injection on the + * situational `progress` command (injection-safe — no interpolated input). + * + * The OpenCode sub-feature (per-command model/agent/subtask/variant) is + * intentionally NOT implemented — see PR description: `model` reintroduces the + * #1156 ProviderModelNotFoundError regression for non-Anthropic OpenCode users, + * `subtask`/`agent` change execution semantics for GSD's interactive commands, + * and `variant` is not in the OpenCode command schema. + * + * Uses node:test and node:assert (NOT Jest). + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { + convertClaudeCommandToClaudeSkill, + convertClaudeToGeminiMarkdown, + install, +} = require('../bin/install.js'); + +// ─── (b) Qwen Code: priority ordering ─────────────────────────────────────── + +describe('#778 (b) Qwen skills priority', () => { + const mk = (name, desc, body) => + ['---', `name: gsd:${name}`, `description: ${desc}`, '---', '', body].join('\n'); + + test('emits numeric priority for a core-loop command (runtime=qwen)', () => { + const result = convertClaudeCommandToClaudeSkill( + mk('plan-phase', 'Plan a phase', 'Body.'), + 'gsd-plan-phase', + 'qwen', + [] + ); + const m = result.match(/^priority:\s*(\d+)\s*$/m); + assert.ok(m, 'priority field present for gsd-plan-phase'); + assert.equal(Number(m[1]) > 0, true, 'priority is a positive number'); + }); + + test('core loop ranks higher than mid-tier (higher = earlier per spec)', () => { + const np = convertClaudeCommandToClaudeSkill( + mk('new-project', 'Start a project', 'Body.'), 'gsd-new-project', 'qwen', [] + ).match(/^priority:\s*(\d+)/m); + const help = convertClaudeCommandToClaudeSkill( + mk('help', 'Help', 'Body.'), 'gsd-help', 'qwen', [] + ).match(/^priority:\s*(\d+)/m); + assert.ok(np && help, 'both core and mid-tier get a priority'); + assert.ok( + Number(np[1]) > Number(help[1]), + 'new-project (core) sorts earlier than help (utility) — higher value' + ); + }); + + test('utility command NOT in the priority map gets no priority field', () => { + const result = convertClaudeCommandToClaudeSkill( + mk('stats', 'Show stats', 'Body.'), 'gsd-stats', 'qwen', [] + ); + assert.ok(!/^priority:/m.test(result), 'no priority emitted for unmapped utility'); + }); + + test('does NOT emit priority for non-qwen runtimes (scoped to qwen)', () => { + for (const rt of [null, 'claude', 'hermes']) { + const result = convertClaudeCommandToClaudeSkill( + mk('plan-phase', 'Plan a phase', 'Body.'), 'gsd-plan-phase', rt, [] + ); + assert.ok(!/^priority:/m.test(result), `no priority for runtime=${rt}`); + } + }); +}); + +// ─── (c) Gemini: {{args}} interpolation ───────────────────────────────────── + +describe('#778 (c) Gemini {{args}} interpolation', () => { + const cmd = (body) => + ['---', 'name: gsd:demo', 'description: Demo', '---', '', body].join('\n'); + + test('maps $ARGUMENTS to {{args}} in the TOML prompt', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('Operate on $ARGUMENTS now.'), + { isCommand: true, commandName: 'demo' } + ); + assert.ok(out.includes('{{args}}'), '{{args}} present'); + assert.ok(!out.includes('$ARGUMENTS'), 'literal $ARGUMENTS removed'); + assert.ok(out.startsWith('description =') || out.includes('prompt ='), 'TOML shape'); + }); + + test('command without $ARGUMENTS gets no injected {{args}}', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('No arguments referenced here.'), + { isCommand: true, commandName: 'demo' } + ); + assert.ok(!out.includes('{{args}}'), 'no spurious {{args}}'); + }); + + test('non-command Gemini content is not TOML-converted and keeps $ARGUMENTS', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('Reference $ARGUMENTS.'), + { isCommand: false } + ); + // isCommand:false keeps markdown — no TOML wrap and no {{args}} mapping + // (the $ARGUMENTS→{{args}} translation is scoped to the TOML command path). + assert.ok(!out.startsWith('prompt ='), 'not wrapped as TOML prompt'); + assert.ok(out.includes('$ARGUMENTS'), '$ARGUMENTS left intact for non-command content'); + assert.ok(!out.includes('{{args}}'), 'no {{args}} injected outside the command path'); + }); +}); + +// ─── (c) Gemini: end-to-end install wiring ────────────────────────────────── +// Proves the install path derives the per-command name from the file stem so a +// regression in the call-site wiring (not just the converter) is caught. + +describe('#778 (c) Gemini install wiring (end-to-end)', () => { + let tmpDir; + let tmpHome; + let prevCwd; + let prevHome; + let prevUserprofile; + + beforeEach(() => { + tmpDir = createTempDir('gsd-enh778-gem-'); + tmpHome = createTempDir('gsd-enh778-home-'); + prevCwd = process.cwd(); + prevHome = process.env.HOME; + prevUserprofile = process.env.USERPROFILE; + process.chdir(tmpDir); + // Isolate HOME so a real ~/.gemini/commands/gsd/ doesn't trigger the #3037 + // local-install conflict-skip path. + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + }); + + afterEach(() => { + process.chdir(prevCwd); + if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; + if (prevUserprofile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserprofile; + cleanup(tmpDir); + cleanup(tmpHome); + }); + + test('installed progress.toml carries the !{} block; arg-bearing commands get {{args}}', () => { + const oldLog = console.log; + console.log = () => {}; + try { + install(false, 'gemini'); + } finally { + console.log = oldLog; + } + + const commandsDir = path.join(tmpDir, '.gemini', 'commands', 'gsd'); + const progressToml = path.join(commandsDir, 'progress.toml'); + assert.ok(fs.existsSync(progressToml), 'progress.toml installed'); + const progress = fs.readFileSync(progressToml, 'utf8'); + // Proves commandName was derived as 'progress' from the file stem. + assert.ok( + progress.includes('!{cat .planning/STATE.md 2>/dev/null}'), + 'progress.toml has the live-state shell block' + ); + + // A non-situational command must NOT receive the shell block. + const helpToml = path.join(commandsDir, 'help.toml'); + if (fs.existsSync(helpToml)) { + assert.ok(!fs.readFileSync(helpToml, 'utf8').includes('!{'), 'help.toml has no shell block'); + } + + // At least one installed command must use {{args}} and none may retain a + // literal $ARGUMENTS (every command body's $ARGUMENTS is translated). + const tomls = fs.readdirSync(commandsDir).filter((f) => f.endsWith('.toml')); + const withArgs = tomls.filter((f) => + fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('{{args}}')); + const withLiteral = tomls.filter((f) => + fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('$ARGUMENTS')); + assert.ok(withArgs.length > 0, 'at least one installed command interpolates {{args}}'); + assert.equal(withLiteral.length, 0, 'no installed command retains literal $ARGUMENTS'); + }); +}); + +// ─── (c) Gemini: !{...} live-state injection (progress) ───────────────────── + +describe('#778 (c) Gemini !{} live-state injection', () => { + const cmd = (name) => + ['---', `name: gsd:${name}`, `description: ${name}`, '---', '', 'Workflow body.'].join('\n'); + + test('progress command injects a fixed !{cat .planning/STATE.md} block', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('progress'), + { isCommand: true, commandName: 'progress' } + ); + assert.ok(out.includes('!{cat .planning/STATE.md'), 'STATE.md injection present'); + }); + + test('non-progress commands get no !{} shell block', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('help'), + { isCommand: true, commandName: 'help' } + ); + assert.ok(!out.includes('!{'), 'no shell block for non-situational command'); + }); + + test('SECURITY: the !{} block interpolates NO user input ({{args}})', () => { + const out = convertClaudeToGeminiMarkdown( + ['---', 'name: gsd:progress', 'description: progress', '---', '', + 'Body uses $ARGUMENTS too.'].join('\n'), + { isCommand: true, commandName: 'progress' } + ); + const blocks = out.match(/!\{([^}]*)\}/g) || []; + assert.equal(blocks.length, 1, 'exactly one shell block'); + assert.ok(!/\{\{args\}\}/.test(blocks[0]), 'no {{args}} inside the shell block'); + assert.ok(/^!\{cat \.planning\/STATE\.md/.test(blocks[0]), 'fixed cat command only'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-769-context-fork-effort.install.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-769-context-fork-effort.install (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: integration-test-input (see #769) +// Exercises install() as a black-box by inspecting produced SKILL.md output +// in a temp dir. Source command .md files are inputs whose installed +// transformation is asserted — not inspected for string presence. + +/** + * #769 — effort: frontmatter on heavy workflow skills. + * #921 — spawning orchestrators must NOT carry context: fork. + * + * Context: context:fork was added by #769 to protect context budget, but + * plan-phase, execute-phase, and autonomous are spawning orchestrators — a + * forked subagent has no Agent/Task tool, breaking their core function. + * effort: max is preserved; context: fork is removed from these three. + * The converter still passes context: fork through if a source file has it + * (for any future leaf skill that legitimately needs isolation). + * + * Verifies: + * 1. Source commands/gsd/autonomous.md does NOT have context: fork, has effort: max + * 2. Source commands/gsd/execute-phase.md does NOT have context: fork, has effort: max + * 3. Source commands/gsd/plan-phase.md does NOT have context: fork, has effort: max + * 4. Source commands/gsd/progress.md has effort: low + * 5. Source commands/gsd/stats.md has effort: low + * 6. Claude global install: SKILL.md for autonomous has effort: max, NOT context: fork + * 7. Claude global install: SKILL.md for execute-phase has effort: max, NOT context: fork + * 8. Claude global install: SKILL.md for plan-phase has effort: max, NOT context: fork + * 9. Claude global install: SKILL.md for progress has effort: low + * 10. Claude global install: SKILL.md for stats has effort: low + * 11. convertClaudeCommandToClaudeSkill still passes context: fork through (for non-orchestrator skills) + * 12. convertClaudeCommandToClaudeSkill emits portable effort: field values + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +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 os = require('node:os'); + +const { install, convertClaudeCommandToClaudeSkill } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +// #924: Claude global install is now FLAT — concrete skills are at the top level. +// flatSkillPath returns: /gsd-/SKILL.md +function flatSkillPath(skillsRoot, stem) { + return path.join(skillsRoot, `gsd-${stem}`, 'SKILL.md'); +} + +const REPO_ROOT = path.resolve(__dirname, '..'); +const SOURCE_COMMANDS_DIR = path.join(REPO_ROOT, 'commands', 'gsd'); + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makeTmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function readFrontmatter(mdPath) { + const content = fs.readFileSync(mdPath, 'utf8'); + if (!content.startsWith('---')) return ''; + const end = content.indexOf('---', 3); + if (end === -1) return ''; + return content.substring(3, end); +} + +/** + * Run a global install for Claude, redirecting its home dir to tmpHome. + * Returns the tmpHome for inspection. + */ +function runClaudeGlobalInstall(claudeHome) { + const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-769-home-')); + + const prevCwd = process.cwd(); + const prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK; + + process.env.CLAUDE_CONFIG_DIR = claudeHome; + process.env.HOME = isolatedHome; + process.env.USERPROFILE = isolatedHome; + process.env.GSD_SKIP_STALE_SDK_CHECK = '1'; + process.chdir(REPO_ROOT); + + try { + install(true, 'claude'); + } finally { + process.chdir(prevCwd); + if (prevClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK; + else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale; + cleanup(isolatedHome); + } + + return claudeHome; +} + +// ─── describe 1: Source command files have correct frontmatter ──────────────── + +// #921/#922: spawning orchestrators must NOT carry context: fork — a forked +// subagent has no Agent/Task tool, making it impossible for orchestrators to +// spawn their required subagents. context: fork is appropriate only for leaf +// skills that do not themselves dispatch agents. effort: max is portable across Claude Code models. +describe('#769/#921/#1319 source commands: spawning orchestrators have effort: max but NOT context: fork', () => { + test('commands/gsd/autonomous.md does NOT have context: fork (#921)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `autonomous.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('commands/gsd/autonomous.md has effort: max (#1319)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); + assert.match(fm, /^effort:[ \t]*max$/m, + `autonomous.md frontmatter must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `autonomous.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); + + test('commands/gsd/execute-phase.md does NOT have context: fork (#921)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `execute-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('commands/gsd/execute-phase.md has effort: max (#1319)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); + assert.match(fm, /^effort:[ \t]*max$/m, + `execute-phase.md frontmatter must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `execute-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); + + test('commands/gsd/plan-phase.md does NOT have context: fork (#921)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `plan-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('commands/gsd/plan-phase.md has effort: max (#1319)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); + assert.match(fm, /^effort:[ \t]*max$/m, + `plan-phase.md frontmatter must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `plan-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); +}); + +describe('#769 source commands: quick-status skills have effort: low', () => { + test('commands/gsd/progress.md has effort: low', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'progress.md')); + assert.match(fm, /^effort:[ \t]*low$/m, + `progress.md frontmatter must have effort: low\nActual:\n${fm}`); + }); + + test('commands/gsd/stats.md has effort: low', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'stats.md')); + assert.match(fm, /^effort:[ \t]*low$/m, + `stats.md frontmatter must have effort: low\nActual:\n${fm}`); + }); +}); + +// ─── describe 2: convertClaudeCommandToClaudeSkill preserves new fields ─────── + +describe('#769/#1319 convertClaudeCommandToClaudeSkill: preserves context and emits portable effort fields', () => { + test('preserves context: fork in emitted SKILL.md frontmatter', () => { + const input = [ + '---', + 'name: gsd:test-heavy', + 'description: Test heavy skill', + 'context: fork', + 'effort: xhigh', + 'allowed-tools:', + ' - Read', + ' - Bash', + '---', + '', + 'Heavy skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.match(fm, /^context:[ \t]*fork$/m, + `SKILL.md frontmatter must include context: fork\nActual frontmatter:\n${fm}`); + }); + + test('normalizes effort: xhigh to effort: max in emitted SKILL.md frontmatter (#1319)', () => { + const input = [ + '---', + 'name: gsd:test-heavy', + 'description: Test heavy skill', + 'context: fork', + 'effort: xhigh', + 'allowed-tools:', + ' - Read', + ' - Bash', + '---', + '', + 'Heavy skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.match(fm, /^effort:[ \t]*max$/m, + `SKILL.md frontmatter must include portable effort: max\nActual frontmatter:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `SKILL.md frontmatter must not include rejected effort: xhigh (#1319)\nActual frontmatter:\n${fm}`); + }); + + test('preserves effort: low in emitted SKILL.md frontmatter', () => { + const input = [ + '---', + 'name: gsd:test-light', + 'description: Test light skill', + 'effort: low', + 'allowed-tools:', + ' - Read', + '---', + '', + 'Light skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-light'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.match(fm, /^effort:[ \t]*low$/m, + `SKILL.md frontmatter must include effort: low\nActual frontmatter:\n${fm}`); + }); + + test('does NOT emit context: or effort: when absent from source', () => { + const input = [ + '---', + 'name: gsd:test-plain', + 'description: Plain skill without context or effort', + 'allowed-tools:', + ' - Read', + '---', + '', + 'Plain skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-plain'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.doesNotMatch(fm, /^context:/m, + `SKILL.md must not emit context: when absent from source\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:/m, + `SKILL.md must not emit effort: when absent from source\nActual:\n${fm}`); + }); +}); + +// ─── describe 3: Claude global install — SKILL.md files include new fields ──── + +// #921/#922: after install, spawning orchestrators must NOT carry context: fork +// in their emitted SKILL.md. #1319: heavyweight skills must use portable max effort. +describe('#769/#921/#1319 Claude global install: spawning-orchestrator SKILL.md files have effort: max but NOT context: fork', () => { + let tmpDir; + let claudeHome; + + beforeEach(() => { + tmpDir = makeTmpDir('gsd-769-claude-'); + claudeHome = path.join(tmpDir, 'claude-home'); + fs.mkdirSync(claudeHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-autonomous SKILL.md does NOT have context: fork after global install (#921)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous'); + const fm = readFrontmatter(skillPath); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `gsd-autonomous is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('gsd-autonomous SKILL.md has effort: max after global install (#1319)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*max$/m, + `gsd-autonomous SKILL.md must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `gsd-autonomous SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); + + test('gsd-execute-phase SKILL.md does NOT have context: fork after global install (#921)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase'); + const fm = readFrontmatter(skillPath); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `gsd-execute-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('gsd-execute-phase SKILL.md has effort: max after global install (#1319)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*max$/m, + `gsd-execute-phase SKILL.md must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `gsd-execute-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); + + test('gsd-plan-phase SKILL.md does NOT have context: fork after global install (#921)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase'); + const fm = readFrontmatter(skillPath); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `gsd-plan-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('gsd-plan-phase SKILL.md has effort: max after global install (#1319)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*max$/m, + `gsd-plan-phase SKILL.md must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `gsd-plan-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); + + test('gsd-progress SKILL.md has effort: low after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'progress'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*low$/m, + `gsd-progress SKILL.md must have effort: low\nActual:\n${fm}`); + }); + + test('gsd-stats SKILL.md has effort: low after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'stats'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*low$/m, + `gsd-stats SKILL.md must have effort: low\nActual:\n${fm}`); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-443-effort-install-wiring.install.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-443-effort-install-wiring.install (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: integration-test-input (see #443) +// Exercises install() + generateCodexAgentToml() as a black-box by inspecting +// produced output files in temp dirs. Source agent .md files are inputs whose +// installed transformation is asserted — not inspected for string presence. + +/** + * #443 — Effort per-runtime wiring at install time. + * + * Verifies: + * 1. Claude global install injects `effort:` into agent .md frontmatter. + * 2. Gemini global install does NOT inject `effort:` (Gemini-safe .md). + * 3. Codex inherited-model installs omit `model_reasoning_effort` so model + * and effort are not partially pinned (#838). + * 4. Config-driven proof: effort.agent_overrides wins over tier defaults + * for Claude .md and for Codex .toml when runtime:"codex" pins a model. + * 5. Source agents/gsd-planner.md has NO effort: key (injection is + * install-only, source stays Gemini-safe). + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +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 os = require('node:os'); + +const { install } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const SOURCE_AGENTS_DIR = path.join(REPO_ROOT, 'agents'); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function makeTmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function readFrontmatter(mdPath) { + const content = fs.readFileSync(mdPath, 'utf8'); + if (!content.startsWith('---')) return ''; + const end = content.indexOf('---', 3); + if (end === -1) return ''; + return content.substring(3, end); +} + +/** + * Run a global install for the given runtime, redirecting its home dir to + * tmpHome. Returns the tmpHome for inspection. + * + * Env-var redirection: + * claude → CLAUDE_CONFIG_DIR + * gemini → GEMINI_CONFIG_DIR + * codex → CODEX_HOME + * + * HOME is also redirected to an isolated temp dir for the duration of the + * install call. This prevents any install.js code that uses os.homedir() + * directly (e.g. ~/.cache/gsd update-check deletion, ~/.gsd/defaults.json + * reads, stale-SDK npm subprocess writes to ~/.npm) from touching the real + * HOME and polluting the test environment for other concurrently-running + * test files (e.g. runtime-launcher-parity test (D) checks that + * $HOME/.claude/gsd-core/bin/gsd-tools.cjs is absent). + * + * GSD_SKIP_STALE_SDK_CHECK=1 is set to suppress the `npm ls -g` subprocess + * that the installer spawns for global installs — that subprocess is slow, + * writes to ~/.npm cache, and is irrelevant to effort-wiring assertions. + * + * The working directory is set to REPO_ROOT so install() can find the source + * agents/. For config-driven tests, place tmpHome inside the project dir + * so that readGsdEffectiveEffortConfig(targetDir) can walk up from tmpHome + * and find .planning/config.json. + */ +function runGlobalInstall(runtime, tmpHome) { + const envVarMap = { + claude: 'CLAUDE_CONFIG_DIR', + gemini: 'GEMINI_CONFIG_DIR', + codex: 'CODEX_HOME', + }; + const envVar = envVarMap[runtime]; + if (!envVar) throw new Error(`Unsupported runtime in test: ${runtime}`); + + // Isolate HOME to a fresh temp dir so install.js code that calls + // os.homedir() (cache deletion, defaults.json reads, npm subprocess) + // never touches the real $HOME/.claude / $HOME/.cache / $HOME/.gsd. + const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-443-home-')); + + const prev = process.env[envVar]; + const prevCwd = process.cwd(); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK; + + process.env[envVar] = tmpHome; + process.env.HOME = isolatedHome; + process.env.USERPROFILE = isolatedHome; + process.env.GSD_SKIP_STALE_SDK_CHECK = '1'; + process.chdir(REPO_ROOT); + + try { + install(true, runtime); + } finally { + process.chdir(prevCwd); + if (prev === undefined) delete process.env[envVar]; + else process.env[envVar] = prev; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK; + else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale; + // Clean up the isolated HOME dir + cleanup(isolatedHome); + } + + return tmpHome; +} + +// ─── Tier default expectations ──────────────────────────────────────────────── +// light → low, standard → high, heavy → xhigh (catalog defaults) +// gsd-planner: heavy → xhigh +// gsd-codebase-mapper: light → low +// gsd-executor: standard → high + +// ─── describe 1: Claude install injects effort: ─────────────────────────────── + +describe('#443 Claude install: effort: injected into frontmatter', () => { + let tmpDir; + let claudeHome; + + beforeEach(() => { + tmpDir = makeTmpDir('gsd-443-claude-'); + claudeHome = path.join(tmpDir, 'claude-home'); + fs.mkdirSync(claudeHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-planner.md contains effort: xhigh (heavy tier default)', () => { + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); + assert.match(fm, /^effort:\s*xhigh$/m, + `gsd-planner frontmatter should have effort: xhigh\nActual:\n${fm}`); + }); + + test('gsd-codebase-mapper.md contains effort: low (light tier default)', () => { + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-codebase-mapper.md')); + assert.match(fm, /^effort:\s*low$/m, + `gsd-codebase-mapper frontmatter should have effort: low\nActual:\n${fm}`); + }); + + test('gsd-executor.md contains effort: high (standard tier default)', () => { + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-executor.md')); + assert.match(fm, /^effort:\s*high$/m, + `gsd-executor frontmatter should have effort: high\nActual:\n${fm}`); + }); +}); + +// ─── describe 2: Gemini install does NOT inject effort: ────────────────────── + +describe('#443 Gemini install: effort: absent (Gemini-safe)', () => { + let tmpDir; + let geminiHome; + + beforeEach(() => { + tmpDir = makeTmpDir('gsd-443-gemini-'); + geminiHome = path.join(tmpDir, 'gemini-home'); + fs.mkdirSync(geminiHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-planner.md does NOT contain effort: (Gemini install)', () => { + runGlobalInstall('gemini', geminiHome); + const fm = readFrontmatter(path.join(geminiHome, 'agents', 'gsd-planner.md')); + assert.doesNotMatch(fm, /^effort:/m, + `gsd-planner (Gemini) frontmatter must NOT have effort:\nActual:\n${fm}`); + }); + + test('gsd-executor.md does NOT contain effort: (Gemini install)', () => { + runGlobalInstall('gemini', geminiHome); + const fm = readFrontmatter(path.join(geminiHome, 'agents', 'gsd-executor.md')); + assert.doesNotMatch(fm, /^effort:/m, + `gsd-executor (Gemini) frontmatter must NOT have effort:\nActual:\n${fm}`); + }); +}); + +// ─── describe 3: Codex inherited-model install omits model_reasoning_effort ── + +describe('#838 Codex install: inherited model omits model_reasoning_effort', () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = makeTmpDir('gsd-443-codex-'); + codexHome = path.join(tmpDir, 'codex-home'); + fs.mkdirSync(codexHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-planner.toml omits both model and model_reasoning_effort when model is inherited', () => { + runGlobalInstall('codex', codexHome); + const tomlContent = fs.readFileSync( + path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' + ); + assert.doesNotMatch(tomlContent, /^model\s*=/m, + `gsd-planner.toml should omit model when inheriting Codex chat model\nActual:\n${tomlContent.slice(0, 500)}`); + assert.doesNotMatch(tomlContent, /^model_reasoning_effort\s*=/m, + `gsd-planner.toml should omit model_reasoning_effort when model is inherited\nActual:\n${tomlContent.slice(0, 500)}`); + }); +}); + +// ─── describe 4: Config-driven proof ───────────────────────────────────────── +// +// The runtime home dir must be INSIDE (or a sibling of) the project root so +// that readGsdEffectiveEffortConfig(targetDir) can walk up from the runtime +// home and find .planning/config.json. We put .claude/ and .codex/ as siblings +// of .planning/ inside the project dir — this is the natural local-install shape. + +describe('#443 Config-driven: effort.agent_overrides drives install-time effort', () => { + let tmpDir; + let claudeHome; + let codexHome; + + beforeEach(() => { + // Layout: tmpDir/project/ <-- project root (cwd for install) + // .planning/config.json + // .claude/ <-- claudeHome (CLAUDE_CONFIG_DIR) + // .codex/ <-- codexHome (CODEX_HOME) + tmpDir = makeTmpDir('gsd-443-cfg-'); + const projectDir = path.join(tmpDir, 'project'); + claudeHome = path.join(projectDir, '.claude'); + codexHome = path.join(projectDir, '.codex'); + + fs.mkdirSync(claudeHome, { recursive: true }); + fs.mkdirSync(codexHome, { recursive: true }); + fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true }); + + // Write a project config with effort.agent_overrides overriding gsd-planner to 'low'. + // runtime:"codex" pins a Codex-native model, so emitting model_reasoning_effort + // remains valid under the #838 model/effort coupling rule. + const config = { + runtime: 'codex', + effort: { + agent_overrides: { + 'gsd-planner': 'low', + }, + }, + }; + fs.writeFileSync( + path.join(projectDir, '.planning', 'config.json'), + JSON.stringify(config, null, 2) + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('Claude .md gets effort: low when agent_overrides.gsd-planner=low', () => { + // projectDir is the cwd for install — chdir handled inside runGlobalInstall. + // claudeHome is inside projectDir, so walking up from claudeHome finds .planning/config.json. + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); + assert.match(fm, /^effort:\s*low$/m, + `gsd-planner should have effort: low from config override\nActual:\n${fm}`); + }); + + test('Codex .toml gets model_reasoning_effort = "low" when agent_overrides.gsd-planner=low', () => { + runGlobalInstall('codex', codexHome); + const tomlContent = fs.readFileSync( + path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' + ); + assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, + `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); + assert.match(tomlContent, /^model_reasoning_effort\s*=\s*"low"$/m, + `gsd-planner.toml should have model_reasoning_effort = "low" from config override\nActual:\n${tomlContent.slice(0, 500)}`); + }); + + test('Codex .toml clamps effort max → xhigh when agent_overrides.gsd-planner=max', () => { + const projectDir = path.dirname(codexHome); + // Overwrite config with max override + const config = { + runtime: 'codex', + effort: { + agent_overrides: { + 'gsd-planner': 'max', + }, + }, + }; + fs.writeFileSync( + path.join(projectDir, '.planning', 'config.json'), + JSON.stringify(config, null, 2) + ); + + runGlobalInstall('codex', codexHome); + const tomlContent = fs.readFileSync( + path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' + ); + assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, + `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); + // Codex does not support 'max' → clamped to 'xhigh' + assert.match(tomlContent, /^model_reasoning_effort\s*=\s*"xhigh"$/m, + `gsd-planner.toml should clamp max → xhigh for Codex\nActual:\n${tomlContent.slice(0, 500)}`); + assert.doesNotMatch(tomlContent, /model_reasoning_effort\s*=\s*"max"/, + 'Codex .toml must never contain model_reasoning_effort = "max"'); + }); +}); + +// ─── describe 5b: Invalid effort tokens fall through (Codex adversarial finding #2) ─ +// +// These tests FAIL before the fix: resolveInstallTimeEffort returns the raw +// invalid string without validating it against VALID_EFFORTS. + +describe('#443 resolveInstallTimeEffort: invalid tokens fall through to valid effort', () => { + let tmpDir; + let claudeHome; + let codexHome; + + beforeEach(() => { + // Layout: tmpDir/project/ <-- project root + // .planning/config.json + // .claude/ <-- claudeHome + // .codex/ <-- codexHome + tmpDir = makeTmpDir('gsd-443-invalid-effort-'); + const projectDir = path.join(tmpDir, 'project'); + claudeHome = path.join(projectDir, '.claude'); + codexHome = path.join(projectDir, '.codex'); + + fs.mkdirSync(claudeHome, { recursive: true }); + fs.mkdirSync(codexHome, { recursive: true }); + fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + function writeProjectConfig(config) { + const projectDir = path.dirname(claudeHome); + fs.writeFileSync( + path.join(projectDir, '.planning', 'config.json'), + JSON.stringify(config, null, 2) + ); + } + + const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max']; + + test('effort.default="ultra" (invalid) -> Claude .md effort: is a VALID value (falls through to high)', () => { + // BUG before fix: resolveInstallTimeEffort returns "ultra" verbatim + writeProjectConfig({ effort: { default: 'ultra' } }); + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); + const match = fm.match(/^effort:\s*(\S+)$/m); + assert.ok(match, `effort: must be present in frontmatter\nActual:\n${fm}`); + assert.ok(VALID_EFFORTS.includes(match[1]), + `effort: must be a VALID effort string, got: "${match[1]}"\nActual frontmatter:\n${fm}`); + }); + + test('effort.agent_overrides.gsd-planner="bogus" (invalid) with valid default -> falls through to valid default', () => { + // BUG before fix: "bogus" is returned and written verbatim + writeProjectConfig({ + effort: { + agent_overrides: { 'gsd-planner': 'bogus' }, + default: 'medium', + }, + }); + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); + const match = fm.match(/^effort:\s*(\S+)$/m); + assert.ok(match, `effort: must be present in frontmatter\nActual:\n${fm}`); + assert.ok(VALID_EFFORTS.includes(match[1]), + `effort: must be a VALID effort string, got: "${match[1]}"\nActual frontmatter:\n${fm}`); + // Falls through invalid "bogus" -> valid tier default or "medium" default + // "medium" is valid, so it should appear (or tier default if medium is invalid, but medium is valid) + }); + + test('effort.default="ultra" (invalid) + runtime:"codex" -> Codex .toml model_reasoning_effort is VALID', () => { + // BUG before fix: "ultra" written into .toml verbatim + writeProjectConfig({ runtime: 'codex', effort: { default: 'ultra' } }); + runGlobalInstall('codex', codexHome); + const tomlContent = fs.readFileSync( + path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' + ); + assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, + `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); + const match = tomlContent.match(/^model_reasoning_effort\s*=\s*"([^"]+)"/m); + assert.ok(match, `model_reasoning_effort must be present in .toml\nActual:\n${tomlContent.slice(0, 500)}`); + assert.ok(VALID_EFFORTS.includes(match[1]), + `model_reasoning_effort must be VALID, got: "${match[1]}"\nActual:\n${tomlContent.slice(0, 500)}`); + }); +}); + +// ─── describe 5: Source stays clean ────────────────────────────────────────── + +describe('#443 Source purity: agents/gsd-planner.md has no effort: key', () => { + test('source agents/gsd-planner.md frontmatter does not contain effort:', () => { + const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-planner.md')); + assert.doesNotMatch(fm, /^effort:/m, + `Source agents/gsd-planner.md must NOT contain effort: (injection is install-only)`); + }); + + test('source agents/gsd-executor.md frontmatter does not contain effort:', () => { + const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-executor.md')); + assert.doesNotMatch(fm, /^effort:/m, + `Source agents/gsd-executor.md must NOT contain effort: (injection is install-only)`); + }); + + test('source agents/gsd-codebase-mapper.md frontmatter does not contain effort:', () => { + const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-codebase-mapper.md')); + assert.doesNotMatch(fm, /^effort:/m, + `Source agents/gsd-codebase-mapper.md must NOT contain effort: (injection is install-only)`); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1510-rewrite-engine-helper-relocation.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1510-rewrite-engine-helper-relocation (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +// Enhancement #1510 (epic #1507, ADR-1508 Phase 1): behavior-preserving +// relocation of pure rewrite-engine helpers out of hand-authored bin/install.js. +// - getDirName -> gsd-core/bin/lib/runtime-name-policy.cjs +// - processAttribution -> gsd-core/bin/lib/runtime-artifact-conversion.cjs +// getCommitAttribution stays in install.js (impure install-time config I/O); the +// convertClaudeToAugmentMarkdown duplicate dedup is deferred to Phase 2's cleanup +// (entangled converter cluster; not required to unblock Phase 2). +// These tests exercise the REAL relocated functions at their new home (the +// generated .cjs) and assert install.js re-exports the SAME references +// (Hyrum: existing consumers import these names from bin/install.js). + +const { test, describe } = require('node:test'); +const assert = require('node:assert'); + +const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); +const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); +const installer = require('../bin/install.js'); + +// ── Slice A: getDirName relocated to runtime-name-policy ────────────────────── +describe('getDirName (relocated to runtime-name-policy)', () => { + const EXPECTED = { + claude: '.claude', + copilot: '.github', + opencode: '.opencode', + gemini: '.gemini', + kilo: '.kilo', + codex: '.codex', + antigravity: '.agents', + cursor: '.cursor', + windsurf: '.windsurf', + augment: '.augment', + trae: '.trae', + qwen: '.qwen', + hermes: '.hermes', + kimi: '.kimi-code', + codebuddy: '.codebuddy', + cline: '.cline', + }; + + for (const [runtime, dir] of Object.entries(EXPECTED)) { + test(`maps '${runtime}' to '${dir}'`, () => { + assert.strictEqual(runtimeNamePolicy.getDirName(runtime), dir); + }); + } + + test('falls back to .claude for an unknown runtime', () => { + assert.strictEqual(runtimeNamePolicy.getDirName('definitely-not-a-runtime'), '.claude'); + }); + + test('falls back to .claude for empty input', () => { + assert.strictEqual(runtimeNamePolicy.getDirName(''), '.claude'); + }); + + test('bin/install.js re-exports the SAME getDirName reference (no drift)', () => { + assert.strictEqual(installer.getDirName, runtimeNamePolicy.getDirName); + }); +}); + +// ── Slice B: processAttribution relocated to runtime-artifact-conversion ─────── +describe('processAttribution (relocated to runtime-artifact-conversion)', () => { + test('null removes the Co-Authored-By line and its preceding blank line', () => { + const input = 'Commit body line.\n\nCo-Authored-By: Someone '; + assert.strictEqual(conversion.processAttribution(input, null), 'Commit body line.'); + }); + + test('undefined leaves content unchanged', () => { + const input = 'Commit body.\n\nCo-Authored-By: Someone '; + assert.strictEqual(conversion.processAttribution(input, undefined), input); + }); + + test('a string replaces the attribution value', () => { + const input = 'Body\n\nCo-Authored-By: Old Name '; + assert.strictEqual( + conversion.processAttribution(input, 'New Name '), + 'Body\n\nCo-Authored-By: New Name ', + ); + }); + + test('escapes $ in the attribution to prevent backreference injection', () => { + const input = 'Body\n\nCo-Authored-By: x'; + // "$1" must survive literally, not be interpreted as a regex backreference. + assert.strictEqual( + conversion.processAttribution(input, 'A $1 B'), + 'Body\n\nCo-Authored-By: A $1 B', + ); + }); + + test('handles CRLF when removing (null)', () => { + const input = 'Body\r\n\r\nCo-Authored-By: Someone '; + assert.strictEqual(conversion.processAttribution(input, null), 'Body'); + }); + + test('replaces every Co-Authored-By line (global)', () => { + const input = 'Body\nCo-Authored-By: A \nCo-Authored-By: B '; + assert.strictEqual( + conversion.processAttribution(input, 'Z '), + 'Body\nCo-Authored-By: Z \nCo-Authored-By: Z ', + ); + }); + + test('bin/install.js re-exports the SAME processAttribution reference (no drift)', () => { + // processAttribution remains an explicit installer compatibility relay, so + // the export must keep pointing at the conversion module's implementation. + assert.strictEqual(installer.processAttribution, conversion.processAttribution); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1511-rewrite-engine-relocation.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1511-rewrite-engine-relocation (consolidation epic #1969 B1 #1970)", () => { +'use strict'; +/** + * Tests for ADR-1508 Phase 2: rewrite engine relocation to runtime-artifact-conversion. + * Issue #1511 — verifies the deep public seam signatures and behavior. + * + * Tests are behavioral (no source-grep). All filesystem operations use tmp dirs. + */ + +const { describe, test, before } = 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 { cleanup } = require('./helpers.cjs'); + +let conversion; +before(() => { + process.env['GSD_TEST_MODE'] = '1'; + conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); +}); + +// --------------------------------------------------------------------------- +// _computePathPrefix unit tests +// --------------------------------------------------------------------------- + +describe('_computePathPrefix', () => { + test('global under home → $HOME/... form', () => { + const prefix = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: '/home/u/.cursor', + homeDir: '/home/u', + }); + assert.equal(prefix, '$HOME/.cursor/'); + }); + + test('non-global → resolvedTarget/ form', () => { + const prefix = conversion._computePathPrefix({ + isGlobal: false, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: '/project/.cursor', + homeDir: '/home/u', + }); + assert.equal(prefix, '/project/.cursor/'); + }); + + test('global opencode skips $HOME shorthand', () => { + // OpenCode uses ~/.config/opencode which breaks $HOME shorthand in content + const prefix = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: true, + isWindowsHost: false, + resolvedTarget: '/home/u/.config/opencode', + homeDir: '/home/u', + }); + assert.equal(prefix, '/home/u/.config/opencode/'); + }); + + test('global target outside home → resolvedTarget/ form', () => { + const prefix = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: '/opt/custom-cursor', + homeDir: '/home/u', + }); + assert.equal(prefix, '/opt/custom-cursor/'); + }); + + test('isWindowsHost tripwire — Windows paths collapse to $HOME/ same as POSIX (no-op today)', () => { + // Documents CURRENT behavior: isWindowsHost is accepted but not branched on. + // Both win32=true and win32=false return '$HOME/.cursor/' for a home-relative target. + // If a future Windows-specific branch is added, this tripwire fails and forces + // an explicit decision about what to return on Windows. + const withWindows = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: true, + resolvedTarget: 'C:/Users/matte/.cursor', + homeDir: 'C:/Users/matte', + }); + const withoutWindows = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: 'C:/Users/matte/.cursor', + homeDir: 'C:/Users/matte', + }); + assert.equal(withWindows, '$HOME/.cursor/'); + assert.strictEqual(withWindows, withoutWindows); + }); + + test('backslash-style resolvedTarget is normalized to forward slashes (#1615 regression)', () => { + // path.join on Windows produces backslashes; the returned prefix is + // substituted into markdown @-references which must use POSIX paths. + // Without normalization the backslashes leak into workflow file content + // and break substring checks on Windows CI. + const prefix = conversion._computePathPrefix({ + isGlobal: false, + isOpencode: false, + isWindowsHost: true, + resolvedTarget: 'C:\\Users\\runner\\AppData\\Local\\Temp\\gsd-1615-windsurf', + homeDir: 'C:\\Users\\runner', + }); + assert.strictEqual(prefix, 'C:/Users/runner/AppData/Local/Temp/gsd-1615-windsurf/'); + assert.ok(!prefix.includes('\\'), `prefix must not contain backslashes: ${prefix}`); + }); +}); + +// --------------------------------------------------------------------------- +// _applyRuntimeRewrites with injected attribution +// --------------------------------------------------------------------------- + +describe('_applyRuntimeRewrites — attribution injection', () => { + const PREFIX = '$HOME/.cursor/'; + + test('attribution=null removes Co-Authored-By line', () => { + const content = '# Hello\n\nSome text\n\nCo-Authored-By: Claude\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, null); + assert.ok(!result.includes('Co-Authored-By:'), 'Co-Authored-By should be removed'); + }); + + test('attribution=undefined leaves Co-Authored-By unchanged', () => { + const content = '# Hello\n\nCo-Authored-By: Claude\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, undefined); + assert.ok(result.includes('Co-Authored-By: Claude'), 'Co-Authored-By should be preserved when attribution=undefined'); + }); + + test('attribution=string replaces Co-Authored-By value', () => { + const content = '# Hello\n\nCo-Authored-By: OldName\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, 'NewName '); + assert.ok(result.includes('Co-Authored-By: NewName '), 'Co-Authored-By should be replaced'); + }); + + test('cursor runtime replaces ~/.claude/ paths', () => { + const content = 'See ~/.claude/skills/ for more info\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', '/home/u/.cursor/', false, undefined); + assert.ok(result.includes('/home/u/.cursor/skills/'), 'cursor should replace ~/.claude/ with pathPrefix'); + }); +}); + +// --------------------------------------------------------------------------- +// rewriteStagedSkillBodies — behavioral filesystem test +// --------------------------------------------------------------------------- + +describe('rewriteStagedSkillBodies', () => { + test('rewrites .md files in-place for cursor runtime', () => { + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); + try { + // Create a skill dir with a SKILL.md referencing ~/.claude/skills/foo + // NOTE: the rewrite engine handles path replacement and attribution only. + // Bash→Shell conversion is done by the stage-1 skill converter, not the engine. + const skillDir = path.join(stagedDir, 'gsd-test-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + const content = '# Test\n\nSee ~/.claude/skills/foo\n\nAlso ~/.cursor/skills/bar\n'; + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); + + // Call with injected homedir + platform for determinism + conversion.rewriteStagedSkillBodies(stagedDir, { + runtime: 'cursor', + configDir, + scope: 'global', + homedir: () => '/home/u', + platform: 'linux', + }); + + const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); + // cursor rewrites ~/.claude/ → pathPrefix + // configDir is a tmpdir, not under /home/u, so prefix = resolvedTarget + '/' + // Mirror the engine's backslash→slash normalization so the assertion holds on Windows. + const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); + assert.ok(result.includes(`${resolvedTarget}/skills/foo`), `Should replace ~/.claude/skills/ with ${resolvedTarget}/skills/`); + // cursor also rewrites ~/.cursor/ → pathPrefix + assert.ok(result.includes(`${resolvedTarget}/skills/bar`), `Should replace ~/.cursor/skills/ with ${resolvedTarget}/skills/`); + } finally { + cleanup(stagedDir); + cleanup(configDir); + } + }); + + test('with injected homedir: global under home uses $HOME prefix', () => { + // Real absolute path so Windows path.resolve does not re-root a POSIX literal onto a drive. + // The dir need not exist — the engine only string-processes it. + const HOME = path.resolve(os.tmpdir(), 'gsd-1511-fake-home'); + const configDir = path.join(HOME, '.cursor'); + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); + try { + const skillDir = path.join(stagedDir, 'gsd-help'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'Use ~/.claude/skills/ here\n'); + + conversion.rewriteStagedSkillBodies(stagedDir, { + runtime: 'cursor', + configDir, + scope: 'global', + homedir: () => HOME, + platform: process.platform, + }); + + const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); + assert.ok(result.includes('$HOME/.cursor/skills/'), 'Should use $HOME shorthand when configDir is under homedir'); + } finally { + cleanup(stagedDir); + } + }); + + test('non-existent stagedDir is a no-op', () => { + assert.doesNotThrow(() => { + conversion.rewriteStagedSkillBodies('/nonexistent/dir', { + runtime: 'cursor', + configDir: '/tmp/fake', + scope: 'global', + }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// rewriteStagedCommandBodies — returns temp dir, does not mutate source +// --------------------------------------------------------------------------- + +describe('rewriteStagedCommandBodies', () => { + test('returns a temp dir (not the source dir) with rewritten content', () => { + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-cmd-')); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); + let tempDir; + try { + // NOTE: rewrite engine handles path replacement + attribution, NOT tool renames. + fs.writeFileSync(path.join(stagedDir, 'help.md'), '# Help\n\nSee ~/.claude/skills/\n\nSee ~/.cursor/skills/\n'); + + tempDir = conversion.rewriteStagedCommandBodies(stagedDir, { + runtime: 'cursor', + configDir, + scope: 'global', + homedir: () => '/home/u', + platform: 'linux', + }); + + assert.notEqual(tempDir, stagedDir, 'must return a different dir, never the source'); + assert.ok(fs.existsSync(tempDir), 'returned tempDir should exist'); + + const result = fs.readFileSync(path.join(tempDir, 'help.md'), 'utf8'); + // Source dir should be unchanged + const source = fs.readFileSync(path.join(stagedDir, 'help.md'), 'utf8'); + assert.ok(source.includes('~/.claude/skills/'), 'source file must not be mutated'); + // configDir is /tmp/... (not under /home/u), so prefix = resolvedTarget + '/' + const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); + assert.ok(result.includes(`${resolvedTarget}/skills/`), 'output should have cursor path rewrite applied'); + // ~/.cursor/ also rewrites to prefix + assert.ok(!result.includes('~/.cursor/'), 'output should have ~/.cursor/ replaced too'); + } finally { + cleanup(stagedDir); + cleanup(configDir); + if (tempDir && tempDir !== stagedDir) { + cleanup(tempDir); + } + } + }); + + test('non-existent stagedDir returns stagedDir unchanged (safe)', () => { + const result = conversion.rewriteStagedCommandBodies('/nonexistent/dir', { + runtime: 'cursor', + configDir: '/tmp/fake', + scope: 'global', + }); + assert.equal(result, '/nonexistent/dir', 'should return input path unchanged for missing dir'); + }); +}); + +// --------------------------------------------------------------------------- +// Error-path: applyRuntimeContentRewritesForCommandsInPlace must rm the tempDir +// on any exception and NOT leave an orphaned gsd-cmd-rewrites-* directory. +// --------------------------------------------------------------------------- + +describe('applyRuntimeContentRewritesForCommandsInPlace — error-path tempDir cleanup', () => { + test('rmSync is called on the tempDir when readFileSync throws (deterministic monkeypatch)', () => { + // Asserting the injected error propagates proves the throw happens AFTER the tempDir is + // created (the function creates tempDir, then reads .md), so the catch's rmSync cleanup + // is genuinely exercised — deterministic on every platform/uid. + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-error-path-')); + fs.writeFileSync(path.join(stagedDir, 'x.md'), '# test\n'); + + const before = new Set( + fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')) + ); + + const origReadFileSync = fs.readFileSync; + let leaked = []; + try { + fs.readFileSync = () => { throw new Error('injected read failure'); }; + + assert.throws( + () => conversion.applyRuntimeContentRewritesForCommandsInPlace(stagedDir, 'cursor', '/tmp/x/', false), + /injected read failure/, + ); + + // Restore before any further fs use so the snapshot read is trustworthy. + fs.readFileSync = origReadFileSync; + + const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')); + leaked = after.filter(n => !before.has(n)); + assert.deepStrictEqual(leaked, [], `tempDir not cleaned up on error: ${leaked.join(',')}`); + } finally { + // Idempotent restore — guard against early-throw paths above. + fs.readFileSync = origReadFileSync; + // Clean up the staged dir created for this test. + cleanup(stagedDir); + // Clean up any genuinely leaked gsd-cmd-rewrites-* dirs so the runner stays clean. + for (const n of leaked) { + cleanup(path.join(os.tmpdir(), n)); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// Guard: runtime-artifact-layout no longer exports getInstallExports +// --------------------------------------------------------------------------- + +describe('layout module no longer exports getInstallExports', () => { + test('getInstallExports is not on the layout module export', () => { + process.env['GSD_TEST_MODE'] = '1'; + const layout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + assert.equal( + typeof layout.getInstallExports, + 'undefined', + 'getInstallExports should have been removed from runtime-artifact-layout exports (ADR-1508 Phase 2)', + ); + }); +}); + +// --------------------------------------------------------------------------- +// DEFECT.GENERATIVE-FIX: single-owner reference-identity guard (#1511) +// Proves install.js binds to the conversion module's implementation, not a +// duplicate local copy. If these fail, a duplicate body was re-introduced. +// --------------------------------------------------------------------------- + +describe('single-owner reference-identity guard (ADR-1508 / #1511 Phase 2)', () => { + let install; + let conversionCjs; + before(() => { + process.env['GSD_TEST_MODE'] = '1'; + install = require('../bin/install.js'); + conversionCjs = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); + }); + + test('install.computePathPrefix === conversion._computePathPrefix (single implementation)', () => { + assert.strictEqual( + install.computePathPrefix, + conversionCjs._computePathPrefix, + 'install.js must bind computePathPrefix from conversion (not a duplicate body)', + ); + }); + + test('install.applyRuntimeContentRewritesInPlace === conversion.applyRuntimeContentRewritesInPlace (single walk loop)', () => { + assert.strictEqual( + install.applyRuntimeContentRewritesInPlace, + conversionCjs.applyRuntimeContentRewritesInPlace, + 'install.js must bind applyRuntimeContentRewritesInPlace from conversion (not a duplicate walk loop)', + ); + }); + + test('install.applyRuntimeContentRewritesForCommandsInPlace === conversion.applyRuntimeContentRewritesForCommandsInPlace (single copy+rewrite loop)', () => { + assert.strictEqual( + install.applyRuntimeContentRewritesForCommandsInPlace, + conversionCjs.applyRuntimeContentRewritesForCommandsInPlace, + 'install.js must bind applyRuntimeContentRewritesForCommandsInPlace from conversion (not a duplicate copy+rewrite loop)', + ); + }); + + test('install._applyRuntimeRewrites === conversion._applyRuntimeRewrites (single switch engine)', () => { + assert.strictEqual( + install._applyRuntimeRewrites, + conversionCjs._applyRuntimeRewrites, + 'install.js must bind _applyRuntimeRewrites from conversion (not a local shim)', + ); + }); + + // #1675 (ADR-1508): the augment converter family is single-sourced in the + // conversion module. install.js must re-bind (not re-define) these so there + // is exactly one body — the generative-drift hazard the dedup removes. + test('install.convertClaudeToAugmentMarkdown === conversion.convertClaudeToAugmentMarkdown (single converter)', () => { + assert.strictEqual( + install.convertClaudeToAugmentMarkdown, + conversionCjs.convertClaudeToAugmentMarkdown, + 'install.js must bind convertClaudeToAugmentMarkdown from conversion (not a duplicate body)', + ); + }); + + test('install.convertClaudeCommandToAugmentSkill === conversion.convertClaudeCommandToAugmentSkill (single converter)', () => { + assert.strictEqual( + install.convertClaudeCommandToAugmentSkill, + conversionCjs.convertClaudeCommandToAugmentSkill, + 'install.js must bind convertClaudeCommandToAugmentSkill from conversion (not a duplicate body)', + ); + }); + + test('install.convertClaudeAgentToAugmentAgent === conversion.convertClaudeAgentToAugmentAgent (single converter)', () => { + assert.strictEqual( + install.convertClaudeAgentToAugmentAgent, + conversionCjs.convertClaudeAgentToAugmentAgent, + 'install.js must bind convertClaudeAgentToAugmentAgent from conversion (not a duplicate body)', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2973-profile-user-skills-path.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2973-profile-user-skills-path (consolidation epic #1969 B5 #1974)", () => { +'use strict'; + +// allow-test-rule: source-text-is-the-product. profile-user.md IS the (see #2973) +// shipped workflow product; the `Display:` line at line 356 IS the +// user-visible artifact-name message. This test parses the markdown's +// structured `Display: "..."` line via a regex (not source-grep) to +// extract the path argument as a typed value, then asserts on the +// typed value. The .includes() at the end is a structural absence-check +// against the legacy path literal — the same shape the bug-2470 +// installer-leak test uses to enforce a known-pattern invariant. + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #2973: /gsd-profile-user --refresh writes dev-preferences.md to the + * legacy commands/gsd subdirectory, contradicting v1.39.0's skills-only + * migration claim that "Legacy commands/gsd directory removed + * (replaced by skills/)". + * + * Root cause: the writer at gsd-core/bin/lib/profile-output.cjs + * fell back to commands/gsd/dev-preferences.md when no --output was passed. + * The /gsd-profile-user workflow does not pass --output, so every refresh + * deterministically re-creates the legacy directory. + * + * Fix: + * 1. profile-output.cjs default targets skills/gsd-dev-preferences/SKILL.md + * 2. profile-user.md confirmation message references the new path + * 3. install.js migrates any existing legacy file into the new skill + * location during install (no-op if SKILL.md already exists) + * + * This test exercises the runtime behavior of the writer (writes to the + * skills path) and the structural shape of the workflow message. No + * source-grep on the .cjs body — assertions go against the writer's + * actual output and the parsed workflow message. + */ + +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 { cleanup } = require('./helpers.cjs'); + +const ROOT = path.join(__dirname, '..'); +const PROFILE_OUTPUT = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'profile-output.cjs'); +const WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'profile-user.md'); + +const installEngine = require('../gsd-core/bin/lib/install-engine.cjs'); + +describe('Bug #2973: dev-preferences default writer path is skills/gsd-dev-preferences/SKILL.md', () => { + test('exercise the writer in a subprocess with HOME pointed at a tmp dir; assert the artifact lands at the skills path', () => { + // Subprocess so fs.writeSync(1, ...) in core.cjs goes to a pipe we can + // capture (the parent process's fd 1 bypasses any in-process stubbing). + const cp = require('node:child_process'); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-')); + try { + const analysisPath = path.join(tmpHome, 'analysis.json'); + fs.writeFileSync(analysisPath, JSON.stringify({ + data_source: 'questionnaire', + dimensions: { rigor: { score: 7 } }, + })); + const driver = path.join(tmpHome, 'driver.js'); + fs.writeFileSync(driver, ` + const m = require(${JSON.stringify(PROFILE_OUTPUT)}); + m.cmdGenerateDevPreferences(${JSON.stringify(tmpHome)}, { analysis: ${JSON.stringify(analysisPath)} }, false); + `); + const result = cp.spawnSync(process.execPath, [driver], { + env: Object.assign({}, process.env, { HOME: tmpHome, USERPROFILE: tmpHome }), + encoding: 'utf-8', + // Bound the subprocess so a regression that hangs the writer + // (or the dispatcher) cannot deadlock CI (PR #3003 CR feedback). + // 30s is generous for what should complete in <1s; if it trips, + // surface that as a clear test failure rather than CI hanging. + timeout: 30_000, + }); + assert.equal(result.signal, null, + `writer subprocess was killed by signal ${result.signal} (likely timeout): ${result.stderr}`); + assert.equal(result.status, 0, `writer subprocess failed: ${result.stderr}`); + const parsed = JSON.parse(result.stdout); + + const expectedPath = path.join(tmpHome, '.claude', 'skills', 'gsd-dev-preferences', 'SKILL.md'); + assert.equal(parsed.command_path, expectedPath, + `writer emitted ${parsed.command_path}; expected skills path ${expectedPath} (#2973)`); + assert.equal(fs.existsSync(expectedPath), true, + `expected SKILL.md at ${expectedPath} after writer ran`); + const legacyPath = path.join(tmpHome, '.claude', 'commands', 'gsd', 'dev-preferences.md'); + assert.equal(fs.existsSync(legacyPath), false, + `writer must not create ${legacyPath} (#2973)`); + } finally { + cleanup(tmpHome); + } + }); +}); + +describe('Bug #2973: profile-user.md confirmation message references the skills path', () => { + test('the Display message points at $HOME/.claude/skills/gsd-dev-preferences/SKILL.md', () => { + const md = fs.readFileSync(WORKFLOW, 'utf-8'); + // Match the structured Display: line; capture the path value. + const m = md.match(/Display:\s*"[^"]*Generated\s*\/gsd-dev-preferences\s*at\s*([^"]+)"/); + assert.notEqual(m, null, 'expected a Display: "Generated /gsd-dev-preferences at " line'); + const referencedPath = m[1].trim(); + assert.equal(referencedPath, '$HOME/.claude/skills/gsd-dev-preferences/SKILL.md', + `workflow references ${referencedPath}; expected skills path (#2973)`); + }); + + test('no occurrence of the legacy commands/gsd/dev-preferences.md path remains in profile-user.md', () => { + const md = fs.readFileSync(WORKFLOW, 'utf-8'); + assert.equal(md.includes('commands/gsd/dev-preferences.md'), false, + 'profile-user.md still references legacy commands/gsd/dev-preferences.md (#2973)'); + }); +}); + +describe('Bug #2973: installer migrates existing legacy dev-preferences.md to skills/gsd-dev-preferences/SKILL.md', () => { + test('migrateLegacyDevPreferencesToSkill is exported and writes to the skills path', () => { + const inst = installEngine; + // Module exports the migration helper for direct testing. + // Note: this is the structural assertion — the helper exists with the + // documented signature. End-to-end install testing is covered by + // tests/install-*.test.cjs which already exercise legacy preservation. + assert.equal(typeof inst.migrateLegacyDevPreferencesToSkill, 'function', + 'expected migrateLegacyDevPreferencesToSkill in install.js exports (#2973)'); + }); + + test('migration writes to skills/gsd-dev-preferences/SKILL.md when no skill exists yet', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-mig-')); + try { + const inst = installEngine; + const saved = new Map([['dev-preferences.md', '# my legacy preferences\n']]); + const migrated = inst.migrateLegacyDevPreferencesToSkill(tmpDir, saved); + assert.equal(migrated, true, 'expected migration to succeed when no SKILL.md exists'); + const skillFile = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md'); + assert.equal(fs.existsSync(skillFile), true, `expected SKILL.md at ${skillFile}`); + assert.equal(fs.readFileSync(skillFile, 'utf-8'), '# my legacy preferences\n'); + } finally { + cleanup(tmpDir); + } + }); + + test('migration is a no-op when a SKILL.md already exists at the new location (do not clobber user-customized skill content)', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-skip-')); + try { + const inst = installEngine; + const skillDir = path.join(tmpDir, 'skills', 'gsd-dev-preferences'); + const skillFile = path.join(skillDir, 'SKILL.md'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(skillFile, '# user-customized skill\n'); + const saved = new Map([['dev-preferences.md', '# legacy content\n']]); + const migrated = inst.migrateLegacyDevPreferencesToSkill(tmpDir, saved); + assert.equal(migrated, false, 'expected migration to skip when SKILL.md exists'); + // Existing content untouched. + assert.equal(fs.readFileSync(skillFile, 'utf-8'), '# user-customized skill\n'); + } finally { + cleanup(tmpDir); + } + }); +}); + +// ─── #3003 CR follow-up: installRuntimeArtifacts preserves user-owned skills ── +// +// Production install() calls installRuntimeArtifacts() without a prior +// uninstallRuntimeArtifacts(). This means _copyStaged overlays new skills +// on top of the existing skills/ directory — it does NOT wipe first. +// As a result, user-owned gsd-dev-preferences/SKILL.md is preserved across +// a plain install because _copyStaged only cpSync's newly staged skill dirs. +// +// NOTE: If callers run uninstallRuntimeArtifacts() before installRuntimeArtifacts() +// (e.g. full reinstall), gsd-dev-preferences IS wiped by uninstall and NOT +// restored by install (#3664 production gap — tracked separately). + +describe('Bug #2973 (#3003 CR): installRuntimeArtifacts preserves user-owned gsd-dev-preferences across install', () => { + test('user-customized skills/gsd-dev-preferences/SKILL.md survives a plain install (no pre-uninstall)', () => { + // Production install() does NOT call uninstallRuntimeArtifacts() first. + // installRuntimeArtifacts → _copyStaged overlays only staged skill dirs; + // gsd-dev-preferences (not in source) is left untouched. + const inst = installEngine; + const { loadSkillsManifest, resolveProfile } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-wipe-')); + try { + const configDir = path.join(tmp, 'config'); + fs.mkdirSync(configDir, { recursive: true }); + + // Set up a minimal source dir (plan-phase only; no dev-preferences). + const srcDir = path.join(tmp, 'src-commands'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'plan-phase.md'), '---\nname: gsd:plan-phase\ndescription: Plan\n---\n\nPlan body.\n'); + fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir + '\n'); + + const skillsDir = path.join(configDir, 'skills'); + const userSkillDir = path.join(skillsDir, 'gsd-dev-preferences'); + fs.mkdirSync(userSkillDir, { recursive: true }); + const userContent = '# my customized dev preferences\n\nstack: rust\n'; + fs.writeFileSync(path.join(userSkillDir, 'SKILL.md'), userContent); + + // Plain install (matching production install() call site). + const manifest = loadSkillsManifest(); + const resolvedProfile = resolveProfile({ modes: [], manifest }); + inst.installRuntimeArtifacts('claude', configDir, 'global', resolvedProfile); + + const skillFile = path.join(userSkillDir, 'SKILL.md'); + assert.equal(fs.existsSync(skillFile), true, + 'gsd-dev-preferences/SKILL.md must survive a plain install (#3003 CR)'); + assert.equal(fs.readFileSync(skillFile, 'utf-8'), userContent, + 'user content must be byte-identical after the install'); + } finally { + cleanup(tmp); + } + }); + + test('non-user-owned gsd-* skills are wiped and recreated via uninstall+install cycle', () => { + // Stale artifacts (e.g. STALE-MARKER.txt left from a previous version) + // are removed when the caller runs uninstallRuntimeArtifacts() before + // installRuntimeArtifacts() — the full uninstall+reinstall cycle. + // uninstallRuntimeArtifacts removes all gsd-* entries; installRuntimeArtifacts + // then writes fresh ones from source. + const inst = installEngine; + const { loadSkillsManifest, resolveProfile } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-wipe-shipped-')); + try { + const configDir = path.join(tmp, 'config'); + fs.mkdirSync(configDir, { recursive: true }); + + const srcDir = path.join(tmp, 'src-commands'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'plan-phase.md'), '---\nname: gsd:plan-phase\ndescription: Plan fresh\n---\n\nFresh body.\n'); + fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir + '\n'); + + const skillsDir = path.join(configDir, 'skills'); + const staleSkillDir = path.join(skillsDir, 'gsd-plan-phase'); + fs.mkdirSync(staleSkillDir, { recursive: true }); + fs.writeFileSync(path.join(staleSkillDir, 'STALE-MARKER.txt'), 'wipe me'); + + const manifest = loadSkillsManifest(); + const resolvedProfile = resolveProfile({ modes: [], manifest }); + + // Full uninstall+install cycle (e.g. --reinstall flow) + inst.uninstallRuntimeArtifacts('claude', configDir, 'global'); + inst.installRuntimeArtifacts('claude', configDir, 'global', resolvedProfile); + + assert.equal(fs.existsSync(path.join(staleSkillDir, 'STALE-MARKER.txt')), false, + 'stale shipped-skill content must be wiped by uninstall (preservation is opt-in by name)'); + assert.equal(fs.existsSync(path.join(staleSkillDir, 'SKILL.md')), true, + 'fresh SKILL.md from source must be installed after wipe'); + } finally { + cleanup(tmp); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-947-hermes-gsd-prefix.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-947-hermes-gsd-prefix (consolidation epic #1969 B5 #1974)", () => { +// allow-test-rule: source-text-is-the-product (see #947) +// Reads installed .md product artefacts from a real install run — +// testing their on-disk layout + frontmatter tests the deployed contract. + +/** + * Regression test: #947 — Hermes skills must install with canonical gsd- prefix. + * + * Prior to this fix, Hermes installed skills at skills/gsd//SKILL.md + * with frontmatter `name: ` (e.g. name: quick), causing invocation as + * /quick instead of /gsd-quick. This file asserts the corrected behaviour: + * - Fresh install → skills/gsd/gsd-/SKILL.md, name: gsd- + * - The skills/gsd/ category bucket and its DESCRIPTION.md are retained + * - Migration: prior bare-stem dirs (skills/gsd//) are removed + * on reinstall; no orphaned bare-stem directories remain. + * + * Runtime: node:test, node:assert/strict. No Jest. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +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 os = require('node:os'); + +const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); +const { parseFrontmatter, cleanup } = require('./helpers.cjs'); +const { + loadSkillsManifest, + resolveProfile, +} = require('../gsd-core/bin/lib/install-profiles.cjs'); + +// --------------------------------------------------------------------------- +// Shared fixture: a minimal commands/gsd/ source with two skills +// --------------------------------------------------------------------------- + +/** + * Write a minimal commands/gsd/ source tree with the given stem names. + * Returns the path to the commands/gsd directory (used as .gsd-source value). + */ +function writeMinimalSourceTree(baseDir, stems) { + const srcDir = path.join(baseDir, 'src', 'commands', 'gsd'); + fs.mkdirSync(srcDir, { recursive: true }); + for (const stem of stems) { + fs.writeFileSync(path.join(srcDir, `${stem}.md`), [ + '---', + `name: gsd:${stem}`, + `description: ${stem} task description`, + 'allowed-tools:', + ' - Read', + ' - Bash', + '---', + '', + `${stem} body`, + ].join('\n')); + } + return srcDir; +} + +const MANIFEST = loadSkillsManifest(); +const RESOLVED_FULL = resolveProfile({ modes: [], manifest: MANIFEST }); + +// --------------------------------------------------------------------------- +// #947 regression: fresh install produces prefixed layout +// --------------------------------------------------------------------------- + +describe('#947 Hermes: fresh install → gsd- prefixed layout', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-947-fresh-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('skill lands at skills/gsd/gsd-/SKILL.md (NOT skills/gsd//SKILL.md)', () => { + const srcDir = writeMinimalSourceTree(tmpDir, ['quick']); + const configDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); + + installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); + + // Correct (post-fix) path: skills/gsd/gsd-quick/SKILL.md + const correctPath = path.join(configDir, 'skills', 'gsd', 'gsd-quick', 'SKILL.md'); + assert.ok(fs.existsSync(correctPath), + 'skills/gsd/gsd-quick/SKILL.md must exist (canonical gsd- prefix)'); + + // Old (bare-stem) path must NOT exist + const bareStemPath = path.join(configDir, 'skills', 'gsd', 'quick', 'SKILL.md'); + assert.ok(!fs.existsSync(bareStemPath), + 'skills/gsd/quick/SKILL.md must NOT exist (bare-stem path is wrong)'); + }); + + test('SKILL.md frontmatter name is gsd- (NOT bare )', () => { + const srcDir = writeMinimalSourceTree(tmpDir, ['plan']); + const configDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); + + installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); + + const skillPath = path.join(configDir, 'skills', 'gsd', 'gsd-plan', 'SKILL.md'); + assert.ok(fs.existsSync(skillPath), 'skills/gsd/gsd-plan/SKILL.md must exist'); + + const content = fs.readFileSync(skillPath, 'utf8'); + const fm = parseFrontmatter(content); + assert.strictEqual(fm.name, 'gsd-plan', + `frontmatter name must be 'gsd-plan', got '${fm.name}'`); + }); + + test('gsd- identifier satisfies Hermes name rule ^[a-z][a-z0-9_-]*$', () => { + const srcDir = writeMinimalSourceTree(tmpDir, ['plan-phase', 'code-review']); + const configDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); + + installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); + + const HERMES_NAME_RE = /^[a-z][a-z0-9_-]*$/; + for (const stem of ['plan-phase', 'code-review']) { + const skillPath = path.join(configDir, 'skills', 'gsd', `gsd-${stem}`, 'SKILL.md'); + assert.ok(fs.existsSync(skillPath), `skills/gsd/gsd-${stem}/SKILL.md must exist`); + const content = fs.readFileSync(skillPath, 'utf8'); + const fm = parseFrontmatter(content); + assert.ok(HERMES_NAME_RE.test(fm.name), + `name '${fm.name}' must satisfy Hermes identifier rule ${HERMES_NAME_RE}`); + assert.strictEqual(fm.name, `gsd-${stem}`, + `name must be 'gsd-${stem}', got '${fm.name}'`); + } + }); + + test('skills/gsd/ category bucket is retained (not flattened to top-level skills/)', () => { + const srcDir = writeMinimalSourceTree(tmpDir, ['quick']); + const configDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); + + installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); + + // Skill must be INSIDE skills/gsd/ — not at skills/gsd-quick/ directly + const categoryBucket = path.join(configDir, 'skills', 'gsd'); + assert.ok(fs.existsSync(categoryBucket), + 'skills/gsd/ category directory must be retained'); + + // Flat (non-categorised) path must NOT exist + const flatPath = path.join(configDir, 'skills', 'gsd-quick'); + assert.ok(!fs.existsSync(flatPath), + 'skills/gsd-quick/ (flat, non-categorised) must NOT exist for Hermes'); + }); + + test('skills/gsd/ category directory exists (bucket retained after install)', () => { + // Note: DESCRIPTION.md is written by writeHermesCategoryDescription which is + // called from the top-level installGsd flow (not inside installRuntimeArtifacts). + // This test confirms the category bucket itself is present post-install. + const srcDir = writeMinimalSourceTree(tmpDir, ['quick']); + const configDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); + + installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); + + const categoryBucket = path.join(configDir, 'skills', 'gsd'); + assert.ok(fs.existsSync(categoryBucket), + 'skills/gsd/ category directory must exist after Hermes install'); + assert.ok(fs.statSync(categoryBucket).isDirectory(), + 'skills/gsd/ must be a directory, not a file'); + }); + + test('multiple skills all get gsd- prefix', () => { + const srcDir = writeMinimalSourceTree(tmpDir, ['quick', 'plan', 'review']); + const configDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); + + installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); + + for (const stem of ['quick', 'plan', 'review']) { + const correctPath = path.join(configDir, 'skills', 'gsd', `gsd-${stem}`, 'SKILL.md'); + assert.ok(fs.existsSync(correctPath), + `skills/gsd/gsd-${stem}/SKILL.md must exist`); + const bareStem = path.join(configDir, 'skills', 'gsd', stem, 'SKILL.md'); + assert.ok(!fs.existsSync(bareStem), + `bare-stem path skills/gsd/${stem}/SKILL.md must NOT exist`); + } + }); +}); + +// --------------------------------------------------------------------------- +// #947 regression: migration from prior bare-stem install +// --------------------------------------------------------------------------- + +describe('#947 Hermes: migration from prior bare-stem install', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-947-migrate-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('bare-stem dirs from prior install are removed on reinstall', () => { + const srcDir = writeMinimalSourceTree(tmpDir, ['quick']); + const configDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); + + // Seed a prior bare-stem install: skills/gsd/quick/SKILL.md + const legacySkillDir = path.join(configDir, 'skills', 'gsd', 'quick'); + fs.mkdirSync(legacySkillDir, { recursive: true }); + fs.writeFileSync(path.join(legacySkillDir, 'SKILL.md'), [ + '---', + 'name: quick', + 'description: Quick task (legacy bare-stem)', + '---', + '', + 'Legacy body.', + ].join('\n')); + + installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); + + // Bare-stem dir must be gone (migrated) + assert.ok(!fs.existsSync(legacySkillDir), + 'skills/gsd/quick/ (bare-stem legacy dir) must be removed on reinstall'); + + // Prefixed dir must exist + const newPath = path.join(configDir, 'skills', 'gsd', 'gsd-quick', 'SKILL.md'); + assert.ok(fs.existsSync(newPath), + 'skills/gsd/gsd-quick/SKILL.md must exist after migration'); + }); + + test('reinstall over bare-stem install leaves NO orphaned bare-stem dirs', () => { + const srcDir = writeMinimalSourceTree(tmpDir, ['quick', 'plan']); + const configDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); + + // Seed two bare-stem dirs + for (const stem of ['quick', 'plan']) { + const dir = path.join(configDir, 'skills', 'gsd', stem); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${stem}\ndescription: ${stem}\n---\n`); + } + + installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); + + const gsdCategoryDir = path.join(configDir, 'skills', 'gsd'); + const entries = fs.readdirSync(gsdCategoryDir, { withFileTypes: true }); + + // Check NO bare-stem dirs remain + for (const entry of entries) { + if (!entry.isDirectory()) continue; + // Bare-stem dirs: name does NOT start with 'gsd-' and is not a known exception + // (DESCRIPTION.md is a file so it won't appear in isDirectory check) + assert.ok( + entry.name.startsWith('gsd-'), + `All dirs under skills/gsd/ must start with 'gsd-'. Found bare-stem: '${entry.name}'`, + ); + } + }); + + test('pre-#2841 flat skills/gsd-/ dirs are still removed (existing migration path)', () => { + const srcDir = writeMinimalSourceTree(tmpDir, ['quick']); + const configDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); + + // Seed a pre-#2841 flat skill dir: skills/gsd-quick/SKILL.md + const flatSkillDir = path.join(configDir, 'skills', 'gsd-quick'); + fs.mkdirSync(flatSkillDir, { recursive: true }); + fs.writeFileSync(path.join(flatSkillDir, 'SKILL.md'), '---\nname: gsd-quick\n---\nOld flat.'); + + installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); + + // The pre-#2841 flat dir must still be cleaned up + assert.ok(!fs.existsSync(flatSkillDir), + 'Pre-#2841 flat skills/gsd-quick/ dir must be removed (existing migration)'); + + // The correct post-fix dir must exist + assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd', 'gsd-quick', 'SKILL.md')), + 'skills/gsd/gsd-quick/SKILL.md must exist after install'); + }); +}); + +// --------------------------------------------------------------------------- +// #947 adversarial-review: bare-stem cleanup derived from installed set +// (not readGsdCommandNames) — covers skills missing from the commands dir +// --------------------------------------------------------------------------- + +describe('#947 Hermes: adversarial-review bare-stem cleanup (installed-set derivation)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-947-adv-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('bare skills/gsd/dev-preferences/ is removed when gsd-dev-preferences/ is installed this run', () => { + // Seed a source tree that includes a 'dev-preferences' skill (e.g. the user's + // commands/gsd/dev-preferences.md, or any skill whose stem is NOT normally in + // the shipped readGsdCommandNames() set). The old cleanup (readGsdCommandNames- + // based) would MISS this bare dir because readGsdCommandNames() reads GSD's + // shipped source, not the user's actual install state. + const srcDir = writeMinimalSourceTree(tmpDir, ['quick', 'dev-preferences']); + const configDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); + + // Seed the legacy bare-stem dir: skills/gsd/dev-preferences/ (pre-#947 install) + const bareLegacyDir = path.join(configDir, 'skills', 'gsd', 'dev-preferences'); + fs.mkdirSync(bareLegacyDir, { recursive: true }); + fs.writeFileSync(path.join(bareLegacyDir, 'SKILL.md'), [ + '---', + 'name: dev-preferences', + 'description: My dev preferences (legacy bare-stem)', + '---', + '', + 'Legacy body.', + ].join('\n')); + + installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); + + // gsd-dev-preferences/ must be installed (new prefixed form) + const newPath = path.join(configDir, 'skills', 'gsd', 'gsd-dev-preferences', 'SKILL.md'); + assert.ok(fs.existsSync(newPath), + 'skills/gsd/gsd-dev-preferences/SKILL.md must exist after install'); + + // Bare-stem dir must be gone — even though 'dev-preferences' is NOT in the + // shipped readGsdCommandNames() set (it was user-sourced). The fix derives + // the removal set from gsd-/ dirs installed this run. + assert.ok(!fs.existsSync(bareLegacyDir), + 'skills/gsd/dev-preferences/ (bare-stem) must be removed when gsd-dev-preferences/ was installed'); + }); + + test('user-owned bare dir with no gsd- counterpart is preserved (no over-deletion)', () => { + // A user has a dir 'skills/gsd/my-custom-workflow/' that is NOT a GSD shipped + // skill — GSD never installs 'gsd-my-custom-workflow/'. This dir must survive. + const srcDir = writeMinimalSourceTree(tmpDir, ['quick']); + const configDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); + + // Seed user-owned bare dir: no corresponding gsd-my-custom-workflow/ will be installed + const userOwnedDir = path.join(configDir, 'skills', 'gsd', 'my-custom-workflow'); + fs.mkdirSync(userOwnedDir, { recursive: true }); + fs.writeFileSync(path.join(userOwnedDir, 'SKILL.md'), [ + '---', + 'name: my-custom-workflow', + 'description: My personal workflow', + '---', + '', + 'Custom body.', + ].join('\n')); + + installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); + + // User-owned dir must survive — no gsd-my-custom-workflow/ was installed, + // so the removal rule (only remove / when gsd-/ exists) protects it. + assert.ok(fs.existsSync(userOwnedDir), + 'User-owned skills/gsd/my-custom-workflow/ must be preserved (no gsd-my-custom-workflow/ installed)'); + assert.ok(fs.existsSync(path.join(userOwnedDir, 'SKILL.md')), + 'User-owned SKILL.md inside the dir must be preserved'); + }); +}); + +// --------------------------------------------------------------------------- +// #947 regression: manifest/listing prefix +// --------------------------------------------------------------------------- + +describe('#947 Hermes: manifest and skill-listing use gsd- prefix', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-947-manifest-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-manifest.json skill entries use skills/gsd/gsd-/ paths', () => { + const srcDir = writeMinimalSourceTree(tmpDir, ['quick']); + const configDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir); + + installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_FULL); + + // The manifest file lives at gsd-core/gsd-manifest.json inside configDir + const manifestPath = path.join(configDir, 'gsd-core', 'gsd-manifest.json'); + if (!fs.existsSync(manifestPath)) return; // manifest optional in test mode + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const keys = Object.keys(manifest.files || {}); + // Any key for the quick skill must use gsd-quick not bare quick + const bareKey = keys.find(k => k.includes('skills/gsd/quick/')); + assert.ok(!bareKey, + `manifest must not contain bare-stem key 'skills/gsd/quick/', found: ${bareKey}`); + const prefixedKey = keys.find(k => k.includes('skills/gsd/gsd-quick/')); + assert.ok(prefixedKey, + 'manifest must contain prefixed key containing skills/gsd/gsd-quick/'); + }); +}); + +// --------------------------------------------------------------------------- +// #947 regression: non-Hermes runtimes unaffected +// --------------------------------------------------------------------------- + +describe('#947 Non-Hermes runtimes: unaffected by this change', () => { + // Spot-check claude (global/flat) and cline (global/nested) to confirm + // they are not disturbed by the Hermes prefix fix. + + test('claude global install still produces flat skills/gsd-/ layout', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-947-claude-')); + try { + installRuntimeArtifacts('claude', tmpDir, 'global', RESOLVED_FULL); + const skillsDir = path.join(tmpDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ must exist for claude global'); + const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + const gsdEntries = entries.filter(e => e.isDirectory() && e.name.startsWith('gsd-')); + assert.ok(gsdEntries.length >= 10, + `claude must still emit >= 10 gsd-* skill dirs, got ${gsdEntries.length}`); + // No skills/gsd/ category bucket (that is Hermes-specific) + assert.ok(!fs.existsSync(path.join(skillsDir, 'gsd')), + 'claude must NOT have a skills/gsd/ category bucket (that is Hermes-only)'); + } finally { + cleanup(tmpDir); + } + }); + + test('cline global install still produces skills/ with gsd- prefix nested layout', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-947-cline-')); + try { + installRuntimeArtifacts('cline', tmpDir, 'global', RESOLVED_FULL); + const skillsDir = path.join(tmpDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ must exist for cline global'); + const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + const routerDirs = entries.filter(e => e.isDirectory() && e.name.startsWith('gsd-ns-')); + assert.ok(routerDirs.length > 0, + 'cline must still emit gsd-ns-* router dirs with gsd- prefix'); + } finally { + cleanup(tmpDir); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-790-augment-commands.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-790-augment-commands (consolidation epic #1969 B5 #1974)", () => { +'use strict'; +/** + * Regression guard — enh(#790): Augment commands/ emitted alongside skills/. + * + * Verifies that a global Augment install writes: + * - commands/gsd-.md (slash command definitions) + * - skills/gsd-/SKILL.md (existing skill definitions) + * + * mcpServers in settings.json is explicitly excluded: gsd ships no MCP server + * and registering third-party servers is out of scope for the installer. + * + * Ref: https://docs.augmentcode.com/cli/reference — ~/.augment/commands/.md + */ + +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 { createTempDir, cleanup } = require('./helpers.cjs'); + +const { installRuntimeArtifacts, uninstallRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); +const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); +const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); +const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); +const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); + +// ─── Layout contract ───────────────────────────────────────────────────────── + +describe('enh-790 — augment layout has commands + skills + agents kinds', () => { + test('resolveRuntimeArtifactLayout augment returns 3 kinds', () => { + const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir'); + assert.strictEqual(layout.kinds.length, 3, 'augment must have exactly 3 artifact kinds'); + const kindNames = layout.kinds.map(k => k.kind).sort(); + assert.deepStrictEqual(kindNames, ['agents', 'commands', 'skills']); + }); + + test('augment commands kind targets commands/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir'); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'must have commands kind'); + assert.strictEqual(commandsKind.destSubpath, 'commands'); + assert.strictEqual(commandsKind.prefix, 'gsd-'); + }); + + test('augment skills kind targets skills/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'must have skills kind'); + assert.strictEqual(skillsKind.destSubpath, 'skills'); + assert.strictEqual(skillsKind.prefix, 'gsd-'); + }); +}); + +// ─── Install contract ──────────────────────────────────────────────────────── + +describe('enh-790 — installRuntimeArtifacts augment emits both commands and skills', () => { + test('global augment install: commands/gsd-help.md and skills/gsd-help/SKILL.md exist', (t) => { + const configDir = createTempDir('gsd-enh790-augment-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); + + // Commands dir + const commandsDir = path.join(configDir, 'commands'); + assert.ok(fs.existsSync(commandsDir), 'commands/ dir must exist'); + const cmdFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok(cmdFiles.length > 0, 'at least one gsd-*.md command file must be installed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'commands/gsd-help.md must exist'); + + // Skills dir (pre-existing behavior preserved) + const skillsDir = path.join(configDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist'); + assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist'); + }); + + test('commands/gsd-help.md has Augment-compatible content (no raw ~/.claude/ refs)', (t) => { + const configDir = createTempDir('gsd-enh790-content-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); + + const helpCmd = path.join(configDir, 'commands', 'gsd-help.md'); + assert.ok(fs.existsSync(helpCmd), 'gsd-help.md must exist'); + const content = fs.readFileSync(helpCmd, 'utf8'); + // Should not have raw ~/.claude/ references after path rewrite + assert.ok(!content.includes('~/.claude/'), 'commands must not contain raw ~/.claude/ refs'); + }); + + test('command count matches skill count (profile parity)', (t) => { + const configDir = createTempDir('gsd-enh790-parity-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); + + const commandsDir = path.join(configDir, 'commands'); + const skillsDir = path.join(configDir, 'skills'); + const cmdCount = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; + const skillCount = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; + assert.strictEqual(cmdCount, skillCount, 'command count must equal skill count for same profile'); + }); + + test('full profile install does NOT mutate source commands/gsd/ files', (t) => { + // Regression guard: stageSkillsForProfile returns the real source dir on full profile + // (skills === '*'). applyRuntimeContentRewritesForCommandsInPlace must copy to temp + // before rewriting — it must NEVER write back to the source tree. + const { resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); + const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + assert.strictEqual(RESOLVED_FULL.skills, '*', 'full profile must have skills === "*"'); + + const configDir = createTempDir('gsd-enh790-full-'); + t.after(() => cleanup(configDir)); + + // Record source file content before install + const srcHelpPath = path.join(__dirname, '..', 'commands', 'gsd', 'help.md'); + const srcContentBefore = fs.readFileSync(srcHelpPath, 'utf8'); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL); + + // Source file must be identical after install + const srcContentAfter = fs.readFileSync(srcHelpPath, 'utf8'); + assert.strictEqual(srcContentBefore, srcContentAfter, + 'source commands/gsd/help.md must not be mutated by the install'); + + // Installed command file must have rewrites applied (Augment path substitution) + const installedHelp = path.join(configDir, 'commands', 'gsd-help.md'); + assert.ok(fs.existsSync(installedHelp), 'installed gsd-help.md must exist'); + const installedContent = fs.readFileSync(installedHelp, 'utf8'); + assert.ok(!installedContent.includes('~/.claude/'), 'installed command must not have raw ~/.claude/ refs'); + }); +}); + +describe('enh-790 — installRuntimeArtifacts does not leak temp dirs', () => { + test('install cleans up gsd-cmd-rewrites-* temp dirs (no leak) — #856', (t) => { + const { resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); + const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + + // Isolate os.tmpdir() to a private root so parallel test processes can't race + // on the shared system temp dir. os.tmpdir() resolves $TMPDIR/$TEMP/$TMP per call. + const isolatedTmp = createTempDir('gsd-enh790-tmproot-'); + const prev = { TMPDIR: process.env.TMPDIR, TEMP: process.env.TEMP, TMP: process.env.TMP }; + process.env.TMPDIR = isolatedTmp; + process.env.TEMP = isolatedTmp; + process.env.TMP = isolatedTmp; + + const configDir = createTempDir('gsd-enh790-leak-'); + t.after(() => { + for (const k of ['TMPDIR', 'TEMP', 'TMP']) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; + } + cleanup(configDir); + cleanup(isolatedTmp); + }); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL); + + // The install creates its gsd-cmd-rewrites-* temp dirs under the isolated root; + // after the fix none must remain. + const leaked = fs.readdirSync(isolatedTmp).filter(n => n.startsWith('gsd-cmd-rewrites-')); + assert.ok( + leaked.length === 0, + `installer must not leak gsd-cmd-rewrites-* temp dirs; leaked: ${leaked.join(', ')}` + ); + }); +}); + +// ─── Uninstall contract ────────────────────────────────────────────────────── + +describe('enh-790 — uninstallRuntimeArtifacts removes augment commands', () => { + test('uninstall removes gsd-* commands but preserves user commands', (t) => { + const configDir = createTempDir('gsd-enh790-uninstall-'); + t.after(() => cleanup(configDir)); + + // uninstallRuntimeArtifacts is imported from install-engine.cjs at the top of this file + + // Pre-create: a GSD command + a user-owned command + const commandsDir = path.join(configDir, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), '# help\n'); + fs.writeFileSync(path.join(commandsDir, 'user-custom.md'), '# user\n'); + + uninstallRuntimeArtifacts('augment', configDir, 'global'); + + assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'gsd-help.md must be removed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'user-custom.md')), 'user-custom.md must be preserved'); + }); +}); + +// ─── mcpServers exclusion ──────────────────────────────────────────────────── + +describe('enh-790 — mcpServers excluded (gsd ships no MCP server)', () => { + test('augment install does not write settings.json mcpServers', (t) => { + const configDir = createTempDir('gsd-enh790-mcp-excluded-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); + + // No settings.json with mcpServers should be written by the layout + const settingsPath = path.join(configDir, 'settings.json'); + if (fs.existsSync(settingsPath)) { + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.ok(!settings.mcpServers, 'settings.json must not contain mcpServers (gsd ships no MCP server)'); + } + // If no settings.json at all, that is also correct + }); +}); + }); +} diff --git a/tests/install-write-confinement.test.cjs b/tests/install-write-confinement.test.cjs index 2524d2b70..546cbb605 100644 --- a/tests/install-write-confinement.test.cjs +++ b/tests/install-write-confinement.test.cjs @@ -951,3 +951,802 @@ describe('Bug #3407: saveLocalPatches preserves old-release pristine across upgr // per /test-rigor skill. Behavioral tests for populatePristineDir are covered above. }); } + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/fix-1679-destsubpath-confinement.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:fix-1679-destsubpath-confinement (consolidation epic #1969 B5 #1974)", () => { +'use strict'; + +/** + * Tests for ADR-1239 Phase B: destSubpath write-confinement security gate. + * + * Verifies that assertDestWithinConfigHome rejects escaping destSubpath values + * and that createRuntimeArtifactInstallPlan and createRuntimeArtifactUninstallPlan + * both reject them at plan-build time. + * + * Also covers: + * F3 - assertDestWithinConfigHome rejects destSubpath === configHome itself + * F4 - migrateLegacyDevPreferencesToSkill routes through the confinement gate + * F2 - write sites (installOpencodeFamilySkills) reject symlink-escaping destDir + */ + +const { test, describe, beforeEach, afterEach } = 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 { + assertDestWithinConfigHome, + createRuntimeArtifactInstallPlan, + createRuntimeArtifactUninstallPlan, +} = require('../gsd-core/bin/lib/runtime-artifact-install-plan.cjs'); + +const { + migrateLegacyDevPreferencesToSkill, + installOpencodeFamilySkills, + installRuntimeArtifacts, + _copyStaged, +} = require('../gsd-core/bin/lib/install-engine.cjs'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// --------------------------------------------------------------------------- +// Unit tests for assertDestWithinConfigHome +// --------------------------------------------------------------------------- + +describe('assertDestWithinConfigHome', () => { + let configDir; + + beforeEach(() => { + configDir = createTempDir('gsd-confine-test-'); + }); + + afterEach(() => { + cleanup(configDir); + }); + + // --- Rejection cases --- + + test('rejects destSubpath "../../etc" that escapes configDir', () => { + assert.throws( + () => assertDestWithinConfigHome(configDir, '../../etc'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('escapes configHome'), + `expected "escapes configHome" in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('rejects destSubpath "../foo" that escapes configDir', () => { + assert.throws( + () => assertDestWithinConfigHome(configDir, '../foo'), + /escapes configHome/, + ); + }); + + test('rejects destSubpath "a/../../b" that escapes configDir', () => { + assert.throws( + () => assertDestWithinConfigHome(configDir, 'a/../../b'), + /escapes configHome/, + ); + }); + + test('rejects destSubpath containing a NUL byte', () => { + assert.throws( + () => assertDestWithinConfigHome(configDir, 'skills\0evil'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('NUL'), + `expected "NUL" in: ${err.message}`, + ); + return true; + }, + ); + }); + + // --- F3: reject destSubpath that resolves to configHome itself --- + + test('F3: rejects destSubpath "." that resolves to configHome itself', () => { + assert.throws( + () => assertDestWithinConfigHome(configDir, '.'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('not configHome itself') || err.message.includes('escapes configHome'), + `expected confinement error in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('F3: rejects destSubpath "a/.." that resolves to configHome itself', () => { + assert.throws( + () => assertDestWithinConfigHome(configDir, 'a/..'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('not configHome itself') || err.message.includes('escapes configHome'), + `expected confinement error in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('F3: rejects destSubpath "skills/../.." that resolves to configHome parent', () => { + assert.throws( + () => assertDestWithinConfigHome(configDir, 'skills/../..'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('not configHome itself') || err.message.includes('escapes configHome'), + `expected confinement error in: ${err.message}`, + ); + return true; + }, + ); + }); + + // --- Accepted cases --- + + test('accepts "skills" and returns path under configDir', () => { + const result = assertDestWithinConfigHome(configDir, 'skills'); + assert.ok( + result.startsWith(path.resolve(configDir)), + `expected result to start with configDir (${path.resolve(configDir)}), got: ${result}`, + ); + assert.strictEqual(result, path.join(path.resolve(configDir), 'skills')); + }); + + test('accepts "commands/gsd" and returns path under configDir', () => { + const result = assertDestWithinConfigHome(configDir, 'commands/gsd'); + assert.ok(result.startsWith(path.resolve(configDir))); + assert.strictEqual(result, path.join(path.resolve(configDir), 'commands', 'gsd')); + }); + + test('accepts "./skills" and returns resolved path under configDir', () => { + const result = assertDestWithinConfigHome(configDir, './skills'); + assert.ok(result.startsWith(path.resolve(configDir))); + assert.strictEqual(result, path.join(path.resolve(configDir), 'skills')); + }); + + test('does not match a sibling directory with a shared prefix', () => { + // configDir = /tmp/gsd-foo; a sibling like /tmp/gsd-foobar must NOT be accepted. + // The path.sep guard in the implementation prevents a startsWith match + // from crossing directory boundaries. We verify the happy-path: a valid + // nested subpath resolves to a path strictly under configDir (includes sep). + const result = assertDestWithinConfigHome(configDir, 'subdir/nested'); + assert.ok(result.startsWith(path.resolve(configDir) + path.sep)); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests for createRuntimeArtifactInstallPlan +// --------------------------------------------------------------------------- + +describe('createRuntimeArtifactInstallPlan destSubpath confinement', () => { + let configDir; + + beforeEach(() => { + configDir = createTempDir('gsd-plan-confine-'); + }); + + afterEach(() => { + cleanup(configDir); + }); + + function noopStage() { + return '/tmp/staged-noop'; + } + + function makeLayout(destSubpath) { + return { + runtime: 'claude', + configDir, + scope: 'global', + kinds: [ + { + kind: 'skills', + destSubpath, + prefix: 'gsd-', + stage: noopStage, + }, + ], + }; + } + + test('rejects an escaping destSubpath ("../../escape") at plan-build time', () => { + const layout = makeLayout('../../escape'); + assert.throws( + () => createRuntimeArtifactInstallPlan({ + layout, + resolvedProfile: { name: 'core' }, + deps: { + rewriteStagedSkillBodies: () => undefined, + rewriteStagedCommandBodies: () => undefined, + }, + }), + (err) => { + assert.ok(err instanceof Error); + assert.ok( + err.message.includes('escapes'), + `expected "escapes" in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('normal destSubpath produces plan with destDir under configDir', () => { + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-staged-')); + try { + const layout = { + runtime: 'claude', + configDir, + scope: 'global', + kinds: [ + { + kind: 'skills', + destSubpath: 'skills', + prefix: 'gsd-', + stage: () => stagedDir, + }, + ], + }; + + const result = createRuntimeArtifactInstallPlan({ + layout, + resolvedProfile: { name: 'core' }, + deps: { + rewriteStagedSkillBodies: () => undefined, + rewriteStagedCommandBodies: () => undefined, + }, + }); + + assert.strictEqual(result.ok, true, 'plan must succeed for normal destSubpath'); + assert.strictEqual(result.plan.items.length, 1); + const destDir = result.plan.items[0].destDir; + assert.ok( + destDir.startsWith(path.resolve(configDir)), + `destDir (${destDir}) must be under configDir (${configDir})`, + ); + } finally { + cleanup(stagedDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests for createRuntimeArtifactUninstallPlan +// --------------------------------------------------------------------------- + +describe('createRuntimeArtifactUninstallPlan destSubpath confinement', () => { + let configDir; + + beforeEach(() => { + configDir = createTempDir('gsd-uninstall-confine-'); + }); + + afterEach(() => { + cleanup(configDir); + }); + + function makeUninstallLayout(destSubpath) { + return { + runtime: 'claude', + configDir, + kinds: [ + { + kind: 'skills', + destSubpath, + prefix: 'gsd-', + stage: () => '/tmp/staged-noop', + }, + ], + }; + } + + test('rejects an escaping destSubpath ("../../escape") at uninstall-plan-build time', () => { + const layout = makeUninstallLayout('../../escape'); + assert.throws( + () => createRuntimeArtifactUninstallPlan(layout), + (err) => { + assert.ok(err instanceof Error); + assert.ok( + err.message.includes('escapes'), + `expected "escapes" in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('rejects destSubpath "../outside" at uninstall-plan-build time', () => { + const layout = makeUninstallLayout('../outside'); + assert.throws( + () => createRuntimeArtifactUninstallPlan(layout), + /escapes/, + ); + }); + + test('normal destSubpath produces uninstall plan with destDir under configDir', () => { + const layout = makeUninstallLayout('skills'); + const plan = createRuntimeArtifactUninstallPlan(layout); + assert.strictEqual(plan.items.length, 1); + const destDir = plan.items[0].destDir; + assert.ok( + destDir.startsWith(path.resolve(configDir)), + `destDir (${destDir}) must be under configDir (${configDir})`, + ); + assert.strictEqual(destDir, path.join(path.resolve(configDir), 'skills')); + }); + + test('normal nested destSubpath ("commands/gsd") produces uninstall plan with destDir under configDir', () => { + const layout = makeUninstallLayout('commands/gsd'); + const plan = createRuntimeArtifactUninstallPlan(layout); + assert.strictEqual(plan.items.length, 1); + const destDir = plan.items[0].destDir; + assert.ok( + destDir.startsWith(path.resolve(configDir)), + `destDir (${destDir}) must be under configDir (${configDir})`, + ); + assert.strictEqual(destDir, path.join(path.resolve(configDir), 'commands', 'gsd')); + }); +}); + +// --------------------------------------------------------------------------- +// F4: migrateLegacyDevPreferencesToSkill must route through the confinement gate +// --------------------------------------------------------------------------- + +describe('F4: migrateLegacyDevPreferencesToSkill confinement', () => { + let configDir; + let outsideDir; + + beforeEach(() => { + configDir = createTempDir('gsd-f4-confine-'); + outsideDir = createTempDir('gsd-f4-outside-'); + }); + + afterEach(() => { + cleanup(configDir); + cleanup(outsideDir); + }); + + test('F4: migrateLegacyDevPreferencesToSkill throws when destSubpath resolves to configHome itself (via mocked layout with "." destSubpath)', () => { + // We cannot easily inject a bad destSubpath through the real layout resolver + // (it resolves to a real valid path). Instead we validate that the function + // uses assertDestWithinConfigHome by passing a runtime whose layout's + // skillsKindEntry.destSubpath, when joined with configDir, would escape — but + // since real layouts are always safe, we test the guard on a deliberately + // crafted saved map calling the real function and observing the path written + // is always within configDir for a real runtime. + // + // Real-layout sanity: verify 'opencode' produces a write inside configDir. + const savedLegacy = new Map([['dev-preferences.md', '# dev prefs\n']]); + // Real opencode layout — should succeed without throwing + assert.doesNotThrow(() => { + migrateLegacyDevPreferencesToSkill(configDir, savedLegacy, 'opencode', 'global'); + }, 'migrateLegacyDevPreferencesToSkill with real opencode layout must not throw'); + + // Verify the written file is inside configDir + const written = []; + function findMd(dir) { + if (!fs.existsSync(dir)) return; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.isDirectory()) findMd(path.join(dir, e.name)); + else if (e.name.endsWith('.md')) written.push(path.join(dir, e.name)); + } + } + findMd(configDir); + assert.ok(written.length > 0, 'at least one .md must have been written'); + for (const f of written) { + assert.ok( + f.startsWith(path.resolve(configDir) + path.sep), + `written file ${f} must be inside configDir ${configDir}`, + ); + } + }); + + test('F4: migrateLegacyDevPreferencesToSkill uses assertDestWithinConfigHome — path.join on configDir+destSubpath cannot escape via symlink in destSubpath string', () => { + // Validate that the guard (assertDestWithinConfigHome) would have caught a + // manipulated destSubpath value. We simulate by calling assertDestWithinConfigHome + // directly with a "."-equivalent subpath (F3 guard) to prove F4 now relies on it. + assert.throws( + () => assertDestWithinConfigHome(configDir, '.'), + (err) => { + assert.ok(err instanceof Error); + return true; + }, + 'assertDestWithinConfigHome must reject "." (used by F4 guard)', + ); + }); +}); + +// --------------------------------------------------------------------------- +// F2: write sites reject a symlink-escaping destDir +// --------------------------------------------------------------------------- + +describe('F2: installOpencodeFamilySkills rejects symlink-escaping destDir', () => { + let configDir; + let outsideDir; + let symlinkTarget; + + beforeEach(() => { + configDir = createTempDir('gsd-f2-config-'); + outsideDir = createTempDir('gsd-f2-outside-'); + // Create a symlink inside configDir pointing outside + symlinkTarget = path.join(configDir, 'skills'); + fs.symlinkSync(outsideDir, symlinkTarget); + }); + + afterEach(() => { + // Remove symlink before cleanup to avoid errors + try { fs.unlinkSync(symlinkTarget); } catch { /* already gone */ } + cleanup(configDir); + cleanup(outsideDir); + }); + + test('F2: installOpencodeFamilySkills throws when skills/ is a symlink pointing outside configDir', () => { + // Create a minimal rawCommandsDir with one .md file + const rawDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-f2-raw-')); + try { + fs.writeFileSync(path.join(rawDir, 'help.md'), '# help\n', 'utf8'); + + assert.throws( + () => installOpencodeFamilySkills('opencode', configDir, rawDir, '~/.opencode/'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.toLowerCase().includes('symlink') || + err.message.toLowerCase().includes('escap') || + err.message.toLowerCase().includes('outside') || + err.message.toLowerCase().includes('confinement'), + `expected symlink/escape error in: ${err.message}`, + ); + return true; + }, + ); + + // Verify nothing was written to outsideDir + const outsideFiles = fs.readdirSync(outsideDir); + assert.strictEqual(outsideFiles.length, 0, 'must not have written anything outside configDir'); + } finally { + cleanup(rawDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// M1: _copyStaged defense-in-depth must also reject dest === configRoot +// --------------------------------------------------------------------------- + +describe('M1: _copyStaged rejects dest equal to configRoot', () => { + let configDir; + let stagedDir; + + beforeEach(() => { + configDir = createTempDir('gsd-m1-config-'); + stagedDir = createTempDir('gsd-m1-staged-'); + // Write a dummy file into stagedDir so _copyStaged has something to copy + fs.writeFileSync(path.join(stagedDir, 'help.md'), '# help\n', 'utf8'); + }); + + afterEach(() => { + cleanup(configDir); + cleanup(stagedDir); + }); + + test('M1: _copyStaged throws when destDir equals configRoot (was silently accepted before fix)', () => { + // dest === configRoot: the canonical gate (assertDestWithinConfigHome) rejects + // resolved === root with "escapes configHome" / "not configHome itself". + assert.throws( + () => _copyStaged(stagedDir, configDir, { kind: 'commands', destSubpath: '.', prefix: 'gsd-' }, configDir), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('escapes configHome') || + err.message.includes('not configHome itself') || + err.message.includes('outside') || + err.message.includes('inside'), + `expected confinement error in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('M1: _copyStaged throws when destDir is outside configRoot', () => { + const outsideDir = createTempDir('gsd-m1-outside-'); + try { + assert.throws( + () => _copyStaged(stagedDir, outsideDir, { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, configDir), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + // After EDIT 1, _copyStaged delegates to assertDestWithinConfigHome which + // emits "escapes configHome"; the old "_copyStaged" prefix is no longer present. + err.message.includes('escapes configHome') || + err.message.includes('strict subpath') || + err.message.includes('refusing'), + `expected confinement error in: ${err.message}`, + ); + return true; + }, + ); + } finally { + cleanup(outsideDir); + } + }); + + test('M1: _copyStaged accepts destDir strictly under configRoot', () => { + const destDir = path.join(configDir, 'commands', 'gsd'); + fs.mkdirSync(destDir, { recursive: true }); + // Should not throw — just copies (stagedDir has help.md, kind=commands) + assert.doesNotThrow( + () => _copyStaged(stagedDir, destDir, { kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' }, configDir), + ); + }); +}); + +// --------------------------------------------------------------------------- +// L2: symlink guard BEFORE mkdirSync in installRuntimeArtifacts +// --------------------------------------------------------------------------- + +describe('L2: installRuntimeArtifacts rejects symlink-escaping dest before mkdirSync', () => { + let configDir; + let outsideDir; + + beforeEach(() => { + configDir = createTempDir('gsd-l2-config-'); + outsideDir = createTempDir('gsd-l2-outside-'); + // Create configDir/skills as a symlink pointing outside + fs.symlinkSync(outsideDir, path.join(configDir, 'skills')); + }); + + afterEach(() => { + // Remove symlink before cleanup to avoid crossing dir boundaries + try { fs.unlinkSync(path.join(configDir, 'skills')); } catch { /* already gone */ } + cleanup(configDir); + cleanup(outsideDir); + }); + + test('L2: installRuntimeArtifacts throws before creating dirs when skills/ is a symlink pointing outside', () => { + // Use the full profile shape (skills: '*') so staging short-circuits early + // and the symlink guard is the first thing that fires. + assert.throws( + () => installRuntimeArtifacts('opencode', configDir, 'global', { name: 'full', skills: '*', agents: new Set() }), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.toLowerCase().includes('symlink') || + err.message.toLowerCase().includes('escap') || + err.message.toLowerCase().includes('outside') || + err.message.toLowerCase().includes('confinement') || + err.message.toLowerCase().includes('install root'), + `expected symlink/escape error in: ${err.message}`, + ); + return true; + }, + ); + + // The symlink itself still exists but no new entries were created in outsideDir + const outsideEntries = fs.readdirSync(outsideDir); + assert.strictEqual(outsideEntries.length, 0, 'must not have created any dirs/files outside configDir'); + }); +}); + +// --------------------------------------------------------------------------- +// L1: symlink guard in migrateLegacyDevPreferencesToSkill +// --------------------------------------------------------------------------- + +describe('L1: migrateLegacyDevPreferencesToSkill rejects symlink-escaping skillDir', () => { + let configDir; + let outsideDir; + + beforeEach(() => { + configDir = createTempDir('gsd-l1-config-'); + outsideDir = createTempDir('gsd-l1-outside-'); + // Create configDir/skills as a symlink pointing outside + fs.symlinkSync(outsideDir, path.join(configDir, 'skills')); + }); + + afterEach(() => { + try { fs.unlinkSync(path.join(configDir, 'skills')); } catch { /* already gone */ } + cleanup(configDir); + cleanup(outsideDir); + }); + + test('L1: migrateLegacyDevPreferencesToSkill throws when skills/ is a symlink pointing outside', () => { + const saved = new Map([['dev-preferences.md', '# dev prefs\n']]); + assert.throws( + () => migrateLegacyDevPreferencesToSkill(configDir, saved, 'opencode', 'global'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.toLowerCase().includes('symlink') || + err.message.toLowerCase().includes('escap') || + err.message.toLowerCase().includes('outside') || + err.message.toLowerCase().includes('install root'), + `expected symlink/escape error in: ${err.message}`, + ); + return true; + }, + ); + + // Nothing must have been written outside + const outsideFiles = fs.readdirSync(outsideDir); + assert.strictEqual(outsideFiles.length, 0, 'must not have written anything outside configDir'); + }); +}); + +// --------------------------------------------------------------------------- +// L3: relative configDir support +// --------------------------------------------------------------------------- + +describe('L3: assertDestWithinConfigHome handles relative configDir', () => { + test('L3: throws when relative configDir + escaping destSubpath resolves outside', () => { + // path.resolve handles relative roots; '../../etc' from '.' would escape + assert.throws( + () => assertDestWithinConfigHome('.', '../../etc'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('escapes configHome') || err.message.includes('outside'), + `expected escape error in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('L3: throws when "." destSubpath resolves to the relative configDir itself', () => { + // '.' resolves to the same directory as the configDir — must be rejected (F3) + assert.throws( + () => assertDestWithinConfigHome('.', '.'), + /escapes configHome|not configHome itself/, + ); + }); + + test('L3: accepts "skills" under relative "./somedir" and returns absolute path', () => { + const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-l3-')); + const relDir = path.relative(process.cwd(), tmpBase); + try { + const result = assertDestWithinConfigHome(relDir, 'skills'); + const expectedBase = path.resolve(relDir); + assert.ok( + result.startsWith(expectedBase + path.sep), + `result (${result}) must be under resolved relDir (${expectedBase})`, + ); + assert.strictEqual(result, path.join(expectedBase, 'skills')); + } finally { + fs.rmdirSync(tmpBase); + } + }); +}); + +// --------------------------------------------------------------------------- +// N1: sibling-prefix NEGATIVE assertion +// --------------------------------------------------------------------------- + +describe('N1: sibling directory with shared prefix is rejected', () => { + test('N1: rejects sibling path sharing a prefix with configDir', () => { + // /tmp/gsd-foobar is NOT inside /tmp/gsd-foo — must throw despite the + // startsWith prefix overlap at the string level (the sep-check prevents it). + assert.throws( + () => assertDestWithinConfigHome('/tmp/gsd-foo', '../gsd-foobar'), + (err) => { + assert.ok(err instanceof Error, 'must be an Error'); + assert.ok( + err.message.includes('escapes configHome') || err.message.includes('outside'), + `expected confinement error in: ${err.message}`, + ); + return true; + }, + ); + }); + + test('N1: accepts a true child subpath inside configDir', () => { + // 'bar' appended INSIDE /tmp/gsd-foo => the child path — accepted. + // Compute expected via path.resolve (the same primitive the helper uses) so + // the assertion is platform-portable: on Windows path.resolve prepends the + // cwd drive (C:\...) and uses backslashes, which a hardcoded posix literal / + // path.join (no drive) would not match (#1679 Windows-CI portability). + const root = path.resolve('/tmp/gsd-foo'); + const result = assertDestWithinConfigHome('/tmp/gsd-foo', 'bar'); + assert.strictEqual(result, path.resolve('/tmp/gsd-foo', 'bar')); + assert.ok(result.startsWith(root + path.sep)); + }); + + test('N1: the accepted child does not imply the sibling is accepted', () => { + // Double-check: 'bar' inside is fine, but '../gsd-foobar' (the sibling) is not. + // 'bar' resolves to /tmp/gsd-foo/bar ✓ + assert.doesNotThrow(() => assertDestWithinConfigHome('/tmp/gsd-foo', 'bar')); + // '../gsd-foobar' resolves to /tmp/gsd-foobar — NOT inside /tmp/gsd-foo + assert.throws( + () => assertDestWithinConfigHome('/tmp/gsd-foo', '../gsd-foobar'), + /escapes configHome/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// N3: Windows-separator coverage (structural guard using path.win32) +// --------------------------------------------------------------------------- + +describe('N3: Windows-separator confinement logic (path.win32 semantics)', () => { + /** + * Replicate the assertDestWithinConfigHome predicate using path.win32 + * so we can test the sep-guard logic on any platform. + * + * This mirrors the implementation in runtime-artifact-install-plan.cjs + * but forces win32 path semantics. + */ + function assertDestWithinConfigHomeWin32(configDir, destSubpath) { + if (destSubpath.includes('\0')) { + throw new Error(`destSubpath "${destSubpath}" contains a NUL byte and is not valid`); + } + const root = path.win32.resolve(configDir); + const resolved = path.win32.resolve(configDir, destSubpath); + if (resolved === root || !resolved.startsWith(root + path.win32.sep)) { + throw new Error( + `destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`, + ); + } + return resolved; + } + + const winRoot = 'C:\\Users\\me\\.claude'; + + test('N3: rejects ..\\..\\Windows (Windows backslash traversal)', () => { + assert.throws( + () => assertDestWithinConfigHomeWin32(winRoot, '..\\..\\Windows'), + /escapes configHome/, + ); + }); + + test('N3: rejects mixed ../..\\x traversal', () => { + assert.throws( + () => assertDestWithinConfigHomeWin32(winRoot, '../..\\x'), + /escapes configHome/, + ); + }); + + test('N3: rejects "." that resolves to configHome itself', () => { + assert.throws( + () => assertDestWithinConfigHomeWin32(winRoot, '.'), + /escapes configHome/, + ); + }); + + test('N3: accepts "skills" under Windows root', () => { + const result = assertDestWithinConfigHomeWin32(winRoot, 'skills'); + assert.strictEqual(result, path.win32.join(winRoot, 'skills')); + assert.ok(result.startsWith(winRoot + path.win32.sep)); + }); + + test('N3: accepts "commands\\gsd" (Windows nested path) under Windows root', () => { + const result = assertDestWithinConfigHomeWin32(winRoot, 'commands\\gsd'); + assert.strictEqual(result, path.win32.join(winRoot, 'commands', 'gsd')); + assert.ok(result.startsWith(winRoot + path.win32.sep)); + }); + + test('N3: rejects sibling C:\\Users\\me\\.claude-extra under win32 semantics', () => { + assert.throws( + () => assertDestWithinConfigHomeWin32(winRoot, '..\\.claude-extra'), + /escapes configHome/, + ); + }); +}); + }); +} diff --git a/tests/install.test.cjs b/tests/install.test.cjs index c81ab05f7..aa3f89056 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -5725,3 +5725,96 @@ describe('bug #376 — Suite 4: shouldNormalizeHyphenNamespaceInAgentBody covers }); }); } + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-443-effort-defaults-drift.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-443-effort-defaults-drift (consolidation epic #1969 B5 #1974)", () => { +'use strict'; +/** + * feat-443-effort-defaults-drift.test.cjs + * + * Drift-guard: asserts that install.js's resolved baseline effort defaults + * equal config-defaults.manifest.json's effort block. Any future divergence + * (someone edits the manifest without updating install.js or vice-versa) fails + * CI immediately rather than silently injecting stale effort values. + * + * Real assertions on runtime values — no source-grep. + */ + +// MUST be set before require('bin/install.js') so the main install block +// (guarded by !GSD_TEST_MODE) does not execute and perform a real global +// install into $HOME/.claude/ — which would leak gsd-tools.cjs into the +// ambient HOME and break runtime-launcher-parity test (D) in the same +// node --test run (all unit tests share the same HOME on CI). +process.env.GSD_TEST_MODE = '1'; + +const assert = require('assert'); +const path = require('path'); + +const { test } = require('node:test'); + +// Load the manifest directly (JSON, not a .cjs source file — allowed by lint rule) +const manifestPath = path.join( + __dirname, + '..', + 'gsd-core', + 'bin', + 'shared', + 'config-defaults.manifest.json' +); +const manifest = require(manifestPath); + +// Load install.js exported values (executes the module, not text inspection) +const installPath = path.join(__dirname, '..', 'bin', 'install.js'); +const { + _GSD_EFFORT_MANIFEST_TIER_DEFAULTS, + _GSD_EFFORT_MANIFEST_DEFAULT, +} = require(installPath); + +test('install.js _GSD_EFFORT_MANIFEST_TIER_DEFAULTS.light matches manifest effort.routing_tier_defaults.light', () => { + assert.strictEqual( + _GSD_EFFORT_MANIFEST_TIER_DEFAULTS.light, + manifest.effort.routing_tier_defaults.light, + `install.js tier default for "light" (${_GSD_EFFORT_MANIFEST_TIER_DEFAULTS.light}) differs from manifest (${manifest.effort.routing_tier_defaults.light})` + ); +}); + +test('install.js _GSD_EFFORT_MANIFEST_TIER_DEFAULTS.standard matches manifest effort.routing_tier_defaults.standard', () => { + assert.strictEqual( + _GSD_EFFORT_MANIFEST_TIER_DEFAULTS.standard, + manifest.effort.routing_tier_defaults.standard, + `install.js tier default for "standard" (${_GSD_EFFORT_MANIFEST_TIER_DEFAULTS.standard}) differs from manifest (${manifest.effort.routing_tier_defaults.standard})` + ); +}); + +test('install.js _GSD_EFFORT_MANIFEST_TIER_DEFAULTS.heavy matches manifest effort.routing_tier_defaults.heavy', () => { + assert.strictEqual( + _GSD_EFFORT_MANIFEST_TIER_DEFAULTS.heavy, + manifest.effort.routing_tier_defaults.heavy, + `install.js tier default for "heavy" (${_GSD_EFFORT_MANIFEST_TIER_DEFAULTS.heavy}) differs from manifest (${manifest.effort.routing_tier_defaults.heavy})` + ); +}); + +test('install.js _GSD_EFFORT_MANIFEST_DEFAULT matches manifest effort.default', () => { + assert.strictEqual( + _GSD_EFFORT_MANIFEST_DEFAULT, + manifest.effort.default, + `install.js effort default (${_GSD_EFFORT_MANIFEST_DEFAULT}) differs from manifest (${manifest.effort.default})` + ); +}); + +test('install.js tier-defaults object has exactly the same keys as manifest effort.routing_tier_defaults', () => { + const installKeys = Object.keys(_GSD_EFFORT_MANIFEST_TIER_DEFAULTS).sort(); + const manifestKeys = Object.keys(manifest.effort.routing_tier_defaults).sort(); + assert.deepStrictEqual( + installKeys, + manifestKeys, + `Key mismatch — install.js: [${installKeys.join(', ')}], manifest: [${manifestKeys.join(', ')}]` + ); +}); + }); +} diff --git a/tests/installer-migration-report.test.cjs b/tests/installer-migration-report.test.cjs index ac573ef02..c726515f8 100644 --- a/tests/installer-migration-report.test.cjs +++ b/tests/installer-migration-report.test.cjs @@ -172,3 +172,664 @@ test('throws when installer migrations require user choice', () => { assert.ok(captured.blockedByReason, 'error exposes grouped-by-reason data'); assert.equal(captured.resolutionEnvVar, 'GSD_INSTALLER_MIGRATION_RESOLVE'); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3541-installer-migration-prompt-user-resolution.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3541-installer-migration-prompt-user-resolution (consolidation epic #1969 B5 #1974)", () => { +/** + * Regression test for #3541: first-time-baseline installer migration + * `prompt-user` actions threw hard with no resolution path, making + * `/gsd-update` unrecoverable when leftover `gsd-*` files were classified + * as `stale-gsd-looking`. + * + * Fix shape (per triage brief): + * A. Classify-and-default for safe categories - stale SDK build + * artifacts default to "remove"; user-facing skills/gsd-asterisk/SKILL.md + * defaults to "keep". Each resolution is logged. + * B. Improved error message when an unresolved prompt-user action + * remains: lists choices, suggests the resolution path, groups + * blocked paths by reason. + * + * Behavioural test — exercises the actual installer migration code paths + * via the public `runInstallerMigrations` + new resolver entry points. + * No source-grep (per CONTEXT.md L98–101 / RULESET.TESTS). + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +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 { + runInstallerMigrations, +} = require('../gsd-core/bin/lib/installer-migrations.cjs'); +const { + assertInstallerMigrationsUnblocked, + resolveInstallerMigrationPromptsForNonTty, +} = require('../gsd-core/bin/lib/installer-migration-report.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +function writeFile(root, relPath, content) { + const fullPath = path.join(root, relPath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, 'utf8'); +} + +function writeManifest(root, files) { + fs.writeFileSync( + path.join(root, 'gsd-file-manifest.json'), + JSON.stringify({ + version: '1.41.2', + timestamp: '2026-05-10T00:00:00.000Z', + mode: 'full', + files, + }, null, 2), + 'utf8' + ); +} + +describe('#3541: installer migration prompt-user non-TTY resolution', { concurrency: false }, () => { + let configDir; + + beforeEach(() => { + configDir = createTempDir('gsd-3541-'); + }); + + afterEach(() => { + cleanup(configDir); + }); + + test('Test A: non-TTY default resolution removes stale SDK artifacts and keeps user skills', () => { + // Stale SDK build artifact: replicates the 1.41.2 → 1.42.2 upgrade where + // 24 stale `gsd-core/sdk/{dist,src}/gsd-*` files leaked into the + // baseline because the new manifest no longer classifies them as managed. + writeFile(configDir, 'gsd-core/sdk/dist/gsd-old-bundle.js', 'stale sdk bundle\n'); + // User-facing skill: replicates `skills/gsd-roadmap/SKILL.md` from the + // same incident — user-owned content that must be preserved. + writeFile(configDir, 'skills/gsd-roadmap/SKILL.md', '# Roadmap skill\nuser content\n'); + + // Plant an empty manifest so both files classify as `stale-gsd-looking` + // (they look like GSD artifacts but are not manifest-managed). + writeManifest(configDir, {}); + + const result = runInstallerMigrations({ + configDir, + runtime: 'claude', + scope: 'global', + baselineScan: true, + }); + + // Confirm the migration framework classified both as prompt-user + // blockers — this is the precondition the fix resolves. + const blockedPaths = (result.blocked || []).map((a) => a.relPath).sort(); + assert.deepEqual( + blockedPaths, + ['gsd-core/sdk/dist/gsd-old-bundle.js', 'skills/gsd-roadmap/SKILL.md'], + 'precondition: both stale-looking files should be flagged for explicit user choice' + ); + + // Now run the non-TTY resolver. It must classify-and-default each + // blocked action and return a structured log of resolutions. + const resolved = resolveInstallerMigrationPromptsForNonTty(result, { isTty: false }); + + assert.ok(Array.isArray(resolved.resolutions), 'resolver returns a resolutions log'); + assert.equal( + resolved.resolutions.length, + 2, + 'one resolution entry per blocked action' + ); + + const byPath = new Map(resolved.resolutions.map((r) => [r.relPath, r])); + const sdkResolution = byPath.get('gsd-core/sdk/dist/gsd-old-bundle.js'); + const skillResolution = byPath.get('skills/gsd-roadmap/SKILL.md'); + + assert.ok(sdkResolution, 'SDK artifact resolution logged'); + assert.equal(sdkResolution.choice, 'remove', 'stale SDK build artifact defaults to remove'); + assert.equal(sdkResolution.category, 'stale-sdk-build-artifact'); + + assert.ok(skillResolution, 'user skill resolution logged'); + assert.equal(skillResolution.choice, 'keep', 'user-facing skill defaults to keep'); + assert.equal(skillResolution.category, 'user-facing-skill'); + + // After resolution there must be no blocked actions remaining; the + // assertion gatekeeper must not throw. + assert.equal( + (resolved.result.blocked || []).length, + 0, + 'all prompt-user actions resolved' + ); + assert.doesNotThrow(() => assertInstallerMigrationsUnblocked(resolved.result)); + }); + + test('Test B: error message groups paths by reason and suggests a resolution path', () => { + // Build a synthetic result with two blocked prompt-user actions of + // distinct reasons. The improved error message must (1) list the + // documented choices, (2) suggest the non-interactive resolution + // path, (3) group blocked paths by reason rather than emit each path + // individually. + const blocked = [ + { + type: 'prompt-user', + relPath: 'gsd-core/sdk/dist/gsd-a.js', + reason: 'GSD-looking file is not proven manifest-managed and needs explicit user choice', + classification: 'stale-gsd-looking', + prompt: 'Choose whether to remove this stale-looking GSD artifact or keep it as user-owned.', + choices: ['keep', 'remove'], + }, + { + type: 'prompt-user', + relPath: 'gsd-core/sdk/dist/gsd-b.js', + reason: 'GSD-looking file is not proven manifest-managed and needs explicit user choice', + classification: 'stale-gsd-looking', + prompt: 'Choose whether to remove this stale-looking GSD artifact or keep it as user-owned.', + choices: ['keep', 'remove'], + }, + ]; + + let captured = null; + try { + assertInstallerMigrationsUnblocked({ blocked }); + assert.fail('expected assertInstallerMigrationsUnblocked to throw'); + } catch (err) { + captured = err; + } + + assert.ok(captured instanceof Error); + const message = captured.message; + + // (a) Documented choices listed. + assert.match(message, /keep/, 'error message lists `keep` choice'); + assert.match(message, /remove/, 'error message lists `remove` choice'); + + // (b) Suggests the resolution path. The fix introduces an + // environment variable as the documented non-interactive resolution + // surface — the message must point users at it. + assert.match( + message, + /GSD_INSTALLER_MIGRATION_RESOLVE/, + 'error message suggests the non-interactive resolution env var' + ); + + // (c) Paths grouped by reason — two paths sharing the same reason + // appear under one summary count, not as two separate path lines. + // The message must include a `2 files` (or similar) grouped summary + // and must NOT list each individual relPath in the top-level message. + assert.match( + message, + /2 (files?|paths?|artifacts?)/, + 'error message groups blocked paths into a count summary' + ); + + // Structured surface: the thrown error must carry a `blockedByReason` + // map so callers can render their own report without re-parsing. + assert.ok(captured.blockedByReason, 'error carries blockedByReason data'); + const reasons = Object.keys(captured.blockedByReason); + assert.equal(reasons.length, 1, 'two same-reason paths grouped under one key'); + assert.equal(captured.blockedByReason[reasons[0]].length, 2); + }); + + test('Test C: non-TTY env override resolves otherwise-unclassified prompt-user actions', () => { + const result = { + blocked: [ + { + type: 'prompt-user', + relPath: 'skills/gsd-custom/SKILL.toml', + reason: 'custom skill metadata requires user decision', + choices: ['keep', 'remove'], + }, + ], + plan: { + actions: [], + blocked: [ + { + type: 'prompt-user', + relPath: 'skills/gsd-custom/SKILL.toml', + reason: 'custom skill metadata requires user decision', + choices: ['keep', 'remove'], + }, + ], + }, + }; + + const resolved = resolveInstallerMigrationPromptsForNonTty(result, { + isTty: false, + env: { GSD_INSTALLER_MIGRATION_RESOLVE: 'keep' }, + }); + + assert.equal(resolved.resolutions.length, 1, 'env override resolves prompt-user action'); + assert.equal(resolved.resolutions[0].choice, 'keep'); + assert.equal(resolved.resolutions[0].source, 'GSD_INSTALLER_MIGRATION_RESOLVE'); + assert.equal(resolved.resolutions[0].category, 'operator-override'); + assert.equal((resolved.result.blocked || []).length, 0); + assert.equal((resolved.result.plan.blocked || []).length, 0); + assert.equal((resolved.result.plan.actions || []).length, 1); + assert.equal(resolved.result.plan.actions[0].type, 'baseline-preserve-user'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3610-installer-migration-bundled-hooks-classification.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3610-installer-migration-bundled-hooks-classification (consolidation epic #1969 B5 #1974)", () => { +/** + * Regression test for #3610: fresh `npx @opengsd/gsd-core@latest --codex` + * hard-aborts when the target ~/.codex/hooks/ contains the bundled GSD + * hook files (`gsd-check-update-worker.js`, `gsd-prompt-guard.js`, …) + * left over from a previous version. The installer-migration report + * classifies them as "GSD-looking file is not proven manifest-managed + * and needs explicit user choice" and `assertInstallerMigrationsUnblocked` + * throws. + * + * The files in question are NOT user-owned — they are the GSD bundled + * hooks shipped under `hooks/gsd-*` in the npm package. The fix adds a + * `bundled-gsd-hook` classification to `classifyPromptUserAction` so the + * resolver removes them (the installer then writes the fresh bundled + * versions in their place). + * + * Because this classification is unambiguous (these are not user files), + * it must apply regardless of whether stdin is a TTY — the reporter's + * `npx ... --codex` run was interactive and the existing non-TTY + * resolver gate at install.js:8069 skipped the safe-default pass. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +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 { + runInstallerMigrations, +} = require('../gsd-core/bin/lib/installer-migrations.cjs'); +const { + assertInstallerMigrationsUnblocked, + resolveInstallerMigrationPromptsForNonTty, + classifyPromptUserAction, +} = require('../gsd-core/bin/lib/installer-migration-report.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +function writeFile(root, relPath, content) { + const fullPath = path.join(root, relPath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, 'utf8'); +} + +function writeManifest(root, files) { + fs.writeFileSync( + path.join(root, 'gsd-file-manifest.json'), + JSON.stringify( + { + version: '1.41.2', + timestamp: '2026-05-10T00:00:00.000Z', + mode: 'full', + files, + }, + null, + 2, + ), + 'utf8', + ); +} + +// Reporter's exact list of blocked files from the v1.42.2 → v1.42.0 upgrade +// abort. Each is a real `hooks/gsd-*` file shipped under hooks/ in the npm +// package (verified by `ls hooks/`). +const BUNDLED_HOOK_RELPATHS = [ + 'hooks/gsd-check-update-worker.js', + 'hooks/gsd-check-update.js', + 'hooks/gsd-context-monitor.js', + 'hooks/gsd-phase-boundary.sh', + 'hooks/gsd-prompt-guard.js', + 'hooks/gsd-read-guard.js', + 'hooks/gsd-read-injection-scanner.js', + 'hooks/gsd-session-state.sh', + 'hooks/gsd-statusline.js', + 'hooks/gsd-update-banner.js', + 'hooks/gsd-validate-commit.sh', + 'hooks/gsd-workflow-guard.js', +]; + +describe('bug #3610: classifyPromptUserAction recognizes bundled GSD hooks', () => { + test('classifies hooks/gsd-*.js as bundled-gsd-hook → remove', () => { + const result = classifyPromptUserAction({ + relPath: 'hooks/gsd-prompt-guard.js', + }); + assert.ok(result, 'classifier returned null for a bundled GSD hook (.js)'); + assert.strictEqual(result.category, 'bundled-gsd-hook'); + assert.strictEqual( + result.choice, + 'remove', + 'bundled hook must default to remove so the installer can write the fresh bundled version', + ); + }); + + test('classifies hooks/gsd-*.sh as bundled-gsd-hook → remove', () => { + const result = classifyPromptUserAction({ + relPath: 'hooks/gsd-validate-commit.sh', + }); + assert.ok(result); + assert.strictEqual(result.category, 'bundled-gsd-hook'); + assert.strictEqual(result.choice, 'remove'); + }); + + test('does NOT classify non-gsd hooks (preserves user-owned hook files)', () => { + // A user's custom hook that happens to live under hooks/ must NOT be + // auto-classified as bundled — the existing block-then-choose flow + // continues to apply, preserving the user's control over their files. + const result = classifyPromptUserAction({ + relPath: 'hooks/my-custom-hook.js', + }); + assert.strictEqual( + result, + null, + 'non-gsd-prefixed hook must NOT auto-classify (would clobber user files)', + ); + }); + + test('does NOT classify deeper paths under hooks/gsd-* (e.g. hooks/lib/) as bundled-gsd-hook', () => { + // The bundled GSD distribution has hooks/lib/ (helper modules). Those + // are managed differently — verify the classifier limits itself to + // top-level hooks/gsd-. files, not nested directories. + const result = classifyPromptUserAction({ + relPath: 'hooks/gsd-helpers/index.js', + }); + assert.strictEqual(result, null); + }); +}); + +describe('bug #3610: fresh upgrade with leftover bundled hooks does not throw', () => { + let configDir; + + beforeEach(() => { + configDir = createTempDir('gsd-3610-'); + }); + + afterEach(() => { + cleanup(configDir); + }); + + test('end-to-end: 12 leftover bundled hooks + empty manifest → resolver clears all blockers', () => { + // Recreate the reporter's environment: 12 bundled `gsd-*` hook files + // present at target, but the manifest has not yet seeded their baseline + // entries (first-time-baseline scan). + for (const rel of BUNDLED_HOOK_RELPATHS) { + writeFile(configDir, rel, '#!/usr/bin/env node\n// stale 1.42.0 hook\n'); + } + writeManifest(configDir, {}); + + const result = runInstallerMigrations({ + configDir, + runtime: 'codex', + scope: 'global', + baselineScan: true, + }); + + // Precondition: all 12 leftover hooks classify as prompt-user blockers. + const blockedPaths = (result.blocked || []).map((a) => a.relPath).sort(); + assert.deepStrictEqual( + blockedPaths, + [...BUNDLED_HOOK_RELPATHS].sort(), + 'precondition: every leftover hooks/gsd-* should be a prompt-user blocker', + ); + + // Resolve through the safe-default classifier (passing isTty=false to + // exercise the same code path the bundled-hook classification will hit + // regardless of TTY once the fix removes the gate). + const resolved = resolveInstallerMigrationPromptsForNonTty(result, { isTty: false }); + + assert.strictEqual( + resolved.resolutions.length, + BUNDLED_HOOK_RELPATHS.length, + 'every bundled hook should produce a safe-default resolution entry', + ); + + for (const entry of resolved.resolutions) { + assert.strictEqual(entry.category, 'bundled-gsd-hook'); + assert.strictEqual(entry.choice, 'remove'); + assert.strictEqual(entry.resolvedActionType, 'backup-and-remove'); + } + + assert.strictEqual( + (resolved.result.blocked || []).length, + 0, + 'no blockers should remain after bundled-hook classification fires', + ); + assert.doesNotThrow(() => assertInstallerMigrationsUnblocked(resolved.result)); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3628-bundled-hook-classifier-whitelist.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3628-bundled-hook-classifier-whitelist (consolidation epic #1969 B5 #1974)", () => { +// allow-test-rule: architectural-invariant (see #3628) +// classifyPromptUserAction returns a typed result object; this test asserts +// on that typed surface (category + choice fields) for both the positive +// (shipped) and negative (user-owned / retired) cases. There is no rendered +// text or stdout under test — the classifier's structured return value IS +// the contract. + +/** + * Bug #3628: `bundled-gsd-hook` classifier (added in #3610) uses a shape + * regex (`/^hooks\/gsd-[^/]+\.(?:js|sh|cjs|mjs)$/`) that matches ANY file + * named `hooks/gsd-.{js,sh,cjs,mjs}`, not only the 13 hook files + * actually shipped in the npm distribution. The permissive shape regex + * silently auto-classifies — and on first-time-baseline scan auto-removes: + * + * - User-authored custom hooks (e.g. `hooks/gsd-personal-experiment.js`) + * - Retired bundled hooks from prior GSD versions + * + * Fix: the classifier must whitelist the explicit set of shipped hook + * filenames sourced from a single point of truth (`BUNDLED_GSD_HOOK_FILES` + * exported from the classifier module). Any `hooks/gsd-` file NOT in + * that set must fall through to the existing block-or-prompt flow so the + * user retains control. + */ + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + classifyPromptUserAction, + BUNDLED_GSD_HOOK_FILES, +} = require('../gsd-core/bin/lib/installer-migration-report.cjs'); +const path = require('node:path'); +const fs = require('node:fs'); + +describe('bug #3628: BUNDLED_GSD_HOOK_FILES is an explicit whitelist', () => { + test('exports a Set of shipped hook filenames', () => { + assert.ok( + BUNDLED_GSD_HOOK_FILES instanceof Set, + 'BUNDLED_GSD_HOOK_FILES must be exported as a Set so callers can probe membership', + ); + assert.ok( + BUNDLED_GSD_HOOK_FILES.size > 0, + 'BUNDLED_GSD_HOOK_FILES must enumerate at least one shipped hook', + ); + }); + + test('every entry is a hooks/-prefixed posix path', () => { + for (const relPath of BUNDLED_GSD_HOOK_FILES) { + assert.ok( + relPath.startsWith('hooks/'), + `entry ${JSON.stringify(relPath)} must be prefixed with "hooks/"`, + ); + assert.ok( + !relPath.includes('\\'), + `entry ${JSON.stringify(relPath)} must use POSIX slashes`, + ); + assert.ok( + relPath.includes('gsd-'), + `entry ${JSON.stringify(relPath)} must contain the "gsd-" prefix`, + ); + } + }); + + test('every BUNDLED_GSD_HOOK_FILES entry corresponds to a real file in hooks/', () => { + // Sourcing the whitelist from a frozen constant is only durable if the + // constant stays aligned with the on-disk distribution. This guard + // fails the day someone removes a hook file but forgets to update the + // whitelist (or vice-versa). + const hooksDir = path.join(__dirname, '..', 'hooks'); + for (const relPath of BUNDLED_GSD_HOOK_FILES) { + const fullPath = path.join(hooksDir, relPath.slice('hooks/'.length)); + assert.ok( + fs.existsSync(fullPath), + `whitelisted ${relPath} is missing from hooks/ on disk — whitelist drifted`, + ); + } + }); + + test('every gsd-*.{js,sh,cjs,mjs} file in hooks/ is in BUNDLED_GSD_HOOK_FILES (no shipping drift)', () => { + const hooksDir = path.join(__dirname, '..', 'hooks'); + const onDisk = fs + .readdirSync(hooksDir, { withFileTypes: true }) + .filter((e) => e.isFile() && /^gsd-[^/]+\.(?:js|sh|cjs|mjs)$/.test(e.name)) + .map((e) => `hooks/${e.name}`); + for (const relPath of onDisk) { + assert.ok( + BUNDLED_GSD_HOOK_FILES.has(relPath), + `${relPath} ships in hooks/ but is missing from BUNDLED_GSD_HOOK_FILES — whitelist drifted`, + ); + } + }); +}); + +describe('bug #3628: classifyPromptUserAction whitelists shipped bundled hooks', () => { + test('classifies every entry in BUNDLED_GSD_HOOK_FILES as bundled-gsd-hook → remove', () => { + for (const relPath of BUNDLED_GSD_HOOK_FILES) { + const result = classifyPromptUserAction({ relPath }); + assert.deepStrictEqual( + result, + { category: 'bundled-gsd-hook', choice: 'remove' }, + `${relPath} should classify as bundled-gsd-hook`, + ); + } + }); + + const USER_OWNED_OR_RETIRED = [ + 'hooks/gsd-personal-experiment.js', + 'hooks/gsd-my-custom-guard.sh', + 'hooks/gsd-team-policy.cjs', + 'hooks/gsd-retired-hook.js', + 'hooks/gsd-old-statusline.js', + 'hooks/gsd-experimental.mjs', + ]; + + for (const relPath of USER_OWNED_OR_RETIRED) { + test(`does NOT classify ${relPath} (user-owned / retired)`, () => { + assert.strictEqual( + classifyPromptUserAction({ relPath }), + null, + `${relPath} must NOT auto-classify — falls through to block-or-prompt`, + ); + }); + } + + test('still does NOT classify nested gsd-* directories (existing #3610 boundary preserved)', () => { + assert.strictEqual( + classifyPromptUserAction({ relPath: 'hooks/gsd-helpers/index.js' }), + null, + ); + }); + + test('still does NOT classify non-gsd hooks (existing boundary preserved)', () => { + assert.strictEqual( + classifyPromptUserAction({ relPath: 'hooks/my-custom-hook.js' }), + null, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3442-codex-legacy-hooks-json-migration.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3442-codex-legacy-hooks-json-migration (consolidation epic #1969 B5 #1974)", () => { +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const migration = require(path.join( + __dirname, + '..', + 'gsd-core', + 'bin', + 'lib', + 'installer-migrations', + '002-codex-legacy-hooks-json.cjs', +)); + +describe('bug #3442: codex legacy hooks.json migration consumes shared managed-hook policy', () => { + test('plan prunes managed codex hook commands including legacy alias', () => { + const configDir = '/Users/me/.codex'; + const hooksJson = { + hooks: [ + { command: '"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-check-update.js"' }, + { command: '"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-update-check.js"' }, + { command: '"/usr/local/bin/node" "/Users/me/.codex/hooks/custom-hook.js"' }, + ], + }; + + const actions = migration.plan({ + configDir, + readJson: () => ({ exists: true, error: null, value: hooksJson }), + }); + + assert.equal(actions.length, 1); + assert.equal(actions[0].type, 'rewrite-json'); + assert.equal(actions[0].relPath, 'hooks.json'); + assert.deepEqual(actions[0].value, { + hooks: [ + { command: '"/usr/local/bin/node" "/Users/me/.codex/hooks/custom-hook.js"' }, + ], + }); + }); + + test('plan preserves similarly named commands outside the managed hooks directory', () => { + const configDir = '/Users/me/.codex'; + const hooksJson = { + hooks: [ + { command: '"/usr/local/bin/node" "/tmp/other/hooks/gsd-check-update.js"' }, + ], + }; + + const actions = migration.plan({ + configDir, + readJson: () => ({ exists: true, error: null, value: hooksJson }), + }); + + assert.deepEqual(actions, []); + }); +}); + }); +} diff --git a/tests/installer-migrations.test.cjs b/tests/installer-migrations.test.cjs index 10c51f6f8..e64822c9c 100644 --- a/tests/installer-migrations.test.cjs +++ b/tests/installer-migrations.test.cjs @@ -1578,3 +1578,781 @@ test('reconciles a drifted applied-migration checksum into install state on appl cleanup(configDir); } }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3357-codex-legacy-hooks-json-migration (consolidation epic #1969 B5 #1974)", () => { +/** + * Regression test for bug #3357. + * + * Older Codex installs carried legacy GSD SessionStart commands in hooks.json. + * Current install keeps the managed SessionStart hook in hooks.json (single + * representation per layer) and strips stale managed entries before writing + * exactly one canonical managed command. + * + * Bug #1348 (addendum): reconcileCodexHooksJsonEvent must always write the + * canonical nested { "hooks": { "": [...] } } shape — never top-level + * event keys — mirroring reconcileCursorHooksJson. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +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 installModule = require('../bin/install.js'); +const { readInstallState } = require('../gsd-core/bin/lib/installer-migrations.cjs'); +const { install, parseTomlToObject, reconcileCodexHooksJsonEvent } = installModule; +const { createTempDir, cleanup } = require('./helpers.cjs'); +const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); + +function withCodexHome(codexHome, fn) { + const previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = codexHome; + try { + return fn(); + } finally { + if (previousCodexHome == null) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + } +} + +function legacyGsdHook(codexHome) { + return { + hooks: [{ + type: 'command', + command: `node "${path.join(codexHome, 'hooks', 'gsd-check-update.js')}"`, + }], + }; +} + +function userHook() { + return { + hooks: [{ + type: 'command', + command: 'node "/Users/example/bin/user-hook.js"', + }], + }; +} + +function tomlGsdHookCount(codexHome) { + const parsed = parseTomlToObject(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8')); + const sessionStart = parsed.hooks?.SessionStart ?? []; + return sessionStart + .flatMap((entry) => Array.isArray(entry.hooks) ? entry.hooks : []) + .filter((hook) => typeof hook.command === 'string' && hook.command.includes('gsd-check-update')) + .length; +} + +describe('#3357 — Codex install removes legacy GSD hooks.json entries', { concurrency: false }, () => { + let tmpRoot; + let codexHome; + + beforeEach(() => { + if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { + execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + } + tmpRoot = createTempDir('gsd-3357-'); + codexHome = path.join(tmpRoot, '.codex'); + fs.mkdirSync(codexHome, { recursive: true }); + }); + + afterEach(() => { + delete installModule.__codexSchemaValidator; + cleanup(tmpRoot); + }); + + test('rewrites hooks.json to one managed SessionStart hook when file only had legacy managed entry', () => { + fs.writeFileSync( + path.join(codexHome, 'hooks.json'), + JSON.stringify({ SessionStart: [legacyGsdHook(codexHome)] }, null, 2), + ); + + withCodexHome(codexHome, () => install(true, 'codex')); + + // #1348: output must be nested { hooks: { SessionStart: [...] } }, not top-level + const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); + assert.ok( + hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks), + 'hooks.json must use nested { hooks: { ... } } shape (bug #1348)', + ); + assert.ok( + !Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'), + 'hooks.json must NOT have a top-level SessionStart key (bug #1348)', + ); + const commands = hooksJson.hooks.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command); + const managed = commands.filter((cmd) => typeof cmd === 'string' && cmd.includes('gsd-check-update')); + assert.equal(managed.length, 1); + assert.equal(tomlGsdHookCount(codexHome), 0); + }); + + test('preserves user hooks.json entries while removing the legacy GSD hook', () => { + const userOwnedSameBasenameHook = { + hooks: [{ + type: 'command', + command: 'node "/Users/example/bin/gsd-check-update.js"', + }], + }; + fs.writeFileSync( + path.join(codexHome, 'hooks.json'), + JSON.stringify({ SessionStart: [legacyGsdHook(codexHome), userHook(), userOwnedSameBasenameHook] }, null, 2), + ); + + withCodexHome(codexHome, () => install(true, 'codex')); + + // #1348: output must be nested { hooks: { SessionStart: [...] } }, not top-level + const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); + assert.ok( + hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks), + 'hooks.json must use nested { hooks: { ... } } shape (bug #1348)', + ); + assert.ok( + !Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'), + 'hooks.json must NOT have a top-level SessionStart key (bug #1348)', + ); + const commands = hooksJson.hooks.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command); + const managed = commands.filter((cmd) => typeof cmd === 'string' && cmd.includes('gsd-check-update')); + assert.equal(commands.includes('node "/Users/example/bin/user-hook.js"'), true); + assert.equal(commands.includes('node "/Users/example/bin/gsd-check-update.js"'), true); + assert.equal(managed.length, 2); + assert.equal(tomlGsdHookCount(codexHome), 0); + }); + + test('restores migrated hooks.json and install state when later Codex validation fails', () => { + const before = JSON.stringify({ SessionStart: [legacyGsdHook(codexHome)] }, null, 2); + fs.writeFileSync(path.join(codexHome, 'hooks.json'), before); + + installModule.__codexSchemaValidator = () => ({ + ok: false, + reason: 'forced migration rollback test', + }); + + assert.throws( + () => withCodexHome(codexHome, () => install(true, 'codex')), + /forced migration rollback test/ + ); + + assert.equal(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'), before); + assert.equal( + readInstallState(codexHome).appliedMigrations.some((entry) => entry.id === '2026-05-11-codex-legacy-hooks-json'), + false + ); + }); +}); + +// --------------------------------------------------------------------------- +// #1348 — reconcileCodexHooksJsonEvent must always write canonical nested shape +// --------------------------------------------------------------------------- + +describe('#1348 — reconcileCodexHooksJsonEvent canonical nested shape', { concurrency: false }, () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1348-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // (a) Fresh/absent hooks.json: register → { "hooks": { "SessionStart": [...] } } + test('(a) fresh/absent hooks.json writes nested { hooks: { SessionStart: [...] } } shape', () => { + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`; + assert.ok(!fs.existsSync(hooksJsonPath), 'precondition: hooks.json must not exist'); + + reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD }); + + assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must be created'); + const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + + assert.ok( + hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks), + `Expected nested { hooks: { ... } } shape; got: ${JSON.stringify(hooksJson)}`, + ); + assert.ok( + !Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'), + `hooks.json must NOT have a top-level SessionStart key; got: ${JSON.stringify(hooksJson)}`, + ); + assert.ok( + Array.isArray(hooksJson.hooks.SessionStart) && hooksJson.hooks.SessionStart.length > 0, + `Expected hooks.hooks.SessionStart to be a non-empty array; got: ${JSON.stringify(hooksJson)}`, + ); + }); + + // (b) Legacy migration: seed top-level { "SessionStart": [] }, register → + // nested hooks.SessionStart contains BOTH migrated user entry AND managed entry + test('(b) legacy top-level shape: user entries migrate into hooks.SessionStart alongside managed entry', () => { + const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`; + const userEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/my-hook.js"' }] }; + fs.writeFileSync( + path.join(tmpDir, 'hooks.json'), + JSON.stringify({ SessionStart: [userEntry] }, null, 2), + ); + + reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD }); + + const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8')); + + // Canonical nested shape + assert.ok( + hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks), + `Expected nested { hooks: { ... } } shape; got: ${JSON.stringify(hooksJson)}`, + ); + assert.ok( + !Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'), + `hooks.json must NOT have a top-level SessionStart key; got: ${JSON.stringify(hooksJson)}`, + ); + + // User entry was migrated under hooks.SessionStart (not dropped) + const allCommands = hooksJson.hooks.SessionStart + .flatMap((e) => Array.isArray(e.hooks) ? e.hooks : []) + .map((h) => h.command); + assert.ok( + allCommands.includes('node "/Users/alice/my-hook.js"'), + `User entry must be preserved under hooks.SessionStart; commands: ${JSON.stringify(allCommands)}`, + ); + + // Managed entry is also present + const managedCount = allCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length; + assert.equal(managedCount, 1, 'Exactly one managed entry must be present under hooks.SessionStart'); + }); + + // (c-i) Dedup: re-registering the same managed command does not duplicate it + test('(c-i) re-registering managed command produces exactly one managed entry', () => { + const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`; + reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD }); + reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD }); + + const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8')); + const allCommands = hooksJson.hooks.SessionStart + .flatMap((e) => Array.isArray(e.hooks) ? e.hooks : []) + .map((h) => h.command); + const managedCount = allCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length; + assert.equal(managedCount, 1, 'Re-register must yield exactly one managed entry'); + }); + + // (c-ii) Removal: user entries remain under hooks, managed entry is gone + test('(c-ii) removing managed hook leaves user entry under hooks.SessionStart', () => { + const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`; + const userEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/my-hook.js"' }] }; + // Seed already-nested file with both user + managed + reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD }); + // Now manually seed a user entry into the existing nested file + const seeded = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8')); + seeded.hooks.SessionStart = [userEntry, ...seeded.hooks.SessionStart]; + fs.writeFileSync(path.join(tmpDir, 'hooks.json'), JSON.stringify(seeded, null, 2)); + + // Remove managed + reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: null }); + + const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8')); + // User entry must still be under hooks.SessionStart + const allCommands = hooksJson.hooks.SessionStart + .flatMap((e) => Array.isArray(e.hooks) ? e.hooks : []) + .map((h) => h.command); + assert.ok( + allCommands.includes('node "/Users/alice/my-hook.js"'), + `User entry must remain after managed removal; commands: ${JSON.stringify(allCommands)}`, + ); + // No managed entry + const managedCount = allCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length; + assert.equal(managedCount, 0, 'No managed entry must remain after removal'); + }); + + // (c-iii) Removal from absent file does NOT materialize { "hooks": {} } + test('(c-iii) removing from absent hooks.json does not write a spurious empty { "hooks": {} }', () => { + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + assert.ok(!fs.existsSync(hooksJsonPath), 'precondition: hooks.json must not exist'); + + reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: null }); + + assert.ok( + !fs.existsSync(hooksJsonPath), + 'hooks.json must NOT be created when removing from absent file (no spurious { "hooks": {} })', + ); + }); + + // (d) Mixed nested + top-level shape: { "hooks": { "PreToolUse": [...] }, "SessionStart": [...] } + // The stray top-level event array must be lifted into hooks and merged; no top-level key survives. + test('(d) mixed nested + top-level shape: stray top-level event array is lifted and merged', () => { + const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`; + const existingNestedEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/pre-tool.js"' }] }; + const userTopLevelEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/session-start.js"' }] }; + + // Seed a mixed-shape file: nested PreToolUse AND top-level SessionStart + fs.writeFileSync( + path.join(tmpDir, 'hooks.json'), + JSON.stringify( + { + hooks: { PreToolUse: [existingNestedEntry] }, + SessionStart: [userTopLevelEntry], + }, + null, + 2, + ), + ); + + reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD }); + + const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8')); + + // No stray top-level SessionStart key + assert.ok( + !Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'), + `hooks.json must NOT have a top-level SessionStart key; got: ${JSON.stringify(hooksJson)}`, + ); + + // hooks.SessionStart contains the migrated user entry AND exactly one managed entry + assert.ok( + Array.isArray(hooksJson.hooks.SessionStart), + `hooks.hooks.SessionStart must be an array; got: ${JSON.stringify(hooksJson)}`, + ); + const sessionCommands = hooksJson.hooks.SessionStart + .flatMap((e) => Array.isArray(e.hooks) ? e.hooks : []) + .map((h) => h.command); + assert.ok( + sessionCommands.includes('node "/Users/alice/session-start.js"'), + `Migrated user entry must be present in hooks.SessionStart; commands: ${JSON.stringify(sessionCommands)}; full: ${JSON.stringify(hooksJson)}`, + ); + const managedCount = sessionCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length; + assert.equal(managedCount, 1, `Exactly one managed entry must be present in hooks.SessionStart; commands: ${JSON.stringify(sessionCommands)}`); + + // hooks.PreToolUse is untouched + assert.ok( + Array.isArray(hooksJson.hooks.PreToolUse) && hooksJson.hooks.PreToolUse.length === 1, + `hooks.hooks.PreToolUse must be preserved with one entry; got: ${JSON.stringify(hooksJson.hooks.PreToolUse)}`, + ); + const preToolCommands = hooksJson.hooks.PreToolUse + .flatMap((e) => Array.isArray(e.hooks) ? e.hooks : []) + .map((h) => h.command); + assert.ok( + preToolCommands.includes('node "/Users/alice/pre-tool.js"'), + `Existing nested PreToolUse entry must be preserved; commands: ${JSON.stringify(preToolCommands)}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3670-cursor-local-install-migration-lock.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3670-cursor-local-install-migration-lock (consolidation epic #1969 B5 #1974)", () => { +/** + * Regression tests for issue #3670: --cursor --local install self-deadlocks + * on gsd-install-migration.lock. + * + * Root cause: On Windows, `fs.rmSync(lockPath, { force: true })` in the lock + * release closure silently swallows EPERM errors that NTFS returns when a + * recently-closed file descriptor's handle has not yet been fully released by + * the OS. The lock file is left on disk. The next `runInstallerMigrations` + * call in the same install() invocation hits EEXIST, spins for + * DEFAULT_LOCK_TIMEOUT_MS (30 s), then throws "installer migration lock is + * held". There is also no stale-PID reclamation: if the lock names the + * current process's PID, the helper should reclaim rather than spin. + * + * Windows wall-clock deadlock repro depends on Docker matrix Windows runners. + * These tests reproduce the failure modes via mock-injected fs faults on any + * platform (macOS/Linux/Windows). They fail deterministically WITHOUT the fix + * and pass WITH it. + * + * Test plan: + * T1 (same-process re-entry / stale-PID reclamation — primary regression) + * Pre-seed the lock file with {pid: process.pid, ...}. Verify that a + * runInstallerMigrations call reclaims the lock and succeeds rather than + * spinning 30 s and throwing. + * + * T2 (dead-PID reclamation — cross-invocation stale lock) + * Pre-seed the lock file with a PID known to be dead. Verify that acquire + * reclaims rather than throws. + * + * T3 (silent rmSync swallow / Windows EPERM simulation) + * Inject a fault that makes fs.rmSync throw EPERM for the lock file only + * (simulating Windows NTFS delete-pending). Verify that the lock file IS + * removed by an alternative path (or that the error propagates) — i.e. + * verify that the fix does not silently leave the lock on disk. + * + * T4 (counter-test: normal single acquire/release round-trip still works) + * No pre-seeded lock. One runInstallerMigrations call. Must succeed and + * leave no lock file behind. + * + * T5 (counter-test: genuinely-held live lock still surfaces an error) + * Pre-seed lock with a live PID (process.pid) AND simulate a lock that + * has been "truly acquired" (fd still open). With lockTimeoutMs: 0 and a + * truly un-reclaimable lock, must still throw with a useful message naming + * the holder PID. (This guards against over-reclamation.) + * + * @see https://github.com/open-gsd/gsd-core/issues/3670 + */ + +'use strict'; + +const { test, mock } = 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 { + INSTALL_MIGRATION_LOCK_NAME, + runInstallerMigrations, +} = require('../gsd-core/bin/lib/installer-migrations.cjs'); +const { cleanup } = require('./helpers.cjs'); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3670-')); +} + +function lockPath(dir) { + return path.join(dir, INSTALL_MIGRATION_LOCK_NAME); +} + +function writeLockFile(dir, pid, acquiredAt) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + lockPath(dir), + JSON.stringify({ pid, acquiredAt: acquiredAt || new Date().toISOString() }) + '\n', + 'utf8' + ); +} + +/** + * Find a PID that is guaranteed to be dead on this host. + * We probe a set of high candidate PIDs (far outside the running set) and + * pick the first one for which process.kill(pid, 0) throws ESRCH. + * Falls back to 99999 if the probe loop exhausts (extremely unlikely). + */ +function findDeadPid() { + // Avoid process.pid ± small numbers — those could be live siblings. + for (let candidate = 600000; candidate < 700000; candidate += 1000) { + try { + process.kill(candidate, 0); + // Still alive (or permission denied but exists) — try next + } catch (err) { + if (err.code === 'ESRCH') return candidate; + } + } + return 99999; // fallback: extremely unlikely to be a live PID +} + +// --------------------------------------------------------------------------- +// T1: Same-process re-entry — stale lock with current process.pid reclaimed +// --------------------------------------------------------------------------- +test('T1: reclaims stale lock that names the current process PID (same-process re-entry)', (t) => { + const configDir = createTempDir(); + t.after(() => cleanup(configDir)); + + // Pre-seed lock file with the CURRENT process's PID — exactly what happens + // on Windows when rmSync swallows EPERM after the first runInstallerMigrations + // call releases (or fails to release) the lock. + writeLockFile(configDir, process.pid); + + // Without the fix: this would spin for lockTimeoutMs then throw. + // With the fix: detects own PID → reclaims → succeeds. + // lockTimeoutMs: 200 (fail fast so the test doesn't hang for 30 s without fix) + const result = runInstallerMigrations({ + configDir, + migrations: [], + lockTimeoutMs: 200, + }); + + assert.ok(result, 'runInstallerMigrations must return a result object'); + // Lock file must be removed after the call completes. + assert.equal( + fs.existsSync(lockPath(configDir)), + false, + 'lock file must not remain on disk after successful runInstallerMigrations' + ); +}); + +// --------------------------------------------------------------------------- +// T2: Dead-PID reclamation — cross-invocation stale lock +// --------------------------------------------------------------------------- +test('T2: reclaims stale lock whose PID is no longer alive', (t) => { + const configDir = createTempDir(); + t.after(() => cleanup(configDir)); + + const deadPid = findDeadPid(); + writeLockFile(configDir, deadPid); + + const result = runInstallerMigrations({ + configDir, + migrations: [], + lockTimeoutMs: 200, + }); + + assert.ok(result, 'runInstallerMigrations must return a result object'); + assert.equal( + fs.existsSync(lockPath(configDir)), + false, + 'lock file must not remain on disk after stale-PID reclamation' + ); +}); + +// --------------------------------------------------------------------------- +// T3: Windows EPERM simulation — unlinkSync failure surfaces (not silently swallowed) +// --------------------------------------------------------------------------- +test('T3: lock release does not silently leave lock file on disk when unlink fails (Windows EPERM simulation)', (t) => { + const configDir = createTempDir(); + const originalUnlinkSync = fs.unlinkSync; + + t.after(() => { + fs.unlinkSync = originalUnlinkSync; + cleanup(configDir); + }); + + // The fix uses fs.unlinkSync (not fs.rmSync with { force: true }) in the + // release closure. Inject EPERM on the lock file to simulate the Windows + // NTFS condition where the recently-closed handle has not been fully + // released by the OS. + // + // The fix's contract: EPERM must NOT be silently swallowed. + // Either (a) the error propagates as a releaseError, or (b) some alternative + // deletion path succeeds. Silent-swallow (no error + file still exists) is + // the failure condition we guard against. + let unlinkCallCount = 0; + fs.unlinkSync = function faultInjectUnlinkSync(targetPath) { + const isLock = path.basename(String(targetPath)) === INSTALL_MIGRATION_LOCK_NAME; + if (isLock) { + unlinkCallCount++; + // Simulate Windows EPERM (file handle not fully released by OS) + const err = Object.assign( + new Error('EPERM: operation not permitted, unlink ' + targetPath), + { code: 'EPERM' } + ); + throw err; + } + return originalUnlinkSync.call(fs, targetPath); + }; + + // With the fix: unlinkSync throws EPERM → releaseError is thrown by the + // release closure → runInstallerMigrations throws releaseError. + // With the buggy code (rmSync + force:true): EPERM was swallowed silently, + // no error thrown, lock file left on disk. + // + // Assert: if the call succeeds (no throw), the lock file must be gone. + // If the call throws, the error message must reference the lock. + let threw = false; + let thrownError = null; + try { + runInstallerMigrations({ + configDir, + migrations: [], + lockTimeoutMs: 500, + }); + } catch (err) { + threw = true; + thrownError = err; + } + + if (threw) { + // Acceptable: error surfaced. Verify it's lock-related (not a bug elsewhere). + assert.match( + thrownError.message, + /lock/i, + 'thrown error must reference the lock file' + ); + } else { + // If no error was thrown, the lock file must have been removed by some + // alternative path (not left silently on disk). + assert.equal( + fs.existsSync(lockPath(configDir)), + false, + 'if unlinkSync EPERM is encountered but no error thrown, lock file must still be removed' + ); + } + + // Sanity: the fault injection was actually triggered. + assert.ok(unlinkCallCount > 0, 'unlinkSync must have been called for the lock file at least once'); +}); + +// --------------------------------------------------------------------------- +// T4: Counter-test — normal single acquire/release round-trip still works +// --------------------------------------------------------------------------- +test('T4: normal (non-recursive) runInstallerMigrations acquires and releases lock correctly', (t) => { + const configDir = createTempDir(); + t.after(() => cleanup(configDir)); + + // No pre-seeded lock. Standard happy path. + const result = runInstallerMigrations({ + configDir, + migrations: [], + }); + + assert.ok(result, 'runInstallerMigrations must return a result'); + assert.equal( + fs.existsSync(lockPath(configDir)), + false, + 'lock file must be cleaned up after normal completion' + ); +}); + +// --------------------------------------------------------------------------- +// T5: Counter-test — unreclaimable live lock must surface a bounded error +// --------------------------------------------------------------------------- +// This test guards against over-reclamation: if the reclaim-unlink fails +// (e.g. Windows EPERM on a live open handle), the fix must NOT spin +// indefinitely — it must fall through to the timeout path and throw. +// +// Conditions forced by this test: +// 1. Lock file contains the CURRENT process.pid (triggers isSameProcess branch). +// 2. fs.unlinkSync is mocked to throw EPERM for the lock file (reclaim fails). +// 3. lockTimeoutMs: 200 — timeout must fire within a short wall-clock window. +// +// Expected outcome: throws with /installer migration lock is held/ within +// ~200ms. SUCCESS (no throw) is NOT acceptable here — that would mean the fix +// over-reclaimed a lock that it couldn't actually remove. +test('T5: unreclaimable same-PID lock throws bounded error (reclaim-unlink failure falls through to timeout)', (t) => { + const configDir = createTempDir(); + const originalUnlinkSync = fs.unlinkSync; + + t.after(() => { + mock.restoreAll(); + fs.unlinkSync = originalUnlinkSync; + cleanup(configDir); + }); + + // Pre-seed lock file with the CURRENT process's PID. + // This triggers the isSameProcess reclamation path inside acquireInstallerMigrationLock. + writeLockFile(configDir, process.pid); + + // Mock unlinkSync to throw EPERM for the lock file only. + // This simulates Windows NTFS refusing to delete a file with an open handle. + // With the fix: reclaim-unlink fails → reclaimed=false → falls through to + // the timeout check → throws "installer migration lock is held" after ≤200ms. + // Without the fix (original code): unlink throws but continue runs anyway → + // spins indefinitely, never reaches the timeout check → deadlock. + mock.method(fs, 'unlinkSync', function faultInjectUnlinkSync(targetPath) { + const isLock = path.basename(String(targetPath)) === INSTALL_MIGRATION_LOCK_NAME; + if (isLock) { + const err = Object.assign( + new Error('EPERM: operation not permitted, unlink ' + targetPath), + { code: 'EPERM' } + ); + throw err; + } + return originalUnlinkSync.call(fs, targetPath); + }); + + assert.throws( + () => runInstallerMigrations({ + configDir, + migrations: [], + lockTimeoutMs: 200, + }), + (err) => { + assert.match(err.message, /installer migration lock is held/, 'error must name the held lock'); + return true; + }, + 'must throw "installer migration lock is held" when reclaim-unlink fails — not spin indefinitely' + ); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/installer-migrations/001-legacy-orphan-files.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:installer-migrations/001-legacy-orphan-files (consolidation epic #1969 B5 #1974)", () => { +'use strict'; + +/** + * Characterization tests for the 001-legacy-orphan-files installer migration. + * Locks the migration metadata shape and plan() logic (managed-pristine and + * managed-modified classification paths; unmanaged artifacts are skipped). + */ +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const migration = require('../gsd-core/bin/lib/installer-migrations/001-legacy-orphan-files.cjs'); + +describe('migration metadata', () => { + test('exports a single migration object with required fields', () => { + assert.equal(typeof migration, 'object'); + assert.equal(migration.id, '2026-05-11-legacy-orphan-files'); + assert.equal(typeof migration.title, 'string'); + assert.equal(typeof migration.description, 'string'); + assert.equal(migration.introducedIn, '1.50.0'); + assert.ok(Array.isArray(migration.scopes)); + assert.ok(migration.scopes.includes('global')); + assert.ok(migration.scopes.includes('local')); + assert.strictEqual(migration.destructive, true); + assert.equal(typeof migration.plan, 'function'); + }); +}); + +describe('migration.plan()', () => { + function makeClassifier(classification) { + return { classifyArtifact: () => ({ classification }) }; + } + + test('returns remove-managed action for managed-pristine artifact', () => { + const actions = migration.plan(makeClassifier('managed-pristine')); + assert.equal(actions.length, 2); // two files in LEGACY_ORPHAN_FILES + for (const action of actions) { + assert.equal(action.type, 'remove-managed'); + assert.equal(typeof action.relPath, 'string'); + assert.equal(typeof action.reason, 'string'); + assert.equal(typeof action.ownershipEvidence, 'string'); + } + }); + + test('returns backup-and-remove action for managed-modified artifact', () => { + const actions = migration.plan(makeClassifier('managed-modified')); + assert.equal(actions.length, 2); + for (const action of actions) { + assert.equal(action.type, 'backup-and-remove'); + } + }); + + test('returns no actions for unmanaged artifact', () => { + const actions = migration.plan(makeClassifier('unmanaged')); + assert.deepStrictEqual(actions, []); + }); + + test('relPaths match the two legacy orphan hook files', () => { + const actions = migration.plan(makeClassifier('managed-pristine')); + const relPaths = actions.map((a) => a.relPath).sort(); + assert.deepStrictEqual(relPaths, [ + 'hooks/gsd-notify.sh', + 'hooks/statusline.js', + ]); + }); + + test('plan handles mixed classifications per file', () => { + let callCount = 0; + const ctx = { + classifyArtifact: (_relPath) => { + callCount++; + // first call: managed-pristine; second call: unmanaged + return { classification: callCount === 1 ? 'managed-pristine' : 'unmanaged' }; + }, + }; + const actions = migration.plan(ctx); + assert.equal(actions.length, 1); + assert.equal(actions[0].type, 'remove-managed'); + }); +}); + }); +} diff --git a/tests/installer-migrations/001-legacy-orphan-files.test.cjs b/tests/installer-migrations/001-legacy-orphan-files.test.cjs deleted file mode 100644 index 64b3c87b8..000000000 --- a/tests/installer-migrations/001-legacy-orphan-files.test.cjs +++ /dev/null @@ -1,79 +0,0 @@ -'use strict'; - -/** - * Characterization tests for the 001-legacy-orphan-files installer migration. - * Locks the migration metadata shape and plan() logic (managed-pristine and - * managed-modified classification paths; unmanaged artifacts are skipped). - */ -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); - -const migration = require('../../gsd-core/bin/lib/installer-migrations/001-legacy-orphan-files.cjs'); - -describe('migration metadata', () => { - test('exports a single migration object with required fields', () => { - assert.equal(typeof migration, 'object'); - assert.equal(migration.id, '2026-05-11-legacy-orphan-files'); - assert.equal(typeof migration.title, 'string'); - assert.equal(typeof migration.description, 'string'); - assert.equal(migration.introducedIn, '1.50.0'); - assert.ok(Array.isArray(migration.scopes)); - assert.ok(migration.scopes.includes('global')); - assert.ok(migration.scopes.includes('local')); - assert.strictEqual(migration.destructive, true); - assert.equal(typeof migration.plan, 'function'); - }); -}); - -describe('migration.plan()', () => { - function makeClassifier(classification) { - return { classifyArtifact: () => ({ classification }) }; - } - - test('returns remove-managed action for managed-pristine artifact', () => { - const actions = migration.plan(makeClassifier('managed-pristine')); - assert.equal(actions.length, 2); // two files in LEGACY_ORPHAN_FILES - for (const action of actions) { - assert.equal(action.type, 'remove-managed'); - assert.equal(typeof action.relPath, 'string'); - assert.equal(typeof action.reason, 'string'); - assert.equal(typeof action.ownershipEvidence, 'string'); - } - }); - - test('returns backup-and-remove action for managed-modified artifact', () => { - const actions = migration.plan(makeClassifier('managed-modified')); - assert.equal(actions.length, 2); - for (const action of actions) { - assert.equal(action.type, 'backup-and-remove'); - } - }); - - test('returns no actions for unmanaged artifact', () => { - const actions = migration.plan(makeClassifier('unmanaged')); - assert.deepStrictEqual(actions, []); - }); - - test('relPaths match the two legacy orphan hook files', () => { - const actions = migration.plan(makeClassifier('managed-pristine')); - const relPaths = actions.map((a) => a.relPath).sort(); - assert.deepStrictEqual(relPaths, [ - 'hooks/gsd-notify.sh', - 'hooks/statusline.js', - ]); - }); - - test('plan handles mixed classifications per file', () => { - let callCount = 0; - const ctx = { - classifyArtifact: (_relPath) => { - callCount++; - // first call: managed-pristine; second call: unmanaged - return { classification: callCount === 1 ? 'managed-pristine' : 'unmanaged' }; - }, - }; - const actions = migration.plan(ctx); - assert.equal(actions.length, 1); - assert.equal(actions[0].type, 'remove-managed'); - }); -}); diff --git a/tests/io.test.cjs b/tests/io.test.cjs index 67199cccf..6735184ea 100644 --- a/tests/io.test.cjs +++ b/tests/io.test.cjs @@ -417,3 +417,74 @@ describe('bug #1008: io.error() tolerates a full non-blocking stderr pipe', () = assert.equal(exitCode, 1, 'error() must still exit(1) after a retried write'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1891-file-resolution.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1891-file-resolution (consolidation epic #1969 B5 #1974)", () => { +// allow-test-rule: structural-implementation-guard (see #1891) +// gsd-tools.cjs @file: resolution is a low-level stdout interception that cannot be +// exercised end-to-end via runGsdTools without a real workflow that emits @file: output. +// These structural tests guard the interception wiring until a behavioral integration +// test suite for the full @file: path is added. + +/** + * Regression tests for bug #1891 + * + * gsd-tools.cjs must transparently resolve @file: references in stdout + * so that workflows never see the @file: prefix. This eliminates the + * bash-specific `if [[ "$INIT" == @file:* ]]` check that breaks on + * PowerShell and other non-bash shells. + */ + +'use strict'; + +const { describe, test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const GSD_TOOLS_SRC = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); + +describe('bug #1891: @file: resolution in gsd-tools.cjs', () => { + let src; + + before(() => { + src = fs.readFileSync(GSD_TOOLS_SRC, 'utf-8'); + }); + + test('main() intercepts stdout and resolves @file: references', () => { + // The non-pick path should have @file: resolution, just like the --pick path + assert.ok( + src.includes("captured.startsWith('@file:')") || + src.includes('captured.startsWith(\'@file:\')'), + 'main() should check for @file: prefix in captured output' + ); + }); + + test('@file: resolution reads file content via readFileSync', () => { + // Verify the resolution reads the actual file + assert.ok( + src.includes("readFileSync(captured.slice(6)") || + src.includes('readFileSync(captured.slice(6)'), + '@file: resolution should read file at the path after the prefix' + ); + }); + + test('stdout interception wraps runCommand in the non-pick path', () => { + // The main function should resolve @file: output in BOTH --pick and + // non-pick paths. This can be either two inline checks or a shared helper. + const mainFunc = src.slice(src.indexOf('async function main()')); + const resolveCalls = (mainFunc.match(/resolveAtFileOutput\(/g) || []).length; + const inlineAtFileChecks = (mainFunc.match(/@file:/g) || []).length; + assert.ok( + resolveCalls >= 2 || inlineAtFileChecks >= 2, + 'Both --pick and normal paths should resolve @file: references' + ); + }); +}); + }); +} diff --git a/tests/reapply-patches.test.cjs b/tests/reapply-patches.test.cjs index 1d5f199ae..cf1164d62 100644 --- a/tests/reapply-patches.test.cjs +++ b/tests/reapply-patches.test.cjs @@ -646,3 +646,93 @@ describe('Bug #3516: git-enhanced two-way merge filter includes gsd-update arm', }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2994-verify-reapply-patches-installed-path.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2994-verify-reapply-patches-installed-path (consolidation epic #1969 B5 #1974)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #2994: scripts/verify-reapply-patches.cjs ships in tarball but is + * not installed at ${GSD_HOME}/scripts/. + * + * Root cause: bin/install.js copies the gsd-core/ source tree to + * ${configDir}/gsd-core/ but does NOT copy the top-level scripts/ + * directory. The verifier script lived under scripts/ so /gsd-reapply-patches + * Step 5 hit `Cannot find module …/scripts/verify-reapply-patches.cjs`. + * + * Fix: move the script to gsd-core/bin/verify-reapply-patches.cjs + * (which IS installed) and update reapply-patches.md to point there. + * + * This test enforces the structural invariant that prevents regression. + */ + +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 RUNTIME_SCRIPT_PATH = path.join(ROOT, 'gsd-core', 'bin', 'verify-reapply-patches.cjs'); +const STALE_SCRIPT_PATH = path.join(ROOT, 'scripts', 'verify-reapply-patches.cjs'); +const REAPPLY_WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'reapply-patches.md'); + +describe('Bug #2994: verify-reapply-patches.cjs lives at the runtime-installed path', () => { + test('the script exists under gsd-core/bin/ (installed by copyWithPathReplacement)', () => { + assert.equal(fs.existsSync(RUNTIME_SCRIPT_PATH), true, + `Expected verifier script at ${RUNTIME_SCRIPT_PATH} -- installer copies gsd-core/ recursively`); + }); + + test('the script does NOT live at the legacy scripts/ path (not installed)', () => { + assert.equal(fs.existsSync(STALE_SCRIPT_PATH), false, + `scripts/ is not copied by installer; verifier must be under gsd-core/bin/ instead`); + }); + + test('the script is requireable (loads without throwing)', () => { + const mod = require(RUNTIME_SCRIPT_PATH); + assert.equal(typeof mod.REASON, 'object'); + assert.notEqual(mod.REASON, null); + }); +}); + +// Parse reapply-patches.md to extract every `node "${GSD_HOME}/...cjs"` +// invocation as structured records. Assertions go against the parsed +// records, not against the markdown text. +function extractScriptInvocations(markdown) { + const invocations = []; + const re = /node\s+"\$\{GSD_HOME\}\/([^"]+\.cjs)"/g; + let match; + while ((match = re.exec(markdown)) !== null) { + invocations.push({ relPath: match[1] }); + } + return invocations; +} + +describe('Bug #2994: reapply-patches workflow references the runtime-installed path', () => { + test('every node ${GSD_HOME}/... invocation in reapply-patches.md uses an installed runtime path', () => { + const md = fs.readFileSync(REAPPLY_WORKFLOW, 'utf-8'); + const invocations = extractScriptInvocations(md); + assert.ok(invocations.length > 0, 'sanity: expected at least one node ${GSD_HOME}/... invocation in reapply-patches.md'); + + const violations = invocations.filter(inv => !inv.relPath.startsWith('gsd-core/')); + assert.deepEqual(violations, [], `invocations under non-installed paths: ${JSON.stringify(violations)}`); + }); + + test('reapply-patches.md references the verifier at gsd-core/bin/verify-reapply-patches.cjs', () => { + const md = fs.readFileSync(REAPPLY_WORKFLOW, 'utf-8'); + const invocations = extractScriptInvocations(md); + const verifierInvocations = invocations.filter(inv => inv.relPath.endsWith('verify-reapply-patches.cjs')); + assert.deepEqual( + verifierInvocations.map(i => i.relPath), + ['gsd-core/bin/verify-reapply-patches.cjs'], + 'workflow must call the runtime-installed verifier path exactly once', + ); + }); +}); + }); +} diff --git a/tests/reapply-verify-hunks.test.cjs b/tests/reapply-verify-hunks.test.cjs index b60ed0945..553078914 100644 --- a/tests/reapply-verify-hunks.test.cjs +++ b/tests/reapply-verify-hunks.test.cjs @@ -106,3 +106,952 @@ describe('reapply-patches post-merge verification (#1758)', () => { ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2969-verify-reapply-patches.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2969-verify-reapply-patches (consolidation epic #1969 B5 #1974)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #2969: /gsd-reapply-patches Step 5 hunk verification gate reports + * success on lost content because the LLM-driven workflow fills in + * "verified: yes" without actually checking content presence. + * + * Fix: deterministic verifier script (scripts/verify-reapply-patches.cjs) + * that the workflow calls. + * + * Per the repo's no-source-grep testing standard (CONTRIBUTING.md): + * tests must assert on TYPED structured fields — not regex/substring + * matching against script output, formatter prose, or file content. + * + * The script's --json mode emits a structured report whose `reason` + * field is a stable enum (exposed as REASON), and whose `missing` field + * is an array of typed strings (exact set membership, not substring). + * Every assertion below is a deepEqual / equal / Array.includes against + * those typed fields. Zero regex, zero String#includes on text. + */ + +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 cp = require('node:child_process'); +const { cleanup } = require('./helpers.cjs'); + +const ROOT = path.join(__dirname, '..'); +// Script lives at gsd-core/bin/ so the installer ships it under +// `${GSD_HOME}/gsd-core/bin/` (issue #2994). The top-level scripts/ +// directory is not copied to user installs. +const SCRIPT = path.join(ROOT, 'gsd-core', 'bin', 'verify-reapply-patches.cjs'); +const { REASON } = require(SCRIPT); + +let tmpRoot; +let patchesDir; +let configDir; +let pristineDir; + +function writeFile(absPath, content) { + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, content); +} + +function resetFixture({ withPristine = true } = {}) { + for (const dir of [patchesDir, configDir, pristineDir]) { + cleanup(dir); + } + fs.mkdirSync(patchesDir); + fs.mkdirSync(configDir); + if (withPristine) fs.mkdirSync(pristineDir); +} + +/** Runs the verifier with --json. Returns parsed structured report. */ +function runVerifier({ includePristine = true } = {}) { + const args = [ + SCRIPT, + '--patches-dir', patchesDir, + '--config-dir', configDir, + ...(includePristine ? ['--pristine-dir', pristineDir] : []), + '--json', + ]; + const r = cp.spawnSync(process.execPath, args, { encoding: 'utf8' }); + return { + status: r.status, + report: r.stdout && r.stdout.length ? JSON.parse(r.stdout) : null, + }; +} + +before(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2969-')); + patchesDir = path.join(tmpRoot, 'patches'); + configDir = path.join(tmpRoot, 'installed'); + pristineDir = path.join(tmpRoot, 'pristine'); + resetFixture(); +}); + +after(() => { + cleanup(tmpRoot); +}); + +describe('Bug #2969: deterministic Step 5 verification gate', () => { + test('REASON enum exposes the documented set of stable codes', () => { + // Locks the public diagnostic surface — adding a code requires updating + // this assertion, removing one breaks consumers that switch on the enum. + // Bug #3657 added OK_PRISTINE_DRIFT_DETECTED. + // Bug #934 added OK_NO_BASELINE. + assert.deepEqual( + Object.keys(REASON).sort(), + [ + 'FAIL_INSTALLED_MISSING', + 'FAIL_INSTALLED_NOT_REGULAR_FILE', + 'FAIL_READ_ERROR', + 'FAIL_USER_LINES_MISSING', + 'OK_NO_BASELINE', + 'OK_NO_SIGNIFICANT_BACKUP_LINES', + 'OK_NO_USER_LINES_VS_PRISTINE', + 'OK_PRISTINE_DRIFT_DETECTED', + ], + ); + }); + + test('exits 0 with status=ok when every user-added line is present in the merged file', () => { + resetFixture(); + const pristine = 'line one of stock content here\nline two of stock content here\nline three of stock content here\n'; + const userAdded = 'a custom line the user added for behavior X\nanother substantial line that the user inserted\n'; + + writeFile(path.join(pristineDir, 'skills', 'foo', 'SKILL.md'), pristine); + writeFile(path.join(patchesDir, 'skills', 'foo', 'SKILL.md'), pristine + userAdded); + writeFile(path.join(configDir, 'skills', 'foo', 'SKILL.md'), pristine + userAdded); + + const { status, report } = runVerifier(); + assert.equal(status, 0); + assert.equal(report.failures, 0); + assert.equal(report.checked, 1); + assert.equal(report.results[0].status, 'ok'); + assert.deepEqual(report.results[0].missing, []); + }); + + test('reason=FAIL_USER_LINES_MISSING with the exact dropped line in .missing[]', () => { + resetFixture(); + const pristine = 'first stock line in the original file here\nsecond stock line in the original file here\n'; + const lostLine = 'this is the visual companion block that must survive'; + writeFile(path.join(pristineDir, 'skills', 'discuss-phase', 'SKILL.md'), pristine); + writeFile(path.join(patchesDir, 'skills', 'discuss-phase', 'SKILL.md'), `${pristine}${lostLine}\n`); + writeFile(path.join(configDir, 'skills', 'discuss-phase', 'SKILL.md'), pristine); + + const { status, report } = runVerifier(); + assert.equal(status, 1); + assert.equal(report.failures, 1); + const r0 = report.results[0]; + // Normalize separators: on Windows the SUT emits 'skills\discuss-phase\SKILL.md'. + assert.equal(r0.file.replace(/\\/g, '/'), 'skills/discuss-phase/SKILL.md'); + assert.equal(r0.status, 'fail'); + assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING); + assert.ok( + r0.missing.includes(lostLine), + `dropped line should be in .missing[]; got ${JSON.stringify(r0.missing)}`, + ); + }); + + test('reason=FAIL_INSTALLED_NOT_REGULAR_FILE when installed path is a directory', () => { + resetFixture(); + writeFile(path.join(pristineDir, 'a.md'), 'pristine line of substantial content here\n'); + writeFile(path.join(patchesDir, 'a.md'), 'pristine line of substantial content here\nuser added line that is substantial\n'); + fs.mkdirSync(path.join(configDir, 'a.md')); // EISDIR trap + + const { status, report } = runVerifier(); + assert.equal(status, 1); + assert.equal(report.results[0].status, 'fail'); + assert.equal(report.results[0].reason, REASON.FAIL_INSTALLED_NOT_REGULAR_FILE); + }); + + test('reason=FAIL_INSTALLED_MISSING when the merged file has been deleted', () => { + resetFixture(); + const pristine = 'stock line one with substantial content for the test\n'; + writeFile(path.join(pristineDir, 'workflow.md'), pristine); + writeFile(path.join(patchesDir, 'workflow.md'), `${pristine}user line that should survive but does not\n`); + // configDir intentionally missing the file. + + const { status, report } = runVerifier(); + assert.equal(status, 1); + assert.equal(report.results[0].status, 'fail'); + assert.equal(report.results[0].reason, REASON.FAIL_INSTALLED_MISSING); + }); + + test('--json report has the documented shape: { checked, failures, results: [{ file, status, missing, reason }] }', () => { + resetFixture(); + const pristine = 'pristine line that is sufficiently long to be significant\n'; + const userAdded = 'extra line the user wrote for their workflow customisation'; + writeFile(path.join(pristineDir, 'a.md'), pristine); + writeFile(path.join(patchesDir, 'a.md'), `${pristine}${userAdded}\n`); + writeFile(path.join(configDir, 'a.md'), pristine); + + const { status, report } = runVerifier(); + assert.equal(status, 1); + // Bug #3657 (Finding 1): drifted + drifted_files are additive fields added to surface + // pristine-drift skips distinctly from failures. Shape-lock updated to include them. + // Bug #934: no_baseline + no_baseline_files are additive fields for missing-pristine advisory. + assert.deepEqual(Object.keys(report).sort(), ['checked', 'drifted', 'drifted_files', 'failures', 'no_baseline', 'no_baseline_files', 'results']); + const r0 = report.results[0]; + assert.deepEqual(Object.keys(r0).sort(), ['file', 'missing', 'reason', 'status']); + assert.equal(typeof r0.file, 'string'); + assert.equal(typeof r0.status, 'string'); + assert.equal(typeof r0.reason, 'string'); + assert.ok(Array.isArray(r0.missing)); + }); + + test('ignores backup-meta.json — it is metadata, not a patched file', () => { + resetFixture(); + writeFile(path.join(patchesDir, 'backup-meta.json'), JSON.stringify({ files: [] })); + + const { status, report } = runVerifier(); + assert.equal(status, 0); + assert.equal(report.checked, 0); + assert.equal(report.failures, 0); + assert.deepEqual(report.results, []); + }); + + test('without --pristine-dir, treats every significant backup line as required (safe over-broad fallback)', () => { + resetFixture({ withPristine: false }); + const presentLine = 'this is a substantial line of user content here'; + const droppedLine = 'another substantial line that should survive'; + writeFile(path.join(patchesDir, 'b.md'), `${presentLine}\n${droppedLine}\n`); + writeFile(path.join(configDir, 'b.md'), `${presentLine}\n`); + + const { status, report } = runVerifier({ includePristine: false }); + assert.equal(status, 1); + assert.equal(report.results[0].reason, REASON.FAIL_USER_LINES_MISSING); + assert.ok(report.results[0].missing.includes(droppedLine)); + assert.ok(!report.results[0].missing.includes(presentLine)); + }); + + test('treats gsd-hook-version install-time substitution as upstream-owned, not missing user content (#229)', () => { + resetFixture(); + const rel = path.join('hooks', 'gsd-statusline.js'); + const pristine = [ + '// gsd-hook-version: {{GSD_VERSION}}', + 'console.log("statusline hook");', + '', + ].join('\n'); + const backup = [ + '// gsd-hook-version: 1.41.0', + 'console.log("statusline hook");', + '', + ].join('\n'); + const installed = [ + '// gsd-hook-version: 1.42.3', + 'console.log("statusline hook");', + '', + ].join('\n'); + + writeFile(path.join(pristineDir, rel), pristine); + writeFile(path.join(patchesDir, rel), backup); + writeFile(path.join(configDir, rel), installed); + + const { status, report } = runVerifier(); + assert.equal(status, 0, `expected pass for upstream-owned version substitution; report=${JSON.stringify(report)}`); + assert.equal(report.failures, 0); + assert.equal(report.checked, 1); + assert.equal(report.results[0].status, 'ok'); + assert.deepStrictEqual(report.results[0].missing, []); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3657-verify-reapply-patches-pristine-drift.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3657-verify-reapply-patches-pristine-drift (consolidation epic #1969 B5 #1974)", () => { +// allow-test-rule: source-text-is-the-product — Finding 2 reads reapply-patches.md to (see #3657) +// assert structural presence of the Step 5a drift-check block; the .md file is the +// product (workflow instructions consumed by AI agents), not a source .cjs file. +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #3657: verify-reapply-patches false-fails when gsd-pristine/ snapshot is + * newer than backup-meta baseline. + * + * Root cause: the verifier computes user-added lines as + * diff(backup, pristine_on_disk) + * but pristine_on_disk is from a LATER GSD version than the one captured in + * backup-meta.json.pristine_hashes. Lines present in the backup but removed by + * the upstream update appear as "user-added lines that must survive", causing + * FAIL_USER_LINES_MISSING false positives even when the user's real + * customisation survived the merge. + * + * Fix: when backup-meta.json contains `pristine_hashes` and the on-disk + * pristine file's SHA-256 does NOT match the recorded hash, the verifier must + * skip the stale pristine and fall back to the over-broad mode (treating every + * significant backup line as required) rather than computing a diff against the + * wrong baseline. Over-broad mode still passes if all backup lines are present + * in the installed file — it never false-fails for a DIFFERENT reason. + * + * Per CONTRIBUTING.md testing standard: assert on typed structured fields from + * the --json report and the REASON frozen enum. Zero regex / String#includes on + * formatter prose. + */ + +const { test, describe, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const os = require('node:os'); +const path = require('node:path'); +const cp = require('node:child_process'); +const { cleanup } = require('./helpers.cjs'); + +const ROOT = path.join(__dirname, '..'); +const SCRIPT = path.join(ROOT, 'gsd-core', 'bin', 'verify-reapply-patches.cjs'); +const { REASON } = require(SCRIPT); + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +let tmpRoot; +let patchesDir; +let configDir; +let pristineDir; + +function sha256(content) { + return crypto.createHash('sha256').update(content).digest('hex'); +} + +function writeFile(absPath, content) { + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, content); +} + +function writeBackupMeta(overrides = {}) { + const meta = { pristine_hashes: {}, ...overrides }; + writeFile(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2)); +} + +function resetFixture() { + for (const dir of [patchesDir, configDir, pristineDir]) { + cleanup(dir); + } + fs.mkdirSync(patchesDir); + fs.mkdirSync(configDir); + fs.mkdirSync(pristineDir); +} + +/** Runs the verifier with --json. Returns { status, report }. */ +function runVerifier({ pristine = true } = {}) { + const args = [ + SCRIPT, + '--patches-dir', patchesDir, + '--config-dir', configDir, + ...(pristine ? ['--pristine-dir', pristineDir] : []), + '--json', + ]; + const r = cp.spawnSync(process.execPath, args, { encoding: 'utf8' }); + return { + status: r.status, + report: r.stdout && r.stdout.length ? JSON.parse(r.stdout) : null, + }; +} + +before(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3657-')); + patchesDir = path.join(tmpRoot, 'patches'); + configDir = path.join(tmpRoot, 'installed'); + pristineDir = path.join(tmpRoot, 'pristine'); + resetFixture(); +}); + +after(() => { + cleanup(tmpRoot); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Bug #3657: pristine-drift does not produce false FAIL_USER_LINES_MISSING', () => { + + /** + * Core regression: the user has one real customisation line. The pristine + * snapshot on disk is a NEWER version that removed a line that was in the + * backup (v_old pristine). Without the fix, the removed-upstream line + * appears as a "user-added line" that is missing from the installed file, + * causing a spurious failure. With the fix, the verifier detects hash + * mismatch and skips the stale pristine, so only the real user line is + * checked — which IS present — and the run exits 0. + */ + test('exits 0 with reason=OK_PRISTINE_DRIFT_DETECTED when on-disk pristine hash does not match recorded hash', () => { + resetFixture(); + + const FILE = 'agents/gsd-executor.md'; + + // v_old pristine: the file as it existed when the backup was made. + const oldPristineContent = + 'line present in old pristine and also in backup\n' + + 'another stock line that was present in old pristine\n'; + + // The user added one customisation line on top of v_old pristine. + const backupContent = + oldPristineContent + + 'model: sonnet in frontmatter — the user customisation to preserve\n'; + + // The installer later refreshed gsd-pristine/ to v_new. + // The upstream update removed the second stock line entirely. + const newPristineContent = + 'line present in old pristine and also in backup\n' + + 'brand-new upstream line added in the newer version here\n'; + + // After reapply-patches, the installed file has the new upstream content + // PLUS the user's real customisation. + const installedContent = + newPristineContent + + 'model: sonnet in frontmatter — the user customisation to preserve\n'; + + // backup-meta.json records the SHA-256 of the OLD pristine content. + writeBackupMeta({ pristine_hashes: { [FILE]: sha256(oldPristineContent) } }); + writeFile(path.join(patchesDir, FILE), backupContent); + writeFile(path.join(configDir, FILE), installedContent); + // The pristine dir has the NEW (mismatched) version. + writeFile(path.join(pristineDir, FILE), newPristineContent); + + const { status, report } = runVerifier(); + + // Must exit 0: drift detected, file skipped with diagnostic code rather + // than false-failing. The user's real line cannot be verified without the + // correct baseline, but the gate must not halt on a false alarm. + assert.equal(status, 0, `expected exit 0 (no failures); got ${status}; report=${JSON.stringify(report)}`); + assert.equal(report.failures, 0, `expected 0 failures; got ${report.failures}`); + const r0 = report.results[0]; + assert.equal(r0.status, 'ok'); + assert.equal(r0.reason, REASON.OK_PRISTINE_DRIFT_DETECTED, + `expected OK_PRISTINE_DRIFT_DETECTED; got ${r0.reason}`); + assert.deepEqual(r0.missing, []); + }); + + /** + * Counter-test (anti-false-positive): when pristine on-disk MATCHES the + * recorded hash (no drift), a real user-added line that was dropped from + * the installed file must still be caught as FAIL_USER_LINES_MISSING. + * The hash-mismatch guard must not suppress legitimate failures. + */ + test('still catches FAIL_USER_LINES_MISSING when pristine matches recorded hash', () => { + resetFixture(); + + const FILE = 'agents/gsd-executor.md'; + + const pristineContent = + 'stock line one that is long enough to be significant\n' + + 'stock line two that is also long enough to matter\n'; + + const droppedLine = 'model: sonnet in frontmatter — the user customisation that was lost'; + const backupContent = pristineContent + droppedLine + '\n'; + + // Installed file is missing the user's line — a real failure. + const installedContent = pristineContent; + + // backup-meta records hash of the SAME pristine currently on disk (no drift). + writeBackupMeta({ pristine_hashes: { [FILE]: sha256(pristineContent) } }); + writeFile(path.join(patchesDir, FILE), backupContent); + writeFile(path.join(configDir, FILE), installedContent); + writeFile(path.join(pristineDir, FILE), pristineContent); + + const { status, report } = runVerifier(); + + assert.equal(status, 1, 'expected exit 1 (real failure should be caught)'); + assert.equal(report.failures, 1); + const r0 = report.results[0]; + assert.equal(r0.status, 'fail'); + assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING); + assert.ok( + r0.missing.includes(droppedLine), + `dropped user line must appear in .missing[]; got ${JSON.stringify(r0.missing)}`, + ); + }); + + /** + * Counter-test (pristine present but no backup-meta.json): behaviour must + * be unchanged from the pre-fix code — use whatever pristine is on disk + * without hash validation (backup-meta is absent so no recorded hash). + */ + test('uses on-disk pristine normally when backup-meta.json is absent (no hash to check)', () => { + resetFixture(); + // No backup-meta.json written — simulate older installer that never recorded hashes. + + const FILE = 'workflow.md'; + const pristineContent = 'stock line that is long enough to be significant in the file\n'; + const droppedLine = 'user line that was added but dropped from the merged install'; + const backupContent = pristineContent + droppedLine + '\n'; + const installedContent = pristineContent; // user line was dropped + + writeFile(path.join(patchesDir, FILE), backupContent); + writeFile(path.join(configDir, FILE), installedContent); + writeFile(path.join(pristineDir, FILE), pristineContent); + + const { status, report } = runVerifier(); + + // Should still catch the dropped user line via normal pristine diff. + assert.equal(status, 1); + assert.equal(report.failures, 1); + assert.equal(report.results[0].reason, REASON.FAIL_USER_LINES_MISSING); + assert.ok(report.results[0].missing.includes(droppedLine)); + }); + + /** + * Counter-test (pristine matches AND user line present): clean run must + * report 0 failures — no false positives even with hash-validation active. + */ + test('reports 0 failures when pristine matches recorded hash and user line is present', () => { + resetFixture(); + + const FILE = 'skills/custom/SKILL.md'; + const pristineContent = 'stock line one with sufficient length to be significant\n'; + const userLine = 'user custom instruction that the user intentionally added here'; + const backupContent = pristineContent + userLine + '\n'; + const installedContent = backupContent; // user line survived + + writeBackupMeta({ pristine_hashes: { [FILE]: sha256(pristineContent) } }); + writeFile(path.join(patchesDir, FILE), backupContent); + writeFile(path.join(configDir, FILE), installedContent); + writeFile(path.join(pristineDir, FILE), pristineContent); + + const { status, report } = runVerifier(); + + assert.equal(status, 0); + assert.equal(report.failures, 0); + assert.equal(report.results[0].status, 'ok'); + }); + + /** + * Multi-file regression: two files; one with hash drift (should not false-fail), + * one with no drift but a real dropped line (should catch it). + * Verifies that per-file hash checking is independent. + */ + test('handles mixed drift + real-failure across multiple files independently', () => { + resetFixture(); + + const DRIFT_FILE = 'agents/gsd-executor.md'; + const CLEAN_FILE = 'workflows/update.md'; + + const driftOldPristine = 'old upstream line that was removed in newer pristine version\n'; + const driftNewPristine = 'brand-new upstream replacement line in the refreshed snapshot\n'; + const driftUserLine = 'model: sonnet — the user customisation that survived reapply'; + const driftBackup = driftOldPristine + driftUserLine + '\n'; + const driftInstalled = driftNewPristine + driftUserLine + '\n'; + + const cleanPristine = 'stock workflow line long enough to pass significance threshold\n'; + const cleanDroppedLine = 'user workflow customisation that was lost in the merge operation'; + const cleanBackup = cleanPristine + cleanDroppedLine + '\n'; + const cleanInstalled = cleanPristine; // dropped + + writeBackupMeta({ + pristine_hashes: { + [DRIFT_FILE]: sha256(driftOldPristine), + [CLEAN_FILE]: sha256(cleanPristine), + }, + }); + + writeFile(path.join(patchesDir, DRIFT_FILE), driftBackup); + writeFile(path.join(configDir, DRIFT_FILE), driftInstalled); + writeFile(path.join(pristineDir, DRIFT_FILE), driftNewPristine); // hash mismatch + + writeFile(path.join(patchesDir, CLEAN_FILE), cleanBackup); + writeFile(path.join(configDir, CLEAN_FILE), cleanInstalled); + writeFile(path.join(pristineDir, CLEAN_FILE), cleanPristine); // hash matches + + const { status, report } = runVerifier(); + + // Exactly 1 failure (the clean file with the genuinely dropped line). + assert.equal(report.failures, 1, `expected 1 failure; got ${report.failures}; report=${JSON.stringify(report, null, 2)}`); + assert.equal(status, 1); + + const driftResult = report.results.find( + (r) => r.file.replace(/\\/g, '/') === DRIFT_FILE, + ); + const cleanResult = report.results.find( + (r) => r.file.replace(/\\/g, '/') === CLEAN_FILE, + ); + + assert.ok(driftResult, 'drift file result must be present in report'); + assert.ok(cleanResult, 'clean file result must be present in report'); + + assert.equal(driftResult.status, 'ok', 'drift file must not false-fail'); + assert.equal(driftResult.reason, REASON.OK_PRISTINE_DRIFT_DETECTED, + `drift file must report OK_PRISTINE_DRIFT_DETECTED; got ${driftResult.reason}`); + assert.equal(cleanResult.status, 'fail', 'clean file with dropped line must fail'); + assert.equal(cleanResult.reason, REASON.FAIL_USER_LINES_MISSING); + assert.ok(cleanResult.missing.includes(cleanDroppedLine)); + }); + + /** + * REASON enum shape-lock: the #3657 fix adds OK_PRISTINE_DRIFT_DETECTED. + * This assertion locks the updated documented set of stable codes. + * Any further additions require updating this assertion. + */ + test('REASON enum includes OK_PRISTINE_DRIFT_DETECTED added by the #3657 fix', () => { + assert.deepEqual( + Object.keys(REASON).sort(), + [ + 'FAIL_INSTALLED_MISSING', + 'FAIL_INSTALLED_NOT_REGULAR_FILE', + 'FAIL_READ_ERROR', + 'FAIL_USER_LINES_MISSING', + 'OK_NO_BASELINE', + 'OK_NO_SIGNIFICANT_BACKUP_LINES', + 'OK_NO_USER_LINES_VS_PRISTINE', + 'OK_PRISTINE_DRIFT_DETECTED', + ], + ); + }); + + // --------------------------------------------------------------------------- + // Finding 1 (BLOCKER) — drifted_files report shape + // Asserts that the JSON report top-level carries `drifted` count + + // `drifted_files` array so that workflow Step 5a has structured data to gate + // on. Per-file shape is unchanged (backward compat). + // --------------------------------------------------------------------------- + + /** + * Single drifted file: the top-level `drifted` count must be 1 and + * `drifted_files` must contain the relative path of the drifted file. + * The `failures` count must remain 0 (drift ≠ failure). + */ + test('Finding 1: JSON report includes top-level drifted count and drifted_files when drift is detected', () => { + resetFixture(); + + const FILE = 'agents/gsd-executor.md'; + const oldPristineContent = 'old pristine line that was present when backup was captured\n'; + const newPristineContent = 'new upstream line in the refreshed pristine snapshot version\n'; + const userLine = 'user customisation line that should be preserved across updates'; + const backupContent = oldPristineContent + userLine + '\n'; + const installedContent = newPristineContent + userLine + '\n'; + + writeBackupMeta({ pristine_hashes: { [FILE]: sha256(oldPristineContent) } }); + writeFile(path.join(patchesDir, FILE), backupContent); + writeFile(path.join(configDir, FILE), installedContent); + writeFile(path.join(pristineDir, FILE), newPristineContent); // hash mismatch → drift + + const { status, report } = runVerifier(); + + // Script exits 0 — drift is not a failure. + assert.equal(status, 0, `expected exit 0; got ${status}`); + assert.equal(report.failures, 0, 'failures must be 0 — drift is not a failure'); + + // Finding 1: top-level drifted fields must be present and accurate. + assert.equal(typeof report.drifted, 'number', 'report.drifted must be a number'); + assert.equal(report.drifted, 1, `expected drifted=1; got ${report.drifted}`); + assert.ok(Array.isArray(report.drifted_files), 'report.drifted_files must be an array'); + assert.equal(report.drifted_files.length, 1, `expected 1 drifted_files entry; got ${report.drifted_files.length}`); + // Normalize path separator so test passes on Windows worktrees too. + assert.equal( + report.drifted_files[0].replace(/\\/g, '/'), + FILE, + `drifted_files[0] must equal the drifted file path; got ${report.drifted_files[0]}`, + ); + + // Per-file shape is unchanged for backward compat. + const r0 = report.results.find((r) => r.file.replace(/\\/g, '/') === FILE); + assert.ok(r0, 'per-file result must be present'); + assert.equal(r0.status, 'ok'); + assert.equal(r0.reason, REASON.OK_PRISTINE_DRIFT_DETECTED); + }); + + /** + * Multi-file drift: two files drifted, one clean pass. Asserts that the + * `drifted` count is 2 and `drifted_files` lists both relative paths. + * Confirms `failures` stays at 0. + */ + test('Finding 1: drifted count and drifted_files aggregate correctly across multiple drifted files', () => { + resetFixture(); + + const FILE_A = 'agents/gsd-executor.md'; + const FILE_B = 'workflows/update.md'; + const FILE_C = 'skills/custom/SKILL.md'; + + const oldPristineA = 'old pristine content for file A that was captured at backup time\n'; + const newPristineA = 'refreshed upstream content for file A in the newer GSD snapshot\n'; + const oldPristineB = 'old pristine content for file B that was captured at backup time\n'; + const newPristineB = 'refreshed upstream content for file B in the newer GSD snapshot\n'; + const pristineC = 'stable pristine for file C — this one did not drift between versions\n'; + const userLineC = 'user customisation for file C that survived the merge successfully'; + + writeBackupMeta({ + pristine_hashes: { + [FILE_A]: sha256(oldPristineA), + [FILE_B]: sha256(oldPristineB), + [FILE_C]: sha256(pristineC), + }, + }); + + // FILE_A: drifted (hash mismatch) + writeFile(path.join(patchesDir, FILE_A), oldPristineA + 'user line A\n'); + writeFile(path.join(configDir, FILE_A), newPristineA + 'user line A\n'); + writeFile(path.join(pristineDir, FILE_A), newPristineA); // mismatch + + // FILE_B: drifted (hash mismatch) + writeFile(path.join(patchesDir, FILE_B), oldPristineB + 'user line B\n'); + writeFile(path.join(configDir, FILE_B), newPristineB + 'user line B\n'); + writeFile(path.join(pristineDir, FILE_B), newPristineB); // mismatch + + // FILE_C: clean (hash matches, user line present) + writeFile(path.join(patchesDir, FILE_C), pristineC + userLineC + '\n'); + writeFile(path.join(configDir, FILE_C), pristineC + userLineC + '\n'); + writeFile(path.join(pristineDir, FILE_C), pristineC); // matches + + const { status, report } = runVerifier(); + + assert.equal(status, 0, `expected exit 0; got ${status}`); + assert.equal(report.failures, 0, 'failures must be 0'); + assert.equal(report.drifted, 2, `expected drifted=2; got ${report.drifted}`); + assert.ok(Array.isArray(report.drifted_files), 'drifted_files must be an array'); + assert.equal(report.drifted_files.length, 2); + const normalised = report.drifted_files.map((f) => f.replace(/\\/g, '/')); + assert.ok(normalised.includes(FILE_A), `drifted_files must include ${FILE_A}`); + assert.ok(normalised.includes(FILE_B), `drifted_files must include ${FILE_B}`); + assert.ok(!normalised.includes(FILE_C), `drifted_files must NOT include the clean file ${FILE_C}`); + }); + + /** + * No-drift baseline: when no files have hash mismatch, the top-level + * `drifted` field must be 0 and `drifted_files` must be an empty array. + * Verifies the additive fields are always present (not omitted on clean runs). + */ + test('Finding 1: drifted=0 and drifted_files=[] when no files have pristine drift', () => { + resetFixture(); + + const FILE = 'skills/custom/SKILL.md'; + const pristineContent = 'stable pristine content that did not change between versions\n'; + const userLine = 'user customisation that survived correctly into the merged file'; + const backupContent = pristineContent + userLine + '\n'; + const installedContent = backupContent; // user line survived + + writeBackupMeta({ pristine_hashes: { [FILE]: sha256(pristineContent) } }); + writeFile(path.join(patchesDir, FILE), backupContent); + writeFile(path.join(configDir, FILE), installedContent); + writeFile(path.join(pristineDir, FILE), pristineContent); + + const { status, report } = runVerifier(); + + assert.equal(status, 0); + assert.equal(report.failures, 0); + assert.equal(report.drifted, 0, `expected drifted=0 on clean run; got ${report.drifted}`); + assert.ok(Array.isArray(report.drifted_files), 'drifted_files must always be an array'); + assert.equal(report.drifted_files.length, 0, 'drifted_files must be empty on clean run'); + }); + + // --------------------------------------------------------------------------- + // Finding 2 (WARNING) — workflow Step 5a drift-check structural test + // Asserts that the workflow markdown source now contains the drift-check + // section that gates on `DRIFTED_COUNT > 0`. Treating the .md source as + // the product per allow-test-rule:source-text-is-the-product. (see #3657) + // --------------------------------------------------------------------------- + + /** + * Structural assertion: the workflow source must now contain the drift-check + * block that Step 5a uses to halt on drifted files. This guarantees that the + * workflow consumer gate exists and uses the structured `drifted` / `drifted_files` + * fields that Finding 1 added to the JSON report. + */ + test('Finding 2: workflow Step 5a source contains drift-check section for DRIFTED_COUNT gate', () => { + const workflowPath = path.join(ROOT, 'gsd-core', 'workflows', 'reapply-patches.md'); + const workflowSource = fs.readFileSync(workflowPath, 'utf8'); + + // The drift-check block must be present in Step 5a. + assert.ok( + workflowSource.includes('Step 5a: drift check'), + 'workflow must contain "Step 5a: drift check" heading', + ); + + // Must gate on the drifted count field from the JSON report. + assert.ok( + workflowSource.includes('DRIFTED_COUNT'), + 'workflow must reference DRIFTED_COUNT so it gates on the structured drifted field', + ); + + // Must reference drifted_files so the halt message names each drifted path. + assert.ok( + workflowSource.includes('drifted_files'), + 'workflow must reference drifted_files to name each drifted path in the halt message', + ); + + // Must instruct the user to resolve drift before re-running. + assert.ok( + workflowSource.includes('DRIFT_DETECTED'), + 'workflow must set DRIFT_DETECTED flag when drift is found (signals halt to subsequent steps)', + ); + + // The drift check must appear BEFORE the VERIFY_STATUS non-zero check. + // (Drift can be present even when exit code is 0.) + const driftCheckPos = workflowSource.indexOf('Step 5a: drift check'); + const verifyStatusPos = workflowSource.indexOf('If `VERIFY_STATUS` is non-zero'); + assert.ok( + driftCheckPos < verifyStatusPos, + 'drift-check block must appear before the VERIFY_STATUS non-zero check in Step 5a', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Bug #934: OK_NO_BASELINE — pristine dir provided, hash recorded, but file absent +// --------------------------------------------------------------------------- + +describe('Bug #934: OK_NO_BASELINE when recordedHash present but pristine file absent', () => { + + /** + * Core regression: backup-meta.json has a pristine_hash for the file but + * the gsd-pristine/ snapshot is absent from disk (the installer's + * saveLocalPatches discarded the only candidate because its hash did not + * match the old-release hash — the file changed upstream between releases). + * Without the fix the verifier falls to over-broad mode and treats every + * upstream-removed line as a "user-added line that must survive", producing + * FAIL_USER_LINES_MISSING false positives. + * With the fix the verifier returns OK_NO_BASELINE (non-blocking, advisory). + */ + test('exits 0 with reason=OK_NO_BASELINE when recordedHash present but pristine absent', () => { + resetFixture(); + + const FILE = 'gsd-core/workflows/execute-phase.md'; + + // The backup contains both the old upstream content and the user's line. + const backupContent = + 'upstream line that was present in 1.4.0 but removed in 1.4.2 release\n' + + 'another upstream line removed upstream between gsd-core releases here\n' + + 'model: sonnet in frontmatter — this is the real user customisation line\n'; + + // The installed file has the new upstream content + the user's real line. + const installedContent = + 'brand-new upstream line that replaced the old content in gsd-core 1.4.2\n' + + 'model: sonnet in frontmatter — this is the real user customisation line\n'; + + // backup-meta.json records a hash (modern installer) but gsd-pristine/ is absent. + writeBackupMeta({ pristine_hashes: { [FILE]: 'sha256:deadbeef00000000000000000000000000000000000000000000000000000001' } }); + writeFile(path.join(patchesDir, FILE), backupContent); + writeFile(path.join(configDir, FILE), installedContent); + // Deliberately do NOT write a pristine file — this is the gap-1 scenario. + + const { status, report } = runVerifier(); + + // Must exit 0: cannot reason without baseline → non-blocking advisory. + assert.equal(status, 0, `expected exit 0; got ${status}; report=${JSON.stringify(report)}`); + assert.equal(report.failures, 0, `expected 0 failures; got ${report.failures}`); + const r0 = report.results[0]; + assert.equal(r0.status, 'ok', `expected status ok; got ${r0.status}`); + assert.equal(r0.reason, REASON.OK_NO_BASELINE, + `expected OK_NO_BASELINE; got ${r0.reason}`); + assert.deepEqual(r0.missing, []); + }); + + /** + * Counter-test: when pristine is absent but NO recordedHash is present + * (pre-fix installer that never wrote backup-meta.json), the verifier must + * still fall to over-broad mode — the old behaviour for untracked backups. + * OK_NO_BASELINE must NOT fire in this case. + */ + test('falls through to over-broad mode when pristine absent AND no recordedHash', () => { + resetFixture(); + + const FILE = 'gsd-core/workflows/plan-phase.md'; + const droppedLine = 'user-added instruction that was dropped from the install output'; + const backupContent = + 'stock upstream line long enough to be significant in the file\n' + + droppedLine + '\n'; + const installedContent = 'stock upstream line long enough to be significant in the file\n'; + + // No backup-meta.json — simulates pre-fix installer with no hash records. + writeFile(path.join(patchesDir, FILE), backupContent); + writeFile(path.join(configDir, FILE), installedContent); + // No pristine file. + + const { status, report } = runVerifier(); + + // Over-broad mode catches the genuinely dropped user line. + assert.equal(status, 1, 'over-broad mode should catch the dropped user line'); + assert.equal(report.failures, 1); + const r0 = report.results[0]; + assert.equal(r0.status, 'fail'); + assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING); + assert.ok(r0.missing.includes(droppedLine), + `dropped line must appear in .missing[]; got ${JSON.stringify(r0.missing)}`); + // Must NOT be OK_NO_BASELINE — that only fires when a hash WAS recorded. + assert.notEqual(r0.reason, REASON.OK_NO_BASELINE); + }); + + /** + * Presence check: when pristine IS present AND hash matches, the normal + * flow must proceed (not short-circuit to OK_NO_BASELINE). + * A real dropped user line must still be caught. + */ + test('does not short-circuit to OK_NO_BASELINE when pristine exists and hash matches', () => { + resetFixture(); + + const FILE = 'gsd-core/workflows/plan-phase.md'; + const pristineContent = 'stock upstream line long enough to be significant content\n'; + const droppedLine = 'user customisation that was genuinely dropped from the merged output'; + const backupContent = pristineContent + droppedLine + '\n'; + const installedContent = pristineContent; // user line dropped — real failure + + writeBackupMeta({ pristine_hashes: { [FILE]: sha256(pristineContent) } }); + writeFile(path.join(patchesDir, FILE), backupContent); + writeFile(path.join(configDir, FILE), installedContent); + writeFile(path.join(pristineDir, FILE), pristineContent); + + const { status, report } = runVerifier(); + + assert.equal(status, 1, 'real dropped user line must be caught'); + assert.equal(report.failures, 1); + const r0 = report.results[0]; + assert.equal(r0.status, 'fail'); + assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING); + assert.notEqual(r0.reason, REASON.OK_NO_BASELINE); + assert.ok(r0.missing.includes(droppedLine)); + }); + + /** + * When --pristine-dir is NOT provided at all (old CLI invocation without the + * flag), the OK_NO_BASELINE path must never fire — there is no pristine dir + * context to consult and the old over-broad behaviour must be preserved. + */ + test('does not return OK_NO_BASELINE when --pristine-dir is not provided', () => { + resetFixture(); + + const FILE = 'gsd-core/workflows/execute-phase.md'; + const backupContent = + 'upstream line removed in newer version but present in backup\n' + + 'model: sonnet — user customisation line in the backup file\n'; + const installedContent = + 'replacement upstream line in the newer release version\n' + + 'model: sonnet — user customisation line in the backup file\n'; + + // Record a hash — but no pristine dir will be passed to the verifier. + writeBackupMeta({ pristine_hashes: { [FILE]: 'sha256:deadbeef00000000000000000000000000000000000000000000000000000001' } }); + writeFile(path.join(patchesDir, FILE), backupContent); + writeFile(path.join(configDir, FILE), installedContent); + + // Run without --pristine-dir flag. + const { status, report } = runVerifier({ pristine: false }); + + // Over-broad mode: every significant backup line is required. + // "upstream line removed in newer version but present in backup" is NOT in + // the installed content → over-broad mode FAILS this file (exit 1). + // OK_NO_BASELINE must NOT fire — there was no pristine dir to consult. + assert.equal(status, 1, `over-broad mode should fail (upstream-removed line absent); got ${status}`); + const r0 = report.results[0]; + assert.equal(r0.status, 'fail', `expected fail status; got ${r0.status}`); + assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING, + `expected FAIL_USER_LINES_MISSING from over-broad mode; got ${r0.reason}`); + assert.notEqual(r0.reason, REASON.OK_NO_BASELINE, + `OK_NO_BASELINE must not fire when --pristine-dir is not provided`); + }); +}); + }); +} diff --git a/tests/repo-layout.test.cjs b/tests/repo-layout.test.cjs index b86b200f8..eb6465df9 100644 --- a/tests/repo-layout.test.cjs +++ b/tests/repo-layout.test.cjs @@ -105,3 +105,75 @@ test('repo-layout: installer writes AGENTS.md only for local Copilot scope (not 'AGENTS.md in the working directory on every Copilot install, including repo-root runs.', ); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-191-retire-sdk-package.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-191-retire-sdk-package (consolidation epic #1969 B5 #1974)", () => { +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const PKG_PATH = path.join(ROOT, 'package.json'); +const INSTALL_PATH = path.join(ROOT, 'bin', 'install.js'); +const ACTIVE_GUIDANCE_PATHS = [ + 'docs/contributing/bootstrap.md', +]; + +function readPackageJson() { + return JSON.parse(fs.readFileSync(PKG_PATH, 'utf8')); +} + +test('enhancement #191: sdk package artifacts are removed from repository layout', () => { + const sdkDir = path.join(ROOT, 'sdk'); + const shimPath = path.join(ROOT, 'bin', 'gsd-sdk.js'); + + assert.equal(fs.existsSync(sdkDir), false, 'sdk/ directory must be deleted'); + assert.equal(fs.existsSync(shimPath), false, 'bin/gsd-sdk.js must be deleted'); +}); + +test('enhancement #191: published package no longer exposes gsd-sdk artifacts', () => { + const pkg = readPackageJson(); + + assert.equal(Object.prototype.hasOwnProperty.call(pkg.bin || {}, 'gsd-sdk'), false, + 'package.json bin must not expose gsd-sdk'); + assert.equal(pkg.bin && pkg.bin['gsd-tools'], 'gsd-core/bin/gsd-tools.cjs', + 'package.json bin.gsd-tools must point to gsd-core/bin/gsd-tools.cjs'); + + const publishedFiles = Array.isArray(pkg.files) ? pkg.files : []; + const hasSdkPublishedPaths = publishedFiles.some((entry) => String(entry).startsWith('sdk')); + assert.equal(hasSdkPublishedPaths, false, + 'package.json files must not include sdk artifacts'); +}); + +test('enhancement #191: installer does not maintain gsd-sdk shim compatibility path', () => { + const installJs = fs.readFileSync(INSTALL_PATH, 'utf8'); + + assert.equal(/\b--sdk\b/.test(installJs), false, + 'bin/install.js must not expose --sdk flag'); + assert.equal(/\b--no-sdk\b/.test(installJs), false, + 'bin/install.js must not expose --no-sdk flag'); + assert.equal(/installSdkIfNeeded\(\{/.test(installJs), false, + 'bin/install.js must not run installSdkIfNeeded during installation'); +}); + +test('enhancement #191: active contributor guidance does not reference retired SDK build steps', () => { + for (const relPath of ACTIVE_GUIDANCE_PATHS) { + const body = fs.readFileSync(path.join(ROOT, relPath), 'utf8'); + + assert.equal( + /\bbuild:sdk\b|\bcd sdk\b|\bsdk\/dist\b|\bsdk\/src\b/.test(body), + false, + `${relPath} must not direct contributors or agents to use the retired SDK package workflow`, + ); + } +}); + }); +} diff --git a/tests/worktree-safety.test.cjs b/tests/worktree-safety.test.cjs index a6be84f70..55e49c550 100644 --- a/tests/worktree-safety.test.cjs +++ b/tests/worktree-safety.test.cjs @@ -2447,3 +2447,131 @@ describe('bug-3707: reapOrphanWorktrees — adversarial edge cases', () => { }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3129-validate-commit-git-bypass.test.cjs — consolidation epic #1969 (B5 #1974) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3129-validate-commit-git-bypass (consolidation epic #1969 B5 #1974)", () => { +'use strict'; +// allow-test-rule: reads hook shell script to verify delegation pattern — structural contract test, not source-grep (see #3129) + +// Regression tests for bug #3129. +// +// gsd-validate-commit.sh used `[[ "$CMD" =~ ^git[[:space:]]+commit ]]` to +// detect git commit invocations. This regex silently bypasses Conventional +// Commits enforcement for three real git commit forms: +// 1. git -C /some/path commit -m "..." (working-directory prefix) +// 2. GIT_AUTHOR_NAME=x git commit "..." (env-var prefix) +// 3. /usr/bin/git commit -m "..." (full path) +// +// Fix: the hook delegates detection to hooks/lib/git-cmd.js isGitSubcommand(), +// a token-walk classifier that correctly handles all four forms. The module +// is the canonical single source of truth for all hooks that gate on git commits. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); + +const ROOT = path.join(__dirname, '..'); +const { isGitSubcommand, tokenize } = require(path.join(ROOT, 'hooks', 'lib', 'git-cmd.js')); + +// ── tokenize ───────────────────────────────────────────────────────────────── + +describe('git-cmd.js tokenize', () => { + test('splits bare command', () => { + assert.deepEqual(tokenize('git commit -m "msg"'), ['git', 'commit', '-m', 'msg']); + }); + test('handles single-quoted args', () => { + assert.deepEqual(tokenize("git commit -m 'my message'"), ['git', 'commit', '-m', 'my message']); + }); + test('handles env-prefix assignment', () => { + assert.deepEqual( + tokenize('GIT_AUTHOR_NAME=Alice git commit -m "fix"'), + ['GIT_AUTHOR_NAME=Alice', 'git', 'commit', '-m', 'fix'], + ); + }); + test('handles -C path', () => { + assert.deepEqual( + tokenize('git -C /some/path commit -m "x"'), + ['git', '-C', '/some/path', 'commit', '-m', 'x'], + ); + }); +}); + +// ── isGitSubcommand: must-match cases ──────────────────────────────────────── + +describe('git-cmd.js isGitSubcommand: should match commit', () => { + const cases = [ + ['bare form', 'git commit -m "feat: add thing"'], + ['single-quoted message', "git commit -m 'fix: typo'"], + ['with --no-verify', 'git commit --no-verify -m "wip"'], + ['-C path form (bug #3129)', 'git -C /some/path commit -m "fix: x"'], + ['env-prefix form (bug #3129)', 'GIT_AUTHOR_NAME=Alice git commit -m "fix"'], + ['full-path form (bug #3129)', '/usr/bin/git commit -m "feat: y"'], + ['multiple env vars', 'GIT_AUTHOR_NAME=A GIT_AUTHOR_EMAIL=b@c git commit -m "x"'], + ['--git-dir= flag', 'git --git-dir=.git commit -m "x"'], + ['--git-dir two-token', 'git --git-dir .git commit -m "x"'], + ['--no-pager before subcommand', 'git --no-pager commit -m "x"'], + ['-C + full path', '/usr/bin/git -C /proj commit -m "x"'], + ['-p paginate flag', 'git -p commit -m "x"'], + ]; + for (const [desc, cmd] of cases) { + test(desc, () => { + assert.ok(isGitSubcommand(cmd, 'commit'), `Expected match for: ${cmd}`); + }); + } +}); + +// ── isGitSubcommand: must-not-match cases ──────────────────────────────────── + +describe('git-cmd.js isGitSubcommand: should NOT match commit', () => { + const cases = [ + ['git push', 'git push origin main'], + ['git status', 'git status'], + ['git add', 'git add .'], + ['git log', 'git log --oneline'], + ['not git at all', 'npm install'], + ['empty string', ''], + ['git checkout (not commit)', 'git checkout main'], + ['git -C path push', 'git -C /path push'], + ]; + for (const [desc, cmd] of cases) { + test(desc, () => { + assert.ok(!isGitSubcommand(cmd, 'commit'), `Expected NO match for: ${cmd}`); + }); + } +}); + +// ── gsd-validate-commit.sh source check ────────────────────────────────────── + +describe('gsd-validate-commit.sh delegates to git-cmd.js', () => { + const hookSrc = fs.readFileSync( + path.join(ROOT, 'hooks', 'gsd-validate-commit.sh'), 'utf8', + ); + + test('hook no longer uses the stale ^git\\s+commit bash regex', () => { + assert.ok( + !hookSrc.includes('^git[[:space:]]+commit'), + 'gsd-validate-commit.sh still uses the bypassed regex — fix not applied', + ); + }); + + test('hook delegates to git-cmd.js isGitSubcommand', () => { + assert.ok( + hookSrc.includes('git-cmd.js') && hookSrc.includes('isGitSubcommand'), + 'gsd-validate-commit.sh does not reference git-cmd.js or isGitSubcommand', + ); + }); + + test('hooks/lib/git-cmd.js exists at the expected install path', () => { + assert.ok( + fs.existsSync(path.join(ROOT, 'hooks', 'lib', 'git-cmd.js')), + 'hooks/lib/git-cmd.js does not exist — library file missing', + ); + }); +}); + }); +}