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 <noreply@anthropic.com>
This commit is contained in:
@@ -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` 描画を壊す変更があればスイートが失敗します。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` 렌더링을 깨는 변경 사항은 스위트에서 실패합니다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` 渲染的变更都将导致该套件失败。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 :: <source-grep reason> must still error",
|
||||
"tests/eslint-rules.test.cjs :: pending migration",
|
||||
"tests/eslint-rules.test.cjs :: source-text-is-the-product",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(/<step name="run_update">[\s\S]*?<\/step>/);
|
||||
assert.ok(stepMatch, 'update.md must have a <step name="run_update"> 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)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -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 <path>" 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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: '<html>not a version</html>\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'/);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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": { "<Event>": [...] } } 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": [<user>] }, 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)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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-<name>.<ext> 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));
|
||||
});
|
||||
});
|
||||
@@ -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-<name>.{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-<name>` 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
@@ -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 <cluster> — 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 <cluster>.
|
||||
// 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`);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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/<stem>/SKILL.md
|
||||
* with frontmatter `name: <stem>` (e.g. name: quick), causing invocation as
|
||||
* /quick instead of /gsd-quick. This file asserts the corrected behaviour:
|
||||
* - Fresh install → skills/gsd/gsd-<stem>/SKILL.md, name: gsd-<stem>
|
||||
* - The skills/gsd/ category bucket and its DESCRIPTION.md are retained
|
||||
* - Migration: prior bare-stem dirs (skills/gsd/<stem>/) 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',
|
||||
'---',
|
||||
'',
|
||||
`<objective>${stem} body</objective>`,
|
||||
].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-<stem>/SKILL.md (NOT skills/gsd/<stem>/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-<stem> (NOT bare <stem>)', () => {
|
||||
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-<stem> 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-<stem>/ 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-<stem>/ 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-<stem> 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 <stem>/ when gsd-<stem>/ 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-<stem>/ 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-<stem>/ 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
* <command-name>/gsd-plan-phase</command-name>, 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: '<command-name>/gsd-plan-phase</command-name>' } }) + '\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: /<cmd>"', () => {
|
||||
const transcript =
|
||||
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-plan-phase</command-name>' } }) + '\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: '<command-name>/gsd-discuss-phase</command-name>' } }) + '\n' +
|
||||
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-plan-phase</command-name>' } }) + '\n' +
|
||||
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-execute-phase</command-name>' } }) + '\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);
|
||||
});
|
||||
@@ -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 <stage>"
|
||||
* - Scene 2: idle + next_action set → "next <action> <phases>"
|
||||
* - Scene 3: percent 100 / all done → "milestone complete"
|
||||
* - Scene 4: default fallback → unchanged "<status> · <phase>"
|
||||
*
|
||||
* 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 <stage>"', () => {
|
||||
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 <action> <phases>"', () => {
|
||||
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 "<milestone> · <status>"', () => {
|
||||
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'));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,212 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression guard — enh(#790): Augment commands/ emitted alongside skills/.
|
||||
*
|
||||
* Verifies that a global Augment install writes:
|
||||
* - commands/gsd-<stem>.md (slash command definitions)
|
||||
* - skills/gsd-<stem>/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/<name>.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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(', ')}]`
|
||||
);
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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: '<html>not a version</html>\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(/<step name="run_update">[\s\S]*?<\/step>/);
|
||||
assert.ok(stepMatch, 'update.md must have a <step name="run_update"> 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* <command-name>/gsd-plan-phase</command-name>, 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: '<command-name>/gsd-plan-phase</command-name>' } }) + '\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: /<cmd>"', () => {
|
||||
const transcript =
|
||||
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-plan-phase</command-name>' } }) + '\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: '<command-name>/gsd-discuss-phase</command-name>' } }) + '\n' +
|
||||
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-plan-phase</command-name>' } }) + '\n' +
|
||||
JSON.stringify({ type: 'user', message: { content: '<command-name>/gsd-execute-phase</command-name>' } }) + '\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 <stage>"
|
||||
* - Scene 2: idle + next_action set → "next <action> <phases>"
|
||||
* - Scene 3: percent 100 / all done → "milestone complete"
|
||||
* - Scene 4: default fallback → unchanged "<status> · <phase>"
|
||||
*
|
||||
* 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 <stage>"', () => {
|
||||
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 <action> <phases>"', () => {
|
||||
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 "<milestone> · <status>"', () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 <cluster> — 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 <cluster>.
|
||||
// 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)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(', ')}]`
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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-<name>.<ext> 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-<name>.{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-<name>` 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, []);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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": { "<Event>": [...] } } 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": [<user>] }, 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user