diff --git a/docs/adr/0009-shell-command-projection-module.md b/docs/adr/0009-shell-command-projection-module.md index 98f92d8cf..e1ecf9b89 100644 --- a/docs/adr/0009-shell-command-projection-module.md +++ b/docs/adr/0009-shell-command-projection-module.md @@ -52,11 +52,11 @@ These call sites should migrate behind the Shell Command Projection Module: ### Tests expected to move with the seam -- `tests/bug-2979-hook-absolute-node.test.cjs` +- `tests/install.test.cjs` (folds former `bug-2979-hook-absolute-node`, consolidation epic #1969) - `tests/sh-hook-paths.test.cjs` - `tests/bug-3011-sdk-path-diagnostic.test.cjs` -- `tests/bug-3017-codex-hook-absolute-node.test.cjs` -- `tests/bug-2376-opencode-windows-home-path.test.cjs` +- `tests/codex-config.test.cjs` (folds former `bug-3017-codex-hook-absolute-node`, consolidation epic #1969) +- `tests/path-replacement.test.cjs` (folds former `bug-2376-opencode-windows-home-path`, consolidation epic #1969) - `tests/bug-3020-install-shell-path-probe.test.cjs` - `tests/bug-3359-stale-gsd-sdk-path-version.test.cjs` diff --git a/scripts/lint-allow-test-rule-refs.allowlist.json b/scripts/lint-allow-test-rule-refs.allowlist.json index 4bee3ccdd..fc4b510ee 100644 --- a/scripts/lint-allow-test-rule-refs.allowlist.json +++ b/scripts/lint-allow-test-rule-refs.allowlist.json @@ -19,7 +19,6 @@ "tests/bug-17-askuserquestion-option-cap.test.cjs :: source-text-is-the-product", "tests/bug-170-workflow-fallback-install-hint.test.cjs :: workflow markdown is shipped product text; this test validates fallback hint literals across all workflow files", "tests/bug-1891-file-resolution.test.cjs :: structural-implementation-guard", - "tests/bug-1924-preserve-user-artifacts.test.cjs :: source-text-is-the-product", "tests/bug-1967-cache-invalidation.test.cjs :: source-text-is-the-product", "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", @@ -44,13 +43,10 @@ "tests/bug-2559-stale-search-year.test.cjs :: source-text-is-the-product", "tests/bug-2661-roadmap-sync-parallel.test.cjs :: source-text-is-the-product", "tests/bug-2686-review-fix-worktree.test.cjs :: source-text-is-the-product", - "tests/bug-2698-crlf-install.test.cjs :: source-text-is-the-product", "tests/bug-2770-annotate-deps-int-coerce.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-279-codex-agent-mapping.test.cjs :: source-text-is-the-product [adapter header contract in bin/install.js]", "tests/bug-2808-skill-hyphen-name.test.cjs :: source-text-is-the-product", - "tests/bug-2831-opencode-home-path-prefix.test.cjs :: source-text-is-the-product", "tests/bug-2839-review-fix-transactional-cleanup.test.cjs :: source-text-is-the-product", "tests/bug-2948-spike-wrap-up-dispatch.test.cjs :: source-text-is-the-product", "tests/bug-2949-sketch-wrap-up-dispatch.test.cjs :: source-text-is-the-product", @@ -85,7 +81,6 @@ "tests/bug-3521-quick-cleanup-cwd-pin.test.cjs :: source-text-is-the-product", "tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs :: validates runtime CLI stdout/stderr warning behavior, not source grep", "tests/bug-3542-executor-git-stash-prohibition.test.cjs :: source-text-is-the-product", - "tests/bug-3582-codex-skills-materialized.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.", @@ -108,7 +103,6 @@ "tests/bug-444-resolver-local-claude-install.test.cjs :: structural/behavioral regression for the repo-local .claude/ install", "tests/bug-474-clock-seam-date-determinism.test.cjs :: source-text-is-the-product", "tests/bug-503-update-agent-antigravity-detection.test.cjs :: source-text-is-the-product", - "tests/bug-570-codex-leak-scanner.test.cjs :: source-text-is-the-product", "tests/bug-571-doc-writer-fix-mode-edit-only.test.cjs :: source-text-is-the-product", "tests/bug-619-codebase-drift-gate-shim.test.cjs :: source-text-is-the-product", "tests/bug-621-plan-phase-gap-analysis-gsd-run.test.cjs :: source-text-is-the-product", @@ -117,8 +111,6 @@ "tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs :: source-text-is-the-product", "tests/bug-685-windowshide-spawn.test.cjs :: source-text-is-the-product", "tests/bug-687-agy-timeout.test.cjs :: source-text-is-the-product", - "tests/bug-704-codex-launcher-path-corruption.test.cjs :: source-text-is-the-product", - "tests/bug-851-codex-quick-adapter-agent-type-fallback.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-924-claude-flat-skill-layout.test.cjs :: source-text-is-the-product", "tests/bug-936-no-nested-spawner-wrap.test.cjs :: source-text-is-the-product", @@ -126,7 +118,6 @@ "tests/bug-950-quick-summary-status-complete.test.cjs :: source-text-is-the-product", "tests/bug-967-verify-key-links-strict-paths.test.cjs :: the plan-md.md reference", "tests/bug-967-verify-key-links-strict-paths.test.cjs :: the plan-md.md reference example IS the documented authoring surface for key_links; asserting it uses a file path (not an endpoint) directly tests the documented contract.", - "tests/bug-983-trae-windsurf-claude-path-leak.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", @@ -180,14 +171,11 @@ "tests/enh-3209-plan-phase-ingest-adr.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-769-context-fork-effort.install.test.cjs :: integration-test-input", "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/enh-773-codex-exec-automation-flags.test.cjs :: source-text-is-the-product", - "tests/enh-778-cross-runtime-command-enrichment.test.cjs :: source-text-is-the-product", - "tests/enh-789-codebuddy-commands.test.cjs :: source-text-is-the-product", "tests/eslint-rules.test.cjs :: must still error", "tests/eslint-rules.test.cjs :: pending migration", "tests/eslint-rules.test.cjs :: source-text-is-the-product", @@ -207,7 +195,6 @@ "tests/feat-3210-fallow-integration.test.cjs :: source-text-is-the-product", "tests/feat-3210-fallow-integration.test.cjs :: source-text-is-the-product — code-review.md IS the workflow the orchestrator", "tests/feat-3309-human-verify-mode.test.cjs :: source-text-is-the-product", - "tests/feat-443-effort-install-wiring.install.test.cjs :: integration-test-input", "tests/feat-488-effort-sync.test.cjs :: structural-regression-guard — readFileSync asserts on installed agent .md files (the product under mutation) to verify dry-run safety and apply correctness; stderr.includes guards the CLI argument-rejection contract.", "tests/fix-3722-execute-phase-human-needed-checkpoint.test.cjs :: source-text-is-the-product", "tests/forensics.test.cjs :: source-text-is-the-product", diff --git a/scripts/lint-regression-test-names.allowlist.json b/scripts/lint-regression-test-names.allowlist.json index 2b2620a85..b9b10b975 100644 --- a/scripts/lint-regression-test-names.allowlist.json +++ b/scripts/lint-regression-test-names.allowlist.json @@ -1,6 +1,5 @@ [ "bug-10-semver-policy-consolidation.test.cjs", - "bug-130-finishinstall-opencode-testmode.test.cjs", "bug-131-release-tarball-smoke-explicit-home.test.cjs", "bug-1367-claude-local-flat-command-layout.test.cjs", "bug-14-progress-auto-flag-dropped.test.cjs", @@ -8,11 +7,8 @@ "bug-1695-state-patch-clobbers-phase-name.test.cjs", "bug-17-askuserquestion-option-cap.test.cjs", "bug-170-workflow-fallback-install-hint.test.cjs", - "bug-1736-local-install-commands.test.cjs", - "bug-1754-js-hook-guard.test.cjs", "bug-1760-state-prune-noop-template-field.test.cjs", "bug-1761-state-sync-wrong-progress.test.cjs", - "bug-1817-sh-hook-guard.test.cjs", "bug-1818-unknown-flags.test.cjs", "bug-1826-phases-clear-confirm.test.cjs", "bug-1829-inherit-model-profile.test.cjs", @@ -20,8 +16,6 @@ "bug-1891-file-resolution.test.cjs", "bug-190-bridge-collapse.test.cjs", "bug-1906-hook-relative-paths.test.cjs", - "bug-1908-uninstall-manifest.test.cjs", - "bug-1924-preserve-user-artifacts.test.cjs", "bug-1967-cache-invalidation.test.cjs", "bug-1974-context-exhaustion-record.test.cjs", "bug-2002-offer-next-context.test.cjs", @@ -33,19 +27,15 @@ "bug-214-writer-agents-write-truncation-contract.test.cjs", "bug-222-research-synthesizer-write-contract.test.cjs", "bug-224-pick-stdout-capture.test.cjs", - "bug-2248-local-install-statusline.test.cjs", - "bug-2256-model-overrides-transport.test.cjs", "bug-2268-parallel-discuss.test.cjs", "bug-2344-read-guard-claudecode-env.test.cjs", "bug-2346-agent-read-loop-guards.test.cjs", "bug-2351-intel-kilo-layout.test.cjs", - "bug-2376-opencode-windows-home-path.test.cjs", "bug-2384-post-merge-deletion-audit.test.cjs", "bug-2388-plan-phase-no-branch-rename.test.cjs", "bug-2396-makefile-test-priority.test.cjs", "bug-2399-commit-docs-plan-phase.test.cjs", "bug-2410-stream-checkpoint-heartbeats.test.cjs", - "bug-2418-antigravity-bare-path.test.cjs", "bug-2419-project-researcher-agent.test.cjs", "bug-2421-planner-grep-gate-hygiene.test.cjs", "bug-2424-reapply-patches-baseline-detection.test.cjs", @@ -62,7 +52,6 @@ "bug-2523-quick-deferred-items.test.cjs", "bug-2530-valid-config-keys.test.cjs", "bug-2543-gsd-slash-namespace.test.cjs", - "bug-2545-copilot-unreplaced-paths.test.cjs", "bug-2549-2550-2552-discuss-phase-context.test.cjs", "bug-2554-decimal-phase-filter.test.cjs", "bug-2557-gemini-local-hook-paths.test.cjs", @@ -72,24 +61,17 @@ "bug-261-worktree-force-add-guard.test.cjs", "bug-2630-state-frontmatter-milestone-switch.test.cjs", "bug-2638-sub-repos-canonical-location.test.cjs", - "bug-2643-skill-frontmatter-name.test.cjs", "bug-2659-audit-open-crash.test.cjs", "bug-2660-one-liner-extraction.test.cjs", "bug-2661-roadmap-sync-parallel.test.cjs", "bug-2686-review-fix-worktree.test.cjs", - "bug-2698-crlf-install.test.cjs", - "bug-2760-codex-install-defensive.test.cjs", "bug-2769-requirements-header-variants.test.cjs", "bug-2770-annotate-deps-int-coerce.test.cjs", - "bug-2771-user-profile-manifest.test.cjs", "bug-2772-gitmodules-path-intersection.test.cjs", "bug-2784-update-cache-clear-path.test.cjs", - "bug-279-codex-agent-mapping.test.cjs", - "bug-2794-opencode-model-profile-overrides.test.cjs", "bug-2798-context-window-config-key.test.cjs", "bug-2801-ingest-docs-handler.test.cjs", "bug-2808-skill-hyphen-name.test.cjs", - "bug-2831-opencode-home-path-prefix.test.cjs", "bug-2836-audit-open-summary-uat-drift.test.cjs", "bug-2838-summary-rescue-gitignored-planning.test.cjs", "bug-2839-review-fix-transactional-cleanup.test.cjs", @@ -105,20 +87,14 @@ "bug-2949-sketch-wrap-up-dispatch.test.cjs", "bug-2950-stale-command-refs.test.cjs", "bug-2954-help-md-slash-command-stubs.test.cjs", - "bug-2957-claude-global-postinstall-message.test.cjs", "bug-2969-verify-reapply-patches.test.cjs", "bug-2973-profile-user-skills-path.test.cjs", - "bug-2979-hook-absolute-node.test.cjs", "bug-2986-config-schema-mutation-killers.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-2998-pristine-dir-populated.test.cjs", - "bug-3017-codex-hook-absolute-node.test.cjs", - "bug-3018-codex-discuss-fallback.test.cjs", "bug-3019-help-passthrough.test.cjs", - "bug-3037-gemini-duplicate-commands.test.cjs", "bug-3050-update-backup-eacces-nonfatal.test.cjs", "bug-3054-stale-gsd-next-references.test.cjs", "bug-3072-optional-sketch-findings-guard.test.cjs", @@ -138,7 +114,6 @@ "bug-3156-plan-phase-opencode-dispatch.test.cjs", "bug-3163-codex-agents-md.test.cjs", "bug-3168-task-to-agent-rename.test.cjs", - "bug-3181-node-cellar-path.test.cjs", "bug-3195-quick-resurrection-guard.test.cjs", "bug-3197-gsd-tools-config-whitelist.test.cjs", "bug-321-config-defaults-clone-strategy.test.cjs", @@ -147,28 +122,19 @@ "bug-3236-capture-seed-one-shot.test.cjs", "bug-3242-state-update-progress-trample.test.cjs", "bug-3243-dotted-command-form.test.cjs", - "bug-3245-codex-toml-floats.test.cjs", "bug-3257-nested-plans-undercount.test.cjs", "bug-3258-no-stale-gsd-intel-references.test.cjs", "bug-3275-fmstr-non-string-scalars.test.cjs", - "bug-3285-codex-hooks-state-allowed.test.cjs", "bug-3286-state-write-routing.test.cjs", - "bug-3288-model-catalog-install-path.test.cjs", "bug-3290-intel-updater-layout-block.test.cjs", "bug-33-settings-model-profile-adaptive.test.cjs", "bug-3320-planner-deep-work-rules.test.cjs", "bug-3321-verifier-runs-probes.test.cjs", - "bug-3346-codex-aot-toml-key.test.cjs", "bug-3357-codex-legacy-hooks-json-migration.test.cjs", - "bug-3360-codex-execute-phase-worktrees.test.cjs", - "bug-338-local-install-settings-local-json.test.cjs", "bug-3381-verify-work-workstream.test.cjs", "bug-3384-secondary-defects.test.cjs", - "bug-3407-pristine-stale-content.test.cjs", "bug-3413-shell-command-projection.test.cjs", "bug-3418-progress-flag-routing.test.cjs", - "bug-3426-codex-windows-hooks.test.cjs", - "bug-3427-3433-codex-install-shape.test.cjs", "bug-3430-planner-phase-contract.test.cjs", "bug-3431-debug-command-yaml.test.cjs", "bug-3441-path-action-projection.test.cjs", @@ -185,10 +151,6 @@ "bug-3537-padded-id-against-unpadded-roadmap.test.cjs", "bug-3541-installer-migration-prompt-user-resolution.test.cjs", "bug-3542-executor-git-stash-prohibition.test.cjs", - "bug-3562-codex-install-skill-surface.test.cjs", - "bug-3566-codex-hooks-feature-canonical-key.test.cjs", - "bug-3571-configuration-manifest-install-path.test.cjs", - "bug-3582-codex-skills-materialized.test.cjs", "bug-3584-runtime-slash-emitters.test.cjs", "bug-3584-runtime-slash-formatter.test.cjs", "bug-3588-npm-audit-clean.test.cjs", @@ -214,17 +176,13 @@ "bug-3727-code-review-fix-flag-dispatch.test.cjs", "bug-3735-profiles-core-includes-surface.test.cjs", "bug-3739-gap-checker-padded-prefix-context.test.cjs", - "bug-376-claude-js-hook-gsd-rewriter.test.cjs", "bug-378-update-check-scoped-name.test.cjs", "bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs", "bug-3805-fast-md-log-to-state-schema.test.cjs", - "bug-3808-codex-adapter-text-mode-fallback.test.cjs", "bug-3810-no-gsd-sdk-runtime-refs.test.cjs", "bug-384-agents-runtime-aware.test.cjs", "bug-397-state-preserve-executor-authored.test.cjs", - "bug-410-install-defaults-test-mode-guard.test.cjs", "bug-416-archive-dir-null.test.cjs", - "bug-442-config-dir-equals-in-path.test.cjs", "bug-444-resolver-local-claude-install.test.cjs", "bug-447-gap-analysis-phase-req-ids.test.cjs", "bug-474-clock-seam-date-determinism.test.cjs", @@ -232,10 +190,8 @@ "bug-500-planned-phase-progress-corruption.test.cjs", "bug-501-flat-phase-details-milestone-leak.test.cjs", "bug-503-update-agent-antigravity-detection.test.cjs", - "bug-505-remove-dead-sdk-verification.test.cjs", "bug-549-total-phases-overcounts-with-phase-section-heading.test.cjs", "bug-557-details-summary-milestone-strip.test.cjs", - "bug-570-codex-leak-scanner.test.cjs", "bug-571-doc-writer-fix-mode-edit-only.test.cjs", "bug-580-local-sh-hook-bash-wrapper.test.cjs", "bug-619-codebase-drift-gate-shim.test.cjs", @@ -247,11 +203,8 @@ "bug-663-redos-roadmap-phase-parsing.test.cjs", "bug-685-windowshide-spawn.test.cjs", "bug-687-agy-timeout.test.cjs", - "bug-704-codex-launcher-path-corruption.test.cjs", "bug-730-milestone-phase-details-scope.test.cjs", - "bug-782-cline-skills-emission.test.cjs", "bug-783-kilo-global-skills-base.test.cjs", - "bug-851-codex-quick-adapter-agent-type-fallback.test.cjs", "bug-853-bg-dispatch-runtime-gating.test.cjs", "bug-866-profile-pipeline-temp-root.test.cjs", "bug-891-non-claude-runtime-home-fallback.test.cjs", @@ -267,7 +220,5 @@ "bug-967-verify-key-links-strict-paths.test.cjs", "bug-969-test-infra-flake-hardening.test.cjs", "bug-974-graphify-budget-missing-value.test.cjs", - "bug-977-fnm-multishell-path.test.cjs", - "bug-978-milestone-complete-force.test.cjs", - "bug-983-trae-windsurf-claude-path-leak.test.cjs" + "bug-978-milestone-complete-force.test.cjs" ] diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index da4d884af..e0b182dd2 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -16,7 +16,6 @@ "bug-2943-config-get-context-window-default.test.cjs", "bug-321-config-defaults-clone-strategy.test.cjs", "bug-3227-config-set-model-overrides.test.cjs", - "bug-442-config-dir-equals-in-path.test.cjs", "config-field-docs.test.cjs", "config-get-default.test.cjs", "config-schema.property.test.cjs", @@ -110,12 +109,12 @@ "bug-3454-state-dollar-backreference-growth.test.cjs", "bug-397-state-preserve-executor-authored.test.cjs", "bug-905-state-syncstatefrontmatter-preserve-scalars.test.cjs", - "bug-948-state-noop-write-guard.test.cjs", - "state-acquirestatelock-non-eexist.test.cjs", - "state-prune.test.cjs", - "state-rebuild-cli.test.cjs", - "state-rebuild.test.cjs", - "state.test.cjs" + "bug-948-state-noop-write-guard.test.cjs", + "state-acquirestatelock-non-eexist.test.cjs", + "state-prune.test.cjs", + "state-rebuild-cli.test.cjs", + "state-rebuild.test.cjs", + "state.test.cjs" ], "issue": "180" }, @@ -137,10 +136,7 @@ }, "install": { "files": [ - "bug-410-install-defaults-test-mode-guard.test.cjs", - "enh-1077-install-hook-events-dialect-drive.test.cjs", "enh-1082-install-plan-capstone.test.cjs", - "enh-776-install-gemini-hook-events.test.cjs", "install-minimal-hooks.test.cjs", "install-nested-layout.test.cjs", "install-path-detection.test.cjs", diff --git a/tests/bug-130-finishinstall-opencode-testmode.test.cjs b/tests/bug-130-finishinstall-opencode-testmode.test.cjs deleted file mode 100644 index 353105bd8..000000000 --- a/tests/bug-130-finishinstall-opencode-testmode.test.cjs +++ /dev/null @@ -1,76 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #130: finishInstall calls configureOpencodePermissions unconditionally, - * violating the GSD_TEST_MODE side-effect-free contract. - * - * configureOpencodePermissions does fs.mkdirSync + fs.writeFileSync, which - * must NOT run under GSD_TEST_MODE='1'. This test asserts that the opencode - * config file (opencode.json) is NOT created when GSD_TEST_MODE is set. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); -const os = require('node:os'); -const fs = require('node:fs'); - -const ROOT = path.join(__dirname, '..'); - -// Point HOME at a temp dir so configureOpencodePermissions can't write to -// the real ~/.config/opencode/ even if the guard is missing. -const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-130-test-')); -process.env.HOME = FAKE_HOME; -process.env.USERPROFILE = FAKE_HOME; - -// The opencode config dir that configureOpencodePermissions would use for a -// global install when configDir=null: /.config/opencode/ -// The file it writes is opencode.json (or opencode.jsonc if pre-existing). -const OPENCODE_CONFIG_DIR = path.join(FAKE_HOME, '.config', 'opencode'); -const OPENCODE_CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json'); - -// configDir is passed explicitly so the function targets our FAKE_HOME dir -// regardless of how getGlobalDir resolves. -const installModule = require(path.join(ROOT, 'bin', 'install.js')); - -const SETTINGS_PATH = path.join(FAKE_HOME, `gsd-test-settings-${process.pid}.json`); - -function callFinishInstall() { - const original = console.log; - console.log = () => {}; - try { - installModule.finishInstall( - SETTINGS_PATH, - {}, - null, - false, - 'opencode', - true, - OPENCODE_CONFIG_DIR, // pass explicit configDir pointing at our temp dir - ); - } finally { - console.log = original; - } -} - -describe('Bug #130: finishInstall opencode + GSD_TEST_MODE side-effect guard', () => { - test('configureOpencodePermissions does NOT write opencode.json under GSD_TEST_MODE', () => { - // Confirm the file does not exist before the call - assert.equal( - fs.existsSync(OPENCODE_CONFIG_FILE), - false, - 'opencode.json should not exist before finishInstall call', - ); - - callFinishInstall(); - - // Assert the file was NOT created — the side-effect must be suppressed - assert.equal( - fs.existsSync(OPENCODE_CONFIG_FILE), - false, - `opencode.json must NOT be created under GSD_TEST_MODE; found at ${OPENCODE_CONFIG_FILE}`, - ); - }); -}); diff --git a/tests/bug-1736-local-install-commands.test.cjs b/tests/bug-1736-local-install-commands.test.cjs deleted file mode 100644 index ece52e4b5..000000000 --- a/tests/bug-1736-local-install-commands.test.cjs +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Regression test for #1736: local Claude install missing commands/gsd/ - * - * After a fresh local install (`--claude --local`), all /gsd-* commands - * except /gsd-help return "Unknown skill: gsd-quick" because - * .claude/commands/gsd/ was not populated. Claude Code reads local project - * commands from .claude/commands/ (one level up) using the file stem as the - * command name. - * - * #1367 follow-up: the fix changed the layout from the old commands/gsd/.md - * (which caused /gsd: colon namespace) to flat commands/gsd-.md - * (which produces /gsd- hyphen form). This test has been updated to assert - * the new flat layout while preserving the core invariant from #1736: commands - * must be present and usable after a local install. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, before, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { execFileSync } = require('child_process'); - -const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); -const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -const { install } = require(INSTALL_SRC); -const { cleanup } = require('./helpers.cjs'); - -// ─── Ensure hooks/dist/ is populated before install tests ──────────────────── -// With --test-concurrency=4, other install tests (bug-1834, bug-1924) run -// build-hooks.js concurrently. That script creates hooks/dist/ empty first, -// then copies files — creating a window where this test sees an empty dir and -// install() fails with "directory is empty" → process.exit(1). - -before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); -}); - -// ─── #1736 + #1367: local install deploys commands in flat gsd-.md layout ─── - -describe('#1736: local Claude install deploys slash commands (flat gsd-.md layout, #1367)', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-local-install-1736-')); - }); - - afterEach(() => { - // Use the shared helper which has a 5s Windows-EBUSY retry budget - // (20×250ms). The inline 1s budget here was insufficient on cold runners. - cleanup(tmpDir); - }); - - test('local install creates .claude/commands/ directory with flat gsd-*.md files (#1367)', (t) => { - // #1736 invariant: commands must be deployed. - // #1367 fix: commands land as flat gsd-.md at commands/ (not commands/gsd/.md). - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - install(false, 'claude'); - - const commandsDir = path.join(tmpDir, '.claude', 'commands'); - assert.ok( - fs.existsSync(commandsDir), - '.claude/commands/ directory must exist after local install' - ); - const flatFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); - assert.ok( - flatFiles.length > 0, - `.claude/commands/ must have flat gsd-*.md files (e.g. gsd-help.md). Found: ${JSON.stringify(flatFiles)}` - ); - // The old commands/gsd/ subdirectory must NOT exist (#1367) - const oldSubdir = path.join(commandsDir, 'gsd'); - assert.ok( - !fs.existsSync(oldSubdir), - '.claude/commands/gsd/ subdir must NOT exist — flat gsd-.md layout required (#1367)' - ); - }); - - test('local install deploys at least one .md command file to .claude/commands/ (#1736 invariant)', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - install(false, 'claude'); - - const commandsDir = path.join(tmpDir, '.claude', 'commands'); - assert.ok( - fs.existsSync(commandsDir), - '.claude/commands/ must exist' - ); - - const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); - assert.ok( - files.length > 0, - `.claude/commands/ must contain at least one gsd-*.md file, found: ${JSON.stringify(files)}` - ); - }); - - test('local install deploys gsd-quick.md to .claude/commands/ (#1367: flat hyphen form)', (t) => { - // Was: .claude/commands/gsd/quick.md (caused /gsd:quick colon form). - // Now: .claude/commands/gsd-quick.md (produces /gsd-quick hyphen form). - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - install(false, 'claude'); - - const quickCmd = path.join(tmpDir, '.claude', 'commands', 'gsd-quick.md'); - assert.ok( - fs.existsSync(quickCmd), - '.claude/commands/gsd-quick.md must exist after local install (#1367 flat layout)' - ); - }); -}); diff --git a/tests/bug-1754-js-hook-guard.test.cjs b/tests/bug-1754-js-hook-guard.test.cjs deleted file mode 100644 index d9b76cbb4..000000000 --- a/tests/bug-1754-js-hook-guard.test.cjs +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Regression tests for bug #1754 - * - * The installer must NOT register .js hook entries in settings.json when the - * corresponding .js file does not exist at the target path. The original bug: - * on fresh installs where hooks/dist/ was missing from the npm package (as in - * v1.32.0), the hook copy step produced no files, yet the registration step - * ran unconditionally for .js hooks — leaving users with "PreToolUse:Bash - * hook error" on every tool invocation. - * - * The .sh hooks already had fs.existsSync() guards (added in #1817). This - * test verifies the same defensive pattern exists for all .js hooks. - */ - -'use strict'; - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); -// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts. -const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts'); - -const JS_HOOKS = [ - { name: 'gsd-check-update.js', registrationAnchor: 'hasGsdUpdateHook' }, - { name: 'gsd-context-monitor.js', registrationAnchor: 'hasContextMonitorHook' }, - { name: 'gsd-prompt-guard.js', registrationAnchor: 'hasPromptGuardHook' }, - { name: 'gsd-read-guard.js', registrationAnchor: 'hasReadGuardHook' }, - { name: 'gsd-workflow-guard.js', registrationAnchor: 'hasWorkflowGuardHook' }, - { name: 'gsd-worktree-path-guard.js', registrationAnchor: 'hasWorktreePathGuardHook' }, -]; - -describe('bug #1754: .js hook registration guards', () => { - let src; - - before(() => { - // ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts. - // Concatenate both sources so structural assertions find patterns in either file. - const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8'); - let hooksSurfaceSrc = ''; - try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ } - src = installSrc + '\n' + hooksSurfaceSrc; - }); - - for (const { name, registrationAnchor } of JS_HOOKS) { - describe(`${name} registration`, () => { - test(`install.js checks file existence before registering ${name}`, () => { - // Find the registration block by locating the "has...Hook" variable - const anchorIdx = src.indexOf(registrationAnchor); - assert.ok( - anchorIdx !== -1, - `${registrationAnchor} variable not found in install.js` - ); - - // Extract a window around the registration block to find the guard - const blockStart = anchorIdx; - const blockEnd = Math.min(src.length, anchorIdx + 1200); - const block = src.slice(blockStart, blockEnd); - - // The block must contain an fs.existsSync check for the hook file - assert.ok( - block.includes('fs.existsSync') || block.includes('existsSync'), - `install.js must call fs.existsSync on the target path before registering ${name} ` + - `in settings.json. Without this guard, hooks are registered even when the .js file ` + - `was never copied (the root cause of #1754).` - ); - }); - - test(`install.js emits a warning when ${name} is missing`, () => { - // The hook file name (without extension) should appear in a warning message - const hookBaseName = name.replace('.js', ''); - const warnPattern = `Skipped`; - const anchorIdx = src.indexOf(registrationAnchor); - const block = src.slice(anchorIdx, Math.min(src.length, anchorIdx + 1200)); - - assert.ok( - block.includes(warnPattern) && block.includes(hookBaseName), - `install.js must emit a skip warning when ${name} is not found at the target path` - ); - }); - }); - } - - test('all .js hooks use the same guard pattern as .sh hooks', () => { - // Count existsSync calls in the hook registration section. - // There should be guards for all JS hooks plus the existing SH hooks. - // This test ensures new hooks added in the future follow the same pattern. - // ADR-857 phase 5f-1b: registration moved to runtime-hooks-surface.cts so scan the - // full concatenated source (install.js + runtime-hooks-surface.cts) rather than slicing. - const registrationSection = src; - - // Count unique hook file existence checks (pattern: path.join(targetDir, 'hooks', 'gsd-*.js')) - const jsGuards = (registrationSection.match(/gsd-[\w-]+\.js.*not found at target/g) || []); - const shGuards = (registrationSection.match(/gsd-[\w-]+\.sh.*not found at target/g) || []); - - assert.ok( - jsGuards.length >= JS_HOOKS.length, - `Expected at least ${JS_HOOKS.length} .js hook guards, found ${jsGuards.length}. ` + - `Every .js hook registration must check file existence before registering.` - ); - - assert.ok( - shGuards.length >= 3, - `Expected at least 3 .sh hook guards (validate-commit, session-state, phase-boundary), ` + - `found ${shGuards.length}.` - ); - }); -}); diff --git a/tests/bug-1817-sh-hook-guard.test.cjs b/tests/bug-1817-sh-hook-guard.test.cjs deleted file mode 100644 index 48f58d2bb..000000000 --- a/tests/bug-1817-sh-hook-guard.test.cjs +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Regression tests for bug #1817 - * - * The installer must NOT register .sh hook entries in settings.json when the - * corresponding .sh file does not exist at the target path. The original bug: - * v1.32.0's npm package omitted the .sh files from hooks/dist/, so the copy - * step produced no files, yet the registration step ran unconditionally — - * leaving users with hook errors on every tool invocation. - * - * Defensive guard: before registering each .sh hook in settings.json, - * install.js must verify the target file exists. If it doesn't, skip - * registration and emit a warning. - */ - -'use strict'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); -// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts. -const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts'); - -const SH_HOOKS = [ - { name: 'gsd-validate-commit.sh', settingsVar: 'validateCommitCommand' }, - { name: 'gsd-session-state.sh', settingsVar: 'sessionStateCommand' }, - { name: 'gsd-phase-boundary.sh', settingsVar: 'phaseBoundaryCommand' }, -]; - -describe('bug #1817: .sh hook registration guards', () => { - let src; - - // Read once — all tests in this suite share the same source snapshot. - // ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts. - // Concatenate both sources so structural assertions find patterns in either file. - try { - const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8'); - let hooksSurfaceSrc = ''; - try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ } - src = installSrc + '\n' + hooksSurfaceSrc; - } catch { - src = ''; - } - - for (const { name, settingsVar } of SH_HOOKS) { - describe(`${name} registration`, () => { - test(`install.js checks file existence before registering ${name}`, () => { - // Find the block where this .sh hook is registered. - // Each registration block is preceded by the command variable declaration - // and followed by the next hook or end of registration section. - const varIdx = src.indexOf(settingsVar); - assert.ok(varIdx !== -1, `${settingsVar} variable not found in install.js`); - - // Extract ~900 chars around the variable to find the registration block - const blockStart = Math.max(0, varIdx - 50); - const blockEnd = Math.min(src.length, varIdx + 900); - const block = src.slice(blockStart, blockEnd); - - assert.ok( - block.includes('fs.existsSync') || block.includes('existsSync'), - `install.js must call fs.existsSync on the target path before registering ${name} in settings.json. ` + - `Without this guard, hooks are registered even when the .sh file was never copied ` + - `(the root cause of #1817).` - ); - }); - }); - } -}); diff --git a/tests/bug-1908-uninstall-manifest.test.cjs b/tests/bug-1908-uninstall-manifest.test.cjs deleted file mode 100644 index f98cc78f3..000000000 --- a/tests/bug-1908-uninstall-manifest.test.cjs +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Regression test for bug #1908 - * - * `--uninstall` did not remove `gsd-file-manifest.json` from the target - * directory, leaving a stale metadata file after uninstall. - * - * Fix: `uninstall()` must call - * fs.rmSync(path.join(targetDir, MANIFEST_NAME), { force: true }) - * after cleaning up the rest of the GSD artefacts. - */ - -'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('fs'); -const path = require('path'); -const os = require('os'); - -const { uninstall } = require('../bin/install.js'); - -const MANIFEST_NAME = 'gsd-file-manifest.json'; - -// ─── helpers ────────────────────────────────────────────────────────────────── - -function createFakeInstall(prefix = 'gsd-uninstall-test-') { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - - // Simulate the minimum directory/file layout produced by the installer: - // gsd-core/ directory, agents/ directory, and the manifest file. - fs.mkdirSync(path.join(dir, 'gsd-core', 'workflows'), { recursive: true }); - fs.writeFileSync(path.join(dir, 'gsd-core', 'workflows', 'execute-phase.md'), '# stub'); - - fs.mkdirSync(path.join(dir, 'agents'), { recursive: true }); - fs.writeFileSync(path.join(dir, 'agents', 'gsd-executor.md'), '# stub'); - - const manifest = { - version: '1.34.0', - timestamp: new Date().toISOString(), - files: { - 'gsd-core/workflows/execute-phase.md': 'abc123', - 'agents/gsd-executor.md': 'def456', - }, - }; - fs.writeFileSync(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2)); - - return dir; -} - -function cleanup(dir) { - // eslint-disable-next-line local/no-raw-rmsync-in-tests -- local teardown helper predates helpers.cjs; renaming would collide with the imported cleanup - try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} -} - -// ─── tests ──────────────────────────────────────────────────────────────────── - -describe('uninstall — manifest cleanup (#1908)', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createFakeInstall(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('gsd-file-manifest.json is removed after global uninstall', () => { - const manifestPath = path.join(tmpDir, MANIFEST_NAME); - - // Pre-condition: manifest exists before uninstall - assert.ok( - fs.existsSync(manifestPath), - 'Test setup failure: manifest file should exist before uninstall' - ); - - // Run uninstall against tmpDir (pass it via CLAUDE_CONFIG_DIR so getGlobalDir() - // resolves to our temp directory; pass isGlobal=true) - const savedEnv = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = tmpDir; - try { - uninstall(true, 'claude'); - } finally { - if (savedEnv === undefined) { - delete process.env.CLAUDE_CONFIG_DIR; - } else { - process.env.CLAUDE_CONFIG_DIR = savedEnv; - } - } - - assert.ok( - !fs.existsSync(manifestPath), - [ - `${MANIFEST_NAME} must be removed by uninstall() but still exists at`, - manifestPath, - ].join(' ') - ); - }); - - test('gsd-file-manifest.json is removed after local uninstall', () => { - const manifestPath = path.join(tmpDir, MANIFEST_NAME); - - assert.ok( - fs.existsSync(manifestPath), - 'Test setup failure: manifest file should exist before uninstall' - ); - - // For a local install, getGlobalDir is not called — targetDir = cwd + dirName. - // Simulate by creating .claude/ inside tmpDir and placing artefacts there. - const localDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(path.join(localDir, 'gsd-core', 'workflows'), { recursive: true }); - fs.writeFileSync(path.join(localDir, 'gsd-core', 'workflows', 'execute-phase.md'), '# stub'); - const localManifestPath = path.join(localDir, MANIFEST_NAME); - fs.writeFileSync(localManifestPath, JSON.stringify({ version: '1.34.0', files: {} }, null, 2)); - - const savedCwd = process.cwd(); - process.chdir(tmpDir); - try { - uninstall(false, 'claude'); - } finally { - process.chdir(savedCwd); - } - - assert.ok( - !fs.existsSync(localManifestPath), - [ - `${MANIFEST_NAME} must be removed by uninstall() (local) but still exists at`, - localManifestPath, - ].join(' ') - ); - }); -}); diff --git a/tests/bug-1924-preserve-user-artifacts.test.cjs b/tests/bug-1924-preserve-user-artifacts.test.cjs deleted file mode 100644 index dfacc6001..000000000 --- a/tests/bug-1924-preserve-user-artifacts.test.cjs +++ /dev/null @@ -1,278 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Workflow .md / agent .md / command .md / reference .md files — their text -// IS what the runtime loads. Testing text content tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. - -/** - * Regression tests for bug #1924: gsd-update silently deletes user-generated files - * - * Running the installer (gsd-update / re-install) must not delete: - * - gsd-core/USER-PROFILE.md (created by /gsd-profile-user) - * - commands/gsd/dev-preferences.md (created by /gsd-profile-user) - * - * Root cause: - * 1. copyWithPathReplacement() calls fs.rmSync(destDir, {recursive:true}) before - * copying — no preserve allowlist. This wipes USER-PROFILE.md. - * 2. ~line 5211 explicitly rmSync's commands/gsd/ during global install legacy - * cleanup — no preserve. This wipes dev-preferences.md. - * - * Fix requirement: - * - install() must preserve USER-PROFILE.md across the gsd-core/ wipe - * - install() must preserve dev-preferences.md across the commands/gsd/ wipe - * - * Closes: #1924 - */ - -'use strict'; - -const { describe, test, beforeEach, afterEach, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { execFileSync } = require('child_process'); - -const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); -const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); - -// ─── Ensure hooks/dist/ is populated before any install test ───────────────── - -before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); -}); - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function createTempDir(prefix) { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -function cleanup(dir) { - // eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup() helper wrapping rmSync; cannot use imported cleanup() without naming collision - try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } -} - -/** - * Run the installer with CLAUDE_CONFIG_DIR redirected to a temp directory. - * Explicitly removes GSD_TEST_MODE so the subprocess actually runs the installer - * (not just the export block). Uses --yes to suppress interactive prompts. - */ -function runInstaller(configDir) { - const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir }; - delete env.GSD_TEST_MODE; - // --no-sdk: this test covers user-artifact preservation only; skip SDK - // build (covered by install-smoke.yml) to keep the test deterministic. - execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], { - encoding: 'utf-8', - stdio: 'pipe', - env, - }); -} - -// ─── Test 1: USER-PROFILE.md is preserved across re-install ───────────────── - -describe('#1924: USER-PROFILE.md preserved across re-install (global Claude)', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-1924-userprofile-'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('USER-PROFILE.md exists after initial install + user creation', () => { - runInstaller(tmpDir); - - // Simulate /gsd-profile-user creating USER-PROFILE.md inside gsd-core/ - const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); - fs.writeFileSync(profilePath, '# My Profile\n\nCustom user content.\n'); - - assert.ok( - fs.existsSync(profilePath), - 'USER-PROFILE.md should exist after being created by /gsd-profile-user' - ); - }); - - test('USER-PROFILE.md is preserved after re-install', () => { - // First install - runInstaller(tmpDir); - - // User runs /gsd-profile-user, creating USER-PROFILE.md - const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); - const originalContent = '# My Profile\n\nThis is my custom user profile content.\n'; - fs.writeFileSync(profilePath, originalContent); - - // Re-run installer (simulating gsd-update) - runInstaller(tmpDir); - - assert.ok( - fs.existsSync(profilePath), - 'USER-PROFILE.md must survive re-install — gsd-update must not delete user-generated profiles' - ); - - const afterContent = fs.readFileSync(profilePath, 'utf8'); - assert.strictEqual( - afterContent, - originalContent, - 'USER-PROFILE.md content must be identical after re-install' - ); - }); - - test('USER-PROFILE.md is preserved even when gsd-core/ is wiped and recreated', () => { - runInstaller(tmpDir); - - const gsdDir = path.join(tmpDir, 'gsd-core'); - const profilePath = path.join(gsdDir, 'USER-PROFILE.md'); - - // Confirm gsd-core/ was created by install - assert.ok(fs.existsSync(gsdDir), 'gsd-core/ must exist after install'); - - // Write profile - fs.writeFileSync(profilePath, '# Profile\n\nMy coding style preferences.\n'); - - // Re-install - runInstaller(tmpDir); - - // gsd-core/ must still exist AND profile must be intact - assert.ok(fs.existsSync(gsdDir), 'gsd-core/ must still exist after re-install'); - assert.ok( - fs.existsSync(profilePath), - 'USER-PROFILE.md must still exist after gsd-core/ was wiped and recreated' - ); - }); -}); - -// ─── Test 2: dev-preferences.md is preserved across re-install ─────────────── - -describe('#1924: dev-preferences.md preserved across re-install (global Claude)', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-1924-devprefs-'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('dev-preferences.md is preserved when commands/gsd/ is cleaned up during re-install', () => { - // First install (creates skills/ structure for global Claude) - runInstaller(tmpDir); - - // User runs /gsd-profile-user — it creates dev-preferences.md in commands/gsd/ - const commandsGsdDir = path.join(tmpDir, 'commands', 'gsd'); - fs.mkdirSync(commandsGsdDir, { recursive: true }); - const devPrefsPath = path.join(commandsGsdDir, 'dev-preferences.md'); - const originalContent = '# Dev Preferences\n\nI prefer TDD. I like short functions.\n'; - fs.writeFileSync(devPrefsPath, originalContent); - - // Re-run installer (simulating gsd-update). - // In the layout-driven path (B2), legacy commands/gsd/ is removed and - // dev-preferences.md is migrated to skills/gsd-dev-preferences/SKILL.md (#2973). - runInstaller(tmpDir); - - // Content is migrated to the new canonical skills location (#2973). - // The old commands/gsd/ path is cleaned up; the skill file carries the content. - const devPrefSkillPath = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md'); - assert.ok( - fs.existsSync(devPrefSkillPath), - 'dev-preferences.md must be migrated to skills/gsd-dev-preferences/SKILL.md — gsd-update legacy cleanup must not silently drop user-generated content' - ); - - const afterContent = fs.readFileSync(devPrefSkillPath, 'utf8'); - assert.strictEqual( - afterContent, - originalContent, - 'migrated dev-preferences content must be identical to the original' - ); - }); - - test('legacy non-user GSD commands are still cleaned up during re-install', () => { - // First install - runInstaller(tmpDir); - - // Simulate a legacy GSD command file being left in commands/gsd/ - const commandsGsdDir = path.join(tmpDir, 'commands', 'gsd'); - fs.mkdirSync(commandsGsdDir, { recursive: true }); - const legacyFile = path.join(commandsGsdDir, 'next.md'); - fs.writeFileSync(legacyFile, '---\nname: gsd:next\n---\n\nLegacy content.'); - - // But dev-preferences.md is also there (user-generated) - const devPrefsContent = '# Dev Preferences\n\nMy preferences.\n'; - const devPrefsPath = path.join(commandsGsdDir, 'dev-preferences.md'); - fs.writeFileSync(devPrefsPath, devPrefsContent); - - // Re-install - runInstaller(tmpDir); - - // In the layout-driven path (B2), commands/gsd/ is fully removed but - // dev-preferences.md content is migrated to the new canonical skill location. - const devPrefSkillPath = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md'); - assert.ok( - fs.existsSync(devPrefSkillPath), - 'dev-preferences.md content must be migrated to skills/gsd-dev-preferences/SKILL.md' - ); - - // The legacy GSD command (next.md) is NOT user-generated, must be removed - // (it would exist only as a skill now in skills/gsd-next/SKILL.md) - assert.ok( - !fs.existsSync(legacyFile), - 'legacy GSD command next.md in commands/gsd/ must be removed during cleanup' - ); - }); -}); - -// ─── Test 3: profile-user.md backup path is outside gsd-core/ ─────────── - -describe('#1924: profile-user.md backup path must be outside gsd-core/', () => { - test('profile-user.md backup uses ~/.claude/USER-PROFILE.backup.md not ~/.claude/gsd-core/USER-PROFILE.backup.md', () => { - const workflowPath = path.join( - __dirname, '..', 'gsd-core', 'workflows', 'profile-user.md' - ); - const content = fs.readFileSync(workflowPath, 'utf8'); - - // The backup must NOT be inside gsd-core/ because that directory is wiped on update - assert.ok( - !content.includes('gsd-core/USER-PROFILE.backup.md'), - 'backup path must NOT be inside gsd-core/ — that directory is wiped on gsd-update' - ); - - // The backup should be at ~/.claude/USER-PROFILE.backup.md (outside gsd-core/) - assert.ok( - content.includes('USER-PROFILE.backup.md') && - !content.includes('/gsd-core/USER-PROFILE.backup.md'), - 'backup path must be outside gsd-core/ (e.g. ~/.claude/USER-PROFILE.backup.md)' - ); - }); -}); - -// ─── Test 4: preserveUserArtifacts helper exported from install.js ──────────── - -describe('#1924: preserveUserArtifacts helper exists in install.js', () => { - test('install.js exports preserveUserArtifacts function', () => { - // Set GSD_TEST_MODE so require() reaches the module.exports block - const origMode = process.env.GSD_TEST_MODE; - process.env.GSD_TEST_MODE = '1'; - let mod; - try { - mod = require(INSTALL_SCRIPT); - } finally { - if (origMode === undefined) { - delete process.env.GSD_TEST_MODE; - } else { - process.env.GSD_TEST_MODE = origMode; - } - } - - assert.strictEqual( - typeof mod.preserveUserArtifacts, - 'function', - 'install.js must export preserveUserArtifacts helper for testability' - ); - }); -}); diff --git a/tests/bug-2248-local-install-statusline.test.cjs b/tests/bug-2248-local-install-statusline.test.cjs deleted file mode 100644 index 4e49392ce..000000000 --- a/tests/bug-2248-local-install-statusline.test.cjs +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Regression test for #2248: local Claude install clobbers profile-level statusLine - * - * When installing with `--claude --local`, the repo-level `.claude/settings.json` - * takes precedence over the user's profile-level `~/.claude/settings.json` in - * Claude Code. Writing `statusLine` to repo settings during a local install - * silently overrides any profile-level statusLine the user configured. - * - * Fix: local installs skip writing `statusLine` to settings.json unless - * `--force-statusline` is passed. - * - * Note: `install()` only copies files. `finishInstall()` writes settings.json. - * The production code calls both from `installAllRuntimes()`. Tests must mirror - * that two-phase pattern. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, before, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { execFileSync } = require('child_process'); - -const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); -const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -const { install, finishInstall } = require(INSTALL_SRC); -const { cleanup, captureConsole } = require('./helpers.cjs'); - -// ─── Ensure hooks/dist/ is populated before install tests ──────────────────── -before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); -}); - -// ─── #2248: local install must NOT write statusLine to repo settings.json ──── - -describe('#2248: local Claude install does not clobber profile-level statusLine', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-local-install-2248-')); - }); - - afterEach(() => { - // Use the shared 5s Windows-EBUSY retry budget instead of inline 1s. - cleanup(tmpDir); - }); - - test('local install writes hooks to .claude/settings.local.json and does not write statusLine', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - // Phase 1: copy files (mirrors installAllRuntimes) - const result = install(false, 'claude'); - - // Phase 2: configure settings.local.json (mirrors installAllRuntimes → finalize) - // #338: local Claude installs now write to settings.local.json, not settings.json. - // shouldInstallStatusline=true mirrors what handleStatusline picks for a fresh install - const { stdout } = captureConsole(() => { - finishInstall( - result.settingsPath, - result.settings, - result.statuslineCommand, - true, // shouldInstallStatusline - 'claude', - false // isGlobal=false -> local install - ); - }); - assert.match( - stdout, - /Skipping statusLine for local install/, - 'Local install must explain that it skipped statusLine unless --force-statusline is passed' - ); - - // #338: local installs write to settings.local.json, not settings.json - const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); - assert.ok( - fs.existsSync(localSettingsPath), - '.claude/settings.local.json must exist after local Claude install (#338)' - ); - - const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); - assert.strictEqual( - settings.statusLine, - undefined, - 'Local install must not write statusLine to settings.local.json — it would clobber profile-level settings (#2248)' - ); - - // settings.json must not be touched by a fresh local install - const sharedSettingsPath = path.join(tmpDir, '.claude', 'settings.json'); - assert.strictEqual( - fs.existsSync(sharedSettingsPath), - false, - '.claude/settings.json must NOT be created by a fresh local Claude install (#338)' - ); - }); - - test('global install still writes statusLine to settings.json', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - - // Global install writes to CLAUDE_CONFIG_DIR; point it at our tmpDir - const configDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(configDir, { recursive: true }); - const origEnv = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = configDir; - t.after(() => { - if (origEnv === undefined) { - delete process.env.CLAUDE_CONFIG_DIR; - } else { - process.env.CLAUDE_CONFIG_DIR = origEnv; - } - }); - - // Phase 1: copy files - const result = install(true, 'claude'); - - // Phase 2: configure settings.json - finishInstall( - result.settingsPath, - result.settings, - result.statuslineCommand, - true, // shouldInstallStatusline - 'claude', - true // isGlobal=true - ); - - const settingsPath = path.join(configDir, 'settings.json'); - assert.ok( - fs.existsSync(settingsPath), - '~/.claude/settings.json must exist after global install' - ); - - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); - assert.ok( - settings.statusLine !== undefined, - 'Global install should write statusLine to settings.json' - ); - }); -}); diff --git a/tests/bug-2256-model-overrides-transport.test.cjs b/tests/bug-2256-model-overrides-transport.test.cjs deleted file mode 100644 index f6f6e37dc..000000000 --- a/tests/bug-2256-model-overrides-transport.test.cjs +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Regression tests for issue #2256 — per-agent model_overrides transport - * for Codex and OpenCode runtimes. - * - * The bug: model_overrides set in per-project `.planning/config.json` were - * never read by the Codex / OpenCode install paths, which only probed - * `~/.gsd/defaults.json`. As a result, the configured per-agent model was - * dropped and child agents inherited the runtime's default model. - * - * These tests lock in the fix: per-project overrides must be honored, and - * per-project keys must win over global when both are present. - */ - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const isWindows = process.platform === 'win32'; - -const { - readGsdEffectiveModelOverrides, - generateCodexAgentToml, - convertClaudeToOpencodeFrontmatter, - getCodexSkillAdapterHeader, -} = require('../bin/install.js'); - -const { createTempDir, cleanup } = require('./helpers.cjs'); -const makeTmp = (prefix) => createTempDir(`gsd-2256-${prefix}-`); - -function writeJson(p, obj) { - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.writeFileSync(p, JSON.stringify(obj, null, 2)); -} - -describe('bug #2256 — readGsdEffectiveModelOverrides', () => { - let projectDir; - let homeDir; - let origHome; - let origUserProfile; - - beforeEach(() => { - projectDir = makeTmp('proj'); - homeDir = makeTmp('home'); - origHome = process.env.HOME; - // On Windows, os.homedir() reads USERPROFILE (not HOME). Tests that - // need to redirect ~ must override both — otherwise the SUT reads - // the real user's home and the fixture is invisible. - origUserProfile = process.env.USERPROFILE; - process.env.HOME = homeDir; - if (isWindows) process.env.USERPROFILE = homeDir; - }); - - afterEach(() => { - if (origHome === undefined) delete process.env.HOME; - else process.env.HOME = origHome; - if (isWindows) { - if (origUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = origUserProfile; - } - cleanup(projectDir); - cleanup(homeDir); - }); - - test('returns null when neither source defines model_overrides', () => { - const result = readGsdEffectiveModelOverrides(projectDir); - assert.strictEqual(result, null); - }); - - test('reads overrides from ~/.gsd/defaults.json (global only)', () => { - writeJson(path.join(homeDir, '.gsd', 'defaults.json'), { - model_overrides: { 'gsd-codebase-mapper': 'gpt-5-mini' }, - }); - const result = readGsdEffectiveModelOverrides(projectDir); - assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'gpt-5-mini' }); - }); - - test('reads overrides from per-project .planning/config.json', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - model_overrides: { 'gsd-codebase-mapper': 'claude-haiku-4-5' }, - }); - const result = readGsdEffectiveModelOverrides(projectDir); - assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'claude-haiku-4-5' }); - }); - - test('per-project overrides win over global on conflict', () => { - writeJson(path.join(homeDir, '.gsd', 'defaults.json'), { - model_overrides: { 'gsd-codebase-mapper': 'global-model', 'gsd-planner': 'opus' }, - }); - writeJson(path.join(projectDir, '.planning', 'config.json'), { - model_overrides: { 'gsd-codebase-mapper': 'project-model' }, - }); - const result = readGsdEffectiveModelOverrides(projectDir); - // Per-project wins on conflict; non-conflicting global keys are preserved. - assert.deepStrictEqual(result, { - 'gsd-codebase-mapper': 'project-model', - 'gsd-planner': 'opus', - }); - }); - - test('walks up from nested targetDir to find .planning/', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - model_overrides: { 'gsd-planner': 'project-opus' }, - }); - const nested = path.join(projectDir, '.codex'); - fs.mkdirSync(nested, { recursive: true }); - const result = readGsdEffectiveModelOverrides(nested); - assert.deepStrictEqual(result, { 'gsd-planner': 'project-opus' }); - }); -}); - -describe('bug #2256 — Codex adapter embeds per-project override', () => { - const agentContent = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`; - - test('generateCodexAgentToml embeds model when override provided', () => { - const toml = generateCodexAgentToml( - 'gsd-codebase-mapper', - agentContent, - { 'gsd-codebase-mapper': 'gpt-5-mini' }, - ); - assert.match(toml, /^model = "gpt-5-mini"$/m); - }); - - test('generateCodexAgentToml omits model when no override', () => { - const toml = generateCodexAgentToml('gsd-codebase-mapper', agentContent, null); - assert.doesNotMatch(toml, /^model\s*=/m); - }); -}); - -describe('bug #2256 — OpenCode adapter embeds per-project override', () => { - test('convertClaudeToOpencodeFrontmatter embeds model on agent frontmatter', () => { - const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`; - const out = convertClaudeToOpencodeFrontmatter(input, { - isAgent: true, - modelOverride: 'claude-haiku-4-5', - }); - assert.match(out, /^model: claude-haiku-4-5$/m); - assert.match(out, /^mode: subagent$/m); - }); - - test('convertClaudeToOpencodeFrontmatter omits model when override absent', () => { - const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`; - const out = convertClaudeToOpencodeFrontmatter(input, { isAgent: true, modelOverride: null }); - assert.doesNotMatch(out, /^model:/m); - }); -}); - -describe('bug #2256 — Codex skill adapter header documents transport', () => { - test('Task(model=...) line no longer says "omit" without explanation', () => { - const header = getCodexSkillAdapterHeader('gsd-plan-phase'); - // Header must mention that per-agent model_overrides are embedded in agent - // TOML so spawn_agent picks them up automatically — the old text said - // "Codex uses per-role config, not inline model selection" which left - // users thinking their model_overrides were silently ignored. - assert.match(header, /model_overrides/); - }); -}); diff --git a/tests/bug-2376-opencode-windows-home-path.test.cjs b/tests/bug-2376-opencode-windows-home-path.test.cjs deleted file mode 100644 index 9e3d3d831..000000000 --- a/tests/bug-2376-opencode-windows-home-path.test.cjs +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Regression test for #2376: @$HOME not correctly mapped in OpenCode on Windows. - * - * On Windows, $HOME is not expanded by PowerShell/cmd.exe, so OpenCode cannot - * resolve @$HOME/... file references in installed command files. - * - * Fix: install.js must use the absolute path (not $HOME-relative) when installing - * for OpenCode. (Generalized to all platforms in #2831 — OpenCode `@file` - * references are not shell-expanded on any platform.) - */ - -'use strict'; - -const { describe, test, before, after } = require('node:test'); -const assert = require('node:assert/strict'); - -let computePathPrefix; - -before(() => { - process.env.GSD_TEST_MODE = '1'; - // Re-require fresh in case other tests already loaded it. - delete require.cache[require.resolve('../bin/install.js')]; - ({ computePathPrefix } = require('../bin/install.js')); -}); - -after(() => { - delete process.env.GSD_TEST_MODE; -}); - -describe('bug-2376: OpenCode on Windows must use absolute path, not $HOME', () => { - test('computePathPrefix is exported by install.js', () => { - assert.equal(typeof computePathPrefix, 'function'); - }); - - test('OpenCode on Windows: pathPrefix is absolute (no $HOME substitution)', () => { - const pathPrefix = computePathPrefix({ - isGlobal: true, - isOpencode: true, - isWindowsHost: true, - resolvedTarget: 'C:/Users/user/.config/opencode', - homeDir: 'C:/Users/user', - }); - assert.strictEqual(pathPrefix, 'C:/Users/user/.config/opencode/'); - assert.ok(!pathPrefix.includes('$HOME')); - }); - - test('Claude Code on Windows: pathPrefix still uses $HOME (unaffected)', () => { - const pathPrefix = computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: true, - resolvedTarget: 'C:/Users/user/.claude', - homeDir: 'C:/Users/user', - }); - assert.strictEqual(pathPrefix, '$HOME/.claude/'); - }); -}); diff --git a/tests/bug-2418-antigravity-bare-path.test.cjs b/tests/bug-2418-antigravity-bare-path.test.cjs deleted file mode 100644 index 168486158..000000000 --- a/tests/bug-2418-antigravity-bare-path.test.cjs +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Bug #2418: Found unreplaced .claude path reference(s) in Antigravity install - * - * The Antigravity path converter handles ~/.claude/ (with trailing slash) but - * misses bare ~/.claude (without trailing slash), leaving unreplaced references - * that cause the installer to warn about leaked paths. - * - * Files affected: agents/gsd-debugger.md (configDir = ~/.claude) and - * gsd-core/workflows/update.md (comment with e.g. ~/.claude). - */ - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); - -const { convertClaudeToAntigravityContent } = require('../bin/install.js'); - -describe('convertClaudeToAntigravityContent bare path replacement (#2418)', () => { - describe('global install', () => { - test('replaces ~/.claude (bare, no trailing slash) with ~/.gemini/antigravity', () => { - const input = 'configDir = ~/.claude'; - const result = convertClaudeToAntigravityContent(input, true); - assert.ok( - result.includes('~/.gemini/antigravity'), - `Expected ~/.gemini/antigravity in output, got: ${result}` - ); - assert.ok( - !result.includes('~/.claude'), - `Expected ~/ .claude to be replaced, got: ${result}` - ); - }); - - test('replaces $HOME/.claude (bare, no trailing slash) with $HOME/.gemini/antigravity', () => { - const input = 'export DIR=$HOME/.claude'; - const result = convertClaudeToAntigravityContent(input, true); - assert.ok( - result.includes('$HOME/.gemini/antigravity'), - `Expected $HOME/.gemini/antigravity in output, got: ${result}` - ); - assert.ok( - !result.includes('$HOME/.claude'), - `Expected $HOME/.claude to be replaced, got: ${result}` - ); - }); - - test('handles bare ~/.claude followed by comma (comment context)', () => { - const input = '# e.g. ~/.claude, ~/.config/opencode'; - const result = convertClaudeToAntigravityContent(input, true); - assert.ok( - !result.includes('~/.claude'), - `Expected ~/ .claude to be replaced in comment context, got: ${result}` - ); - }); - - test('still replaces ~/.claude/ (with trailing slash) correctly', () => { - const input = 'See ~/.claude/gsd-core/workflows/'; - const result = convertClaudeToAntigravityContent(input, true); - assert.ok( - result.includes('~/.gemini/antigravity/gsd-core/workflows/'), - `Expected path with trailing slash to be replaced, got: ${result}` - ); - assert.ok(!result.includes('~/.claude/'), `Expected ~/ .claude/ to be fully replaced, got: ${result}`); - }); - - test('does not double-replace ~/.claude/ paths', () => { - const input = 'See ~/.claude/gsd-core/'; - const result = convertClaudeToAntigravityContent(input, true); - // Result should contain exactly one occurrence of the replacement path - const count = (result.match(/~\/.gemini\/antigravity\//g) || []).length; - assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`); - }); - }); - - describe('local install', () => { - test('replaces ~/.claude (bare, no trailing slash) with .agents', () => { - const input = 'configDir = ~/.claude'; - const result = convertClaudeToAntigravityContent(input, false); - assert.ok( - result.includes('.agents'), - `Expected .agents in output, got: ${result}` - ); - assert.ok( - !result.includes('~/.claude'), - `Expected ~/ .claude to be replaced, got: ${result}` - ); - }); - - test('replaces $HOME/.claude (bare, no trailing slash) with .agents', () => { - const input = 'export DIR=$HOME/.claude'; - const result = convertClaudeToAntigravityContent(input, false); - assert.ok( - result.includes('.agents'), - `Expected .agents in output, got: ${result}` - ); - assert.ok( - !result.includes('$HOME/.claude'), - `Expected $HOME/.claude to be replaced, got: ${result}` - ); - }); - - test('does not double-replace ~/.claude/ paths', () => { - const input = 'See ~/.claude/gsd-core/'; - const result = convertClaudeToAntigravityContent(input, false); - // .agents/ should appear exactly once - const count = (result.match(/\.agents\//g) || []).length; - assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`); - }); - }); - - describe('installed files contain no bare ~/.claude references after conversion', () => { - const fs = require('fs'); - const path = require('path'); - const repoRoot = path.join(__dirname, '..'); - - // The scanner regex used by the installer to detect leaked paths - const leakedPathRegex = /(?:~|\$HOME)\/\.claude\b/g; - - function convertFile(filePath, isGlobal) { - const content = fs.readFileSync(filePath, 'utf8'); - return convertClaudeToAntigravityContent(content, isGlobal); - } - - test('gsd-debugger.md has no leaked ~/.claude after global Antigravity conversion', () => { - const debuggerPath = path.join(repoRoot, 'agents', 'gsd-debugger.md'); - if (!fs.existsSync(debuggerPath)) return; // skip if file doesn't exist - const converted = convertFile(debuggerPath, true); - const matches = converted.match(leakedPathRegex); - assert.strictEqual( - matches, null, - `gsd-debugger.md still contains leaked .claude paths after Antigravity conversion: ${matches}` - ); - }); - - test('update.md has no leaked ~/.claude after global Antigravity conversion', () => { - const updatePath = path.join(repoRoot, 'gsd-core', 'workflows', 'update.md'); - if (!fs.existsSync(updatePath)) return; // skip if file doesn't exist - const converted = convertFile(updatePath, true); - const matches = converted.match(leakedPathRegex); - assert.strictEqual( - matches, null, - `update.md still contains leaked .claude paths after Antigravity conversion: ${matches}` - ); - }); - }); -}); diff --git a/tests/bug-2545-copilot-unreplaced-paths.test.cjs b/tests/bug-2545-copilot-unreplaced-paths.test.cjs deleted file mode 100644 index 4d633fe62..000000000 --- a/tests/bug-2545-copilot-unreplaced-paths.test.cjs +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Regression test for issue #2545. - * - * The Copilot content converter's `~/.claude/` and `$HOME/.claude/` replacements - * only matched when a literal slash followed, so bare `~/.claude` references - * (end of line, quotes, punctuation) were left unreplaced. Those leaks then - * triggered the installer's "Found N unreplaced .claude path reference(s)" - * warning, which scans for `(?:~|$HOME)/\.claude\b`. - * - * Fix: replace with a word-boundary pattern so both forms are caught in a - * single pass, matching the approach already used by the Antigravity, OpenCode, - * Kilo, and Codex converters. - */ - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); - -const { convertClaudeToCopilotContent } = require('../bin/install.js'); - -describe('convertClaudeToCopilotContent — bare ~/.claude (issue #2545)', () => { - test('global install replaces bare ~/.claude at end of line', () => { - const input = 'configDir = ~/.claude\n'; - const out = convertClaudeToCopilotContent(input, /* isGlobal */ true); - assert.ok( - !/(?:~|\$HOME)\/\.claude\b/.test(out), - `expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`, - ); - assert.match(out, /~\/\.copilot\b/); - }); - - test('global install replaces bare $HOME/.claude at end of line', () => { - const input = 'configDir = $HOME/.claude\n'; - const out = convertClaudeToCopilotContent(input, /* isGlobal */ true); - assert.ok( - !/(?:~|\$HOME)\/\.claude\b/.test(out), - `expected no leaked $HOME/.claude reference, got: ${JSON.stringify(out)}`, - ); - assert.match(out, /\$HOME\/\.copilot\b/); - }); - - test('global install replaces bare ~/.claude before punctuation', () => { - const input = 'paths include `~/.claude`, `~/.copilot`'; - const out = convertClaudeToCopilotContent(input, true); - assert.ok(!/(?:~|\$HOME)\/\.claude\b/.test(out)); - }); - - test('local install replaces bare ~/.claude', () => { - const input = 'configDir = ~/.claude\n'; - const out = convertClaudeToCopilotContent(input, /* isGlobal */ false); - assert.ok( - !/(?:~|\$HOME)\/\.claude\b/.test(out), - `expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`, - ); - }); - - test('does not double-replace trailing-slash form', () => { - const input = '@~/.claude/gsd-core/foo.md\n'; - const out = convertClaudeToCopilotContent(input, true); - assert.match(out, /~\/\.copilot\/gsd-core\/foo\.md/); - assert.ok(!/\.copilot\/\.copilot/.test(out)); - }); -}); diff --git a/tests/bug-2643-skill-frontmatter-name.test.cjs b/tests/bug-2643-skill-frontmatter-name.test.cjs deleted file mode 100644 index 6b4b62e28..000000000 --- a/tests/bug-2643-skill-frontmatter-name.test.cjs +++ /dev/null @@ -1,206 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #2643 / #2808: skill frontmatter name parity. - * - * Original (#2643): workflows emitted Skill(skill="gsd:") and the - * installer registered colon form in SKILL.md name: to match. - * - * Updated (#2808): workflows now use Skill(skill="gsd-") (hyphen), - * and the installer emits name: gsd- (hyphen). Claude Code autocomplete - * now shows the canonical hyphen form instead of the deprecated colon form. - * The directory name (gsd-) is unchanged. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const { - convertClaudeCommandToClaudeSkill, - skillFrontmatterName, -} = require(path.join(ROOT, 'bin', 'install.js')); - -const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows'); -const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); - -function collectFiles(dir, results) { - if (!results) results = []; - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; } - for (const e of entries) { - const full = path.join(dir, e.name); - if (e.isDirectory()) collectFiles(full, results); - else if (e.name.endsWith('.md')) results.push(full); - } - return results; -} - -/** - * Extract every `Skill(skill="")` invocation as a structured record. - * - * Per project test rigor (`feedback_no_source_grep_tests.md`), this parses - * each call as a unit instead of leaning on a single regex over raw bytes. - * The flow is: - * - * 1. Strip HTML comments so commented-out examples don't count as drift. - * 2. Walk the content for `Skill(` openers; for each, find the matching - * `)` closer (Skill bodies are simple kwarg lists, no nesting). - * 3. Parse the call body for the `skill = "..."` keyword argument. - * Permissive whitespace around the keyword and `=`, permissive - * single/double quoting (with optional `\` escapes from string- - * embedded examples), permissive name body — so malformed drift like - * `Skill(skill="gsd:extract_learnings")` is surfaced rather than - * silently skipped by an over-strict character class. - * - * Returns `[{ name, raw }]` per call. Filtering by namespace (gsd- vs gsd:) - * happens at the call site so the extractor stays neutral. - */ -function extractSkillCalls(content) { - // regex-free HTML-comment stripper (CodeQL: avoid incomplete-multi-character-sanitization) - let stripped = ''; - { - let rest = content; - let idx; - while ((idx = rest.indexOf('', idx + 4); - if (end === -1) { rest = ''; break; } - rest = rest.slice(end + 3); - } - stripped += rest; - } - const calls = []; - // Body class excludes backslash so the extractor doesn't include an - // escape character that precedes the closing quote in embedded examples - // (e.g. `Skill(skill=\"gsd-plan-phase\", …)` written inside a string - // context). A trailing `\` is permitted on the closing-quote side via the - // optional `\\?` so both `\"` and `"` close the value cleanly. - const argRe = /^\s*skill\s*=\s*\\?(['"])([^'"\\]+)\\?\1/i; - let i = 0; - while (i < stripped.length) { - const open = stripped.indexOf('Skill(', i); - if (open === -1) break; - const close = stripped.indexOf(')', open); - if (close === -1) break; - const body = stripped.slice(open + 'Skill('.length, close); - const match = body.match(argRe); - if (match) calls.push({ name: match[2], raw: stripped.slice(open, close + 1) }); - i = close + 1; - } - return calls; -} - -function extractSkillNamesHyphen(content) { - return new Set( - extractSkillCalls(content) - .map((c) => c.name) - .filter((n) => n.startsWith('gsd-')), - ); -} - -function extractSkillNamesColon(content) { - return new Set( - extractSkillCalls(content) - .map((c) => c.name) - .filter((n) => n.startsWith('gsd:')), - ); -} - -describe('skill frontmatter name parity (#2643 / #2808)', () => { - test('skillFrontmatterName helper emits hyphen form (#2808)', () => { - assert.strictEqual(typeof skillFrontmatterName, 'function'); - assert.strictEqual(skillFrontmatterName('gsd-execute-phase'), 'gsd-execute-phase'); - assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd-plan-phase'); - assert.strictEqual(skillFrontmatterName('gsd-next'), 'gsd-next'); - }); - - test('convertClaudeCommandToClaudeSkill emits name: gsd- (hyphen)', () => { - const input = '---\nname: old\ndescription: test\n---\n\nBody.'; - const result = convertClaudeCommandToClaudeSkill(input, 'gsd-execute-phase'); - // Parse the frontmatter block structurally: extract the name: field value. - const frontmatterMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/); - assert.ok(frontmatterMatch, 'output must have a frontmatter block delimited by ---'); - const frontmatterLines = frontmatterMatch[1].split(/\r?\n/); - const nameEntry = frontmatterLines.find((l) => l.startsWith('name:')); - assert.ok(nameEntry, 'frontmatter must contain a name: field'); - const nameValue = nameEntry.replace(/^name:\s*/, '').trim(); - assert.strictEqual( - nameValue, - 'gsd-execute-phase', - `frontmatter name: must be 'gsd-execute-phase' (hyphen form), got '${nameValue}'` - ); - }); - - test('no workflow uses deprecated Skill(skill="gsd:") colon form', () => { - const workflowFiles = collectFiles(WORKFLOWS_DIR); - const colonRefs = []; - for (const f of workflowFiles) { - const src = fs.readFileSync(f, 'utf-8'); - for (const n of extractSkillNamesColon(src)) { - colonRefs.push(path.basename(f) + ': ' + n); - } - } - assert.deepStrictEqual( - colonRefs, - [], - 'deprecated colon-form Skill() calls found (update to hyphen): ' + colonRefs.join(', ') - ); - }); - - test('every workflow Skill(skill="gsd-") resolves to an emitted skill name', () => { - const workflowFiles = collectFiles(WORKFLOWS_DIR); - const referenced = new Set(); - const templatedSkipped = []; - for (const f of workflowFiles) { - const src = fs.readFileSync(f, 'utf-8'); - for (const n of extractSkillNamesHyphen(src)) { - // Skip template expressions (e.g. `gsd-${ref.skill}`): these are - // capability-dispatched — the skill stem is resolved at runtime from - // the `loop render-hooks` registry output (ADR-857 phase 6), so there - // is no single literal skill file to validate against here. - // The capability registry's own validateStep gate (gen-capability-registry.cjs) - // is responsible for ensuring each `steps[].ref.skill` corresponds to a - // real skill declared in the capability's `skills` array. - if (n.includes('${')) { - templatedSkipped.push(path.basename(f) + ': ' + n); - } else { - referenced.add(n); - } - } - } - assert.ok( - referenced.size > 0, - `expected at least one literal Skill(skill="gsd-") reference in workflows under ${WORKFLOWS_DIR}` - ); - - const emitted = new Set(); - const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md')); - for (const cmd of cmdFiles) { - const base = cmd.replace(/\.md$/, ''); - const skillDirName = 'gsd-' + base; - const src = fs.readFileSync(path.join(COMMANDS_DIR, cmd), 'utf-8'); - const out = convertClaudeCommandToClaudeSkill(src, skillDirName); - const m = out.match(/^---\r?\nname:\s*(.+)$/m); - if (m) emitted.add(m[1].trim()); - } - - const missing = []; - for (const r of referenced) if (!emitted.has(r)) missing.push(r); - assert.deepStrictEqual( - missing, - [], - 'workflow refs not emitted as skill names: ' + missing.join(', '), - ); - // Informational: report how many templated dispatches were intentionally skipped. - // (Templated names are validated by the capability registry, not statically here.) - if (templatedSkipped.length > 0) { - // Not a failure — just a note for test output transparency. - // Use a diagnostic comment: node:test does not have a skip-within-test API. - } - }); -}); diff --git a/tests/bug-2698-crlf-install.test.cjs b/tests/bug-2698-crlf-install.test.cjs deleted file mode 100644 index 2c63ac6fc..000000000 --- a/tests/bug-2698-crlf-install.test.cjs +++ /dev/null @@ -1,186 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Workflow .md / agent .md / command .md / reference .md files — their text -// IS what the runtime loads. Testing text content tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. - -/** - * Regression test for #2698: CRLF line endings break agent-block strip regexes - * - * The legacy `gsd-update-check` hook migration in bin/install.js uses two - * separate .replace() calls: - * 1. LF-only regex: /\n# GSD Hooks\n\[\[hooks\]\]\nevent = ...\n/ - * 2. CRLF-only regex: /\r\n# GSD Hooks\r\n\[\[hooks\]\]\r\nevent = ...\r\n/ - * - * These patterns fail when config.toml has mixed line endings — e.g. the - * "# GSD Hooks" header uses LF but the body uses CRLF, or vice versa. This - * can happen when the file is created cross-platform (Windows/Linux), when - * editors convert only part of the file, or when a previous GSD version wrote - * the block with different EOL than the file's dominant EOL. - * - * Fix: consolidate to a single \r?\n-aware regex that handles LF, CRLF, and - * any mix in a single pass, making the migration robust regardless of the - * platform the file was last written on. - * - * Test approach: write a `.codex/config.toml` with a stale gsd-update-check - * block that uses mixed line endings (header in LF, body in CRLF), then run - * install() and assert the stale block is gone. - * - * Note: The local Codex install writes to `.codex/` in the current directory. - * Tests `process.chdir(tmpDir)` and write fixtures to `tmpDir/.codex/`. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, before, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { execFileSync } = require('child_process'); - -const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); -const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -const { install, GSD_CODEX_MARKER } = require(INSTALL_SRC); -const { cleanup } = require('./helpers.cjs'); - -// Ensure hooks/dist/ is populated before install tests -before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); -}); - -describe('#2698: CRLF stale gsd-update-check block is removed on Codex reinstall', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-crlf-install-2698-')); - }); - - afterEach(() => { - // Use the shared 5s Windows-EBUSY retry budget instead of inline 1s. - cleanup(tmpDir); - }); - - // Helper: pre-populate .codex/config.toml with a GSD marker + stale hooks block - // using the given line ending for the stale hooks block header, and a potentially - // different EOL for the hooks body. This exercises the cross-platform mixed scenario. -function writeCodexConfigWithStaleHooks(dir, headerEol, bodyEol) { - // Build the stale block with header EOL for the "# GSD Hooks" line, but body EOL - // for the content lines (simulates a file edited by two different platforms). - const staleBlock = [ - '# GSD Hooks', // line that starts the stale section - '[[hooks]]', - 'event = "SessionStart"', - 'command = "node /old/path/gsd-update-check.js"', - ].join(bodyEol); - - // Put the stale block in user content BEFORE the GSD marker. The GSD marker area - // will be regenerated by mergeCodexConfig during install(); the stale block in - // the user area is what the hooks migration must remove. - const content = [ - '[features]', - 'codex_hooks = true', - '', - ].join(headerEol) + headerEol + staleBlock + headerEol + headerEol + GSD_CODEX_MARKER + headerEol; - - const codexDir = path.join(dir, '.codex'); - fs.mkdirSync(codexDir, { recursive: true }); - const configPath = path.join(codexDir, 'config.toml'); - fs.writeFileSync(configPath, content, 'utf-8'); - return configPath; - } - - function readHooksSessionStartCommands(codexHome) { - const hooksPath = path.join(codexHome, 'hooks.json'); - if (!fs.existsSync(hooksPath)) return []; - const raw = fs.readFileSync(hooksPath, 'utf8').trim(); - if (!raw) return []; - const parsed = JSON.parse(raw); - const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) - ? parsed.hooks - : parsed; - const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; - return sessionStart.flatMap((entry) => [ - ...(typeof entry?.command === 'string' ? [entry.command] : []), - ...(Array.isArray(entry?.hooks) - ? entry.hooks.map((hook) => hook && hook.command).filter((cmd) => typeof cmd === 'string') - : []), - ]); - } - - test('LF config.toml: stale gsd-update-check block removed on reinstall', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - writeCodexConfigWithStaleHooks(tmpDir, '\n', '\n'); - install(false, 'codex'); - - const configPath = path.join(tmpDir, '.codex', 'config.toml'); - const content = fs.readFileSync(configPath, 'utf-8'); - - assert.ok( - !content.includes('gsd-update-check'), - 'Stale gsd-update-check entry must be removed from LF config.toml (#2698)' - ); - const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex')); - assert.equal( - hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')), - true, - 'New gsd-check-update hook must appear in hooks.json after reinstall' - ); - }); - - test('CRLF config.toml: stale gsd-update-check block removed on reinstall', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - writeCodexConfigWithStaleHooks(tmpDir, '\r\n', '\r\n'); - install(false, 'codex'); - - const configPath = path.join(tmpDir, '.codex', 'config.toml'); - const content = fs.readFileSync(configPath, 'utf-8'); - - assert.ok( - !content.includes('gsd-update-check'), - 'Stale gsd-update-check entry must be removed from CRLF config.toml (#2698)' - ); - const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex')); - assert.equal( - hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')), - true, - 'New gsd-check-update hook must appear in hooks.json after reinstall' - ); - }); - - test('mixed-EOL config.toml: stale block with LF header but CRLF body removed on reinstall', (t) => { - // This is the primary failure case: header line uses LF but the body uses CRLF. - // The old LF-only regex requires all-\n separators; the old CRLF-only regex requires - // all-\r\n separators. Neither matches a block with mixed endings, so the stale - // block survives reinstall with the old code (#2698). - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - // headerEol='\n' (file dominant), bodyEol='\r\n' (hook block from another platform) - writeCodexConfigWithStaleHooks(tmpDir, '\n', '\r\n'); - install(false, 'codex'); - - const configPath = path.join(tmpDir, '.codex', 'config.toml'); - const content = fs.readFileSync(configPath, 'utf-8'); - - assert.ok( - !content.includes('gsd-update-check'), - [ - 'Stale gsd-update-check block with mixed LF/CRLF endings must be removed (#2698).', - 'Old code used two separate LF-only and CRLF-only regexes; neither matched mixed content.', - 'Fix consolidates to a single \\r?\\n-aware regex.', - ].join(' ') - ); - }); -}); diff --git a/tests/bug-2760-codex-install-defensive.test.cjs b/tests/bug-2760-codex-install-defensive.test.cjs deleted file mode 100644 index 6e9fcd85e..000000000 --- a/tests/bug-2760-codex-install-defensive.test.cjs +++ /dev/null @@ -1,1087 +0,0 @@ -/** - * Regression: issue #2760 — Codex install path corrupts existing config.toml. - * - * Three defects, three fixes (defensive triple): - * - * Defect 3 (confirmed real) — Hooks AoT downgrade. When the user already has - * `[[hooks.SessionStart]]` (namespaced AoT) entries in their config, GSD - * used to append a `[[hooks]]` (top-level AoT) block that confuses - * round-trip writers and produces a config Codex refuses to load. - * Fix: detect the user's preferred shape and emit GSD's hook in the same - * namespaced form so both coexist cleanly. - * - * Defects 1+2 (defensive) — Strip-step robustness. Pre-existing legacy - * `[agents]` (single-bracket) and `[[agents]]` (sequence) blocks are - * invalid in current Codex schema and break Codex even though GSD now - * emits the correct `[agents.]` struct form. Fix: install-time - * stripping always purges these forms regardless of GSD marker presence - * so reinstall self-heals files where the marker was edited out or never - * existed (third-party tools). - * - * Fix 3 (defensive) — Post-write validation. Parse the bytes we are about - * to commit, assert they match Codex's expected schema (no bare/sequence - * `agents`, no bare `hooks.`); on failure, restore the pre-install - * backup and abort so the user never gets a broken Codex CLI. - */ - -// Scope GSD_TEST_MODE to module load only — restore prior value (or unset) so -// downstream tests in the same node process never see test-only behaviour -// leak through (#2760 CR4 finding 5). -const previousGsdTestMode = process.env.GSD_TEST_MODE; -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -const { - install, - validateCodexConfigSchema, - hasUserNamespacedAotHooks, - parseTomlToObject, -} = require('../bin/install.js'); - -const { cleanup } = require('./helpers.cjs'); - -if (previousGsdTestMode === undefined) { - delete process.env.GSD_TEST_MODE; -} else { - process.env.GSD_TEST_MODE = previousGsdTestMode; -} - -function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) { - const previousCodeHome = process.env.CODEX_HOME; - const previousCwd = process.cwd(); - process.env.CODEX_HOME = codexHome; - try { - process.chdir(cwd); - return install(true, 'codex'); - } finally { - process.chdir(previousCwd); - if (previousCodeHome === undefined) { - delete process.env.CODEX_HOME; - } else { - process.env.CODEX_HOME = previousCodeHome; - } - } -} - -function readCodexConfig(codexHome) { - return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); -} - -function writeCodexConfig(codexHome, content) { - fs.mkdirSync(codexHome, { recursive: true }); - fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); -} - -function readCodexHooksJson(codexHome) { - const hooksPath = path.join(codexHome, 'hooks.json'); - if (!fs.existsSync(hooksPath)) return {}; - const raw = fs.readFileSync(hooksPath, 'utf8').trim(); - if (!raw) return {}; - return JSON.parse(raw); -} - -function readHooksSessionStartCommands(codexHome) { - const parsed = readCodexHooksJson(codexHome); - const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) - ? parsed.hooks - : parsed; - const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; - return sessionStart.flatMap((entry) => - (Array.isArray(entry?.hooks) ? entry.hooks : []) - .map((hook) => hook && hook.command) - .filter((cmd) => typeof cmd === 'string') - ); -} - -describe('#2760 defect 3 — Hooks AoT preservation across install/uninstall/reinstall', () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-d3-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('fresh install emits the two-level nested AoT schema (#2773)', () => { - // Codex 0.124.0+ requires [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]] - // with type = "command". Neither the flat [[hooks]] + event field form nor - // the single-block [[hooks.SessionStart]] form without .hooks is accepted. - writeCodexConfig(codexHome, ''); - runCodexInstall(codexHome); - const content = readCodexConfig(codexHome); - const parsed = parseTomlToObject(content); - - const sessionStartCommands = readHooksSessionStartCommands(codexHome); - const managed = sessionStartCommands.filter((cmd) => /gsd-check-update/.test(cmd)); - assert.equal(managed.length, 1, 'hooks.json must contain exactly one managed gsd-check-update command'); - assert.ok( - !parsed.hooks || !Array.isArray(parsed.hooks.SessionStart), - 'config.toml should not carry managed SessionStart hooks for GSD' - ); - }); - - test('preserves user [[hooks.SessionStart]] entries and registers managed GSD handler in hooks.json', () => { - // Users may have their own [[hooks.SessionStart]] entries using the new schema. - // GSD must append its own two-level block without disturbing theirs. - const userConfig = [ - '[[hooks.SessionStart]]', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "echo first user hook"', - '', - '[[hooks.SessionStart]]', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "echo second user hook"', - '', - ].join('\n'); - writeCodexConfig(codexHome, userConfig); - - runCodexInstall(codexHome); - const afterInstall = readCodexConfig(codexHome); - const parsed = parseTomlToObject(afterInstall); - - assert.ok( - parsed.hooks && Array.isArray(parsed.hooks.SessionStart), - 'hooks.SessionStart must remain an array-of-tables after install' - ); - - // Collect all handler commands across all event entries. - const allCommands = parsed.hooks.SessionStart.flatMap((entry) => - Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command) : [] - ); - - assert.ok( - allCommands.includes('echo first user hook'), - 'first user hook preserved: ' + JSON.stringify(allCommands) - ); - assert.ok( - allCommands.includes('echo second user hook'), - 'second user hook preserved: ' + JSON.stringify(allCommands) - ); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - assert.ok( - hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)), - 'GSD handler must appear in hooks.json SessionStart entries: ' + JSON.stringify(hooksJsonCommands) - ); - assert.ok(!Array.isArray(parsed.hooks), 'no flat [[hooks]] entries'); - }); - - test('reinstall replaces flat [[hooks]] + event form with nested schema', () => { - // Upgrade path: user has a config written by GSD 1.38.x (flat [[hooks]] form). - const legacyConfig = [ - '[features]', - 'codex_hooks = true', - '', - '# GSD Hooks', - '[[hooks]]', - 'event = "SessionStart"', - 'command = "node /old/path/to/gsd-check-update.js"', - '', - ].join('\n'); - writeCodexConfig(codexHome, legacyConfig); - - runCodexInstall(codexHome); - const content = readCodexConfig(codexHome); - const parsed = parseTomlToObject(content); - - // Old flat form must be gone. - assert.ok(!Array.isArray(parsed.hooks), 'flat [[hooks]] must be stripped on upgrade'); - // Only one GSD hook entry must exist (no duplication) in hooks.json. - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); - assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed handler after upgrade'); - }); - - test('reinstall replaces single-block [[hooks.SessionStart]] (no .hooks sub-table) with nested schema', () => { - // Upgrade path: user has a config written by the PR #2802 shape — - // [[hooks.SessionStart]] without a nested [[hooks.SessionStart.hooks]] sub-table. - const prBranchConfig = [ - '[features]', - 'codex_hooks = true', - '', - '# GSD Hooks', - '[[hooks.SessionStart]]', - 'command = "node /old/path/to/gsd-check-update.js"', - '', - ].join('\n'); - writeCodexConfig(codexHome, prBranchConfig); - - runCodexInstall(codexHome); - const content = readCodexConfig(codexHome); - parseTomlToObject(content); - - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); - assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed handler after upgrade from PR-#2802-shape'); - }); - - test('reinstall is idempotent: correct nested schema is stripped and re-emitted cleanly', () => { - writeCodexConfig(codexHome, ''); - runCodexInstall(codexHome); - runCodexInstall(codexHome); // second install - readCodexConfig(codexHome); - - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); - assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed SessionStart handler after double install'); - }); -}); - -describe('#2760 fix 2 — Strip purges invalid legacy [agents] / [[agents]] regardless of marker', () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f2-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('strips bare [agents] single-bracket block (no GSD marker, arbitrary user keys)', () => { - writeCodexConfig(codexHome, [ - '[agents]', - 'default = "custom-agent"', - 'extra_key = "value"', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - const content = readCodexConfig(codexHome); - const parsed = parseTomlToObject(content); - - // Bare [agents] would have left { default, extra_key } as scalar leaves - // on parsed.agents. After strip + struct emit, every key under agents - // must itself be a table (the gsd-* struct form). - assert.ok( - parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents), - 'agents must be a table-of-tables in parsed structure, got: ' + typeof parsed.agents - ); - assert.equal(parsed.agents.default, undefined, 'bare [agents] default key must be stripped'); - assert.equal(parsed.agents.extra_key, undefined, 'bare [agents] extra_key must be stripped'); - const gsdAgents = Object.keys(parsed.agents).filter((k) => k.startsWith('gsd-')); - assert.ok( - gsdAgents.length > 0 && gsdAgents.every((k) => typeof parsed.agents[k] === 'object'), - 'agents.gsd-* struct form must be present: ' + JSON.stringify(Object.keys(parsed.agents)) - ); - - // User's unrelated [model] section preserved structurally. - assert.ok( - parsed.model && parsed.model.name === 'o3', - 'unrelated user [model] section preserved with name = "o3", got: ' + JSON.stringify(parsed.model) - ); - }); - - test('strips [[agents]] sequence-form block without GSD marker (third-party / marker-edited-out)', () => { - writeCodexConfig(codexHome, [ - '[[agents]]', - 'name = "user-helper"', - 'description = "third-party agent"', - '', - '[[agents]]', - 'name = "another-helper"', - 'description = "second one"', - '', - '[projects."/tmp/x"]', - 'trust_level = "trusted"', - '', - ].join('\n')); - - runCodexInstall(codexHome); - const content = readCodexConfig(codexHome); - const parsed = parseTomlToObject(content); - - // [[agents]] sequence form would parse to Array — after strip it must be - // a table-of-tables with gsd-* struct keys. - assert.ok( - parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents), - 'agents must be a table-of-tables in parsed structure (sequence form must be stripped), got: ' - + (Array.isArray(parsed.agents) ? 'array' : typeof parsed.agents) - ); - const gsdAgents = Object.keys(parsed.agents).filter((k) => k.startsWith('gsd-')); - assert.ok( - gsdAgents.length > 0, - 'agents.gsd-* struct form must be present: ' + JSON.stringify(Object.keys(parsed.agents)) - ); - - // User's unrelated [projects."/tmp/x"] section preserved structurally. - assert.ok( - parsed.projects && parsed.projects['/tmp/x'] && parsed.projects['/tmp/x'].trust_level === 'trusted', - 'unrelated user [projects."/tmp/x"] section preserved with trust_level = "trusted", got: ' - + JSON.stringify(parsed.projects) - ); - }); -}); - -// concurrency: false — the third test mutates installModule.__codexSchemaValidator, -// a module-level test seam. Other tests in this file (and in bug-2153, etc.) -// also call runCodexInstall() and would observe the injected validator if -// node:test ran them in parallel. Serializing this describe block keeps the -// seam mutation invisible to siblings. -describe('#2760 fix 3 — Post-write Codex schema validation', { concurrency: false }, () => { - test('passes a clean config produced by GSD install', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f3a-')); - try { - const codexHome = path.join(tmpDir, 'codex-home'); - runCodexInstall(codexHome); - const content = readCodexConfig(codexHome); - const result = validateCodexConfigSchema(content); - assert.equal(result.ok, true, 'GSD-emitted config passes schema validation'); - } finally { - cleanup(tmpDir); - } - }); - - test('rejects bare [agents] and bare [hooks.SessionStart] in arbitrary content', () => { - const bareAgents = [ - '[agents]', - 'default = "x"', - '', - ].join('\n'); - const bareHooks = [ - '[hooks.SessionStart]', - 'command = "x"', - '', - ].join('\n'); - const sequenceAgents = [ - '[[agents]]', - 'name = "x"', - '', - ].join('\n'); - - assert.equal(validateCodexConfigSchema(bareAgents).ok, false, 'bare [agents] rejected'); - assert.equal(validateCodexConfigSchema(bareHooks).ok, false, 'bare [hooks.SessionStart] rejected'); - assert.equal(validateCodexConfigSchema(sequenceAgents).ok, false, '[[agents]] sequence rejected'); - }); - - test('aborts install and restores pre-install backup when post-write validation fails', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f3b-')); - const installModule = require('../bin/install.js'); - try { - const codexHome = path.join(tmpDir, 'codex-home'); - // Pre-install file the user wants protected. - const preInstall = [ - '# user file', - '[model]', - 'name = "o3"', - '', - ].join('\n'); - writeCodexConfig(codexHome, preInstall); - - // Force the post-write validator to fail via the documented test seam. - // This simulates the writer producing legacy-form output that Codex - // would reject — install MUST abort, restore the pre-install bytes, - // and surface a clear error. - installModule.__codexSchemaValidator = () => ({ - ok: false, - reason: 'simulated invalid output for test', - }); - - let threw = false; - try { - runCodexInstall(codexHome); - } catch (e) { - threw = true; - assert.match( - e.message, - /post-write Codex schema validation failed/, - 'thrown error names the validation failure' - ); - assert.match(e.message, /simulated invalid output for test/, 'thrown error includes reason'); - } - assert.equal(threw, true, 'install threw when validator failed'); - - const afterInstall = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); - assert.equal( - afterInstall, - preInstall, - 'pre-install file restored verbatim after validation failure' - ); - } finally { - delete installModule.__codexSchemaValidator; - cleanup(tmpDir); - } - }); -}); - -describe('#2760 — hasUserNamespacedAotHooks helper', () => { - test('detects [[hooks.SessionStart]] AoT entries', () => { - const content = [ - '[[hooks.SessionStart]]', - 'command = "x"', - '', - ].join('\n'); - assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), true); - }); - - test('returns false when only top-level [[hooks]] entries exist', () => { - const content = [ - '[[hooks]]', - 'event = "SessionStart"', - 'command = "x"', - '', - ].join('\n'); - assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), false); - }); - - test('returns false when only single-bracket [hooks.SessionStart] exists', () => { - const content = [ - '[hooks.SessionStart]', - 'command = "x"', - '', - ].join('\n'); - assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), false); - }); -}); - -// concurrency: false — these tests monkey-patch fs.writeFileSync, a global -// shared with every other suite running in parallel. Serializing prevents -// stray writes from sibling tests landing in the stub. -describe('#2760 fix 4 — Write-failure rollback (atomic write + snapshot restore)', { concurrency: false }, () => { - let tmpDir; - let codexHome; - let originalWriteFileSync; - // #2760 CR5 finding 5 — symmetric snapshot/restore for fs.renameSync. The - // first test below monkey-patches renameSync; without a beforeEach/afterEach - // pair, only the local `finally` restores it, which is fragile to future - // edits that add early-return paths. - let originalRenameSync; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f4-')); - codexHome = path.join(tmpDir, 'codex-home'); - originalWriteFileSync = fs.writeFileSync; - originalRenameSync = fs.renameSync; - }); - - afterEach(() => { - fs.renameSync = originalRenameSync; - fs.writeFileSync = originalWriteFileSync; - cleanup(tmpDir); - }); - - test('pre-install config bytes survive when fs.renameSync throws over configPath', () => { - const preInstall = [ - '# user file', - '[model]', - 'name = "o3"', - '', - ].join('\n'); - writeCodexConfig(codexHome, preInstall); - - // After fs is restored we'll re-read the file. Capture the byte buffer - // exactly so the comparison is bit-for-bit. - const preInstallBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); - - const configPath = path.join(codexHome, 'config.toml'); - const tempPattern = new RegExp('^' + configPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.tmp-'); - - // Stub: allow writes to atomic temp files (which renameSync overwrites - // the target, never truncating it directly) but throw on any direct - // write to the canonical configPath. This simulates either: - // (a) an older code path doing a non-atomic write, or - // (b) a downstream module bypassing atomicWriteFileSync. - // Either way the snapshot must be restored. We let the temp write go - // through, then make renameSync throw to simulate the partial write - // never landing. - // #2760 CR5 finding 5 — fs.renameSync is restored by the suite-level - // afterEach; no local finally needed. - fs.renameSync = (src, dst) => { - if (dst === configPath) { - throw new Error('simulated rename failure mid-install'); - } - return originalRenameSync(src, dst); - }; - - let threw = false; - let thrownErr = null; - try { - runCodexInstall(codexHome); - } catch (e) { - threw = true; - thrownErr = e; - assert.ok(/rename failure|simulated|post-write/.test(e.message), - 'thrown error must surface the simulated failure or its post-write wrapper: ' + e.message); - } - // #2760 CR5 finding 4 — tighten contract per finding #1: ALL pre-write - // and write failures must be fatal. This test previously accepted either - // throw OR warn — sibling tests already require throw, so lock parity. - assert.equal(threw, true, 'rename failure must be fatal: ' + (thrownErr && thrownErr.message)); - - const afterBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); - assert.deepStrictEqual( - afterBytes, - preInstallBytes, - 'pre-install config.toml bytes must survive a mid-install write/rename failure' - ); - - // And the parsed structure of the surviving file must still be the - // user's [model] section, not a half-written GSD block. - const parsed = parseTomlToObject(afterBytes.toString('utf8')); - assert.equal(parsed.model && parsed.model.name, 'o3', - 'surviving file must still be the user pre-install content'); - assert.equal(parsed.agents, undefined, - 'no GSD agents block may have leaked into the surviving file'); - - // No stray .tmp-* siblings left behind in the codex home. - const stray = fs.readdirSync(codexHome).filter((f) => tempPattern.test(path.join(codexHome, f))); - assert.equal(stray.length, 0, - 'atomic write must clean up its temp file on failure: ' + stray.join(', ')); - }); - - test('pre-install config bytes survive when fs.writeFileSync throws on the .tmp- target', () => { - const preInstall = [ - '# user file', - '[model]', - 'name = "o3"', - '', - ].join('\n'); - writeCodexConfig(codexHome, preInstall); - - const preInstallBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); - const configPath = path.join(codexHome, 'config.toml'); - const tempPattern = new RegExp('^' + configPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.tmp-'); - - // Stub: fault writes targeting the atomic temp file (the pre-rename branch - // of atomicWriteFileSync). Other writes (agent .toml files in CODEX_HOME) - // pass through. This exercises the failure path where the temp write itself - // throws, not the rename — the case the prior test left untested. - // #2760 CR5 finding 5 — fs.writeFileSync is restored by the suite-level - // afterEach (via originalWriteFileSync); no local finally needed. - const captured = originalWriteFileSync; - fs.writeFileSync = function patchedWriteFileSync(target, data, options) { - if (typeof target === 'string' && tempPattern.test(target)) { - throw new Error('simulated writeFileSync failure on .tmp- target'); - } - return captured.call(this, target, data, options); - }; - - let threw = false; - try { - runCodexInstall(codexHome); - } catch (e) { - threw = true; - assert.ok(/simulated writeFileSync failure|post-write Codex install failed|pre-write/.test(e.message), - 'thrown error must surface the simulated failure or its post-write wrapper: ' + e.message); - } - // Per #2760 CR4 finding 1 / CR5 finding 1, write failures must abort install (not warn). - assert.equal(threw, true, 'install must throw when atomic temp-write fails'); - - const afterBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); - assert.deepStrictEqual( - afterBytes, - preInstallBytes, - 'pre-install config.toml bytes must survive a temp-write failure' - ); - - const parsed = parseTomlToObject(afterBytes.toString('utf8')); - assert.equal(parsed.model && parsed.model.name, 'o3', - 'surviving file must still be the user pre-install content'); - assert.equal(parsed.agents, undefined, - 'no GSD agents block may have leaked into the surviving file'); - - const stray = fs.readdirSync(codexHome).filter((f) => tempPattern.test(path.join(codexHome, f))); - assert.equal(stray.length, 0, - 'atomic write must clean up its temp file on failure: ' + stray.join(', ')); - }); -}); - -// concurrency: false — these tests rely on the same install path and module- -// level pre-install snapshot that the fix-3/fix-4 suites exercise. Serializing -// keeps state mutations from leaking across parallel siblings. -describe('#2760 CR4 finding 2 — Legacy flat [[hooks]] block migrates to namespaced AoT on reinstall', { concurrency: false }, () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr4-f2-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('pre-install legacy flat [[hooks]] gsd-check-update + user namespaced [[hooks.SessionStart]] → post-install converges on namespaced AoT', () => { - // Reproduce the upgrade scenario: - // - User has [[hooks.SessionStart]] entry of their own (signal that GSD - // should emit in the namespaced shape). - // - A previous GSD install left the legacy flat [[hooks]] managed block - // for gsd-check-update. The pre-CR4 strip step would short-circuit - // the namespaced emit and leave the user stuck in the mixed layout. - const userPlusLegacy = [ - '[[hooks.SessionStart]]', - 'command = "echo user hook"', - '', - '# GSD Hooks', - '[[hooks]]', - 'event = "SessionStart"', - 'command = "node /old/path/hooks/gsd-check-update.js"', - '', - ].join('\n'); - writeCodexConfig(codexHome, userPlusLegacy); - - runCodexInstall(codexHome); - const afterInstall = readCodexConfig(codexHome); - const parsed = parseTomlToObject(afterInstall); - - // After CR4 finding 2: the legacy flat [[hooks]] managed block is stripped - // and the GSD entry is re-emitted in the namespaced AoT shape so the two - // forms do not coexist. - assert.ok( - parsed.hooks && Array.isArray(parsed.hooks.SessionStart), - 'hooks.SessionStart must be an array-of-tables, got: ' - + (parsed.hooks ? typeof parsed.hooks.SessionStart : 'no hooks table') - ); - - // Migration now handles stale [[hooks.SessionStart]] entries with handler - // fields at event-entry level (pre-#2773 shape), promoting them to the - // two-level nested form. Every entry must carry a .hooks sub-array after - // migration, so collect from nested handlers only. - assert.ok( - parsed.hooks.SessionStart.every((entry) => Array.isArray(entry.hooks)), - 'every hooks.SessionStart entry must use nested [[hooks.SessionStart.hooks]] handlers after migration' - ); - const allSessionStartCommands = parsed.hooks.SessionStart.flatMap((entry) => - entry.hooks.map((h) => h.command).filter(Boolean) - ); - assert.ok( - allSessionStartCommands.includes('echo user hook'), - 'user [[hooks.SessionStart]] entry preserved: ' + JSON.stringify(allSessionStartCommands) - ); - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - assert.ok( - hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)), - 'GSD entry must appear in hooks.json SessionStart entries: ' - + JSON.stringify(hooksJsonCommands) - ); - - // The legacy top-level [[hooks]] AoT must NOT coexist with the namespaced - // form after migration. parseTomlToObject distinguishes via Array.isArray. - assert.ok( - !Array.isArray(parsed.hooks) || parsed.hooks.length === 0, - 'no top-level [[hooks]] AoT entries may remain after legacy migration: ' - + JSON.stringify(parsed.hooks) - ); - - // No duplicate gsd-check-update entries — exactly one managed entry. - const gsdEntries = hooksJsonCommands.filter((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)); - assert.equal(gsdEntries.length, 1, - 'exactly one gsd-check-update entry after migration, got: ' + gsdEntries.length); - }); -}); - -describe('#2760 CR4 finding 3 / #3245 — parseTomlToObject handles edge-case value types (floats accepted; dates/trailing-garbage rejected)', () => { - // #3245 inverts the float-rejection requirement: Codex CLI's serde schema - // requires f64 for tool_timeout_sec/startup_timeout_sec, so GSD's parser - // must now ACCEPT floats. The original guard (from #2760 CR4 finding 3) was - // "don't silently truncate 0.5 to integer 0" — that goal is still met - // because we parse the full float as a JS Number (not truncate to prefix). - test('accepts TOML floats (timeout = 0.5) — #3245 fix', () => { - const content = [ - '[server]', - 'timeout = 0.5', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.server.timeout, 0.5, - 'float values must be accepted as JS Number (not truncated to 0) — #3245'); - }); - - test('rejects date values (created = 1979-05-27)', () => { - const content = [ - '[meta]', - 'created = 1979-05-27', - '', - ].join('\n'); - assert.throws( - () => parseTomlToObject(content), - /unsupported TOML value|trailing bytes/, - 'date values must be rejected, not silently truncated' - ); - }); - - test('rejects trailing garbage after a string value (key = "x" junk)', () => { - const content = [ - '[section]', - 'key = "x" junk', - '', - ].join('\n'); - assert.throws( - () => parseTomlToObject(content), - /trailing bytes/, - 'trailing bytes after a complete value must be rejected' - ); - }); - - test('accepts trailing whitespace and # comment after a value', () => { - const content = [ - '[section]', - 'key = "x" # an inline comment', - 'flag = true', - 'count = 7 ', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.equal(parsed.section.key, 'x'); - assert.equal(parsed.section.flag, true); - assert.equal(parsed.section.count, 7); - }); -}); - -// concurrency: false — see the fix-3 suite above for the same rationale. -describe('#2760 CR4 finding 1 — atomicWriteFileSync failure aborts install (post-write fatal)', { concurrency: false }, () => { - let tmpDir; - let codexHome; - let originalRenameSync; - let originalConsoleLog; - let consoleOutput; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr4-f1-')); - codexHome = path.join(tmpDir, 'codex-home'); - originalRenameSync = fs.renameSync; - originalConsoleLog = console.log; - consoleOutput = []; - console.log = (...args) => { consoleOutput.push(args.join(' ')); }; - }); - - afterEach(() => { - fs.renameSync = originalRenameSync; - console.log = originalConsoleLog; - cleanup(tmpDir); - }); - - test('install throws and never prints "Done!" when atomicWriteFileSync fails on configPath', () => { - const preInstall = [ - '# user file', - '[model]', - 'name = "o3"', - '', - ].join('\n'); - writeCodexConfig(codexHome, preInstall); - - const configPath = path.join(codexHome, 'config.toml'); - // Only fault the hook-block atomic rename — earlier writes to config.toml - // happen via mergeCodexConfig (agent-block emit). We want to exercise the - // post-write Codex install branch specifically. Detect by reading the temp - // file's contents and only faulting when the hook block is present. - fs.renameSync = (src, dst) => { - if (dst === configPath) { - let isHookWrite = false; - try { - const data = fs.readFileSync(src, 'utf8'); - isHookWrite = /GSD codex_hooks ownership/.test(data); - } catch (_) { /* ignore */ } - if (isHookWrite) { - throw new Error('simulated rename failure'); - } - } - return originalRenameSync(src, dst); - }; - - let threw = false; - let thrownMessage = ''; - try { - runCodexInstall(codexHome); - } catch (e) { - threw = true; - thrownMessage = e.message; - } - - assert.equal(threw, true, 'install must throw when atomic write fails'); - assert.match( - thrownMessage, - /post-write Codex install failed/, - 'thrown error must use the post-write prefix so the outer catch treats it as fatal' - ); - - // Critical: install must NOT have printed any "Done!" success banner. - const printedDone = consoleOutput.some( - (line) => typeof line === 'string' && /Done!/i.test(line) - ); - assert.equal(printedDone, false, - 'install must NOT print "Done!" after a write failure: ' + JSON.stringify(consoleOutput.filter((l) => /Done|✓/.test(l)))); - - // And the user's pre-install bytes are intact (snapshot restore). - const after = fs.readFileSync(configPath, 'utf8'); - assert.equal(after, preInstall, 'pre-install bytes preserved after fatal abort'); - }); -}); - -// concurrency: false — patches module.exports.__codexSchemaValidator, a -// shared test seam. Serializing prevents stray patches from sibling tests. -describe('#2760 CR5 finding 1 — pre-write failures abort install (outer catch fatal)', { concurrency: false }, () => { - let tmpDir; - let codexHome; - let originalConsoleLog; - let consoleOutput; - const installModule = require('../bin/install.js'); - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr5-f1-')); - codexHome = path.join(tmpDir, 'codex-home'); - originalConsoleLog = console.log; - consoleOutput = []; - console.log = (...args) => { consoleOutput.push(args.join(' ')); }; - }); - - afterEach(() => { - console.log = originalConsoleLog; - delete installModule.__codexSchemaValidator; - cleanup(tmpDir); - }); - - test('pre-write throw (validator throws, not returns {ok:false}) is fatal and restores snapshot', () => { - // A validator that THROWS (vs returning {ok:false}) bypasses the - // validation branch and exits the inner try via the catch at the outer - // level. Pre-CR5, that catch downgraded to console.warn and let the - // install print "Done!" with no Codex hooks. Post-CR5 it must rethrow. - const preInstall = [ - '# user file', - '[model]', - 'name = "o3"', - '', - ].join('\n'); - writeCodexConfig(codexHome, preInstall); - - installModule.__codexSchemaValidator = () => { - throw new Error('synthetic validator-throw simulating a pre-write helper failure'); - }; - - let threw = false; - let thrownMsg = ''; - try { - runCodexInstall(codexHome); - } catch (e) { - threw = true; - thrownMsg = e.message; - } - - assert.equal(threw, true, - 'install must rethrow when a pre-write step throws (CR5 finding 1)'); - assert.match(thrownMsg, /pre-write|synthetic validator-throw/, - 'thrown error must surface the pre-write wrapper or original message: ' + thrownMsg); - - const printedDone = consoleOutput.some( - (line) => typeof line === 'string' && /Done!/i.test(line) - ); - assert.equal(printedDone, false, - 'install must NOT print "Done!" after a pre-write failure: ' + - JSON.stringify(consoleOutput.filter((l) => /Done|✓/.test(l)))); - - // Pre-install bytes intact (snapshot restored). - const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); - assert.equal(after, preInstall, - 'pre-install bytes must survive a pre-write helper throw'); - }); -}); - -describe('#2760 CR5 finding 2 — parseTomlToObject rejects duplicate keys and shape-mismatched headers', () => { - test('rejects duplicate scalar key in same table ([a]\\nx=1\\nx=2)', () => { - const content = [ - '[a]', - 'x = 1', - 'x = 2', - '', - ].join('\n'); - assert.throws( - () => parseTomlToObject(content), - /duplicate key/, - 'real TOML 1.0 rejects duplicate keys in the same table' - ); - }); - - test('rejects duplicate scalar key in root table', () => { - const content = [ - 'x = 1', - 'x = 2', - '', - ].join('\n'); - assert.throws( - () => parseTomlToObject(content), - /duplicate key/, - 'duplicate root-table keys must be rejected' - ); - }); - - test('rejects re-declared [a] table header ([a] then [a] again)', () => { - const content = [ - '[a]', - 'x = 1', - '', - '[a]', - 'y = 2', - '', - ].join('\n'); - assert.throws( - () => parseTomlToObject(content), - /duplicate or shape-mismatched table header/, - 'real TOML 1.0 rejects re-declaring the same [a] header twice' - ); - }); - - test('rejects [[arr]] then [arr] for same path (array-of-tables → table)', () => { - const content = [ - '[[arr]]', - 'x = 1', - '', - '[arr]', - 'y = 2', - '', - ].join('\n'); - assert.throws( - () => parseTomlToObject(content), - /duplicate or shape-mismatched table header/, - 'cannot redeclare an array-of-tables path as a plain table' - ); - }); - - test('accepts repeated [[arr]] (genuine array-of-tables)', () => { - const content = [ - '[[arr]]', - 'x = 1', - '', - '[[arr]]', - 'x = 2', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.ok(Array.isArray(parsed.arr)); - assert.strictEqual(parsed.arr.length, 2); - assert.strictEqual(parsed.arr[0].x, 1); - assert.strictEqual(parsed.arr[1].x, 2); - }); - - test('accepts disjoint nested headers (not duplicates)', () => { - const content = [ - '[a.b]', - 'x = 1', - '', - '[a.c]', - 'y = 2', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.a.b.x, 1); - assert.strictEqual(parsed.a.c.y, 2); - }); -}); - -// concurrency: false — drives the same install pipeline as the other f-suites. -describe('#2760 CR5 finding 3 — migration emits namespaced AoT (no flat/namespaced mixing)', { concurrency: false }, () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr5-f3-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('user has [[hooks.AfterTool]] AND legacy [hooks.SessionStart] → post-install both namespaced, no flat AoT', () => { - // Reproduces the mixed-form scenario from finding 3: - // - User pre-config has both a namespaced AoT entry [[hooks.AfterTool]] - // AND a legacy single-bracket [hooks.SessionStart]. - // - Pre-CR5 migration converts the legacy section to flat [[hooks]] - // with event="SessionStart", leaving a mixed flat+namespaced layout. - // - Post-CR5 migration emits [[hooks.SessionStart]] directly so both - // of the user's hooks coexist in the namespaced shape, and the - // GSD-managed entry converges on namespaced too. - const userPlusLegacy = [ - '[[hooks.AfterTool]]', - 'command = "x"', - '', - '[hooks.SessionStart]', - 'command = "y"', - '', - ].join('\n'); - writeCodexConfig(codexHome, userPlusLegacy); - - runCodexInstall(codexHome); - const after = readCodexConfig(codexHome); - const parsed = parseTomlToObject(after); - - // The pre-existing [[hooks.AfterTool]] entry is preserved. - assert.ok( - parsed.hooks && Array.isArray(parsed.hooks.AfterTool), - 'pre-existing [[hooks.AfterTool]] must remain a namespaced AoT array' - ); - // AfterTool was in [[hooks.AfterTool]] with command at event-entry level - // (pre-#2773 stale namespaced AoT shape). Migration now promotes these to - // the two-level nested form, so every entry must have a .hooks sub-array. - assert.ok( - parsed.hooks.AfterTool.every((e) => Array.isArray(e.hooks)), - 'every AfterTool entry must use nested [[hooks.AfterTool.hooks]] handlers after migration' - ); - const afterToolCommands = parsed.hooks.AfterTool.flatMap((e) => - e.hooks.map((h) => h.command).filter(Boolean) - ); - assert.ok( - afterToolCommands.includes('x'), - 'user AfterTool entry must be preserved: ' + JSON.stringify(afterToolCommands) - ); - - // The migrated SessionStart entry is now namespaced AoT with nested .hooks sub-table. - assert.ok( - parsed.hooks && Array.isArray(parsed.hooks.SessionStart), - 'migrated SessionStart must be namespaced AoT (not flat [[hooks]])' - ); - // After migration, [hooks.SessionStart] map-format is promoted to nested AoT. - // Command lives in [[hooks.SessionStart.hooks]][0].command (nested schema). - assert.ok( - parsed.hooks.SessionStart.every((e) => Array.isArray(e.hooks)), - 'every SessionStart entry must use nested [[hooks.SessionStart.hooks]] handlers after migration' - ); - const ssCommands = parsed.hooks.SessionStart.flatMap((e) => - e.hooks.map((h) => h.command).filter(Boolean) - ); - assert.ok( - ssCommands.includes('y'), - 'user SessionStart command "y" must be preserved in namespaced array: ' + - JSON.stringify(ssCommands) - ); - // GSD's managed gsd-check-update entry also lives in the namespaced array. - const hooksJsonCommands = readHooksSessionStartCommands(codexHome); - assert.ok( - hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)), - 'managed gsd-check-update entry must appear in hooks.json SessionStart entries: ' + - JSON.stringify(hooksJsonCommands) - ); - - // No flat top-level [[hooks]] AoT may remain. - assert.ok( - !Array.isArray(parsed.hooks) || parsed.hooks.length === 0, - 'no flat top-level [[hooks]] AoT entries may remain after migration: ' + - JSON.stringify(parsed.hooks) - ); - - // No synthetic event field on the migrated SessionStart entries — the - // namespace IS the event. - for (const entry of parsed.hooks.SessionStart) { - assert.equal(entry.event, undefined, - 'no synthetic event field — namespace [[hooks.SessionStart]] encodes the event: ' + - JSON.stringify(entry)); - } - }); -}); diff --git a/tests/bug-2771-user-profile-manifest.test.cjs b/tests/bug-2771-user-profile-manifest.test.cjs deleted file mode 100644 index 266dcc508..000000000 --- a/tests/bug-2771-user-profile-manifest.test.cjs +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Regression tests for bug #2771: USER-PROFILE.md tracked in install manifest - * - * USER-PROFILE.md is a user-owned artifact created/refreshed by /gsd-profile-user. - * preserveUserArtifacts() correctly preserves it across reinstalls. But writeManifest() - * also records it under "gsd-core/USER-PROFILE.md" with a SHA-256 of whatever was - * on disk at install time. On the next install, saveLocalPatches() compares the on-disk - * (refreshed) hash to the manifest hash, finds them different, and emits the spurious - * "Found N locally modified GSD file(s) — backed up to gsd-local-patches/" warning. - * - * Invariant: a file is either distribution (manifest-tracked, diff'd against manifest) - * or user artifact (preserved across installs, never diff'd). It cannot be both. The - * shared truth source must be a single USER_OWNED_ARTIFACTS list referenced by both - * preserveUserArtifacts callers and writeManifest. - * - * Closes: #2771 - */ - -'use strict'; - -const { describe, test, beforeEach, afterEach, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const crypto = require('crypto'); -const { execFileSync } = require('child_process'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); -const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -const MANIFEST_NAME = 'gsd-file-manifest.json'; -const PATCHES_DIR_NAME = 'gsd-local-patches'; - -before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); -}); - -function runInstaller(configDir) { - const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir }; - delete env.GSD_TEST_MODE; - return execFileSync( - process.execPath, - [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], - { encoding: 'utf-8', stdio: 'pipe', env } - ); -} - -// ─── Test 1: writeManifest must NOT record USER-PROFILE.md ──────────────────── - -describe('#2771: USER-PROFILE.md is excluded from gsd-file-manifest.json', () => { - let tmpDir; - - beforeEach(() => { tmpDir = createTempDir('gsd-2771-manifest-'); }); - afterEach(() => { cleanup(tmpDir); }); - - test('writeManifest excludes gsd-core/USER-PROFILE.md even when present on disk', () => { - runInstaller(tmpDir); - - // Simulate /gsd-profile-user creating USER-PROFILE.md - const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); - fs.writeFileSync(profilePath, '# My Profile\n\nFirst version.\n'); - - // Re-install: writeManifest runs again with USER-PROFILE.md present on disk - runInstaller(tmpDir); - - const manifestPath = path.join(tmpDir, MANIFEST_NAME); - assert.ok(fs.existsSync(manifestPath), 'manifest must be written'); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - - assert.ok( - !Object.prototype.hasOwnProperty.call(manifest.files, 'gsd-core/USER-PROFILE.md'), - 'manifest.files must NOT contain gsd-core/USER-PROFILE.md — it is a user artifact, not distribution' - ); - }); -}); - -// ─── Test 2: preserveUserArtifacts still preserves USER-PROFILE.md ──────────── - -describe('#2771: USER-PROFILE.md is still preserved across reinstall', () => { - let tmpDir; - - beforeEach(() => { tmpDir = createTempDir('gsd-2771-preserve-'); }); - afterEach(() => { cleanup(tmpDir); }); - - test('USER-PROFILE.md content survives reinstall (preservation regression guard)', () => { - runInstaller(tmpDir); - - const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); - const content = '# Profile\n\nUser content from /gsd-profile-user.\n'; - fs.writeFileSync(profilePath, content); - - runInstaller(tmpDir); - - assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md must survive reinstall'); - assert.strictEqual(fs.readFileSync(profilePath, 'utf8'), content); - }); -}); - -// ─── Test 3: no spurious "local patches" hit for USER-PROFILE.md refresh ────── - -describe('#2771: refreshed USER-PROFILE.md does not trigger local-patches warning', () => { - let tmpDir; - - beforeEach(() => { tmpDir = createTempDir('gsd-2771-patches-'); }); - afterEach(() => { cleanup(tmpDir); }); - - test('saveLocalPatches does not classify a refreshed USER-PROFILE.md as a local patch', () => { - // Initial install - runInstaller(tmpDir); - - // /gsd-profile-user creates USER-PROFILE.md (v1) - const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); - fs.writeFileSync(profilePath, '# Profile v1\n'); - - // Reinstall — manifest written with v1 contents (under buggy code) or excluded (under fix) - runInstaller(tmpDir); - - // /gsd-profile-user --refresh rewrites USER-PROFILE.md (v2 != v1) - fs.writeFileSync(profilePath, '# Profile v2 — refreshed\n'); - - // Reinstall — saveLocalPatches scans manifest. Under bug, v2 hash != v1 manifest - // hash → patch detected. Under fix, file is not in manifest → no patch. - const output = runInstaller(tmpDir); - - const patchesDir = path.join(tmpDir, PATCHES_DIR_NAME); - const patchFile = path.join(patchesDir, 'gsd-core', 'USER-PROFILE.md'); - assert.ok( - !fs.existsSync(patchFile), - 'USER-PROFILE.md must NOT appear in gsd-local-patches/ — it is a user artifact, not a modified distribution file' - ); - - const offendingLine = output - .split('\n') - .find((line) => /locally modified GSD file/.test(line) && /USER-PROFILE/.test(line)); - assert.strictEqual( - offendingLine, - undefined, - 'installer output must not report USER-PROFILE.md as a locally modified GSD file on any single line. Output was:\n' + output - ); - }); -}); - -// ─── Test 5: legacy manifest with USER-PROFILE.md entry is normalized ───────── - -describe('#2771: legacy manifest entries for USER_OWNED_ARTIFACTS are normalized', () => { - let tmpDir; - - beforeEach(() => { tmpDir = createTempDir('gsd-2771-legacy-'); }); - afterEach(() => { cleanup(tmpDir); }); - - test('pre-existing manifest entry for USER-PROFILE.md does not trigger patches warning', () => { - // Initial install - runInstaller(tmpDir); - - const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); - fs.writeFileSync(profilePath, '# Profile v1\n'); - - // Reinstall to populate manifest under the (now-fixed) writer - runInstaller(tmpDir); - - // Inject a stale manifest entry simulating a pre-#2771 install: a hash for - // USER-PROFILE.md that does NOT match current content. - const manifestPath = path.join(tmpDir, MANIFEST_NAME); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - manifest.files = manifest.files || {}; - manifest.files['gsd-core/USER-PROFILE.md'] = 'deadbeef'.repeat(8); // stale hash - fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); - - // /gsd-profile-user --refresh rewrites USER-PROFILE.md - fs.writeFileSync(profilePath, '# Profile v2 — refreshed\n'); - - // Reinstall — saveLocalPatches must strip the legacy entry before scanning - const output = runInstaller(tmpDir); - - const patchesDir = path.join(tmpDir, PATCHES_DIR_NAME); - const patchFile = path.join(patchesDir, 'gsd-core', 'USER-PROFILE.md'); - assert.ok( - !fs.existsSync(patchFile), - 'legacy USER-PROFILE.md manifest entry must be normalized away — not backed up as a patch' - ); - - const offendingLine = output - .split('\n') - .find((line) => /locally modified GSD file/.test(line) && /USER-PROFILE/.test(line)); - assert.strictEqual( - offendingLine, - undefined, - 'legacy manifest entry must not surface a USER-PROFILE.md patches warning. Output was:\n' + output - ); - }); -}); - -// ─── Test 4: shared constant exists and is used by both call sites ──────────── - -describe('#2771: USER_OWNED_ARTIFACTS is a single source of truth', () => { - test('install.js exports USER_OWNED_ARTIFACTS containing USER-PROFILE.md', () => { - const origMode = process.env.GSD_TEST_MODE; - process.env.GSD_TEST_MODE = '1'; - let mod; - try { - delete require.cache[require.resolve(INSTALL_SCRIPT)]; - mod = require(INSTALL_SCRIPT); - } finally { - if (origMode === undefined) delete process.env.GSD_TEST_MODE; - else process.env.GSD_TEST_MODE = origMode; - } - - assert.ok( - Array.isArray(mod.USER_OWNED_ARTIFACTS) || mod.USER_OWNED_ARTIFACTS instanceof Set, - 'install.js must export USER_OWNED_ARTIFACTS as a single source of truth' - ); - const list = Array.isArray(mod.USER_OWNED_ARTIFACTS) - ? mod.USER_OWNED_ARTIFACTS - : Array.from(mod.USER_OWNED_ARTIFACTS); - assert.ok( - list.includes('USER-PROFILE.md'), - 'USER_OWNED_ARTIFACTS must include USER-PROFILE.md' - ); - }); -}); - -describe('manifest path safety', () => { - let tmpDir; - let outside; - - beforeEach(() => { - tmpDir = createTempDir('gsd-manifest-path-safety-'); - outside = path.join(tmpDir, '..', `outside-managed-file-${path.basename(tmpDir)}.txt`); - }); - afterEach(() => { - cleanup(outside); - cleanup(tmpDir); - }); - - test('saveLocalPatches ignores manifest entries that escape the install root', () => { - const origMode = process.env.GSD_TEST_MODE; - process.env.GSD_TEST_MODE = '1'; - let mod; - try { - delete require.cache[require.resolve(INSTALL_SCRIPT)]; - mod = require(INSTALL_SCRIPT); - } finally { - if (origMode === undefined) delete process.env.GSD_TEST_MODE; - else process.env.GSD_TEST_MODE = origMode; - } - - fs.writeFileSync(outside, 'outside user data\n', 'utf8'); - fs.writeFileSync( - path.join(tmpDir, MANIFEST_NAME), - JSON.stringify({ - version: 'legacy', - timestamp: '2026-05-11T00:00:00.000Z', - files: { - '../outside-managed-file.txt': 'deadbeef', - }, - }, null, 2), - 'utf8' - ); - - const modified = mod.saveLocalPatches(tmpDir); - - assert.deepEqual(modified, []); - assert.equal(fs.readFileSync(outside, 'utf8'), 'outside user data\n'); - assert.equal(fs.existsSync(path.join(tmpDir, PATCHES_DIR_NAME, '..', path.basename(outside))), false); - }); - - test('saveLocalPatches does not follow symlinked patch directories outside the install root', () => { - const origMode = process.env.GSD_TEST_MODE; - process.env.GSD_TEST_MODE = '1'; - let mod; - try { - delete require.cache[require.resolve(INSTALL_SCRIPT)]; - mod = require(INSTALL_SCRIPT); - } finally { - if (origMode === undefined) delete process.env.GSD_TEST_MODE; - else process.env.GSD_TEST_MODE = origMode; - } - - const hookPath = path.join(tmpDir, 'hooks', 'managed.js'); - fs.mkdirSync(path.dirname(hookPath), { recursive: true }); - fs.writeFileSync(hookPath, 'user edited hook\n', 'utf8'); - fs.writeFileSync( - path.join(tmpDir, MANIFEST_NAME), - JSON.stringify({ - version: 'legacy', - timestamp: '2026-05-11T00:00:00.000Z', - files: { - 'hooks/managed.js': crypto.createHash('sha256').update('managed hook\n').digest('hex'), - }, - }, null, 2), - 'utf8' - ); - - fs.mkdirSync(outside, { recursive: true }); - try { - fs.symlinkSync(outside, path.join(tmpDir, PATCHES_DIR_NAME), 'dir'); - } catch { - return; - } - - const modified = mod.saveLocalPatches(tmpDir); - - assert.deepEqual(modified, []); - assert.equal(fs.existsSync(path.join(outside, 'hooks', 'managed.js')), false); - }); -}); diff --git a/tests/bug-279-codex-agent-mapping.test.cjs b/tests/bug-279-codex-agent-mapping.test.cjs deleted file mode 100644 index fdbd73b0a..000000000 --- a/tests/bug-279-codex-agent-mapping.test.cjs +++ /dev/null @@ -1,27 +0,0 @@ -'use strict'; -// allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); -const src = fs.readFileSync(INSTALL_JS, 'utf8'); - -describe('bug #279: Codex adapter documents Agent() and deferred tool discovery', () => { - test('adapter mapping section includes explicit Agent(...) -> spawn_agent mapping', () => { - assert.ok( - /Task\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src) && - /Agent\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src), - 'Codex adapter must explicitly map both Task(...) and Agent(...) to spawn_agent', - ); - }); - - test('adapter includes deferred tool_search discovery guidance before inline fallback', () => { - assert.ok( - src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), - 'Codex adapter must instruct deferred tool discovery via tool_search before deciding to run inline', - ); - }); -}); diff --git a/tests/bug-2794-opencode-model-profile-overrides.test.cjs b/tests/bug-2794-opencode-model-profile-overrides.test.cjs deleted file mode 100644 index d9e533e3f..000000000 --- a/tests/bug-2794-opencode-model-profile-overrides.test.cjs +++ /dev/null @@ -1,245 +0,0 @@ -/** - * Regression test for bug #2794 - * - * OpenCode generated agents ignored `model_profile_overrides.opencode.*`. - * The agent install path called `readGsdEffectiveModelOverrides` (explicit - * per-agent overrides) but never called `readGsdRuntimeProfileResolver` - * (tier-based profile overrides). When a user configured: - * - * { runtime: "opencode", model_profile_overrides: { opencode: { sonnet: "..." } } } - * - * generated `.opencode/agents/gsd-*.md` files contained no `model:` frontmatter. - * - * The fix adds a tier-resolver fallback in the OpenCode agent conversion block: - * explicit `model_overrides[agent]` > `model_profile_overrides.opencode.` > omit. - * - * This test exercises: - * 1. `readGsdRuntimeProfileResolver` correctly resolves OpenCode tier overrides. - * 2. The agent install code path embeds the resolved model into OpenCode frontmatter. - * 3. Explicit `model_overrides` still wins over tier-based resolution. - * 4. Missing overrides produce no `model:` field (no regression on omit behavior). - */ - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { - readGsdRuntimeProfileResolver, - install, -} = require('../bin/install.js'); - -const { createTempDir, cleanup } = require('./helpers.cjs'); -const makeTmp = (prefix) => createTempDir(`gsd-2794-${prefix}-`); - -function writeJson(p, obj) { - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.writeFileSync(p, JSON.stringify(obj, null, 2), 'utf-8'); -} - - -describe('bug-2794: readGsdRuntimeProfileResolver resolves opencode tier overrides', () => { - let projectDir; - let homeDir; - let origHome; - let origUP; - - beforeEach(() => { - projectDir = makeTmp('proj'); - homeDir = makeTmp('home'); - origHome = process.env.HOME; - origUP = process.env.USERPROFILE; - process.env.HOME = homeDir; - process.env.USERPROFILE = homeDir; - }); - - afterEach(() => { - if (origHome === undefined) delete process.env.HOME; - else process.env.HOME = origHome; - if (origUP === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = origUP; - cleanup(projectDir); - cleanup(homeDir); - }); - - test('resolves opencode sonnet tier to user-supplied model ID', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - runtime: 'opencode', - model_profile: 'balanced', - model_profile_overrides: { - opencode: { - sonnet: 'anthropic/claude-sonnet-4-7', - }, - }, - }); - - const resolver = readGsdRuntimeProfileResolver(projectDir); - assert.ok(resolver !== null, 'expected a resolver for opencode runtime'); - - // gsd-roadmapper balanced tier = sonnet — should resolve to override - const entry = resolver.resolve('gsd-roadmapper'); - assert.ok(entry !== null, 'expected entry for gsd-roadmapper'); - assert.strictEqual(entry.model, 'anthropic/claude-sonnet-4-7', 'sonnet override applied'); - }); - - test('returns null resolver when runtime is not set', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - model_profile: 'balanced', - model_profile_overrides: { opencode: { sonnet: 'x' } }, - }); - const resolver = readGsdRuntimeProfileResolver(projectDir); - assert.strictEqual(resolver, null, 'no resolver without runtime field'); - }); - - test('resolver returns null for agent not in MODEL_PROFILES', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - runtime: 'opencode', - model_profile: 'balanced', - model_profile_overrides: { opencode: { sonnet: 'x' } }, - }); - const resolver = readGsdRuntimeProfileResolver(projectDir); - assert.ok(resolver !== null); - const entry = resolver.resolve('gsd-nonexistent-agent'); - assert.strictEqual(entry, null, 'unknown agent name yields null'); - }); -}); - -describe('bug-2794: OpenCode agent install embeds model_profile_overrides model', () => { - let projectDir; - let homeDir; - let origHome; - let origUP; - let origCwd; - - beforeEach(() => { - projectDir = makeTmp('proj'); - homeDir = makeTmp('home'); - origHome = process.env.HOME; - origUP = process.env.USERPROFILE; - origCwd = process.cwd(); - process.env.HOME = homeDir; - process.env.USERPROFILE = homeDir; - process.chdir(projectDir); - }); - - afterEach(() => { - if (origHome === undefined) delete process.env.HOME; - else process.env.HOME = origHome; - if (origUP === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = origUP; - process.chdir(origCwd); - cleanup(projectDir); - cleanup(homeDir); - }); - - test('generated OpenCode agent frontmatter includes model from model_profile_overrides', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - runtime: 'opencode', - model_profile: 'balanced', - model_profile_overrides: { - opencode: { - sonnet: 'anthropic/claude-sonnet-4-7', - opus: 'anthropic/claude-opus-4-7', - haiku: 'anthropic/claude-haiku-4-5', - }, - }, - }); - - const oldLog = console.log; - console.log = () => {}; - try { - install(false, 'opencode'); - } finally { - console.log = oldLog; - } - - const agentsDir = path.join(projectDir, '.opencode', 'agents'); - assert.ok(fs.existsSync(agentsDir), 'agents directory should be created'); - - // gsd-roadmapper is balanced -> sonnet tier - const roadmapperPath = path.join(agentsDir, 'gsd-roadmapper.md'); - assert.ok(fs.existsSync(roadmapperPath), 'gsd-roadmapper.md should exist'); - const roadmapperContent = fs.readFileSync(roadmapperPath, 'utf-8'); - assert.match( - roadmapperContent, - /^model: anthropic\/claude-sonnet-4-7$/m, - 'gsd-roadmapper should have sonnet model from model_profile_overrides' - ); - - // gsd-planner is balanced -> opus tier - const plannerPath = path.join(agentsDir, 'gsd-planner.md'); - assert.ok(fs.existsSync(plannerPath), 'gsd-planner.md should exist'); - const plannerContent = fs.readFileSync(plannerPath, 'utf-8'); - assert.match( - plannerContent, - /^model: anthropic\/claude-opus-4-7$/m, - 'gsd-planner should have opus model from model_profile_overrides' - ); - }); - - test('explicit model_overrides[agent] wins over model_profile_overrides tier', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - runtime: 'opencode', - model_profile: 'balanced', - model_overrides: { - 'gsd-roadmapper': 'explicit-winner-model', - }, - model_profile_overrides: { - opencode: { - sonnet: 'tier-model-that-should-lose', - }, - }, - }); - - const oldLog = console.log; - console.log = () => {}; - try { - install(false, 'opencode'); - } finally { - console.log = oldLog; - } - - const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md'); - assert.ok(fs.existsSync(roadmapperPath)); - const content = fs.readFileSync(roadmapperPath, 'utf-8'); - assert.match( - content, - /^model: explicit-winner-model$/m, - 'explicit model_overrides must win over model_profile_overrides tier' - ); - assert.doesNotMatch( - content, - /tier-model-that-should-lose/, - 'tier model must not appear when explicit override is present' - ); - }); - - test('no model field when neither model_overrides nor model_profile_overrides is set', () => { - writeJson(path.join(projectDir, '.planning', 'config.json'), { - runtime: 'opencode', - model_profile: 'balanced', - }); - - const oldLog = console.log; - console.log = () => {}; - try { - install(false, 'opencode'); - } finally { - console.log = oldLog; - } - - const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md'); - if (fs.existsSync(roadmapperPath)) { - const content = fs.readFileSync(roadmapperPath, 'utf-8'); - // When no overrides, model field should either be absent or use built-in default - // The key invariant: no model field if there are no user-supplied overrides - // AND no built-in opencode defaults for this tier - // (gsd-roadmapper balanced = sonnet; opencode has built-in sonnet defaults) - // So we only assert no crash and no tier-model-not-provided entries - assert.ok(typeof content === 'string', 'agent file should be a string'); - } - // Key: no exception thrown (test passes = no crash on missing overrides) - }); -}); diff --git a/tests/bug-2831-opencode-home-path-prefix.test.cjs b/tests/bug-2831-opencode-home-path-prefix.test.cjs deleted file mode 100644 index 3a0e5497b..000000000 --- a/tests/bug-2831-opencode-home-path-prefix.test.cjs +++ /dev/null @@ -1,155 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Workflow .md / agent .md / command .md / reference .md files — their text -// IS what the runtime loads. Testing text content tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. - -/** - * Regression test for #2831: OpenCode @file references contain literal `$HOME` - * which OpenCode does not expand — `@$HOME/.config/opencode/...` is resolved - * as a path relative to the config command/ dir, producing - * `command/$HOME/.config/opencode/...` (file not found). - * - * Root cause: install.js pathPrefix used `$HOME`-relative paths for OpenCode on - * non-Windows hosts (only Windows was guarded by #2376). OpenCode's `@file` - * include syntax does NOT shell-expand `$HOME` on any platform. - * - * Fix: pathPrefix must use the absolute path for OpenCode on all platforms. - * - * Tests exercise install.js's exported `computePathPrefix` directly (no source - * grepping) and additionally simulate the `copyFlattenedCommands` substitution - * pipeline on a temp tree to verify no `$HOME` literal leaks into emitted files. - */ - -'use strict'; - -const { describe, test, before, after } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { cleanup } = require('./helpers.cjs'); - -let computePathPrefix; - -before(() => { - process.env.GSD_TEST_MODE = '1'; - delete require.cache[require.resolve('../bin/install.js')]; - ({ computePathPrefix } = require('../bin/install.js')); -}); - -after(() => { - delete process.env.GSD_TEST_MODE; -}); - -describe('bug-2831: OpenCode pathPrefix uses absolute path on all platforms', () => { - test('computePathPrefix is exported by install.js', () => { - assert.equal(typeof computePathPrefix, 'function'); - }); - - test('OpenCode on macOS: pathPrefix is absolute (no $HOME)', () => { - const pathPrefix = computePathPrefix({ - isGlobal: true, - isOpencode: true, - isWindowsHost: false, - homeDir: '/Users/alice', - resolvedTarget: '/Users/alice/.config/opencode', - }); - assert.strictEqual(pathPrefix, '/Users/alice/.config/opencode/'); - assert.ok(!pathPrefix.includes('$HOME')); - }); - - test('OpenCode on Linux: pathPrefix is absolute (no $HOME)', () => { - const pathPrefix = computePathPrefix({ - isGlobal: true, - isOpencode: true, - isWindowsHost: false, - homeDir: '/home/bob', - resolvedTarget: '/home/bob/.config/opencode', - }); - assert.strictEqual(pathPrefix, '/home/bob/.config/opencode/'); - assert.ok(!pathPrefix.includes('$HOME')); - }); - - test('OpenCode on Windows: pathPrefix is absolute (preserves #2376)', () => { - const pathPrefix = computePathPrefix({ - isGlobal: true, - isOpencode: true, - isWindowsHost: true, - homeDir: 'C:/Users/carol', - resolvedTarget: 'C:/Users/carol/.config/opencode', - }); - assert.strictEqual(pathPrefix, 'C:/Users/carol/.config/opencode/'); - }); - - test('Claude Code on macOS: pathPrefix still uses $HOME (unaffected)', () => { - const pathPrefix = computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - homeDir: '/Users/alice', - resolvedTarget: '/Users/alice/.claude', - }); - assert.strictEqual(pathPrefix, '$HOME/.claude/'); - }); - - test('Local install (non-global): pathPrefix uses absolute path regardless of runtime', () => { - const pathPrefix = computePathPrefix({ - isGlobal: false, - isOpencode: false, - isWindowsHost: false, - homeDir: '/Users/alice', - resolvedTarget: '/Users/alice/projects/foo/.claude', - }); - assert.strictEqual(pathPrefix, '/Users/alice/projects/foo/.claude/'); - }); - - test('Substitution pipeline simulation: OpenCode emits no @$HOME literal', () => { - // This validates the same regex substitution pipeline used by - // copyFlattenedCommands when writing OpenCode command files. We invoke the - // real exported computePathPrefix; the regex passes mirror the install.js - // call sites (globalClaudeRegex / globalClaudeHomeRegex). - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2831-')); - try { - const srcRoot = path.join(tmp, 'src'); - const targetRoot = path.join(tmp, 'home', '.config', 'opencode'); - const srcCmdDir = path.join(srcRoot, 'commands', 'gsd'); - fs.mkdirSync(srcCmdDir, { recursive: true }); - fs.mkdirSync(targetRoot, { recursive: true }); - - const srcFile = path.join(srcCmdDir, 'autonomous.md'); - fs.writeFileSync( - srcFile, - '---\nname: autonomous\n---\n\n@~/.claude/gsd-core/workflows/autonomous.md\n@$HOME/.claude/gsd-core/references/ui-brand.md\n\n' - ); - - const homeDir = path.join(tmp, 'home').replace(/\\/g, '/'); - const resolvedTarget = targetRoot.replace(/\\/g, '/'); - const pathPrefix = computePathPrefix({ - isGlobal: true, - isOpencode: true, - isWindowsHost: false, - homeDir, - resolvedTarget, - }); - - let content = fs.readFileSync(srcFile, 'utf8'); - content = content.replace(/~\/\.claude\//g, pathPrefix); - content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); - - assert.ok( - !/@\$HOME\b/.test(content), - `output must not contain @$HOME literal; got:\n${content}` - ); - assert.ok( - !/\$HOME\b/.test(content), - `output must not contain $HOME literal; got:\n${content}` - ); - assert.ok( - content.includes(`@${resolvedTarget}/`), - `output should include absolute path with @ prefix; got:\n${content}` - ); - } finally { - cleanup(tmp); - } - }); -}); diff --git a/tests/bug-2957-claude-global-postinstall-message.test.cjs b/tests/bug-2957-claude-global-postinstall-message.test.cjs deleted file mode 100644 index a83758c21..000000000 --- a/tests/bug-2957-claude-global-postinstall-message.test.cjs +++ /dev/null @@ -1,73 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #2957: post-install message for `--claude --global` must instruct - * users to restart Claude Code and offer the skill-name fallback, since - * the skills-only install layout (CC 2.1.88+) leaves nothing in - * commands/gsd/ for the slash menu to read on older configurations. - * - * Captures the call to finishInstall(runtime='claude', isGlobal=true) and - * asserts the printed message contains both invocation paths. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); -const os = require('node:os'); - -const ROOT = path.join(__dirname, '..'); -const SETTINGS_PATH = path.join(os.tmpdir(), `gsd-test-settings-${process.pid}.json`); -const installModule = require(path.join(ROOT, 'bin', 'install.js')); - -function captureFinishInstallOutput(runtime, isGlobal) { - const original = console.log; - const lines = []; - console.log = (...args) => { lines.push(args.join(' ')); }; - try { - installModule.finishInstall( - SETTINGS_PATH, - {}, - null, - false, - runtime, - isGlobal, - null, - ); - } finally { - console.log = original; - } - // Strip ANSI color escapes so message-content assertions don't couple to colors. - // 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 - return lines.join('\n').replace(/\x1B\[[0-9;]*m/g, ''); -} - -describe('Bug #2957: claude+global post-install message', () => { - test('claude+global message tells the user to restart and offers skill-name fallback', () => { - const output = captureFinishInstallOutput('claude', true); - - assert.match(output, /restart claude code/i, 'should mention restart'); - assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project'); - assert.match(output, /gsd-new-project skill/i, 'should mention the skill name fallback'); - assert.doesNotMatch( - output, - /open a blank directory/i, - 'global claude install should replace, not extend, the legacy generic instruction', - ); - }); - - test('claude+local message keeps the original /gsd-new-project instruction', () => { - const output = captureFinishInstallOutput('claude', false); - - assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project'); - assert.doesNotMatch(output, /restart claude code/i, 'local install does not require the skills restart note'); - }); - - test('non-claude runtimes keep their original message format', () => { - const output = captureFinishInstallOutput('opencode', true); - - assert.match(output, /Open a blank directory/, 'opencode message should be unchanged'); - assert.doesNotMatch(output, /restart/i, 'opencode message should not have the claude-specific restart note'); - }); -}); diff --git a/tests/bug-2979-hook-absolute-node.test.cjs b/tests/bug-2979-hook-absolute-node.test.cjs deleted file mode 100644 index d872de1d3..000000000 --- a/tests/bug-2979-hook-absolute-node.test.cjs +++ /dev/null @@ -1,555 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #2979: Managed JS hooks fail in GUI/minimal-PATH runtimes because - * the installer emits bare `node`. - * - * Reporter evidence: in a stripped PATH like /usr/bin:/bin:/usr/sbin:/sbin - * (the default for Finder-launched/Antigravity-spawned processes on macOS), - * `node` is not resolvable. Hook commands like - * `node "/.gemini/hooks/gsd-check-update.js"` - * fail with `/bin/sh: node: command not found` (exit 127). - * - * Fix: emit the absolute node path (`process.execPath`, the binary - * running the installer itself) as the runner. Forward-slash-normalized - * and double-quoted so it works on POSIX and Windows. - * - * This test exercises the public buildHookCommand surface plus the - * resolveNodeRunner helper, asserting on structured records: - * - the runner field is an absolute path (not bare 'node') - * - it ends with /node or \\node (or .exe on Windows simulation) - * - .sh hooks still use bare 'bash' (PATH-resolved; portable across - * distros that don't ship /bin/bash, like NixOS) - * - * No source-grep on install.js content — assertions go against the - * value returned by the exported function and the parsed structure of - * the emitted hook command (split into runner + args). - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); -const { buildHookCommand, resolveNodeRunner } = INSTALL; - -/** - * Parse a hook command string into { runner, hookPath } structured - * record. The shape is ` ""` where may itself - * be a quoted absolute path (containing spaces), so we split on the - * trailing quoted-path token rather than the first space. - */ -function parseHookCommand(cmd) { - // Trailing token: a double-quoted string ending the command. - const m = cmd.match(/^(.+?)\s+"([^"]+)"\s*$/); - if (!m) { - return { runner: null, hookPath: null, raw: cmd }; - } - return { runner: m[1], hookPath: m[2], raw: cmd }; -} - -describe('Bug #2979: resolveNodeRunner returns absolute, quoted, forward-slash node path', () => { - test('exported as a function', () => { - assert.equal(typeof resolveNodeRunner, 'function'); - }); - - test('returns a double-quoted absolute path', () => { - const runner = resolveNodeRunner(); - assert.ok(runner.startsWith('"'), `expected leading double-quote, got: ${runner}`); - assert.ok(runner.endsWith('"'), `expected trailing double-quote, got: ${runner}`); - const inner = runner.slice(1, -1); - assert.ok(path.isAbsolute(inner.replace(/\//g, path.sep)), `expected absolute path, got: ${inner}`); - }); - - test('uses forward slashes (Windows-safe, matches buildHookCommand convention)', () => { - const runner = resolveNodeRunner(); - assert.ok(!runner.includes('\\'), `expected forward slashes, got: ${runner}`); - }); - - test('points at a node binary (basename starts with "node")', () => { - const runner = resolveNodeRunner(); - const inner = runner.slice(1, -1); - const base = path.posix.basename(inner); - assert.ok(/^node(\.exe)?$/i.test(base), `expected basename node or node.exe, got: ${base}`); - }); -}); - -describe('Bug #2979: buildHookCommand for .js hooks emits absolute node runner', () => { - test('global install: .js hook uses absolute node path, not bare "node"', () => { - const cmd = buildHookCommand('/tmp/.claude', 'gsd-check-update.js'); - const parsed = parseHookCommand(cmd); - assert.notEqual(parsed.runner, null, `failed to parse: ${cmd}`); - assert.notEqual(parsed.runner, 'node', `must not emit bare node (#2979): ${cmd}`); - // The runner should be a quoted absolute path. - assert.ok(parsed.runner.startsWith('"') && parsed.runner.endsWith('"'), - `runner must be quoted absolute path, got: ${parsed.runner}`); - }); - - test('global install: .js hook command parses with hookPath at expected location', () => { - const cmd = buildHookCommand('/tmp/.gemini', 'gsd-statusline.js'); - const parsed = parseHookCommand(cmd); - assert.equal(parsed.hookPath, '/tmp/.gemini/hooks/gsd-statusline.js'); - }); - - test('portableHooks global install: .js hook still uses absolute node (only the path is $HOME-relative)', () => { - const home = require('node:os').homedir().replace(/\\/g, '/'); - const configDir = home + '/.gemini'; - const cmd = buildHookCommand(configDir, 'gsd-check-update.js', { portableHooks: true }); - const parsed = parseHookCommand(cmd); - assert.notEqual(parsed.runner, 'node', `portableHooks must also use absolute node (#2979): ${cmd}`); - assert.equal(parsed.hookPath, '$HOME/.gemini/hooks/gsd-check-update.js'); - }); -}); - -describe('Bug #3362 / #3413: Windows hook commands are runtime-aware', () => { - test('Gemini global install: .js hook command starts with & so quoted runners execute in PowerShell', () => { - const cmd = buildHookCommand('C:/Program Files/Gemini/.gemini', 'gsd-check-update.js', { - platform: 'win32', - runtime: 'gemini', - }); - assert.ok(cmd.startsWith('& '), `Gemini PowerShell commands need call operator, got: ${cmd}`); - assert.ok(cmd.includes('"C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"')); - }); - - test('Gemini portable install: .js hook command also uses & on Windows PowerShell', () => { - const home = require('node:os').homedir().replace(/\\/g, '/'); - const cmd = buildHookCommand(`${home}/.gemini`, 'gsd-check-update.js', { - portableHooks: true, - platform: 'win32', - runtime: 'gemini', - }); - assert.ok(cmd.startsWith('& '), `Gemini PowerShell commands need call operator, got: ${cmd}`); - assert.equal(parseHookCommand(cmd.slice(2)).hookPath, '$HOME/.gemini/hooks/gsd-check-update.js'); - }); - - test('Claude global install: .js hook command stays shell-neutral on Windows Git Bash', () => { - const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', { - platform: 'win32', - runtime: 'claude', - }); - assert.ok(!cmd.startsWith('& '), `Claude hook command must not use PowerShell call operator: ${cmd}`); - assert.equal(parseHookCommand(cmd).hookPath, 'C:/Users/me/.claude/hooks/gsd-check-update.js'); - }); - - test('Windows .js hook with no runtime stays shell-neutral', () => { - const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', { - platform: 'win32', - }); - assert.ok(!cmd.startsWith('& '), `Missing runtime must not imply PowerShell syntax: ${cmd}`); - assert.equal(parseHookCommand(cmd).hookPath, 'C:/Users/me/.claude/hooks/gsd-check-update.js'); - }); - - test('Gemini runtime on non-Windows platform does not get PowerShell syntax', () => { - const cmd = buildHookCommand('/home/me/.claude', 'gsd-check-update.js', { - platform: 'linux', - runtime: 'gemini', - }); - assert.ok(!cmd.startsWith('& '), `Non-Windows Gemini hook must stay shell-neutral: ${cmd}`); - assert.equal(parseHookCommand(cmd).hookPath, '/home/me/.claude/hooks/gsd-check-update.js'); - }); -}); - -describe('Bug #2979: buildHookCommand for .sh hooks still uses bare "bash" (POSIX std PATH always has /bin)', () => { - test('.sh hook runner is exactly "bash" — bash is in /usr/bin:/bin and resolves under minimal PATH', () => { - const cmd = buildHookCommand('/tmp/.claude', 'gsd-session-state.sh', { platform: 'linux' }); - const parsed = parseHookCommand(cmd); - assert.equal(parsed.runner, 'bash'); - }); - - test('Windows .sh hook uses resolved Git Bash path instead of bare bash (#3393)', () => { - const cmd = buildHookCommand('C:/Users/me/.codex', 'gsd-validate-commit.sh', { - platform: 'win32', - env: { ProgramFiles: 'C:\\Program Files' }, - existsSync: (candidate) => candidate === 'C:\\Program Files\\Git\\bin\\bash.exe', - }); - assert.equal( - cmd, - '"C:/Program Files/Git/bin/bash.exe" "C:/Users/me/.codex/hooks/gsd-validate-commit.sh"', - ); - }); - - test('Windows .sh hook returns null when no supported Bash runner is found (#3393)', () => { - const cmd = buildHookCommand('C:/Users/me/.codex', 'gsd-phase-boundary.sh', { - platform: 'win32', - env: {}, - existsSync: () => false, - }); - assert.equal(cmd, null); - }); - - test('Windows Claude .sh hook omits explicit bash.exe wrapper (#166)', () => { - const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-session-state.sh', { - platform: 'win32', - runtime: 'claude', - env: { ProgramFiles: 'C:\\Program Files' }, - existsSync: (candidate) => candidate === 'C:\\Program Files\\Git\\bin\\bash.exe', - }); - assert.equal( - cmd, - '"C:/Users/me/.claude/hooks/gsd-session-state.sh"', - 'Claude win32 .sh hooks should serialize as script-only commands' - ); - }); -}); - -// ─── #3002 CR follow-up: legacy-bare-node migration ───────────────────────── - -const { rewriteLegacyManagedNodeHookCommands } = INSTALL; - -describe('Bug #2979 (#3002 CR): rewriteLegacyManagedNodeHookCommands rewrites bare-node managed hooks on reinstall', () => { - test('exported as a function', () => { - assert.equal(typeof rewriteLegacyManagedNodeHookCommands, 'function'); - }); - - test('rewrites a managed hook entry that uses bare `node ` to the absolute runner', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [ - { type: 'command', command: 'node "/Users/x/.gemini/hooks/gsd-check-update.js"' }, - ], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, true); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', - ); - }); - - test('does NOT touch entries that already use a quoted absolute runner', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: '"/usr/local/bin/node" "/x/hooks/gsd-statusline.js"' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const before = settings.hooks.SessionStart[0].hooks[0].command; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, false); - assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); - }); - - test('Gemini on Windows adds PowerShell call operator to existing quoted managed hooks', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: '"/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' }); - assert.equal(changed, true); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '& "/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"', - ); - }); - - test('Gemini on Windows does NOT double-prefix managed hooks that already use the PowerShell call operator', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const before = settings.hooks.SessionStart[0].hooks[0].command; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' }); - assert.equal(changed, false); - assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); - }); - - test('Gemini on Windows rewrites PowerShell bare-node managed hooks to absolute runner without dropping &', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: '& node "C:/Users/me/.gemini/hooks/gsd-check-update.js"' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' }); - assert.equal(changed, true); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '& "/usr/local/bin/node" "C:/Users/me/.gemini/hooks/gsd-check-update.js"', - ); - }); - - test('Claude on Windows strips stale PowerShell prefix from managed hooks on reinstall (#3413)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'claude' }); - assert.equal(changed, true); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '"/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"', - ); - }); - - test('does NOT touch user-authored bare-node hooks (filename not in managed allowlist)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: 'node /home/me/my-custom-hook.js' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const before = settings.hooks.SessionStart[0].hooks[0].command; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, false); - assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); - }); - - test('does NOT touch .sh hooks (they correctly use bare bash)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: 'bash "/x/hooks/gsd-session-state.sh"' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, false); - }); - - test('is a no-op when absoluteRunner is null (resolveNodeRunner failed)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: 'node "/x/hooks/gsd-check-update.js"' }], - }], - }, - }; - const before = settings.hooks.SessionStart[0].hooks[0].command; - const changed = rewriteLegacyManagedNodeHookCommands(settings, null); - assert.equal(changed, false); - assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); - }); - - // #3002 CR: substring containment was a false-positive vector. - // User-authored hooks whose path happened to CONTAIN a managed filename - // as a substring would get unconditionally rewritten with the GSD runner. - // The fix matches by basename equality. - test('does NOT rewrite a user hook whose path contains a managed filename as a substring', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ - type: 'command', - // Path contains gsd-check-update.js as substring of a longer - // filename, but is NOT actually that file. - command: 'node /home/me/scripts/wraps-gsd-check-update.js-helper.js', - }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const before = settings.hooks.SessionStart[0].hooks[0].command; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, false, 'must not rewrite user hooks with managed-filename-as-substring paths'); - assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); - }); - - test('rewrites a managed entry whose path is quoted with single quotes', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: "node '/x/hooks/gsd-statusline.js'" }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' }); - assert.equal(changed, true); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`, - ); - }); - - test('rewrites a managed entry with no path quoting (bareword)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: 'node /x/hooks/gsd-context-monitor.js' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' }); - assert.equal(changed, true); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '"/usr/local/bin/node" /x/hooks/gsd-context-monitor.js', - ); - }); - - test('handles Windows-style backslash path separators when extracting basename', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ type: 'command', command: 'node "C:\\\\Users\\\\me\\\\.claude\\\\hooks\\\\gsd-prompt-guard.js"' }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, true); - }); - - test('Gemini on Windows normalizes single-quoted managed hook paths to double-quoted forward-slash paths (#3392)', () => { - const settings = { - hooks: { - PreToolUse: [{ - hooks: [{ - type: 'command', - command: "node 'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js'", - }], - }], - }, - }; - const runner = '"C:/nvm4w/nodejs/node.exe"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' }); - assert.equal(changed, true); - assert.equal( - settings.hooks.PreToolUse[0].hooks[0].command, - '& "C:/nvm4w/nodejs/node.exe" "C:/Users/me/.gemini/hooks/gsd-prompt-guard.js"', - ); - }); -}); - -describe('Bug #2979 (#3002 CR): resolveNodeRunner returns null when execPath unavailable', () => { - test('returns null instead of bare "node" when process.execPath is empty', () => { - const orig = process.execPath; - try { - Object.defineProperty(process, 'execPath', { value: '', configurable: true }); - const r = resolveNodeRunner(); - assert.equal(r, null, 'expected null, not bare "node"'); - } finally { - Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); - } - }); - - test('buildHookCommand returns null when execPath is unavailable (caller skips registration)', () => { - const orig = process.execPath; - try { - Object.defineProperty(process, 'execPath', { value: '', configurable: true }); - const cmd = buildHookCommand('/tmp/.claude', 'gsd-statusline.js'); - assert.equal(cmd, null); - } finally { - Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); - } - }); -}); - -// ─── #3002 CR follow-up #2: null-command guards in settings.json ────────── - -const { validateHookFields } = INSTALL; - -describe('Bug #2979 (#3002 CR follow-up): no command:null hook entries survive serialization', () => { - // CR feedback: assert structurally on the resulting settings object, not by - // grepping bin/install.js source. The push-site guards (each `if` clause's - // `&& ` token) skip null-command pushes at the source. As a - // backstop, install.js now runs validateHookFields(settings) right before - // writeSettings; this test exercises that backstop directly. - // - // Construct a settings object that contains exactly the kind of null-command - // entries that the registration code would have written if my push-site - // guards regressed. Run validateHookFields on it. Assert the null entries - // are gone and the well-formed entries survive. - - function nullCommandEntry(matcher) { - const entry = { hooks: [{ type: 'command', command: null }] }; - if (matcher) entry.matcher = matcher; - return entry; - } - function realCommandEntry(matcher, command) { - const entry = { hooks: [{ type: 'command', command }] }; - if (matcher) entry.matcher = matcher; - return entry; - } - - const MANAGED_JS_HOOKS = [ - { event: 'SessionStart', matcher: undefined, label: 'gsd-check-update.js' }, - { event: 'PostToolUse', matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task', label: 'gsd-context-monitor.js' }, - { event: 'PreToolUse', matcher: 'Write|Edit', label: 'gsd-prompt-guard.js' }, - { event: 'PreToolUse', matcher: 'Write|Edit', label: 'gsd-read-guard.js' }, - { event: 'PostToolUse', matcher: 'Read', label: 'gsd-read-injection-scanner.js' }, - { event: 'PreToolUse', matcher: 'Bash|Edit|Write|MultiEdit', label: 'gsd-workflow-guard.js' }, - ]; - - for (const { event, matcher, label } of MANAGED_JS_HOOKS) { - test(`validateHookFields strips a null-command ${label} entry from settings.hooks.${event}`, () => { - const settings = { - hooks: { - [event]: [ - nullCommandEntry(matcher), - realCommandEntry(matcher, '"/usr/local/bin/node" "/x/hooks/other.js"'), - ], - }, - }; - const out = validateHookFields(settings); - const survivors = out.hooks[event] || []; - // The well-formed entry must remain. - assert.equal(survivors.length, 1, `expected the real-command entry to survive`); - // No survivor entry contains a hook with command === null. - for (const e of survivors) { - for (const h of e.hooks || []) { - assert.notEqual(h.command, null, 'no surviving hook should have command:null'); - } - } - }); - } - - test('validateHookFields drops the entry entirely when all its hooks have null commands', () => { - const settings = { - hooks: { - SessionStart: [nullCommandEntry()], - }, - }; - const out = validateHookFields(settings); - // Empty event arrays should be cleaned up (the entire SessionStart key - // gets removed when nothing valid remains). - assert.ok( - !out.hooks.SessionStart || out.hooks.SessionStart.length === 0, - 'expected SessionStart to be empty/removed after the only entry was dropped', - ); - }); - - test('validateHookFields preserves agent-type hooks while stripping command:null sibling hooks', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [ - { type: 'command', command: null }, - { type: 'agent', prompt: 'analyze the session' }, - { type: 'command', command: '"/usr/local/bin/node" "/x/hooks/y.js"' }, - ], - }], - }, - }; - const out = validateHookFields(settings); - const survivors = out.hooks.SessionStart[0].hooks; - assert.equal(survivors.length, 2, 'expected 2 of 3 hooks to survive (the null-command one is stripped)'); - assert.equal(survivors.find(h => h.command === null), undefined, 'no surviving hook should have command:null'); - }); -}); diff --git a/tests/bug-2998-pristine-dir-populated.test.cjs b/tests/bug-2998-pristine-dir-populated.test.cjs deleted file mode 100644 index ad42a4cfb..000000000 --- a/tests/bug-2998-pristine-dir-populated.test.cjs +++ /dev/null @@ -1,203 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #2998: gsd-pristine/ snapshot is documented but never populated by - * the installer. saveLocalPatches declared a pristineDir variable and - * promised "saves pristine copies (from manifest) to gsd-pristine/ to - * enable three-way merge during reapply-patches" -- but no code ever - * wrote to that directory. Effect: the /gsd-reapply-patches Step 5 - * verifier (#2972) silently degrades to its over-broad fallback heuristic - * ("every significant backup line"), exactly the silent-success-on-lost- - * content failure mode #2969 was designed to prevent. - * - * Fix: new populatePristineDir({...}) helper runs the install transform - * pipeline (copyWithPathReplacement) into a tmp staging dir, then copies - * out the modified-file paths into gsd-pristine/. saveLocalPatches now - * accepts a pristineCtx and calls the helper when local patches are - * detected. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const crypto = require('node:crypto'); - -const ROOT = path.join(__dirname, '..'); -const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); -const { cleanup } = require('./helpers.cjs'); - -function sha256(content) { - return crypto.createHash('sha256').update(content).digest('hex'); -} - -describe('Bug #2998: populatePristineDir is exported and writes pristine for modified files', () => { - test('exported as a function', () => { - assert.equal(typeof INSTALL.populatePristineDir, 'function', - 'expected populatePristineDir in install.js exports (#2998)'); - }); - - test('returns 0 when no files are modified (no-op)', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-')); - try { - const written = INSTALL.populatePristineDir({ - packageSrc: ROOT, - pristineDir: path.join(tmp, 'gsd-pristine'), - modified: [], - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - assert.equal(written, 0); - } finally { - cleanup(tmp); - } - }); - - test('writes one pristine file per modified path that exists in source', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-')); - const pristineDir = path.join(tmp, 'gsd-pristine'); - try { - // Pick a real installed-side relPath from the package source. The - // install transforms map source `gsd-core/` to installed - // `gsd-core/` for skills-aware runtimes (like claude), - // so the relPath is the same on both sides. - const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md'); - const sourcePath = path.join(ROOT, candidate); - assert.equal(fs.existsSync(sourcePath), true, - `precondition: source file exists at ${candidate}`); - const written = INSTALL.populatePristineDir({ - packageSrc: ROOT, - pristineDir, - modified: [candidate], - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - assert.equal(written, 1, 'expected exactly one pristine file written'); - const out = path.join(pristineDir, candidate); - assert.equal(fs.existsSync(out), true, `expected pristine file at ${out}`); - // The pristine content should be the transformed version (not raw source): - // copyWithPathReplacement substitutes ~/.claude/ for the runtime path prefix. - // For claude+global, the prefix is $HOME/.claude/ which equals the original, - // so the transform is effectively identity here. We assert the content is a - // non-empty markdown file rather than asserting on transform specifics. - const content = fs.readFileSync(out, 'utf-8'); - assert.ok(content.length > 0, 'pristine file should be non-empty'); - } finally { - cleanup(tmp); - } - }); - - test('skips paths not present in source (does not corrupt pristine with stale data)', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-')); - const pristineDir = path.join(tmp, 'gsd-pristine'); - try { - const written = INSTALL.populatePristineDir({ - packageSrc: ROOT, - pristineDir, - modified: ['gsd-core/this-path-does-not-exist.md'], - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - assert.equal(written, 0, 'expected zero pristine files for non-existent source paths'); - const out = path.join(pristineDir, 'gsd-core/this-path-does-not-exist.md'); - assert.equal(fs.existsSync(out), false, 'pristine should not contain ghost paths'); - } finally { - cleanup(tmp); - } - }); - - test('pristine files have stable content (transformations are deterministic)', () => { - // Determinism is what makes the verifier's hash check meaningful: - // backup-meta.json records pristine_hashes computed at this same step, - // so re-running with the same inputs must yield byte-identical files. - const tmp1 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d1-')); - const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d2-')); - try { - const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md'); - const ctx = { - packageSrc: ROOT, - modified: [candidate], - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }; - INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp1, 'gsd-pristine') }, ctx)); - INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp2, 'gsd-pristine') }, ctx)); - const a = fs.readFileSync(path.join(tmp1, 'gsd-pristine', candidate)); - const b = fs.readFileSync(path.join(tmp2, 'gsd-pristine', candidate)); - assert.equal(sha256(a), sha256(b), 'two runs of the same inputs must yield identical pristine content'); - } finally { - cleanup(tmp1); - cleanup(tmp2); - } - }); -}); - -// ─── #3004 CR follow-up: multi-root pristine expansion ───────────────────── - -describe('Bug #2998 (#3004 CR): pristine expansion covers every manifest install root', () => { - test('paths under agents/ are staged via copyWithPathReplacement, not silently skipped', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-multi-')); - const pristineDir = path.join(tmp, 'gsd-pristine'); - try { - const candidate = path.join('agents', 'gsd-planner.md'); - const sourcePath = path.join(ROOT, candidate); - assert.equal(fs.existsSync(sourcePath), true, - `precondition: source file exists at ${candidate}`); - const written = INSTALL.populatePristineDir({ - packageSrc: ROOT, - pristineDir, - modified: [candidate], - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - assert.equal(written, 1, 'expected agents/ path to be staged and copied to pristine'); - assert.equal(fs.existsSync(path.join(pristineDir, candidate)), true); - } finally { - cleanup(tmp); - } - }); - - test('a mix of gsd-core/ and agents/ paths in modified list are all staged', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-mix-')); - const pristineDir = path.join(tmp, 'gsd-pristine'); - try { - const a = path.join('gsd-core', 'workflows', 'reapply-patches.md'); - const b = path.join('agents', 'gsd-planner.md'); - assert.equal(fs.existsSync(path.join(ROOT, a)), true); - assert.equal(fs.existsSync(path.join(ROOT, b)), true); - const written = INSTALL.populatePristineDir({ - packageSrc: ROOT, - pristineDir, - modified: [a, b], - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - assert.equal(written, 2, 'expected both top-level dirs to be staged'); - assert.equal(fs.existsSync(path.join(pristineDir, a)), true); - assert.equal(fs.existsSync(path.join(pristineDir, b)), true); - } finally { - cleanup(tmp); - } - }); -}); - -describe('Bug #2998: saveLocalPatches no longer leaves the pristineDir variable unused', () => { - test('saveLocalPatches accepts a pristineCtx and exposes the helper for direct testing', () => { - // Structural assertion: the function exists with the new signature shape. - // Behavioral end-to-end is covered by the populatePristineDir tests above - // (that helper is what saveLocalPatches calls internally). - assert.equal(typeof INSTALL.populatePristineDir, 'function'); - // The signature for saveLocalPatches isn't exported, but the helper IS, - // and it's the unit of behavior the bug is about. Asserting on the helper - // is the structural-IR equivalent of the no-source-grep convention. - }); -}); diff --git a/tests/bug-3017-codex-hook-absolute-node.test.cjs b/tests/bug-3017-codex-hook-absolute-node.test.cjs deleted file mode 100644 index 2a8c3f2dc..000000000 --- a/tests/bug-3017-codex-hook-absolute-node.test.cjs +++ /dev/null @@ -1,258 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #3017: Codex SessionStart hook still emits bare `node` after #3002. - * - * PR #3002 fixed #2979 for settings.json-based managed JS hooks (Claude - * Code, Gemini, Antigravity) by routing through buildHookCommand() → - * resolveNodeRunner(), which emits the absolute Node binary path. But the - * Codex install path writes its SessionStart hook directly into a - * config.toml string, bypassing both helpers: - * - * command = "node ${updateCheckScript}" - * - * Under a GUI/minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) where node - * is not resolvable, the hook fails with `/bin/sh: node: command not - * found` (exit 127). The same failure mode #2979 was meant to fix — - * just on the codex toml branch instead of the settings.json branch. - * - * The fix exposes two pure helpers and tests them as typed records, - * not by grepping install.js content: - * - * buildCodexHookBlock(targetDir, { absoluteRunner }) → toml string - * - emits `command = " "` so the - * hook resolves under minimal PATH. - * - returns null when absoluteRunner is null (caller skips with warn, - * matching settings.json branch behavior). - * - * rewriteLegacyCodexHookBlock(tomlContent, absoluteRunner) → { content, changed } - * - rewrites an existing bare-node managed-hook command on reinstall - * (matches the rewriteLegacyManagedNodeHookCommands shape from #3002). - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); -const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs')); -const { buildCodexHookBlock, rewriteLegacyCodexHookBlock, resolveNodeRunner } = INSTALL; -const { projectCodexHookTomlCommand } = projection; - -/** - * Parse the toml hook block into a typed record so tests can assert on - * the structured shape (what's the runner, what's the hook path, what's - * the type) rather than substring-matching the toml text. - */ -function parseCodexHookBlock(block) { - if (!block) return { ok: false, reason: 'empty' }; - // The block always carries the "# GSD Hooks" marker, the AoT tables, - // a type=command, and a command=" " line. - const hasMarker = /^# GSD Hooks$/m.test(block); - const hasEvent = /^\[\[hooks\.SessionStart\]\]$/m.test(block); - const hasHandler = /^\[\[hooks\.SessionStart\.hooks\]\]$/m.test(block); - const typeMatch = block.match(/^type\s*=\s*"([^"]+)"$/m); - // command = " " — runner may itself be a quoted absolute path. - // Match the whole RHS as one toml double-quoted string, then split into runner + hookpath. - const cmdLine = block.match(/^command\s*=\s*"((?:[^"\\]|\\.)*)"$/m); - if (!cmdLine) return { ok: false, reason: 'no command line' }; - const cmdValue = cmdLine[1]; - // Inside the command value, the runner is either a quoted string (escaped \" in toml) - // or a bare token, followed by a space and the hook path (quoted). - // toml escapes interior " as \", so the cmdValue contains literal \" sequences. - const cmdParsed = cmdValue.match(/^(\\".+?\\"|node|bash|\S+)\s+\\"([^\\]+)\\"\s*$/); - return { - ok: true, - hasMarker, - hasEvent, - hasHandler, - type: typeMatch ? typeMatch[1] : null, - command: cmdValue, - runner: cmdParsed ? cmdParsed[1] : null, - hookPath: cmdParsed ? cmdParsed[2] : null, - }; -} - -// Strip the toml-escape (\") and JSON-quote (") layers from the parsed -// runner token to compare against the raw absolute path the caller -// supplied. parsed.runner round-trips through TWO escape layers: -// 1. JSON.stringify in resolveNodeRunner adds outer "..." quotes -// 2. toml escapes the interior " to \" inside the command field -// After both, parsed.runner ends in `\"` and starts with `\"`. -function unescapeRunner(token) { - if (!token) return token; - let t = token.replace(/^\\"/, '').replace(/\\"$/, ''); - if (t.startsWith('"') && t.endsWith('"')) t = t.slice(1, -1); - return t; -} - -describe('Bug #3017 / #3440: Codex hook projection seam', () => { - test('projectCodexHookTomlCommand renders escaped command value from shared projection module', () => { - const commandValue = projectCodexHookTomlCommand({ - absoluteRunner: '"/usr/local/bin/node"', - scriptPath: '/tmp/codex-test/.codex/hooks/gsd-check-update.js', - platform: 'linux', - }); - assert.equal( - commandValue, - '\\"/usr/local/bin/node\\" \\"/tmp/codex-test/.codex/hooks/gsd-check-update.js\\"', - ); - }); -}); - -describe('Bug #3017: buildCodexHookBlock emits absolute node runner', () => { - test('exported as a function', () => { - assert.equal(typeof buildCodexHookBlock, 'function'); - }); - - test('emits the EXACT absolute node runner the caller supplied (#3022 CR)', () => { - const targetDir = '/tmp/codex-test/.codex'; - const expectedRunnerPath = '/usr/local/bin/node'; - const absoluteRunner = `"${expectedRunnerPath}"`; - const block = buildCodexHookBlock(targetDir, { absoluteRunner }); - const parsed = parseCodexHookBlock(block); - assert.equal(parsed.ok, true, `parse failed: ${block}`); - assert.equal(parsed.hasMarker, true, '# GSD Hooks marker present'); - assert.equal(parsed.hasEvent, true, '[[hooks.SessionStart]] AoT entry present'); - assert.equal(parsed.hasHandler, true, '[[hooks.SessionStart.hooks]] handler entry present'); - assert.equal(parsed.type, 'command', 'handler is type=command'); - // Strict: parsed runner must match the supplied absolute path EXACTLY - // (after stripping toml/JSON escape layers). A loose substring like - // '/node' would let an unrelated absolute token containing '/node' - // pass — e.g. '/Users/x/notnode/foo'. - assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath, - `parsed runner must equal supplied absolute path: got ${parsed.runner}, want ${expectedRunnerPath}`); - // On Windows, path.resolve prepends the current drive letter ("D:") to - // the POSIX-shaped fixture path. Accept either form. - const expectedHookSuffix = '/tmp/codex-test/.codex/hooks/gsd-check-update.js'; - assert.ok( - parsed.hookPath === expectedHookSuffix || - parsed.hookPath.replace(/^[A-Za-z]:/, '') === expectedHookSuffix, - `hook path equality, got: ${parsed.hookPath}, want suffix: ${expectedHookSuffix}`, - ); - }); - - test('returns null when absoluteRunner is null (caller skips registration)', () => { - const block = buildCodexHookBlock('/tmp/x/.codex', { absoluteRunner: null }); - assert.equal(block, null, - 'must return null on missing runner so caller can warn-and-skip instead of writing a broken hook'); - }); - - test('integrates with resolveNodeRunner() in the live process — runner equals resolved node runner (#3022 CR)', () => { - const runner = resolveNodeRunner(); - assert.ok(runner, 'resolveNodeRunner returns a usable value in this test env'); - const block = buildCodexHookBlock('/tmp/x/.codex', { absoluteRunner: runner }); - const parsed = parseCodexHookBlock(block); - assert.equal(parsed.ok, true); - // Strict canonical-runner equality: the parsed runner (after stripping - // toml + JSON escape layers) must be exactly the normalized runner that - // resolveNodeRunner selected. Homebrew Cellar execPath values intentionally - // normalize to the stable Homebrew symlink (#3181). - const expected = JSON.parse(runner); - assert.equal(unescapeRunner(parsed.runner), expected, - `parsed runner must equal resolveNodeRunner(), got: ${parsed.runner}, want: ${expected}`); - }); -}); - -describe('Bug #3017: rewriteLegacyCodexHookBlock migrates bare-node on reinstall', () => { - test('exported as a function', () => { - assert.equal(typeof rewriteLegacyCodexHookBlock, 'function'); - }); - - test('rewrites a bare-node managed-hook command to the absolute runner', () => { - const before = [ - '[model]', - 'name = "o3"', - '', - '# GSD Hooks', - '[[hooks.SessionStart]]', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "node /Users/x/.codex/hooks/gsd-check-update.js"', - '', - ].join('\n'); - const expectedRunnerPath = '/usr/local/bin/node'; - const runner = `"${expectedRunnerPath}"`; - const result = rewriteLegacyCodexHookBlock(before, runner); - assert.equal(result.changed, true, 'must report change=true'); - // The migrated command must use the EXACT absolute runner the caller - // supplied (#3022 CR — was previously asserting a loose '/node' - // substring which let unrelated absolute paths pass). - const parsed = parseCodexHookBlock(result.content); - assert.equal(parsed.ok, true); - assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath, - `runner must equal supplied absolute path: ${parsed.runner}`); - assert.equal(parsed.hookPath, '/Users/x/.codex/hooks/gsd-check-update.js'); - // Non-GSD content (the [model] block) must be preserved verbatim. - assert.ok(result.content.includes('[model]')); - assert.ok(result.content.includes('name = "o3"')); - }); - - test('decodes TOML-escaped quoted script paths before projection', () => { - const before = [ - '# GSD Hooks', - '[[hooks.SessionStart]]', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "node \\"C:\\\\Users\\\\x\\\\.codex\\\\hooks\\\\gsd-check-update.js\\""', - '', - ].join('\n'); - const runner = '"/usr/local/bin/node"'; - const result = rewriteLegacyCodexHookBlock(before, runner, { platform: 'win32' }); - assert.equal(result.changed, true); - const parsed = parseCodexHookBlock(result.content); - assert.equal(parsed.ok, true, 'hook block must parse correctly'); - const expected = projectCodexHookTomlCommand({ - absoluteRunner: runner, - scriptPath: 'C:\\Users\\x\\.codex\\hooks\\gsd-check-update.js', - platform: 'win32', - }); - assert.equal(parsed.command, expected, - 'rewritten command must project from decoded Windows path (not TOML-escaped token text)'); - assert.equal(unescapeRunner(parsed.runner), '/usr/local/bin/node', - 'runner must equal supplied absolute path'); - assert.equal(parsed.hookPath, 'C:/Users/x/.codex/hooks/gsd-check-update.js', - 'hook path must equal decoded Windows path after projection normalization'); - }); - - test('does NOT touch a managed-hook entry that already uses an absolute runner', () => { - const already = [ - '# GSD Hooks', - '[[hooks.SessionStart]]', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "\\"/usr/local/bin/node\\" /Users/x/.codex/hooks/gsd-check-update.js"', - '', - ].join('\n'); - const result = rewriteLegacyCodexHookBlock(already, '"/usr/local/bin/node"'); - assert.equal(result.changed, false); - assert.equal(result.content, already); - }); - - test('does NOT touch user-authored bare-node hooks (filename not in managed allowlist)', () => { - const userOwned = [ - '[[hooks.SessionStart]]', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "node /home/me/my-custom-codex-hook.js"', - '', - ].join('\n'); - const result = rewriteLegacyCodexHookBlock(userOwned, '"/usr/local/bin/node"'); - assert.equal(result.changed, false, - 'user-authored hooks must be left alone; only managed gsd-* hooks are migrated'); - assert.equal(result.content, userOwned); - }); - - test('returns content unchanged when absoluteRunner is null', () => { - const before = 'command = "node /path/to/gsd-check-update.js"'; - const result = rewriteLegacyCodexHookBlock(before, null); - assert.equal(result.changed, false); - assert.equal(result.content, before); - }); -}); diff --git a/tests/bug-3018-codex-discuss-fallback.test.cjs b/tests/bug-3018-codex-discuss-fallback.test.cjs deleted file mode 100644 index 9957d38c4..000000000 --- a/tests/bug-3018-codex-discuss-fallback.test.cjs +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Regression test for bug #3018. - * - * @jon-hendry: running `$gsd-discuss-phase 81` in Codex Default mode (where - * `request_user_input` is rejected) caused the agent to pick "reasonable - * defaults" and proceed straight into writing CONTEXT.md / DISCUSSION-LOG.md - * checkpoints — without ever surfacing the questions to the user. The - * generated Codex skill adapter explicitly told it to do that: - * - * "When `request_user_input` is rejected (Execute mode), present a - * plain-text numbered list and pick a reasonable default." - * - * Discuss-mode is the wrong place for that fallback. The contract should be: - * stop, render the questions as plain text, wait for the user's answer. - * Defaults may only be picked when the user has authorized non-interactive - * mode (--auto / --all) or has explicitly approved them. - * - * Test design (#3027 CR follow-up): instead of grepping the prose with - * regex, parse the fallback section into a typed semantic-flag record and - * assert on those booleans. This adheres to CONTRIBUTING.md "no-source-grep" - * — the test names a behavioral invariant, the parser walks the prose - * once and exposes the invariants as named flags, and the prose can be - * reworded freely as long as the flags stay true. - */ - -'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 INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); -const { getCodexSkillAdapterHeader } = INSTALL; - -/** - * Extract the "Execute mode fallback" section text from the adapter header. - * Returns null if the section is missing. Section runs from the - * "Execute mode fallback:" label up to the next heading or tag. - */ -function extractExecuteModeFallback(header) { - const m = header.match(/Execute mode fallback:\s*\n([\s\S]*?)(?=\n##\s|\n<\/codex_skill_adapter>)/); - return m ? m[1].trim() : null; -} - -/** - * Parse the Execute-mode-fallback section into a typed semantic-flag - * record. Each flag answers a single behavioral question that the #3018 - * fix is contractually required to encode in the prose. Tests assert on - * the booleans, not the wording — so the prose can evolve without test - * churn as long as the semantics stay correct. - * - * The flags are derived from a single pass over the section text: each - * one looks for any of a small set of synonym phrases that a correct - * implementation would use. The negative anti-pattern flag - * (`silentlyPicksDefaults`) is the regression guard — the prose under - * #3018 told the agent to "pick a reasonable default" autonomously, - * which is exactly what this fix removes. - */ -function parseExecuteModeFallback(section) { - if (!section || typeof section !== 'string') { - return { - ok: false, - sectionLength: 0, - instructsStop: false, - presentsPlainTextQuestions: false, - namesPermissionPath: false, - forbidsWritingArtifactsBeforeAnswer: false, - silentlyPicksDefaults: false, - }; - } - const lower = section.toLowerCase(); - // (a) STOP/WAIT directive — the agent must halt instead of proceeding. - const instructsStop = /\b(stop|halt|wait)\b/.test(lower); - // (b) Plain-text fallback presentation — the agent must surface the - // questions in some inspectable form (numbered list / plain text). - const presentsPlainTextQuestions = /plain.?text|numbered list/.test(lower); - // (c) Permission path that DOES allow defaults — must name at least - // one (--auto / --all / explicit user approval / autonomous workflow). - const namesPermissionPath = - /--auto|--all/.test(section) || - /explicit(ly)? (approv|authoriz|consent)/i.test(section) || - /user (has )?approv|user (has )?authoriz|user (has )?consent/i.test(section) || - /autonomous (lifecycle|workflow|paths?)/i.test(section); - // (d) Artifact-write ban — the agent must not produce workflow files - // (CONTEXT.md, DISCUSSION-LOG.md, PLAN.md, checkpoints) before the - // user answers or one of the permission-path conditions applies. - // Require BOTH a "do not write" intent AND a named artifact class so - // generic "do not write" prose elsewhere can't satisfy the flag. - const forbidsWriteIntent = /do not write|don'?t write|must not write|shall not write/i.test(section); - const namesArtifactClass = /artifact|checkpoint|context\.md|discussion.?log|plan\.md/i.test(section); - const forbidsWritingArtifactsBeforeAnswer = forbidsWriteIntent && namesArtifactClass; - // Anti-pattern guard — the prose that caused #3018. This MUST be false. - const silentlyPicksDefaults = /pick (a |the )?(reasonable|sensible|sane) default/i.test(section); - return { - ok: true, - sectionLength: section.length, - instructsStop, - presentsPlainTextQuestions, - namesPermissionPath, - forbidsWritingArtifactsBeforeAnswer, - silentlyPicksDefaults, - }; -} - -describe('bug #3018: codex skill adapter encodes the discuss-mode fallback contract', () => { - test('exports the adapter generator', () => { - assert.equal(typeof getCodexSkillAdapterHeader, 'function'); - }); - - test('Execute mode fallback section exists and has content', () => { - const header = getCodexSkillAdapterHeader('gsd-discuss-phase'); - const section = extractExecuteModeFallback(header); - const parsed = parseExecuteModeFallback(section); - assert.equal(parsed.ok, true, `section must parse, got header:\n${header}`); - assert.ok(parsed.sectionLength > 0, 'section must be non-empty'); - }); - - test('fallback instructs STOP/WAIT (not silent continuation)', () => { - const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); - const parsed = parseExecuteModeFallback(section); - assert.equal(parsed.instructsStop, true, - `must instruct stop/halt/wait — section was:\n${section}`); - }); - - test('fallback prescribes plain-text question presentation', () => { - const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); - const parsed = parseExecuteModeFallback(section); - assert.equal(parsed.presentsPlainTextQuestions, true, - `must mention plain-text / numbered-list presentation — section was:\n${section}`); - }); - - test('fallback names a permission path under which defaults ARE allowed (--auto / --all / explicit approval / autonomous)', () => { - const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); - const parsed = parseExecuteModeFallback(section); - assert.equal(parsed.namesPermissionPath, true, - `must name at least one permission path — section was:\n${section}`); - }); - - test('fallback forbids writing workflow artifacts before user answers', () => { - const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); - const parsed = parseExecuteModeFallback(section); - assert.equal(parsed.forbidsWritingArtifactsBeforeAnswer, true, - `must encode write-ban + named artifact class — section was:\n${section}`); - }); - - test('fallback does NOT contain the #3018 anti-pattern ("pick a reasonable default")', () => { - const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); - const parsed = parseExecuteModeFallback(section); - assert.equal(parsed.silentlyPicksDefaults, false, - `regression — fallback must NOT instruct the agent to pick defaults autonomously, section was:\n${section}`); - }); - - test('all four positive flags + the negative anti-pattern flag — typed-record snapshot', () => { - // Single assertion that the whole semantic record matches the contract. - // If any flag flips, the test fails with a structured diff naming the - // exact invariant that broke. - const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); - const parsed = parseExecuteModeFallback(section); - const semanticContract = { - ok: parsed.ok, - instructsStop: parsed.instructsStop, - presentsPlainTextQuestions: parsed.presentsPlainTextQuestions, - namesPermissionPath: parsed.namesPermissionPath, - forbidsWritingArtifactsBeforeAnswer: parsed.forbidsWritingArtifactsBeforeAnswer, - silentlyPicksDefaults: parsed.silentlyPicksDefaults, - }; - assert.deepStrictEqual(semanticContract, { - ok: true, - instructsStop: true, - presentsPlainTextQuestions: true, - namesPermissionPath: true, - forbidsWritingArtifactsBeforeAnswer: true, - silentlyPicksDefaults: false, - }, `discuss-mode fallback contract violated — section was:\n${section}`); - }); -}); diff --git a/tests/bug-3037-gemini-duplicate-commands.test.cjs b/tests/bug-3037-gemini-duplicate-commands.test.cjs deleted file mode 100644 index 8c1ce7c91..000000000 --- a/tests/bug-3037-gemini-duplicate-commands.test.cjs +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Bug #3037: Gemini global+local install creates duplicate /gsd:* commands - * across user (HOME/.gemini/) and workspace (PROJECT/.gemini/) scopes. - * - * Reproduction (from issue body): - * 1. install --gemini --global with HOME=tmpHome - * 2. cd tmpProject; install --gemini --local - * → both ~/.gemini/commands/gsd/ and PROJECT/.gemini/commands/gsd/ contain - * 65 overlapping command filenames. - * → Gemini conflict detection renames every overlapping command to - * /workspace.gsd:* and /user.gsd:*, breaking the documented /gsd:* - * namespace. - * - * Fix: when the local Gemini install detects the user-scope GSD command - * directory already exists with managed-shape content, skip the local copy - * and emit a clear warning explaining the conflict avoidance. - * - * Tests assert on the post-install filesystem shape and capture the skip - * warning so the full test log remains warning-clean. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); - -const { install } = require('../bin/install.js'); - -describe('bug #3037: Gemini global+local install must not create duplicate command scopes', () => { - let tmpHome; - let tmpProject; - let originalHome; - let originalUserprofile; - let originalCwd; - - beforeEach(() => { - tmpHome = createTempDir('gsd-3037-home-'); - tmpProject = createTempDir('gsd-3037-work-'); - originalHome = process.env.HOME; - originalUserprofile = process.env.USERPROFILE; - originalCwd = process.cwd(); - // Point HOME at the temp dir so install(true, 'gemini') writes to - // tmpHome/.gemini, not the developer's real home. - process.env.HOME = tmpHome; - process.env.USERPROFILE = tmpHome; - }); - - afterEach(() => { - if (originalHome === undefined) delete process.env.HOME; - else process.env.HOME = originalHome; - // CR #3041: also restore USERPROFILE so the temp HOME doesn't leak - // into later tests and create order-dependent failures on Windows - // or any code path that reads USERPROFILE. - if (originalUserprofile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = originalUserprofile; - process.chdir(originalCwd); - cleanup(tmpHome); - cleanup(tmpProject); - }); - - function listCommandFiles(geminiCommandsRoot) { - if (!fs.existsSync(geminiCommandsRoot)) return []; - const out = []; - function walk(dir) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.isFile()) out.push(path.relative(geminiCommandsRoot, full)); - } - } - walk(geminiCommandsRoot); - return out.sort(); - } - - function runInstall(...args) { - return captureConsole(() => install(...args)); - } - - test('global install populates HOME/.gemini/commands/gsd', () => { - runInstall(true, 'gemini'); - const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd'); - const files = listCommandFiles(globalCmds); - assert.ok( - files.length > 0, - 'global install must populate HOME/.gemini/commands/gsd' - ); - }); - - test('local install after global does NOT populate PROJECT/.gemini/commands/gsd (avoids /gsd:* namespace conflict)', () => { - // Step 1: global install - runInstall(true, 'gemini'); - const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd'); - const globalFiles = listCommandFiles(globalCmds); - assert.ok(globalFiles.length > 0, 'precondition: global install must succeed'); - - // Step 2: local install in a temp project - process.chdir(tmpProject); - const { stdout } = runInstall(false, 'gemini'); - assert.match( - stdout, - /Skipping commands\/gsd\/ for local install/, - 'local install must explain why it skips duplicate Gemini commands' - ); - - // Assertion: the local commands/gsd/ directory must NOT exist (or must - // be empty) so Gemini's conflict detection has nothing to rename. The - // fix may either skip the directory entirely (preferred — no leftover - // file system noise) or create an empty directory (acceptable but odd). - const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); - const localFiles = listCommandFiles(localCmds); - assert.equal( - localFiles.length, - 0, - `local install must skip commands/gsd/ when global already exists; ` + - `found ${localFiles.length} duplicate command file(s) at ${localCmds}` - ); - }); - - test('local install with NO existing global GSD does still populate PROJECT/.gemini/commands/gsd', () => { - // No global install first — local should proceed normally so users who - // only ever run --local still get GSD commands in their project. - process.chdir(tmpProject); - runInstall(false, 'gemini'); - - const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); - const localFiles = listCommandFiles(localCmds); - assert.ok( - localFiles.length > 0, - `local-only install must populate PROJECT/.gemini/commands/gsd; ` + - `found ${localFiles.length} files at ${localCmds}` - ); - }); - - test('local install when HOME has hand-dropped overrides UNDER commands/gsd/ (but no full GSD) still populates locally', () => { - // CR #3041 regression: the previous detection was - // `fs.readdirSync(homeGeminiGsd).length > 0` which would skip the - // local install for a user who manually dropped a single override - // command at ~/.gemini/commands/gsd/.toml without ever - // running --gemini --global. The fix narrows detection to require - // at least 3 canonical GSD command files (help.toml, progress.toml, - // new-project.toml) — a marker that's structurally impossible to - // produce by accident. - const homeGsdDir = path.join(tmpHome, '.gemini', 'commands', 'gsd'); - fs.mkdirSync(homeGsdDir, { recursive: true }); - fs.writeFileSync( - path.join(homeGsdDir, 'my-override.toml'), - 'description = "user override"\nprompt = "..."\n' - ); - - process.chdir(tmpProject); - runInstall(false, 'gemini'); - - const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); - const localFiles = listCommandFiles(localCmds); - assert.ok( - localFiles.length > 0, - `local install must proceed when HOME/.gemini/commands/gsd contains ` + - `only user overrides (not the full GSD canary set); ` + - `found ${localFiles.length} files at ${localCmds}` - ); - }); - - test('local install when HOME/.gemini exists but commands/gsd is absent (non-GSD Gemini user) still populates locally', () => { - // Simulate a user who has Gemini configured but never installed GSD - // globally. ~/.gemini/ exists with unrelated content; ~/.gemini/commands/ - // may or may not exist with non-gsd subdirectories. Local install must - // still proceed because no GSD-managed user-scope directory is present. - fs.mkdirSync(path.join(tmpHome, '.gemini', 'commands', 'someone-else'), { - recursive: true, - }); - fs.writeFileSync( - path.join(tmpHome, '.gemini', 'commands', 'someone-else', 'foo.toml'), - 'description = "user command"\nprompt = "..."\n' - ); - - process.chdir(tmpProject); - runInstall(false, 'gemini'); - - const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); - const localFiles = listCommandFiles(localCmds); - assert.ok( - localFiles.length > 0, - `local install must proceed when no GSD-managed user-scope directory ` + - `exists, even if other Gemini commands are present at the user scope` - ); - }); -}); diff --git a/tests/bug-3181-node-cellar-path.test.cjs b/tests/bug-3181-node-cellar-path.test.cjs deleted file mode 100644 index a7b092619..000000000 --- a/tests/bug-3181-node-cellar-path.test.cjs +++ /dev/null @@ -1,264 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #3181: `resolveNodeRunner()` bakes versioned Homebrew Cellar paths - * (e.g. `/usr/local/Cellar/node/25.8.1/bin/node`) into hook commands in - * `~/.claude/settings.json`. After `brew upgrade node` the Cellar binary - * fails with `dyld: Library not loaded` because shared libraries have - * changed SOVERSION. - * - * Fix: prefer the stable Homebrew symlinks (`/usr/local/bin/node` for Intel - * Macs, `/opt/homebrew/bin/node` for Apple Silicon) when a Cellar path is - * detected. Non-Homebrew paths (NVM, system node, Windows, etc.) are - * returned unchanged. - * - * Also: `rewriteLegacyManagedNodeHookCommands()` must normalize Cellar paths - * baked into existing hook commands so reinstall doesn't re-bake them. - * - * All assertions go against exported function return values — no source-grep. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); -const { normalizeNodePath, resolveNodeRunner, rewriteLegacyManagedNodeHookCommands } = INSTALL; - -// ─── normalizeNodePath ──────────────────────────────────────────────────────── - -describe('Bug #3181: normalizeNodePath — exported as a function', () => { - test('normalizeNodePath is exported', () => { - assert.equal(typeof normalizeNodePath, 'function'); - }); -}); - -describe('Bug #3181: normalizeNodePath — Intel Homebrew Cellar paths → /usr/local/bin/node', () => { - test('simple versioned Intel Cellar path', () => { - const result = normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node'); - assert.equal(result, '/usr/local/bin/node'); - }); - - test('Intel Cellar path with long semver', () => { - const result = normalizeNodePath('/usr/local/Cellar/node/20.11.0/bin/node'); - assert.equal(result, '/usr/local/bin/node'); - }); - - test('Intel Cellar path with prerelease version segment', () => { - const result = normalizeNodePath('/usr/local/Cellar/node/22.0.0-rc.1/bin/node'); - assert.equal(result, '/usr/local/bin/node'); - }); - - test('Intel versioned formula Cellar path (node@20) maps to stable symlink', () => { - const result = normalizeNodePath('/usr/local/Cellar/node@20/20.11.0/bin/node'); - assert.equal(result, '/usr/local/bin/node'); - }); -}); - -describe('Bug #3181: normalizeNodePath — Apple Silicon Homebrew Cellar paths → /opt/homebrew/bin/node', () => { - test('simple versioned Apple Silicon Cellar path', () => { - const result = normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node'); - assert.equal(result, '/opt/homebrew/bin/node'); - }); - - test('Apple Silicon Cellar path with another version', () => { - const result = normalizeNodePath('/opt/homebrew/Cellar/node/18.20.4/bin/node'); - assert.equal(result, '/opt/homebrew/bin/node'); - }); - - test('Apple Silicon versioned formula Cellar path (node@18) maps to stable symlink', () => { - const result = normalizeNodePath('/opt/homebrew/Cellar/node@18/18.20.4/bin/node'); - assert.equal(result, '/opt/homebrew/bin/node'); - }); -}); - -describe('Bug #3181: normalizeNodePath — non-Homebrew paths are returned unchanged', () => { - test('NVM path is unchanged', () => { - const nvm = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node'; - assert.equal(normalizeNodePath(nvm), nvm); - }); - - test('already-stable Intel Homebrew symlink is unchanged', () => { - assert.equal(normalizeNodePath('/usr/local/bin/node'), '/usr/local/bin/node'); - }); - - test('already-stable Apple Silicon Homebrew symlink is unchanged', () => { - assert.equal(normalizeNodePath('/opt/homebrew/bin/node'), '/opt/homebrew/bin/node'); - }); - - test('system node (/usr/bin/node) is unchanged', () => { - assert.equal(normalizeNodePath('/usr/bin/node'), '/usr/bin/node'); - }); - - test('Windows path is unchanged', () => { - const win = 'C:\\Program Files\\nodejs\\node.exe'; - assert.equal(normalizeNodePath(win), win); - }); - - test('empty string is returned as-is', () => { - assert.equal(normalizeNodePath(''), ''); - }); - - test('null is returned as-is', () => { - assert.equal(normalizeNodePath(null), null); - }); -}); - -// ─── resolveNodeRunner ──────────────────────────────────────────────────────── - -describe('Bug #3181: resolveNodeRunner — maps Cellar execPath to stable symlink', () => { - test('Intel Cellar execPath → stable symlink quoted token', () => { - const orig = process.execPath; - try { - Object.defineProperty(process, 'execPath', { - value: '/usr/local/Cellar/node/25.8.1/bin/node', - configurable: true, - }); - const runner = resolveNodeRunner(); - assert.equal(runner, '"/usr/local/bin/node"', - `expected stable Intel symlink, got: ${runner}`); - } finally { - Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); - } - }); - - test('Apple Silicon Cellar execPath → stable symlink quoted token', () => { - const orig = process.execPath; - try { - Object.defineProperty(process, 'execPath', { - value: '/opt/homebrew/Cellar/node/25.8.1/bin/node', - configurable: true, - }); - const runner = resolveNodeRunner(); - assert.equal(runner, '"/opt/homebrew/bin/node"', - `expected stable Apple Silicon symlink, got: ${runner}`); - } finally { - Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); - } - }); - - test('non-Homebrew execPath is returned as a quoted absolute path unchanged', () => { - const orig = process.execPath; - const nvmPath = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node'; - try { - Object.defineProperty(process, 'execPath', { value: nvmPath, configurable: true }); - const runner = resolveNodeRunner(); - assert.equal(runner, JSON.stringify(nvmPath)); - } finally { - Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); - } - }); - - test('returns null when execPath is empty (existing null-guard is preserved)', () => { - const orig = process.execPath; - try { - Object.defineProperty(process, 'execPath', { value: '', configurable: true }); - assert.equal(resolveNodeRunner(), null); - } finally { - Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); - } - }); -}); - -// ─── rewriteLegacyManagedNodeHookCommands — Cellar runner rewrite ───────────── - -describe('Bug #3181: rewriteLegacyManagedNodeHookCommands — rewrites baked Cellar runner to stable symlink', () => { - test('Intel Cellar runner in a managed hook is rewritten to the stable symlink', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ - type: 'command', - command: '"/usr/local/Cellar/node/25.8.1/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', - }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, true, 'expected rewrite to occur'); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', - ); - }); - - test('Apple Silicon Cellar runner in a managed hook is rewritten to the stable symlink', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ - type: 'command', - command: '"/opt/homebrew/Cellar/node/25.8.1/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', - }], - }], - }, - }; - const runner = '"/opt/homebrew/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, true, 'expected rewrite to occur'); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '"/opt/homebrew/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', - ); - }); - - test('a hook already using the stable runner is NOT rewritten (no churn)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ - type: 'command', - command: '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', - }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const before = settings.hooks.SessionStart[0].hooks[0].command; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, false, 'already-stable entry must not be touched'); - assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); - }); - - test('a user hook using a Cellar runner but an unmanaged filename is NOT rewritten', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ - type: 'command', - command: '"/usr/local/Cellar/node/25.8.1/bin/node" "/Users/x/my-custom-hook.js"', - }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const before = settings.hooks.SessionStart[0].hooks[0].command; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, false, 'unmanaged hooks with Cellar runner must not be touched'); - assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); - }); - - // Existing bare-node rewrite still works alongside the new Cellar rewrite - test('bare `node` managed hook is still rewritten (existing #2979 behaviour preserved)', () => { - const settings = { - hooks: { - SessionStart: [{ - hooks: [{ - type: 'command', - command: 'node "/Users/x/.gemini/hooks/gsd-check-update.js"', - }], - }], - }, - }; - const runner = '"/usr/local/bin/node"'; - const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); - assert.equal(changed, true); - assert.equal( - settings.hooks.SessionStart[0].hooks[0].command, - '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', - ); - }); -}); diff --git a/tests/bug-3245-codex-toml-floats.test.cjs b/tests/bug-3245-codex-toml-floats.test.cjs deleted file mode 100644 index 725144a93..000000000 --- a/tests/bug-3245-codex-toml-floats.test.cjs +++ /dev/null @@ -1,510 +0,0 @@ -/** - * Regression: issue #3245 — Codex install rejects valid TOML floats. - * - * Two defects, two fixes: - * - * Defect 1 — parseTomlValue rejects TOML floats (e.g. tool_timeout_sec = 20.0). - * Codex CLI's serde schema requires f64 for tool_timeout_sec / startup_timeout_sec - * (integers fail with "invalid type: integer"). GSD's strict-integer-only parser - * was the inverse of what Codex requires — any float triggers the rejection branch. - * Fix: extend parseTomlValue to accept TOML 1.0 float literals and return them as - * JS Number. The merged config.toml preserves the float form verbatim so - * round-trip writes don't coerce 20.0 → 20. - * - * Defect 2 — Partial rollback leaves install in hybrid state. - * restoreCodexSnapshot only knew about config.toml, but skills/, agents/, and VERSION - * are written earlier in the install sequence. A post-install validation failure - * aborts with new agent text on disk, config.toml reverted, and .tmp files - * potentially orphaned. - * Fix: capture pre-install state of skills/, agents/, and VERSION before any - * Codex-specific mutation, and extend the rollback to cover all of them. - */ - -// GSD_TEST_MODE must be set before require('../bin/install.js') so the module -// skips the main CLI entry point and exports its internals. -const previousGsdTestMode = process.env.GSD_TEST_MODE; -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, before, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { execFileSync } = require('child_process'); -const { cleanup } = require('./helpers.cjs'); - -const { parseTomlToObject, validateCodexConfigSchema, install } = require('../bin/install.js'); -const installModule = require('../bin/install.js'); - -if (previousGsdTestMode === undefined) { - delete process.env.GSD_TEST_MODE; -} else { - process.env.GSD_TEST_MODE = previousGsdTestMode; -} - -// Ensure hooks/dist/ is populated — mirrors the pattern used by codex-config.test.cjs. -const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); -const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -before(() => { - if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); - } -}); - -function runCodexInstall(codexHome) { - const previousCodexHome = process.env.CODEX_HOME; - const previousCwd = process.cwd(); - process.env.CODEX_HOME = codexHome; - try { - process.chdir(path.join(__dirname, '..')); - return install(true, 'codex'); - } finally { - process.chdir(previousCwd); - if (previousCodexHome === undefined) { - delete process.env.CODEX_HOME; - } else { - process.env.CODEX_HOME = previousCodexHome; - } - } -} - -function writeCodexConfig(codexHome, content) { - fs.mkdirSync(codexHome, { recursive: true }); - fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); -} - -// --------------------------------------------------------------------------- -// Defect 1 — parseTomlValue must accept TOML floats -// --------------------------------------------------------------------------- - -describe('#3245 — parseTomlToObject accepts TOML floats', () => { - test('parses bare decimal float (20.0)', () => { - const content = [ - 'tool_timeout_sec = 20.0', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(typeof parsed.tool_timeout_sec, 'number', - 'tool_timeout_sec should be a JS number'); - assert.strictEqual(parsed.tool_timeout_sec, 20.0, - 'value must equal 20.0'); - }); - - test('parses startup_timeout_sec = 60.0', () => { - const content = [ - 'startup_timeout_sec = 60.0', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.startup_timeout_sec, 60.0); - }); - - test('parses positive exponent notation (1e10)', () => { - const content = [ - 'x = 1e10', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.x, 1e10); - }); - - test('parses negative exponent (1.5e-3)', () => { - const content = [ - 'x = 1.5e-3', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.ok(Math.abs(parsed.x - 1.5e-3) < 1e-15, 'must be approximately 1.5e-3'); - }); - - test('parses signed positive float (+1.0)', () => { - const content = [ - 'x = +1.0', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.x, 1.0); - }); - - test('parses signed negative float (-0.5)', () => { - const content = [ - 'x = -0.5', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.x, -0.5); - }); - - test('parses float with underscore separators (1_000.0)', () => { - const content = [ - 'x = 1_000.0', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.x, 1000.0); - }); - - test('integer (no decimal) still parses as integer', () => { - const content = [ - 'x = 42', - '', - ].join('\n'); - const parsed = parseTomlToObject(content); - assert.strictEqual(parsed.x, 42); - }); - - test('still rejects bare date (1979-05-27)', () => { - const content = [ - 'x = 1979-05-27', - '', - ].join('\n'); - assert.throws( - () => parseTomlToObject(content), - /unsupported TOML value/, - 'date literals must remain unsupported' - ); - }); - - test('still rejects bare time (07:32:00)', () => { - const content = [ - 'x = 07:32:00', - '', - ].join('\n'); - // With leading-zero rejection (CR4 fix) the parser stops at `0`, and - // `7:32:00` is "trailing bytes". Either error form is acceptable — the - // key invariant is that time literals are never silently accepted. - assert.throws( - () => parseTomlToObject(content), - /unsupported TOML value|trailing bytes/, - 'time literals must remain unsupported' - ); - }); - - test('still rejects hex literal (0x1A)', () => { - const content = [ - 'x = 0x1A', - '', - ].join('\n'); - // 0 is parsed, then 'x1A' is trailing garbage — rejected with "trailing bytes" - // or "unsupported value" depending on where the parser catches it. - assert.throws( - () => parseTomlToObject(content), - /trailing bytes|unsupported (TOML value|value)/, - 'hex literals must remain unsupported' - ); - }); - - test('validateCodexConfigSchema passes a config with tool_timeout_sec = 20.0', () => { - const content = [ - '[model]', - 'name = "o3"', - '', - 'tool_timeout_sec = 20.0', - 'startup_timeout_sec = 60.0', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, true, - 'schema validation must pass for a config containing TOML floats: ' + result.reason); - }); -}); - -// --------------------------------------------------------------------------- -// Defect 1 — full install must succeed and preserve float verbatim -// --------------------------------------------------------------------------- - -// concurrency: false — drives the live install pipeline (shared CODEX_HOME env, -// process.chdir). Serialise to prevent stray mutations across parallel siblings. -describe('#3245 — install succeeds with TOML float in pre-existing config', { concurrency: false }, () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-float-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('install completes when config.toml contains tool_timeout_sec = 20.0', () => { - // Floats at the root level (before any table header) — this is where Codex - // CLI reads tool_timeout_sec / startup_timeout_sec according to its serde schema. - const preInstall = [ - 'tool_timeout_sec = 20.0', - 'startup_timeout_sec = 60.0', - '', - '[model]', - 'name = "o3"', - '', - ].join('\n'); - writeCodexConfig(codexHome, preInstall); - - // Must not throw — pre-#3245 this threw "unsupported TOML value … floats … not supported". - assert.doesNotThrow( - () => runCodexInstall(codexHome), - 'install must not throw when config.toml contains TOML floats' - ); - - // The merged config.toml must still contain the float values at root scope. - const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); - const parsed = parseTomlToObject(after); - assert.strictEqual(parsed.tool_timeout_sec, 20.0, - 'tool_timeout_sec must be preserved as a number after install'); - assert.strictEqual(parsed.startup_timeout_sec, 60.0, - 'startup_timeout_sec must be preserved as a number after install'); - }); - - test('post-install config round-trips tool_timeout_sec as numeric 20', () => { - const preInstall = [ - 'tool_timeout_sec = 20.0', - '', - ].join('\n'); - writeCodexConfig(codexHome, preInstall); - - runCodexInstall(codexHome); - - const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); - // The value must survive round-trip as a float-compatible representation. - // Parse structurally — don't grep for the literal string "20.0". - const parsed = parseTomlToObject(after); - assert.strictEqual(parsed.tool_timeout_sec, 20, - 'tool_timeout_sec must round-trip as numeric 20 (=== 20.0 in JS)'); - }); -}); - -// --------------------------------------------------------------------------- -// CR round-4 finding — TOML 1.0 disallows leading zeros in integer part -// --------------------------------------------------------------------------- -// -// TOML 1.0 §2: integer literals follow decimal-integer rules, which disallow -// leading zeros except the value `0` itself. `01`, `01.5`, `00e2`, `+01.0` -// are therefore invalid. The `parseTomlValue` integer-part regex is tightened -// from `\d(?:_?\d)*` to `(0|[1-9](?:_?\d)*)`. - -describe('#3245 CR4 — parseTomlValue rejects leading zeros in float integer part', () => { - function parseValue(raw) { - // Wrap in a minimal TOML assignment so parseTomlToObject drives the test. - return parseTomlToObject(`x = ${raw}`).x; - } - - function assertRejects(raw, label) { - let threw = false; - try { parseValue(raw); } catch (_) { threw = true; } - assert.strictEqual(threw, true, `expected rejection for ${label}: ${raw}`); - } - - function assertAccepts(raw, expected, label) { - let val; - let threw = false; - try { val = parseValue(raw); } catch (e) { threw = true; } - assert.strictEqual(threw, false, `expected acceptance for ${label}: ${raw}`); - if (expected !== undefined) { - assert.ok(Math.abs(val - expected) < 1e-12, `${label}: expected ${expected}, got ${val}`); - } - } - - // --- rejection cases: leading zeros in the integer part --- - - test('rejects 01 (leading zero on bare integer)', () => assertRejects('01', '01')); - test('rejects 00 (double-zero bare integer)', () => assertRejects('00', '00')); - test('rejects 01.5 (leading zero before decimal point)', () => assertRejects('01.5', '01.5')); - test('rejects 00.5 (double-zero before decimal)', () => assertRejects('00.5', '00.5')); - test('rejects +01 (leading zero with sign)', () => assertRejects('+01', '+01')); - test('rejects -01 (negative leading zero)', () => assertRejects('-01', '-01')); - test('rejects 00e2 (leading zero with exponent)', () => assertRejects('00e2', '00e2')); - test('rejects +01.0 (leading zero in positive float)', () => assertRejects('+01.0', '+01.0')); - test('rejects -01.0 (leading zero in negative float)', () => assertRejects('-01.0', '-01.0')); - test('rejects 01.5e10 (leading zero, decimal, and exponent)', () => assertRejects('01.5e10', '01.5e10')); - - // --- acceptance cases: valid TOML 1.0 numeric forms --- - - test('accepts 0 (single zero)', () => assertAccepts('0', 0, 'single zero')); - test('accepts 0.5 (zero before decimal)', () => assertAccepts('0.5', 0.5, 'zero.decimal')); - test('accepts 0.0 (zero.zero)', () => assertAccepts('0.0', 0.0, 'zero.zero')); - test('accepts 0e1 (zero with exponent)', () => assertAccepts('0e1', 0, '0e1')); - test('accepts +0.5 (positive zero-decimal)', () => assertAccepts('+0.5', 0.5, '+0.5')); - test('accepts -0.5 (negative zero-decimal)', () => assertAccepts('-0.5', -0.5, '-0.5')); - test('accepts 1 (single non-zero digit)', () => assertAccepts('1', 1, '1')); - test('accepts 12 (two digits)', () => assertAccepts('12', 12, '12')); - test('accepts 1.5 (simple float)', () => assertAccepts('1.5', 1.5, '1.5')); - test('accepts 1_000 (underscored integer)', () => assertAccepts('1_000', 1000, '1_000')); - test('accepts 1_000.5 (underscored float)', () => assertAccepts('1_000.5', 1000.5, '1_000.5')); - test('accepts +1.5 (positive float)', () => assertAccepts('+1.5', 1.5, '+1.5')); - test('accepts -2.0 (negative float)', () => assertAccepts('-2.0', -2.0, '-2.0')); - test('accepts 1.5e-3 (float with negative exponent)', () => assertAccepts('1.5e-3', 1.5e-3, '1.5e-3')); - test('accepts 1.05e10 (fractional part may start with zero)', () => assertAccepts('1.05e10', 1.05e10, '1.05e10')); -}); - -// --------------------------------------------------------------------------- -// Defect 2 — idempotent rollback covers skills, agents, VERSION -// --------------------------------------------------------------------------- - -// concurrency: false — patches module.exports.__codexSchemaValidator and drives -// the install pipeline. Serialise to prevent cross-test pollution. -describe('#3245 — idempotent rollback reverts skills/, agents/, and VERSION', { concurrency: false }, () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-rollback-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - delete installModule.__codexSchemaValidator; - cleanup(tmpDir); - }); - - test('validation failure rolls back skills/, agents/, and VERSION to pre-install state', () => { - // Start from a clean codexHome with no pre-existing GSD content — the dirs - // do not exist yet. After a failed install they must be absent (or contain - // only what was there before, i.e. nothing). - fs.mkdirSync(codexHome, { recursive: true }); - - // Force schema validation to fail so we can observe the rollback without - // needing a genuinely broken config. - installModule.__codexSchemaValidator = () => ({ - ok: false, - reason: 'simulated failure for #3245 rollback test', - }); - - let threw = false; - try { - runCodexInstall(codexHome); - } catch (_) { - threw = true; - } - assert.strictEqual(threw, true, 'install must throw when validation fails'); - - // skills/ — GSD writes gsd-* subdirs here. All must be absent after rollback. - const skillsDir = path.join(codexHome, 'skills'); - if (fs.existsSync(skillsDir)) { - const gsdSkills = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); - assert.strictEqual( - gsdSkills.length, - 0, - 'rollback must remove all gsd-* skill directories: ' + gsdSkills.map(e => e.name).join(', ') - ); - } - - // agents/ — GSD writes gsd-*.md and gsd-*.toml here. All must be absent. - // Not the shared listAgentFiles() helper: reads the INSTALLED Codex dest - // dir and is .toml-inclusive, so its semantics differ from the source roster. - const agentsDir = path.join(codexHome, 'agents'); - if (fs.existsSync(agentsDir)) { - const gsdAgents = fs.readdirSync(agentsDir) - .filter(f => f.startsWith('gsd-') && (f.endsWith('.md') || f.endsWith('.toml'))); - assert.strictEqual( - gsdAgents.length, - 0, - 'rollback must remove all gsd-* agent files: ' + gsdAgents.join(', ') - ); - } - - // VERSION — GSD writes gsd-core/VERSION. Must be absent (wasn't there before). - const versionPath = path.join(codexHome, 'gsd-core', 'VERSION'); - assert.strictEqual( - fs.existsSync(versionPath), - false, - 'rollback must remove the VERSION file written during install' - ); - }); - - test('rollback is safe when fired before any snapshots were captured (very early failure)', () => { - // If the validator is injected before ANY install writes happen, the rollback - // must not throw — it should be idempotent when nothing was written yet. - fs.mkdirSync(codexHome, { recursive: true }); - - installModule.__codexSchemaValidator = () => ({ - ok: false, - reason: 'very early simulated failure', - }); - - // The install must throw (validation failure), but the rollback that runs - // internally must not throw — it must be idempotent when nothing was written. - let threw = false; - try { - runCodexInstall(codexHome); - } catch (_) { - threw = true; - } - assert.strictEqual(threw, true, 'install must throw when validation fails (very early failure)'); - // Rollback removes all gsd-* skill dirs it wrote. Even if skills/ was - // created during the install, no gsd-* dirs should survive after rollback. - const skillsDir = path.join(codexHome, 'skills'); - const remainingGsdSkills = fs.existsSync(skillsDir) - ? fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter((e) => e.isDirectory() && e.name.startsWith('gsd-')) - .map((e) => e.name) - : []; - assert.deepStrictEqual( - remainingGsdSkills, - [], - 'rollback must remove all gsd-* skill dirs even when fired after minimal writes' - ); - }); - - test('rollback does not remove pre-existing user skills that GSD did not write', () => { - // If the user has a custom skill dir (not gsd-*) it must survive rollback. - const skillsDir = path.join(codexHome, 'skills'); - const userSkill = path.join(skillsDir, 'my-custom-skill'); - fs.mkdirSync(userSkill, { recursive: true }); - fs.writeFileSync(path.join(userSkill, 'SKILL.md'), '# Custom\n', 'utf8'); - - installModule.__codexSchemaValidator = () => ({ - ok: false, - reason: 'simulated failure — user skill must survive', - }); - - let threw = false; - try { runCodexInstall(codexHome); } catch (_) { threw = true; } - assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (user-skill-survives scenario)'); - - assert.strictEqual( - fs.existsSync(path.join(userSkill, 'SKILL.md')), - true, - 'pre-existing non-gsd-* skill must survive rollback' - ); - }); - - test('rollback removes orphaned atomic-write temp files', () => { - // Any .tmp-- files created during aborted atomic writes - // must be cleaned up by the rollback so targetDir is not left with stray - // temp files consuming disk space. - fs.mkdirSync(codexHome, { recursive: true }); - - installModule.__codexSchemaValidator = () => ({ - ok: false, - reason: 'simulated failure for temp-file cleanup test', - }); - - let threw = false; - try { runCodexInstall(codexHome); } catch (_) { threw = true; } - assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (temp-file cleanup scenario)'); - - // Scan for any *.tmp-* files left in codexHome after rollback. - const tmpPattern = /\.tmp-\d+-\d+$/; - function findTmpFiles(dir) { - if (!fs.existsSync(dir)) return []; - const results = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...findTmpFiles(full)); - } else if (tmpPattern.test(entry.name)) { - results.push(full); - } - } - return results; - } - const stray = findTmpFiles(codexHome); - assert.strictEqual( - stray.length, - 0, - 'rollback must clean up orphaned atomic-write temp files: ' + stray.join(', ') - ); - }); -}); diff --git a/tests/bug-3285-codex-hooks-state-allowed.test.cjs b/tests/bug-3285-codex-hooks-state-allowed.test.cjs deleted file mode 100644 index b2ed9b67f..000000000 --- a/tests/bug-3285-codex-hooks-state-allowed.test.cjs +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Regression: issue #3285 — Codex install fails when config.toml contains - * hooks.state entries. - * - * Root cause: validateCodexConfigSchema walks every `hooks.*` table section - * and asserts array-of-tables (AoT) shape, without distinguishing the - * `hooks.state.*` namespace (Codex-managed per-hook trust persistence, a - * regular table) from `hooks.` (event handlers like SessionStart, - * which DO require AoT shape via [[hooks.SessionStart]]). - * - * Fix: add a carve-out so that any table whose path starts with `hooks.state` - * is validated as a regular table (not AoT). All `hooks.` paths still - * require AoT. - */ - -// GSD_TEST_MODE must be set before require('../bin/install.js') so the module -// skips the main CLI entry point and exports its internals. -const previousGsdTestMode = process.env.GSD_TEST_MODE; -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { execFileSync } = require('child_process'); - -const { validateCodexConfigSchema, install } = require('../bin/install.js'); -const { cleanup } = require('./helpers.cjs'); - -if (previousGsdTestMode === undefined) { - delete process.env.GSD_TEST_MODE; -} else { - process.env.GSD_TEST_MODE = previousGsdTestMode; -} - -// Ensure hooks/dist/ is populated — mirrors the pattern used by codex-config.test.cjs. -const { before, beforeEach, afterEach } = require('node:test'); -const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); -const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -before(() => { - if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); - } -}); - -// --------------------------------------------------------------------------- -// Validator unit tests (no install, just validateCodexConfigSchema) -// --------------------------------------------------------------------------- - -describe('#3285 — validateCodexConfigSchema: hooks.state is a regular table (not AoT)', () => { - test('bare [hooks.state] table header passes validation', () => { - const content = [ - '[hooks.state]', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, true, - 'bare [hooks.state] must be allowed (regular-table namespace): ' + result.reason); - }); - - test('bare [hooks.state.] table header passes validation', () => { - // Mirrors the exact shape Codex CLI 0.130.0+ writes for per-hook trust entries. - // The key contains slashes and colons — must be quoted in TOML. - const content = [ - '[hooks.state]', - '', - "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", - 'enabled = true', - 'trusted_hash = "sha256:abc123"', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, true, - 'bare [hooks.state.] with trust fields must be allowed: ' + result.reason); - }); - - test('hooks.state alongside [[hooks.SessionStart]] AoT both pass', () => { - // The real-world fixture: user has both Codex trust state AND GSD-managed - // event hooks in the same config.toml. - const content = [ - '[hooks.state]', - '', - "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", - 'enabled = true', - 'trusted_hash = "sha256:abc123"', - '', - '[[hooks.SessionStart]]', - '', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "/usr/local/bin/gsd-check-update"', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, true, - 'mixed hooks.state (regular table) + [[hooks.SessionStart]] (AoT) must pass: ' + result.reason); - }); - - test('[[hooks.SessionStart]] AoT still requires array-of-tables shape', () => { - // Regression guard: the fix must NOT relax AoT requirements for event hooks. - // [hooks.SessionStart] (single-bracket) must still fail. - const content = [ - '[hooks.SessionStart]', - 'type = "command"', - 'command = "/some/command"', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, false, - '[hooks.SessionStart] bare table (not AoT) must still be rejected'); - assert.ok( - result.reason.includes('hooks.SessionStart'), - 'rejection reason must mention hooks.SessionStart, got: ' + result.reason - ); - }); - - test('hooks.state object in parsed structure does not trigger non-array rejection', () => { - // The parsed-object check loops over Object.entries(parsed.hooks) and - // asserts !Array.isArray(value) → error. hooks.state is an object, not - // an array. The fix must skip hooks.state in that loop too. - const content = [ - '[hooks.state]', - '', - "[hooks.state.'some-key']", - 'enabled = true', - 'trusted_hash = "sha256:deadbeef"', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, true, - 'parsed hooks.state object must not trigger "hooks.state must be an array" rejection: ' + result.reason); - }); - - test('multiple hooks.state sub-keys all pass validation', () => { - const content = [ - '[hooks.state]', - '', - "[hooks.state.'/project/a/.codex/hooks.json:pre_tool_use:0:0']", - 'enabled = true', - 'trusted_hash = "sha256:aaa"', - '', - "[hooks.state.'/project/b/.codex/hooks.json:pre_tool_use:0:0']", - 'enabled = false', - 'trusted_hash = "sha256:bbb"', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, true, - 'multiple hooks.state sub-keys must all pass: ' + result.reason); - }); - - test('[[hooks.state]] AoT form is rejected', () => { - // hooks.state must be a regular table — array-of-tables shape is invalid. - const content = [ - '[[hooks.state]]', - 'enabled = true', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, false, - '[[hooks.state]] (AoT) must be rejected'); - assert.ok( - result.reason.includes('hooks.state'), - 'rejection reason must mention hooks.state, got: ' + result.reason - ); - }); - - test('[[hooks.state.foo]] AoT sub-key form is rejected', () => { - // hooks.state.* sub-keys must be regular tables — AoT sub-key shape is invalid. - const content = [ - '[[hooks.state.foo]]', - 'enabled = true', - '', - ].join('\n'); - const result = validateCodexConfigSchema(content); - assert.strictEqual(result.ok, false, - '[[hooks.state.foo]] (AoT sub-key) must be rejected'); - assert.ok( - result.reason.includes('hooks.state'), - 'rejection reason must mention hooks.state, got: ' + result.reason - ); - }); -}); - -// --------------------------------------------------------------------------- -// Full install integration test -// --------------------------------------------------------------------------- - -describe('#3285 — install succeeds when config.toml contains hooks.state entries', { concurrency: false }, () => { - let tmpDir; - let codexHome; - - function writeCodexConfig(content) { - fs.mkdirSync(codexHome, { recursive: true }); - fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); - } - - function runCodexInstall() { - const previousCodexHome = process.env.CODEX_HOME; - const previousCwd = process.cwd(); - process.env.CODEX_HOME = codexHome; - try { - process.chdir(path.join(__dirname, '..')); - return install(true, 'codex'); - } finally { - process.chdir(previousCwd); - if (previousCodexHome === undefined) { - delete process.env.CODEX_HOME; - } else { - process.env.CODEX_HOME = previousCodexHome; - } - } - } - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3285-')); - codexHome = path.join(tmpDir, 'codex-home'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('install does not throw when config.toml contains hooks.state trust entries', () => { - // This is the exact failure scenario reported in #3285. - const preInstall = [ - '[hooks.state]', - '', - "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", - 'enabled = true', - 'trusted_hash = "sha256:abc123def456"', - '', - ].join('\n'); - writeCodexConfig(preInstall); - - assert.doesNotThrow( - () => runCodexInstall(), - 'install must not throw when config.toml contains hooks.state trust entries' - ); - }); - - test('hooks.state entries are preserved in post-install config.toml', () => { - const preInstall = [ - '[hooks.state]', - '', - "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", - 'enabled = true', - 'trusted_hash = "sha256:abc123def456"', - '', - ].join('\n'); - writeCodexConfig(preInstall); - - runCodexInstall(); - - const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); - // Verify structurally: the trust hash key must survive the install. - // Do NOT grep for the literal string — parse the TOML structure. - const { parseTomlToObject } = require('../bin/install.js'); - const parsed = parseTomlToObject(after); - assert.ok( - parsed.hooks && typeof parsed.hooks.state === 'object' && parsed.hooks.state !== null, - 'post-install config.toml must have hooks.state as an object' - ); - // Verify the actual trust entry survives — not just that hooks.state is an object. - const trustKey = "/home/user/.codex/hooks.json:pre_tool_use:0:0"; - assert.ok( - parsed.hooks.state[trustKey] != null, - `post-install must preserve the original trust entry for key: ${trustKey}` - ); - assert.strictEqual( - parsed.hooks.state[trustKey].enabled, - true, - 'preserved trust entry must have enabled = true' - ); - assert.strictEqual( - parsed.hooks.state[trustKey].trusted_hash, - 'sha256:abc123def456', - 'preserved trust entry must have the original trusted_hash' - ); - }); -}); diff --git a/tests/bug-3288-model-catalog-install-path.test.cjs b/tests/bug-3288-model-catalog-install-path.test.cjs deleted file mode 100644 index 4f7d7837d..000000000 --- a/tests/bug-3288-model-catalog-install-path.test.cjs +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Regression test for #3288: model-catalog.cjs uses brittle relative path - * that breaks after install. - * - * Repro: - * After `node bin/install.js --global --claude`, the installed - * `~/.claude/gsd-core/bin/lib/model-catalog.cjs` tries: - * require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json')) - * which resolves to `~/.claude/sdk/shared/model-catalog.json`. - * The installer copies `gsd-core/` but never copies `sdk/shared/`, - * so the require throws MODULE_NOT_FOUND. - * - * Fix contract: - * 1. model-catalog.cjs must use a resolve-chain that checks a co-located - * path first (bin/shared/model-catalog.json) before the legacy - * source-repo path. - * 2. bin/install.js must copy shared model-catalog.json into - * gsd-core/bin/shared/model-catalog.json (co-located inside the - * gsd-core/ payload). - * - * Both halves must be true for the install layout to work. - */ - -'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 REPO_ROOT = path.join(__dirname, '..'); -const MODEL_CATALOG_CJS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'model-catalog.cjs'); -const MODEL_CATALOG_JSON = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'model-catalog.json'); - -const { install } = require('../bin/install.js'); - -// ─── helpers ───────────────────────────────────────────────────────────────── - -const { createTempDir, cleanup } = require('./helpers.cjs'); -const makeTmpDir = createTempDir; - -const rmTmpDir = cleanup; - -/** - * Silence console output during install to avoid noise in test output. - */ -function silenceConsole(fn) { - const orig = { - log: console.log, - warn: console.warn, - error: console.error, - }; - console.log = () => {}; - console.warn = () => {}; - console.error = () => {}; - try { - return fn(); - } finally { - console.log = orig.log; - console.warn = orig.warn; - console.error = orig.error; - } -} - -// ─── test 1: fake-install layout reproduces MODULE_NOT_FOUND ──────────────── -// -// Build a fake post-install layout that mirrors what the OLD install did: -// /.claude/gsd-core/bin/lib/model-catalog.cjs (copy of real file) -// /.claude/sdk/shared/model-catalog.json ABSENT -// -// Then attempt to require model-catalog.cjs from that layout. -// Under the old path scheme (3 levels up → sdk/shared/) this should throw. -// After the fix, if we DON'T also copy the json, it should still throw — this -// confirms the co-located path IS required. - -describe('bug #3288: model-catalog.cjs install-layout resolution', () => { - let tmpRoot; - let savedHome; - let savedUserProfile; - let savedExplicitConfigDir; - - beforeEach(() => { - tmpRoot = makeTmpDir('gsd-3288-'); - savedHome = process.env.HOME; - // On Windows, os.homedir() reads USERPROFILE (and HOMEDRIVE+HOMEPATH), NOT - // HOME. install() resolves the install destination via os.homedir(), so the - // tests must also redirect USERPROFILE → tmpRoot on win32 to keep the - // installer writing inside the fixture. - savedUserProfile = process.env.USERPROFILE; - // Stash and clear explicitConfigDir via env so install() picks up our tmp dir. - // Must delete (not just save) so any CI-set value doesn't leak into install() - // and target a different directory than tmpRoot (CR finding, PR #3293). - savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; - delete process.env.GSD_EXPLICIT_CONFIG_DIR; - }); - - afterEach(() => { - process.env.HOME = savedHome; - if (savedUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = savedUserProfile; - if (savedExplicitConfigDir === undefined) { - delete process.env.GSD_EXPLICIT_CONFIG_DIR; - } else { - process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; - } - rmTmpDir(tmpRoot); - }); - - // ── test A ────────────────────────────────────────────────────────────────── - test('OLD layout (3-level __dirname, no co-located json) fails to require', () => { - // Build the old install layout manually: - // /.claude/gsd-core/bin/lib/model-catalog.cjs (copy of the real CJS) - // sdk/shared/model-catalog.json ABSENT - const gsdLibDir = path.join(tmpRoot, '.claude', 'gsd-core', 'bin', 'lib'); - fs.mkdirSync(gsdLibDir, { recursive: true }); - - // Write a minimal model-catalog.cjs that uses ONLY the 3-level path (the old/broken path). - const oldCjsContent = `'use strict'; -const path = require('node:path'); -// This is the BRITTLE path: 3 levels up from bin/lib → sdk/shared/ -const catalog = require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json')); -module.exports = { catalog }; -`; - const catalogCjsPath = path.join(gsdLibDir, 'model-catalog.cjs'); - fs.writeFileSync(catalogCjsPath, oldCjsContent); - - // Deliberately do NOT create sdk/shared/model-catalog.json (simulates missing file post-install). - - // Require must fail with MODULE_NOT_FOUND. - assert.throws( - () => { - // Delete from require cache to force a fresh load. - delete require.cache[catalogCjsPath]; - require(catalogCjsPath); - }, - (err) => { - assert.ok( - err.code === 'MODULE_NOT_FOUND' || err.message.includes('model-catalog.json'), - `Expected MODULE_NOT_FOUND or model-catalog.json error, got: ${err.message}`, - ); - return true; - }, - 'OLD 3-level path must fail when sdk/shared/model-catalog.json is not present (install layout)', - ); - }); - - // ── test B ────────────────────────────────────────────────────────────────── - test('NEW layout (co-located bin/shared/model-catalog.json) resolves correctly', () => { - // Build the new install layout: - // /.claude/gsd-core/bin/lib/model-catalog.cjs (copy of real CJS) - // /.claude/gsd-core/bin/shared/model-catalog.json (co-located copy) - const gsdBinDir = path.join(tmpRoot, '.claude', 'gsd-core', 'bin'); - const gsdLibDir = path.join(gsdBinDir, 'lib'); - const gsdSharedDir = path.join(gsdBinDir, 'shared'); - fs.mkdirSync(gsdLibDir, { recursive: true }); - fs.mkdirSync(gsdSharedDir, { recursive: true }); - - // Copy the real model-catalog.cjs into the fake install. - const catalogCjsPath = path.join(gsdLibDir, 'model-catalog.cjs'); - fs.copyFileSync(MODEL_CATALOG_CJS, catalogCjsPath); - - // Copy the real model-catalog.json to the co-located path. - fs.copyFileSync(MODEL_CATALOG_JSON, path.join(gsdSharedDir, 'model-catalog.json')); - - // Require must succeed and expose catalog with expected shape. - delete require.cache[catalogCjsPath]; - let mod; - assert.doesNotThrow(() => { - mod = require(catalogCjsPath); - }, 'NEW co-located layout must not throw MODULE_NOT_FOUND'); - - assert.ok(mod.catalog, 'module must export catalog'); - assert.ok(Array.isArray(mod.VALID_PROFILES), 'module must export VALID_PROFILES'); - assert.ok(mod.VALID_PROFILES.length > 0, 'VALID_PROFILES must not be empty'); - }); - - // ── test C ────────────────────────────────────────────────────────────────── - test('post-install: install() copies model-catalog.json to co-located path', () => { - // Run the real installer against a tmp target dir, then assert the co-located - // json is present and parseable. - const claudeDir = path.join(tmpRoot, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - process.env.HOME = tmpRoot; - process.env.USERPROFILE = tmpRoot; - - // Capture process.exit to prevent the test from being killed. - const origExit = process.exit; - let exitCalled = false; - process.exit = (code) => { - exitCalled = true; - throw new Error(`process.exit(${code}) during install — should not happen`); - }; - - try { - silenceConsole(() => { - install(true /* isGlobal */, 'claude'); - }); - } catch (e) { - if (exitCalled) { - assert.fail(`install() called process.exit — unexpected: ${e.message}`); - } - throw e; - } finally { - process.exit = origExit; - } - - // The co-located json must be present after install. - const colocatedJson = path.join( - claudeDir, - 'gsd-core', - 'bin', - 'shared', - 'model-catalog.json', - ); - assert.ok( - fs.existsSync(colocatedJson), - `model-catalog.json must be present at co-located path post-install: ${colocatedJson}`, - ); - - // The json must be valid and have expected shape. - let parsed; - assert.doesNotThrow(() => { - parsed = JSON.parse(fs.readFileSync(colocatedJson, 'utf8')); - }, 'co-located model-catalog.json must be valid JSON'); - - assert.ok(Array.isArray(parsed.profiles), 'catalog.profiles must be an array'); - assert.ok(parsed.profiles.length > 0, 'catalog.profiles must not be empty'); - - // And the installed model-catalog.cjs must be requireable from its install location. - const installedCjs = path.join( - claudeDir, - 'gsd-core', - 'bin', - 'lib', - 'model-catalog.cjs', - ); - assert.ok(fs.existsSync(installedCjs), `model-catalog.cjs must be installed at: ${installedCjs}`); - - delete require.cache[installedCjs]; - let installedMod; - assert.doesNotThrow(() => { - installedMod = require(installedCjs); - }, 'installed model-catalog.cjs must not throw MODULE_NOT_FOUND after install'); - - assert.ok(installedMod.catalog, 'installed module must export catalog'); - assert.ok(installedMod.VALID_PROFILES.length > 0, 'installed module must have valid profiles'); - }); -}); diff --git a/tests/bug-3346-codex-aot-toml-key.test.cjs b/tests/bug-3346-codex-aot-toml-key.test.cjs deleted file mode 100644 index d17d3c6b9..000000000 --- a/tests/bug-3346-codex-aot-toml-key.test.cjs +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Regression: issue #3346 — Codex install fails on Windows when the legacy - * Codex `[hooks]` config uses a `:::` location tuple - * as the table key (with the actual event name carried in an `event = "..."` - * body field). `migrateCodexHooksMapFormat` re-emitted the location tuple - * verbatim as the leaf TOML key, producing a header like - * - * [[hooks."C:\Users\helen\.codex\config.toml:session_start:0:0"]] - * - * which Codex 0.124.0+ refuses to load (the leaf key segment is supposed to - * be the event name, not a diagnostic location identifier). - * - * Expected behaviour: when the legacy `[hooks.]` body declares an - * `event = "..."` field, the migrator must use that event name as the leaf - * TOML key for the emitted `[[hooks.]]` two-level nested AoT block. - * - * Test discipline: parse the migrated TOML with the project's own - * `parseTomlToObject` and assert on the resulting object shape — never - * grep the raw string. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); - -const { - migrateCodexHooksMapFormat, - parseTomlToObject, -} = require('../bin/install.js'); - -describe('#3346 — Codex AoT hooks migration emits event-name leaf key, not location tuple', () => { - test('legacy [hooks.""] with event="..." body migrates to [[hooks.]]', () => { - // Pre-install fixture: a legacy `[hooks.]` block whose key is - // a `:::` location identifier. The actual - // event name lives in the body as `event = "session_start"`. - const legacy = [ - '[hooks."C:\\\\Users\\\\helen\\\\.codex\\\\config.toml:session_start:0:0"]', - 'event = "session_start"', - 'command = "echo hi"', - '', - ].join('\n'); - - const migrated = migrateCodexHooksMapFormat(legacy); - const parsed = parseTomlToObject(migrated); - - // The migrated hooks object must be keyed by the event name, not by the - // location tuple. This is the core assertion of #3346. - assert.ok(parsed.hooks, 'migrated TOML must define a hooks table'); - assert.deepEqual( - Object.keys(parsed.hooks), - ['session_start'], - `migrated hooks must be keyed by event name only; got: ${JSON.stringify(Object.keys(parsed.hooks))}` - ); - - // The handler body must survive the migration and live under the two-level - // nested AoT shape (hooks.[0].hooks[0].command). - const eventEntries = parsed.hooks.session_start; - assert.ok(Array.isArray(eventEntries) && eventEntries.length >= 1, - 'hooks.session_start must be an array of tables'); - const handlers = eventEntries[0].hooks; - assert.ok(Array.isArray(handlers) && handlers.length >= 1, - 'hooks.session_start[0].hooks must be an array of handler tables'); - assert.equal(handlers[0].command, 'echo hi', - 'handler command must be preserved through migration'); - assert.equal(handlers[0].type, 'command', - 'handler type must default to "command" when no explicit type given'); - assert.equal(handlers[0].event, undefined, - 'handler body must not retain legacy `event` field after migration'); - }); - - test('legacy [hooks.""] with explicit type and event survives migration cleanly', () => { - // Same as above but with an explicit `type` field — the migrator must not - // duplicate it when re-emitting the handler. - const legacy = [ - '[hooks."/home/user/.codex/config.toml:tool_call_pre:5:0"]', - 'event = "tool_call_pre"', - 'type = "command"', - 'command = "node /path/to/hook.js"', - '', - ].join('\n'); - - const migrated = migrateCodexHooksMapFormat(legacy); - const parsed = parseTomlToObject(migrated); - - assert.deepEqual( - Object.keys(parsed.hooks), - ['tool_call_pre'], - 'leaf key must be the event name from the `event = "..."` body field' - ); - const handler = parsed.hooks.tool_call_pre[0].hooks[0]; - assert.equal(handler.command, 'node /path/to/hook.js'); - assert.equal(handler.type, 'command'); - assert.equal(handler.event, undefined, - 'handler body must not retain legacy `event` field after migration'); - }); - - test('legacy [hooks.] without location-tuple key continues to work unchanged', () => { - // Regression guard: the fix must not break the canonical legacy-map case - // ([hooks.] with handler-fields-only body, no `event` key). - const legacy = [ - '[hooks.session_start]', - 'command = "echo hi"', - '', - ].join('\n'); - - const migrated = migrateCodexHooksMapFormat(legacy); - const parsed = parseTomlToObject(migrated); - - assert.deepEqual( - Object.keys(parsed.hooks), - ['session_start'], - 'bare-event legacy shape must continue to migrate to event-named leaf key' - ); - assert.equal(parsed.hooks.session_start[0].hooks[0].command, 'echo hi'); - }); -}); diff --git a/tests/bug-3360-codex-execute-phase-worktrees.test.cjs b/tests/bug-3360-codex-execute-phase-worktrees.test.cjs deleted file mode 100644 index 7b7327918..000000000 --- a/tests/bug-3360-codex-execute-phase-worktrees.test.cjs +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Regression test for bug #3360. - * - * Codex does not have a direct equivalent of Claude Code's - * `Agent(... isolation="worktree")`. The execute-phase workflow must fail - * closed for Codex + workflow.use_worktrees=true instead of spawning - * workspace-write executors in the main checkout. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const EXECUTE_PHASE = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md'); -const { getCodexSkillAdapterHeader } = require('../bin/install.js'); - -function parseWorkflowSteps(content) { - return [...content.matchAll(/]*>([\s\S]*?)<\/step>/g)] - .map((match) => { - const body = match[2]; - return { - name: match[1], - // After #3797 architectural fix, callsites use gsd_run - readsRuntimeConfig: body.includes('RUNTIME=$(gsd_run query config-get runtime --default claude'), - // #1521: guard generalized from Codex-specific to all non-Claude runtimes - codexWorktreeGuard: body.includes('git worktree isolation') && body.includes('unsupported on runtime'), - worktreeDispatchGuidance: body.includes('isolation="worktree"'), - }; - }); -} - -function executePhaseWorktreeContract(content) { - const steps = parseWorkflowSteps(content); - const initializeIndex = steps.findIndex((step) => step.name === 'initialize'); - const firstWorktreeDispatchIndex = steps.findIndex((step) => step.worktreeDispatchGuidance); - assert.notEqual(initializeIndex, -1, 'workflow must have an initialize step'); - assert.notEqual(firstWorktreeDispatchIndex, -1, 'workflow must still document worktree dispatch guidance'); - - const initialize = steps[initializeIndex]; - return { - initializeReadsRuntimeConfig: initialize.readsRuntimeConfig, - initializeHasCodexWorktreeGuard: initialize.codexWorktreeGuard, - guardStepPrecedesWorktreeDispatch: initializeIndex <= firstWorktreeDispatchIndex, - }; -} - -describe('#3360 — Codex execute-phase fails closed for unsupported worktree isolation', () => { - test('execute-phase reads runtime before worktree dispatch and blocks Codex worktree mode', () => { - const workflow = fs.readFileSync(EXECUTE_PHASE, 'utf8'); - const contract = executePhaseWorktreeContract(workflow); - - assert.deepEqual(contract, { - initializeReadsRuntimeConfig: true, - initializeHasCodexWorktreeGuard: true, - guardStepPrecedesWorktreeDispatch: true, - }); - }); - - test('Codex adapter documents that worktree isolation has no direct spawn_agent mapping', () => { - const header = getCodexSkillAdapterHeader('gsd-execute-phase'); - assert.match(header, /isolation="worktree"/); - assert.match(header, /no direct Codex mapping/i); - }); -}); diff --git a/tests/bug-338-local-install-settings-local-json.test.cjs b/tests/bug-338-local-install-settings-local-json.test.cjs deleted file mode 100644 index f8b55b339..000000000 --- a/tests/bug-338-local-install-settings-local-json.test.cjs +++ /dev/null @@ -1,377 +0,0 @@ -/** - * Regression tests for #338: Claude --local installs must write hook wiring to - * `.claude/settings.local.json` (Claude Code's per-user gitignored slot) instead - * of the repo-shared `.claude/settings.json`. - * - * Three cases: - * 1. Fresh local install: settings.local.json is created with hook block; - * settings.json is not touched. - * 2. Global install (regression guard): continues to write to settings.json. - * 3. Migration: if a prior local install wrote GSD entries to settings.json, - * re-running local install moves them to settings.local.json and removes - * them from settings.json in the same run. - * - * Note: `install()` only copies files. `finishInstall()` writes settings. - * The production code calls both from `installAllRuntimes()`. Tests mirror - * that two-phase pattern. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, before, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { execFileSync } = require('child_process'); - -const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); -const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); -const { install, finishInstall } = require(INSTALL_SRC); -const { cleanup } = require('./helpers.cjs'); - -// ─── Ensure hooks/dist/ is populated before install tests ──────────────────── -before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); -}); - -// ─── Helper: run both install phases ───────────────────────────────────────── - -/** - * Run install + finishInstall (mirrors installAllRuntimes two-phase pattern). - * @param {boolean} isGlobal - * @param {object} [opts] - * @param {boolean} [opts.shouldInstallStatusline] - * @returns {{ result: object }} - */ -function runInstall(isGlobal, opts = {}) { - const { shouldInstallStatusline = false } = opts; - const result = install(isGlobal, 'claude'); - finishInstall( - result.settingsPath, - result.settings, - result.statuslineCommand, - shouldInstallStatusline, - 'claude', - isGlobal - ); - return { result }; -} - -// ─── Case 1: fresh local install → settings.local.json, not settings.json ─── - -describe('#338 case 1: fresh local Claude install writes to settings.local.json', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-local-')); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('settings.local.json is created with hook block', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - runInstall(false); - - const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); - assert.ok( - fs.existsSync(localSettingsPath), - '.claude/settings.local.json must exist after local Claude install (#338)' - ); - - const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); - assert.ok( - settings && typeof settings === 'object', - 'settings.local.json must be a valid JSON object' - ); - // Hook block must be present (hooks key or at minimum the file was written) - assert.ok( - settings.hooks !== undefined || Object.keys(settings).length >= 0, - 'settings.local.json must contain the hook block' - ); - }); - - test('settings.json is NOT created by a fresh local install', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - runInstall(false); - - const sharedSettingsPath = path.join(tmpDir, '.claude', 'settings.json'); - assert.strictEqual( - fs.existsSync(sharedSettingsPath), - false, - '.claude/settings.json must NOT be created by a fresh local Claude install (#338) — ' + - 'engineer-specific absolute paths must not leak into the repo-shared file' - ); - }); - - test('install() returns settingsPath pointing to settings.local.json', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - const result = install(false, 'claude'); - assert.ok( - result.settingsPath.endsWith('settings.local.json'), - `install() must return settingsPath ending in settings.local.json for local Claude installs; got: ${result.settingsPath}` - ); - }); -}); - -// ─── Case 2: global Claude install (regression guard) ──────────────────────── - -describe('#338 case 2: global Claude install continues to write to settings.json', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-global-')); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('global install writes hook block to settings.json', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - - // Point CLAUDE_CONFIG_DIR at a subdir of tmpDir to avoid polluting ~/.claude - const configDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(configDir, { recursive: true }); - const origEnv = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = configDir; - t.after(() => { - if (origEnv === undefined) { - delete process.env.CLAUDE_CONFIG_DIR; - } else { - process.env.CLAUDE_CONFIG_DIR = origEnv; - } - }); - - runInstall(true); - - const settingsPath = path.join(configDir, 'settings.json'); - assert.ok( - fs.existsSync(settingsPath), - '~/.claude/settings.json must exist after global Claude install (regression guard for #338)' - ); - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); - assert.ok( - settings && typeof settings === 'object', - 'settings.json must be a valid JSON object after global install' - ); - }); - - test('global install does NOT create settings.local.json', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - - const configDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(configDir, { recursive: true }); - const origEnv = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = configDir; - t.after(() => { - if (origEnv === undefined) { - delete process.env.CLAUDE_CONFIG_DIR; - } else { - process.env.CLAUDE_CONFIG_DIR = origEnv; - } - }); - - runInstall(true); - - const localSettingsPath = path.join(configDir, 'settings.local.json'); - assert.strictEqual( - fs.existsSync(localSettingsPath), - false, - '~/.claude/settings.local.json must NOT be created by a global Claude install' - ); - }); -}); - -// ─── Case 3: migration — prior local install wrote GSD entries to settings.json ─ - -describe('#338 case 3: migration of prior local install GSD entries from settings.json to settings.local.json', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-migrate-')); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('GSD hook entries are moved from settings.json to settings.local.json', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - // Pre-populate .claude/settings.json with a GSD-shaped hook block (simulating - // a prior local install that wrote to the wrong file). - const claudeDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - const sharedSettingsPath = path.join(claudeDir, 'settings.json'); - const priorSettings = { - hooks: { - SessionStart: [ - { - hooks: [ - { - type: 'command', - command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-check-update.js')}`, - } - ] - } - ], - PostToolUse: [ - { - matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task', - hooks: [ - { - type: 'command', - command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-context-monitor.js')}`, - timeout: 10, - } - ] - } - ] - } - }; - fs.writeFileSync(sharedSettingsPath, JSON.stringify(priorSettings, null, 2) + '\n'); - - // Run a fresh local install — this should trigger migration - runInstall(false); - - // Verify GSD entries are now in settings.local.json - const localSettingsPath = path.join(claudeDir, 'settings.local.json'); - assert.ok( - fs.existsSync(localSettingsPath), - '.claude/settings.local.json must exist after migration run' - ); - const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); - const sessionStartHooks = (localSettings.hooks && localSettings.hooks.SessionStart) || []; - const hasGsdUpdateHook = sessionStartHooks.some( - entry => entry && entry.hooks && Array.isArray(entry.hooks) && - entry.hooks.some(h => h && h.command && h.command.includes('gsd-check-update')) - ); - assert.ok( - hasGsdUpdateHook, - 'settings.local.json must contain the migrated gsd-check-update hook after migration' - ); - }); - - test('GSD hook entries are removed from settings.json after migration', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - const claudeDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - const sharedSettingsPath = path.join(claudeDir, 'settings.json'); - const priorSettings = { - // Include a non-GSD key to verify user content is preserved - myCustomKey: 'keep-me', - hooks: { - SessionStart: [ - { - hooks: [ - { - type: 'command', - command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-check-update.js')}`, - } - ] - } - ] - } - }; - fs.writeFileSync(sharedSettingsPath, JSON.stringify(priorSettings, null, 2) + '\n'); - - runInstall(false); - - // settings.json must exist (we don't delete it — user may have other content) - assert.ok( - fs.existsSync(sharedSettingsPath), - '.claude/settings.json must still exist after migration (may have non-GSD user content)' - ); - const sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf-8')); - - // GSD hooks must be gone from settings.json - const sessionStartHooks = (sharedSettings.hooks && sharedSettings.hooks.SessionStart) || []; - const hasGsdHook = sessionStartHooks.some( - entry => entry && entry.hooks && Array.isArray(entry.hooks) && - entry.hooks.some(h => h && h.command && h.command.includes('gsd-check-update')) - ); - assert.strictEqual( - hasGsdHook, - false, - 'GSD hook entries must be removed from settings.json after migration to settings.local.json' - ); - - // Non-GSD user content must be preserved - assert.strictEqual( - sharedSettings.myCustomKey, - 'keep-me', - 'Non-GSD user content in settings.json must be preserved during migration' - ); - }); - - test('settings.json with no GSD entries is left unchanged', (t) => { - const origCwd = process.cwd(); - t.after(() => { process.chdir(origCwd); }); - process.chdir(tmpDir); - - const claudeDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - const sharedSettingsPath = path.join(claudeDir, 'settings.json'); - const userOnlySettings = { - userKey: 'user-value', - hooks: { - SessionStart: [ - { - hooks: [ - { - type: 'command', - command: '/usr/local/bin/my-own-hook.sh', - } - ] - } - ] - } - }; - const originalContent = JSON.stringify(userOnlySettings, null, 2) + '\n'; - fs.writeFileSync(sharedSettingsPath, originalContent); - - runInstall(false); - - // settings.json must be unchanged (no GSD entries to migrate) - const afterContent = fs.readFileSync(sharedSettingsPath, 'utf-8'); - const afterSettings = JSON.parse(afterContent); - assert.strictEqual( - afterSettings.userKey, - 'user-value', - 'Non-GSD settings.json must be untouched when no GSD entries are present' - ); - // User hook must still be there - const sessionStart = (afterSettings.hooks && afterSettings.hooks.SessionStart) || []; - const hasUserHook = sessionStart.some( - entry => entry && entry.hooks && entry.hooks.some(h => h && h.command === '/usr/local/bin/my-own-hook.sh') - ); - assert.ok( - hasUserHook, - 'User hook in settings.json must be preserved when no migration occurs' - ); - }); -}); diff --git a/tests/bug-3407-pristine-stale-content.test.cjs b/tests/bug-3407-pristine-stale-content.test.cjs deleted file mode 100644 index 36d5ff29b..000000000 --- a/tests/bug-3407-pristine-stale-content.test.cjs +++ /dev/null @@ -1,345 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #3407: Installer leaves stale content in gsd-pristine/ - * - * Root cause: populatePristineDir() in saveLocalPatches() snapshots from - * pristineCtx.packageSrc — the NEWLY-downloaded release tree — and writes - * those bytes into gsd-pristine/. For files changed between the old and new - * release, this writes the NEW bytes into the pristine baseline instead of - * the OLD bytes. The three-way-diff verifier then classifies upstream-changed - * lines as user-added → Step 5a gate fails with false FAIL_USER_LINES_MISSING. - * - * The #3657 fix (OK_PRISTINE_DRIFT_DETECTED) was a symptom workaround: the - * verifier detects hash mismatch (backup-meta.json records old-release hash - * but gsd-pristine/ has new-release bytes) and skips to over-broad mode - * instead of false-failing. The root-cause stale write was never fixed. - * - * Fix: when a correctly-populated gsd-pristine/ already exists from the - * previous install (i.e., the file's sha256 matches the originalHash recorded - * in the manifest), preserve it — do NOT wipe and re-populate from the new - * release source. This ensures gsd-pristine/ holds old-release bytes even - * after an upgrade where the file content changed upstream. - * - * Regression contract (byte-comparison): - * After saveLocalPatches() is called with a user-modified file whose - * gsd-pristine/ entry was correctly set by the previous install, the - * gsd-pristine/ file MUST still contain the old-release bytes, not the - * new-release bytes supplied in pristineCtx.packageSrc. - * - * Closes: #3407 - */ - -const { test, describe, beforeEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const crypto = require('node:crypto'); - -const ROOT = path.join(__dirname, '..'); -const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); -const { cleanup } = require('./helpers.cjs'); - -const MANIFEST_NAME = 'gsd-file-manifest.json'; -const PATCHES_DIR_NAME = 'gsd-local-patches'; - -function sha256(content) { - return crypto.createHash('sha256').update(content instanceof Buffer ? content : Buffer.from(content)).digest('hex'); -} - -// ─── Bug #3407: gsd-pristine/ must preserve OLD-release bytes across upgrade ── - -describe('Bug #3407: saveLocalPatches preserves old-release pristine across upgrade', () => { - let tmpDir; - let configDir; - let fakeSrcDir; - - beforeEach((t) => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3407-')); - configDir = path.join(tmpDir, 'config'); - fakeSrcDir = path.join(tmpDir, 'new-release-src'); - fs.mkdirSync(configDir, { recursive: true }); - fs.mkdirSync(fakeSrcDir, { recursive: true }); - t.after(() => { - cleanup(tmpDir); - }); - }); - - /** - * Core regression test. - * - * Timeline: - * Install v1: file content = OLD_RELEASE_CONTENT, gsd-pristine/ROOT_FILE - * = OLD_RELEASE_CONTENT (correctly set by previous install), - * manifest hash = sha256(OLD_RELEASE_CONTENT) - * User edits: configDir/ROOT_FILE = USER_MODIFIED_CONTENT - * Upgrade v2: pristineCtx.packageSrc has NEW_RELEASE_CONTENT for ROOT_FILE - * saveLocalPatches is called before the wipe. - * - * Expected AFTER fix: gsd-pristine/ROOT_FILE still == OLD_RELEASE_CONTENT - * Actual BEFORE fix: gsd-pristine/ROOT_FILE == NEW_RELEASE_CONTENT (stale) - */ - test('gsd-pristine/ retains old-release bytes when upgrading a user-modified file', () => { - const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1 pristine.\n'; - const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — upstream changed this line.\n'; - const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1 pristine.\n## User addition\nUser customization here.\n'; - - const oldHash = sha256(OLD_RELEASE_CONTENT); - - // Simulate a root-level installed file. Root-level files in the manifest - // are denoted without a subdirectory (slash-free relPath). - const relPath = 'test-root-file.md'; - - // Set up configDir: user-modified installed file + manifest recording old hash - fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); - fs.writeFileSync( - path.join(configDir, MANIFEST_NAME), - JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) - ); - - // Set up fakeSrcDir (new release): the file has NEW content - fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT); - - // Set up gsd-pristine/ with OLD content (as correctly populated by previous install) - const pristineDir = path.join(configDir, 'gsd-pristine'); - fs.mkdirSync(pristineDir, { recursive: true }); - fs.writeFileSync(path.join(pristineDir, relPath), OLD_RELEASE_CONTENT); - - // Call saveLocalPatches with the new release as packageSrc (the buggy scenario) - INSTALL.saveLocalPatches(configDir, { - packageSrc: fakeSrcDir, - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - - // Assert: gsd-pristine/ must still contain OLD-release bytes - const pristineFile = path.join(pristineDir, relPath); - assert.ok( - fs.existsSync(pristineFile), - `gsd-pristine/${relPath} must exist after saveLocalPatches` - ); - - const actualPristineContent = fs.readFileSync(pristineFile, 'utf8'); - assert.equal( - sha256(actualPristineContent), - oldHash, - [ - `gsd-pristine/${relPath} must contain OLD-release bytes (sha256=${oldHash.slice(0, 12)}…)`, - `but got sha256=${sha256(actualPristineContent).slice(0, 12)}…`, - `(If equal to sha256(NEW_RELEASE_CONTENT)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… then #3407 is NOT fixed)`, - ].join(' ') - ); - - // Secondary: confirm backup-meta records the old hash (not new) - const backupMeta = JSON.parse( - fs.readFileSync(path.join(configDir, PATCHES_DIR_NAME, 'backup-meta.json'), 'utf8') - ); - assert.ok( - Object.prototype.hasOwnProperty.call(backupMeta.pristine_hashes, relPath), - 'backup-meta.json must record pristine_hash for modified file' - ); - assert.equal( - backupMeta.pristine_hashes[relPath], - oldHash, - 'backup-meta.json pristine_hash must equal old-release hash (not new-release hash)' - ); - }); - - /** - * Regression test for Codex finding: when gsd-pristine/ entry is absent - * (e.g., post-buggy-run deletion or first upgrade without prior pristine) - * but the file is UNCHANGED between old and new release, the hash-validated - * regeneration path must restore the pristine entry using new-release source. - * - * When sha256(newReleaseBytesForFile) === originalHash, the file is identical - * between releases — new-release generated bytes ARE the old-release pristine - * and may be safely promoted. - * - * Previously (before the regeneration path was added): missing entries were - * left absent unconditionally, causing permanent over-broad fallback even - * when the file was unchanged upstream. - */ - test('gsd-pristine/ is regenerated for missing entries when file is unchanged between releases', () => { - const SHARED_RELEASE_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n'; - const USER_MODIFIED_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n## User addition\nCustom.\n'; - - const oldHash = sha256(SHARED_RELEASE_CONTENT); - const relPath = 'test-unchanged-file.md'; - - // configDir has user-modified file + manifest with old-release hash - fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); - fs.writeFileSync( - path.join(configDir, MANIFEST_NAME), - JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) - ); - - // fakeSrcDir (new release) has the SAME content — file was not changed upstream - fs.writeFileSync(path.join(fakeSrcDir, relPath), SHARED_RELEASE_CONTENT); - - // NOTE: gsd-pristine/ does NOT exist (simulating post-buggy-run or first-time scenario) - - INSTALL.saveLocalPatches(configDir, { - packageSrc: fakeSrcDir, - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - - // The regeneration path should have detected that sha256(new-release candidate) - // === originalHash, and promoted the candidate into gsd-pristine/. - const pristineFile = path.join(configDir, 'gsd-pristine', relPath); - assert.ok( - fs.existsSync(pristineFile), - [ - `gsd-pristine/${relPath} must exist after hash-validated regeneration.`, - `When new-release bytes hash to originalHash, the file was unchanged between`, - `releases and the candidate should be promoted to restore the pristine baseline.`, - ].join(' ') - ); - - const actualContent = fs.readFileSync(pristineFile, 'utf8'); - assert.equal( - sha256(actualContent), - oldHash, - [ - `gsd-pristine/${relPath} must contain bytes matching originalHash after regeneration`, - `(sha256=${oldHash.slice(0, 12)}…)`, - ].join(' ') - ); - }); - - /** - * Stale-pristine recovery test (pre-fix bug artifact). - * - * Timeline: - * Buggy run: gsd-pristine/ was written with NEW_RELEASE_CONTENT - * (the exact #3407 artifact — stale bytes from a buggy populatePristineDir). - * Fix run: saveLocalPatches detects the hash mismatch - * (sha256(NEW_RELEASE_CONTENT) !== originalHash recorded in manifest), - * removes the stale entry, then attempts regeneration. - * - * When the file CHANGED between releases (NEW !== OLD): - * - The stale entry is removed. - * - Regeneration discards the new-release candidate (hash mismatch). - * - gsd-pristine/ must be ABSENT (over-broad fallback — correct). - * - * When the file is UNCHANGED between releases (NEW === OLD): - * - The stale entry (which happens to have correct bytes despite the bug) is - * detected as correct (hash matches originalHash) and PRESERVED. - * - gsd-pristine/ must remain present with the correct bytes. - * - * This test covers the "file changed across release boundary" case. - * The "unchanged" case is already covered by the regeneration test above. - */ - test('stale gsd-pristine/ entry (new-release bytes) is removed when file changed between releases', () => { - const OLD_RELEASE_CONTENT = '# Old Release\nv1 content here.\n'; - const NEW_RELEASE_CONTENT = '# New Release\nv2 content — upstream changed this.\n'; - const USER_MODIFIED_CONTENT = '# Old Release\nv1 content here.\n## User section\nCustom work.\n'; - - const oldHash = sha256(OLD_RELEASE_CONTENT); - const relPath = 'test-stale-recovery.md'; - - // configDir: user-modified file + manifest recording OLD hash - fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); - fs.writeFileSync( - path.join(configDir, MANIFEST_NAME), - JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) - ); - - // fakeSrcDir (new release): contains the NEW content - fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT); - - // Pre-populate gsd-pristine/ with NEW_RELEASE_CONTENT — the exact pre-fix bug artifact. - // This simulates a prior buggy run that wrote new-release bytes into the pristine baseline. - const STALE_BYTES = NEW_RELEASE_CONTENT; // named constant for clarity - const pristineDir = path.join(configDir, 'gsd-pristine'); - fs.mkdirSync(pristineDir, { recursive: true }); - fs.writeFileSync(path.join(pristineDir, relPath), STALE_BYTES); - - // Verify the pre-condition: stale bytes do NOT match the original hash. - // If this assert fails, the test fixture is wrong (not a fix regression). - assert.notEqual( - sha256(STALE_BYTES), - oldHash, - 'test fixture check: stale bytes must differ from originalHash' - ); - - INSTALL.saveLocalPatches(configDir, { - packageSrc: fakeSrcDir, - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - - // The fix must detect the hash mismatch (stale entry) and remove it. - // The regeneration path discards the new-release candidate (its hash !== oldHash). - // Result: gsd-pristine/ must be ABSENT — over-broad fallback is the safe outcome. - const pristineFile = path.join(pristineDir, relPath); - assert.strictEqual( - fs.existsSync(pristineFile), - false, - [ - `expected gsd-pristine/${relPath} to be absent after stale-pristine recovery.`, - `The stale entry (new-release bytes, sha256=${sha256(STALE_BYTES).slice(0, 12)}…)`, - `must be removed; regeneration must discard the candidate because`, - `sha256(new-release)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… !== originalHash=${oldHash.slice(0, 12)}….`, - `Presence of the file means the stale bytes were NOT cleaned up (pre-fix behavior).`, - ].join(' ') - ); - }); - - /** - * Second scenario: gsd-pristine/ does NOT pre-exist (first upgrade with no - * prior pristine population). In this case there is no way to obtain the - * old-release pristine bytes — populatePristineDir must NOT write the new- - * release bytes either. The correct outcome is: gsd-pristine/ stays empty - * for this file, and the verifier falls back to over-broad mode (safe). - */ - test('gsd-pristine/ stays empty when no prior pristine exists (first upgrade, no stale write)', () => { - const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1.\n'; - const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — changed.\n'; - const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1.\n## User addition\nCustom.\n'; - - const oldHash = sha256(OLD_RELEASE_CONTENT); - const relPath = 'test-first-upgrade.md'; - - // configDir has user-modified file + manifest - fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); - fs.writeFileSync( - path.join(configDir, MANIFEST_NAME), - JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) - ); - - // fakeSrcDir (new release) has new content - fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT); - - // NOTE: gsd-pristine/ does NOT exist yet (first upgrade) - - INSTALL.saveLocalPatches(configDir, { - packageSrc: fakeSrcDir, - runtime: 'claude', - pathPrefix: '$HOME/.claude/', - isGlobal: true, - }); - - const pristineFile = path.join(configDir, 'gsd-pristine', relPath); - assert.strictEqual( - fs.existsSync(pristineFile), - false, - [ - `expected gsd-pristine/${relPath} to be absent when file changed across release boundary.`, - `Writing new-release bytes as pristine for a file whose hash is unknown leads to`, - `false FAIL_USER_LINES_MISSING in the reapply-patches verifier (#3407).`, - `Over-broad fallback mode is the correct outcome here.`, - ].join(' ') - ); - }); -}); - -// The former "Antipattern hunt" describe block (structural typeof checks only) was -// removed — it provided no real behavioral coverage and was a vacuous-truth pattern -// per /test-rigor skill. Behavioral tests for populatePristineDir are covered above. diff --git a/tests/bug-3426-codex-windows-hooks.test.cjs b/tests/bug-3426-codex-windows-hooks.test.cjs deleted file mode 100644 index 3a05b1966..000000000 --- a/tests/bug-3426-codex-windows-hooks.test.cjs +++ /dev/null @@ -1,473 +0,0 @@ -'use strict'; - -/** - * Bug #3426 — Codex on Windows: SessionStart/PostToolUse hooks fail with exit code 1 - * - * After PRs #3396/#3397 fixed bare-bash and quote-escaping issues, a new failure - * mode appeared on v1.42.3+: - * - * Failed with non-blocking status code: - * C:/Program Files/Git/bin/bash.exe: C:/Program Files/Git/bin/bash.exe: cannot execute binary file - * - * Root cause: Codex on Windows runs hook commands from a PowerShell/cmd - * execution environment (see install.js comment at buildHookCommand). The - * command string written to hooks.json was: - * - * "C:/Program Files/nodejs/node.exe" "C:/path/.codex/hooks/gsd-check-update.js" - * - * When Codex's hook runner passes this to its subprocess spawner, the quoted - * path resolves through Git Bash (MSYS), which then tries to POSIX-exec - * node.exe — a Windows PE binary — via the MSYS exec layer. The MSYS exec - * path calls execvp() on the PE binary directly, which fails with ENOEXEC, - * reported as "cannot execute binary file". The "bash.exe: bash.exe:" prefix - * appears because the error propagates through the bash.exe process that Codex - * uses as its hook-dispatch shell. - * - * Fix: on Windows, write a .cmd shim (using the same buildWindowsShimTriple - * IR pattern as gsd-sdk.cmd) and put the .cmd path as the hooks.json command. - * cmd.exe executes .cmd files natively via CreateProcess — no POSIX exec layer, - * no MSYS shebang walk. - * - * Test strategy: - * - Assert on the typed IR returned by buildCodexHookWindowsShimIR — not on - * rendered .cmd text (per CONTRIBUTING.md L558-L565 IR-first discipline). - * - Counter-tests confirm darwin/linux paths are unchanged. - * - * NOTE: Windows wall-clock verification depends on Docker matrix Windows - * runners. Local test exercises the generator IR shape only. - */ - -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 INSTALL = require('../bin/install.js'); -const PROJECTION = require('../gsd-core/bin/lib/shell-command-projection.cjs'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -const { - buildCodexHookWindowsShimIR, - ensureCodexHooksJsonSessionStart, - resolveNodeRunner, - uninstall, -} = INSTALL; - -const { projectManagedHookCommand } = PROJECTION; - -/** - * Extract hook handler objects for `eventName` from a hooks.json object. - * Handles both the legacy top-level shape { SessionStart: [...] } and the - * canonical nested shape { hooks: { SessionStart: [...] } } (bug #1348). - */ -function hookHandlersForEvent(hooksJson, eventName) { - if (!hooksJson || typeof hooksJson !== 'object') return []; - const table = - hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks) - ? hooksJson.hooks - : hooksJson; - if (!Array.isArray(table[eventName])) return []; - return table[eventName].flatMap((e) => Array.isArray(e && e.hooks) ? e.hooks : []); -} - -// ─── Step 1: Export surface check ──────────────────────────────────────────── - -describe('#3426 — export surface: buildCodexHookWindowsShimIR must be exported', () => { - test('buildCodexHookWindowsShimIR is a function', () => { - assert.equal(typeof buildCodexHookWindowsShimIR, 'function', - 'buildCodexHookWindowsShimIR must be exported from bin/install.js'); - }); - - test('ensureCodexHooksJsonSessionStart is a function', () => { - assert.equal(typeof ensureCodexHooksJsonSessionStart, 'function', - 'ensureCodexHooksJsonSessionStart must be exported from bin/install.js'); - }); -}); - -// ─── Step 2: Typed IR shape for Windows Codex hook shim ────────────────────── - -describe('#3426 — buildCodexHookWindowsShimIR: typed IR (not rendered text)', () => { - const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js'; - const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"'; - - test('returns typed IR with invocation, cmdPath, and render factory', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - // IR shape assertion — per CONTRIBUTING.md L558 IR-first discipline - assert.ok(ir && typeof ir === 'object', 'must return an object'); - assert.ok(typeof ir.invocation === 'object', 'must have invocation record'); - assert.ok(typeof ir.cmdPath === 'string', 'must have cmdPath string'); - assert.ok(typeof ir.hookCommand === 'string', 'must have hookCommand string (written to hooks.json)'); - assert.ok(typeof ir.render === 'object', 'must have render factory'); - assert.ok(typeof ir.render.cmd === 'function', 'must have render.cmd() factory'); - }); - - test('invocation.target equals the resolved script path', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - // invocation.target is the JS file being wrapped — same IR contract as buildWindowsShimTriple - assert.ok( - ir.invocation.target.includes('gsd-check-update.js'), - `invocation.target must reference the hook script, got: ${ir.invocation.target}`, - ); - }); - - test('invocation.interpreter is the node runner (not bash)', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - // The shim must invoke node, never bash — bash is not a valid Codex hook runner on Windows - const interp = ir.invocation.interpreter; - assert.ok( - typeof interp === 'string' && (interp.includes('node') || interp === 'node'), - `invocation.interpreter must be a node path, not bash. Got: ${interp}`, - ); - assert.ok( - !interp.toLowerCase().includes('bash'), - `invocation.interpreter must NOT be bash — bash is the source of the #3426 failure. Got: ${interp}`, - ); - }); - - test('cmdPath ends with .cmd extension', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - assert.ok( - ir.cmdPath.endsWith('.cmd'), - `cmdPath must end with .cmd for cmd.exe native execution, got: ${ir.cmdPath}`, - ); - }); - - test('hookCommand is the .cmd path (not a "runner script.js" string)', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - // The hook command written to hooks.json must be the .cmd path, not "node.exe script.js" - // because cmd.exe executes .cmd natively without POSIX exec layer - assert.ok( - ir.hookCommand.includes('.cmd'), - `hookCommand must reference the .cmd shim, got: ${ir.hookCommand}`, - ); - // hookCommand must NOT contain bash — this was the failure mode - assert.ok( - !ir.hookCommand.toLowerCase().includes('bash'), - `hookCommand must NOT reference bash, got: ${ir.hookCommand}`, - ); - }); - - test('returns null when absoluteRunnerToken is null (caller skips registration)', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, null); - assert.equal(ir, null, - 'must return null when runner is unavailable so caller can warn-and-skip'); - }); -}); - -// ─── Step 2b: Typed IR — eol / quoting / passthroughArgs ───────────────────── -// Per CONTRIBUTING.md L558-L565: assert on the typed IR, not on rendered text. -// These assertions cover the three bug-critical render semantics that -// text-matching tests would miss (silent EOL/quoting/passthrough regressions). - -describe('#3426 — buildCodexHookWindowsShimIR: typed IR eol / quoting / passthroughArgs', () => { - const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js'; - const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"'; - - test('eol.cmd is CRLF (\\r\\n) — canonical for cmd.exe .cmd files', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - assert.ok(ir && typeof ir.eol === 'object', 'IR must expose an eol field'); - assert.strictEqual( - ir.eol.cmd, - '\r\n', - 'eol.cmd must be CRLF (\\r\\n) — LF-only .cmd files risk silent parse failures on some Windows versions', - ); - }); - - test('invocation.target has no shell-metachar leakage (clean absolute path)', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - const target = ir.invocation.target; - assert.ok(typeof target === 'string' && target.length > 0, 'invocation.target must be a non-empty string'); - // The target stored in the IR is the raw unquoted path — quoting happens at - // render time. A metachar in the raw value means the IR is already corrupted. - assert.ok( - !target.includes('"') && !target.includes("'") && !target.includes('`'), - `invocation.target must be the raw path without shell quoting, got: ${target}`, - ); - assert.ok( - target.endsWith('.js'), - `invocation.target must resolve to the .js script, got: ${target}`, - ); - }); - - test('passthroughArgs is true — shim forwards all args via %*', () => { - const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); - assert.strictEqual( - ir.passthroughArgs, - true, - 'passthroughArgs must be true: the .cmd shim must forward all arguments to the node script via %*', - ); - }); -}); - -// ─── Step 3: Counter-test — non-Windows platforms use node-runner command ──── - -describe('#3426 counter-test: darwin/linux Codex paths use node-runner command (not .cmd shim)', () => { - test('projectManagedHookCommand on darwin emits node-runner command, not .cmd', () => { - const runner = resolveNodeRunner() || '"/usr/local/bin/node"'; - const cmd = projectManagedHookCommand({ - absoluteRunner: runner, - scriptPath: '/Users/me/.codex/hooks/gsd-check-update.js', - runtime: 'codex', - platform: 'darwin', - }); - assert.ok(typeof cmd === 'string', 'must return a string on darwin'); - assert.ok(!cmd.endsWith('.cmd'), 'darwin command must NOT reference a .cmd shim'); - assert.ok( - cmd.includes('gsd-check-update.js'), - `darwin command must reference the .js hook directly, got: ${cmd}`, - ); - }); - - test('projectManagedHookCommand on linux emits node-runner command, not .cmd', () => { - const runner = resolveNodeRunner() || '"/usr/local/bin/node"'; - const cmd = projectManagedHookCommand({ - absoluteRunner: runner, - scriptPath: '/home/me/.codex/hooks/gsd-check-update.js', - runtime: 'codex', - platform: 'linux', - }); - assert.ok(typeof cmd === 'string', 'must return a string on linux'); - assert.ok(!cmd.endsWith('.cmd'), 'linux command must NOT reference a .cmd shim'); - assert.ok( - cmd.includes('gsd-check-update.js'), - `linux command must reference the .js hook directly, got: ${cmd}`, - ); - }); -}); - -// ─── Step 4: Integration — ensureCodexHooksJsonSessionStart on win32 writes .cmd shim ── - -describe('#3426 integration: ensureCodexHooksJsonSessionStart on win32 writes .cmd shim', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-3426-'); - fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true }); - // Stub the hook file that must exist for the hook to be registered - fs.writeFileSync( - path.join(tmpDir, 'hooks', 'gsd-check-update.js'), - '#!/usr/bin/env node\nconsole.log("ok");\n', - ); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('win32: hooks.json command references .cmd shim (not "node.exe script.js")', () => { - const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; - - const result = ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'win32', - }); - - assert.ok(result.wrote || result.changed, 'must write hooks.json on win32'); - - const hooksJsonPath = path.join(tmpDir, 'hooks.json'); - assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must exist after install'); - - const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); - // #1348: hooks.json is now always written in nested { hooks: { ... } } shape - const commands = hookHandlersForEvent(hooksJson, 'SessionStart') - .map((h) => h && h.command) - .filter((c) => typeof c === 'string'); - - assert.ok(commands.length > 0, 'must have at least one SessionStart hook command'); - - const cmd = commands.find((c) => c.includes('gsd-check-update')); - assert.ok(cmd, 'must have a gsd-check-update hook command'); - - // KEY ASSERTION: on win32, the command must reference a .cmd file — not bash - assert.ok( - cmd.includes('.cmd'), - `win32 hook command must reference a .cmd shim to avoid bash.exe exec failure (#3426). Got: ${cmd}`, - ); - assert.ok( - !cmd.toLowerCase().includes('bash'), - `win32 hook command must NOT reference bash.exe — this was the #3426 failure. Got: ${cmd}`, - ); - }); - - test('win32: .cmd shim file is written to the hooks directory', () => { - const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; - - ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'win32', - }); - - const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); - assert.ok( - fs.existsSync(cmdShimPath), - `win32: .cmd shim must be written at ${cmdShimPath}`, - ); - // File must be non-empty — structure check only (IR-first discipline) - const size = fs.statSync(cmdShimPath).size; - assert.ok(size > 0, '.cmd shim must have non-zero content'); - }); - - test('non-Windows (darwin): hooks.json command is "node.exe script.js" (no .cmd shim)', () => { - const fakeRunner = '"/usr/local/bin/node"'; - - const result = ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'darwin', - }); - - assert.ok(result.wrote || result.changed, 'must write hooks.json on darwin'); - - const hooksJson = JSON.parse( - fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'), - ); - // #1348: hooks.json is now always written in nested { hooks: { ... } } shape - const commands = hookHandlersForEvent(hooksJson, 'SessionStart') - .map((h) => h && h.command) - .filter((c) => typeof c === 'string'); - - const cmd = commands.find((c) => c.includes('gsd-check-update')); - assert.ok(cmd, 'must have a gsd-check-update hook command on darwin'); - - // Counter-test: darwin must NOT use a .cmd shim - assert.ok( - !cmd.endsWith('.cmd'), - `darwin hook command must NOT reference a .cmd shim, got: ${cmd}`, - ); - assert.ok( - cmd.includes('gsd-check-update.js'), - `darwin hook command must reference the .js file directly, got: ${cmd}`, - ); - - // .cmd shim must NOT be written on darwin - const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); - assert.ok( - !fs.existsSync(cmdShimPath), - 'darwin must NOT write a .cmd shim', - ); - }); - - test('non-Windows (linux): same as darwin — no .cmd shim', () => { - const fakeRunner = '"/usr/local/bin/node"'; - - ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - - const hooksJson = JSON.parse( - fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'), - ); - // #1348: hooks.json is now always written in nested { hooks: { ... } } shape - const commands = hookHandlersForEvent(hooksJson, 'SessionStart') - .map((h) => h && h.command) - .filter((c) => typeof c === 'string'); - - const cmd = commands.find((c) => c.includes('gsd-check-update')); - assert.ok(cmd, 'linux must have a gsd-check-update hook command'); - assert.ok(!cmd.endsWith('.cmd'), 'linux must NOT use a .cmd shim'); - - const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); - assert.ok(!fs.existsSync(cmdShimPath), 'linux must NOT write a .cmd shim'); - }); -}); - -// ─── Step 5: Uninstall cleanup — .cmd shim removed from disk ───────────────── - -describe('#3426 uninstall: gsd-check-update.cmd is removed from hooks dir on uninstall', () => { - let tmpDir; - - function withCodexHome(dir, fn) { - const prev = process.env.CODEX_HOME; - process.env.CODEX_HOME = dir; - try { return fn(); } - finally { - if (prev == null) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = prev; - } - } - - beforeEach(() => { - tmpDir = createTempDir('gsd-3426-uninstall-'); - fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true }); - // Write the .js hook (required by install) and a pre-existing .cmd shim - fs.writeFileSync( - path.join(tmpDir, 'hooks', 'gsd-check-update.js'), - '#!/usr/bin/env node\nconsole.log("ok");\n', - ); - fs.writeFileSync( - path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'), - '@ECHO OFF\r\n@SETLOCAL\r\n@"C:/node.exe" "C:/path/gsd-check-update.js" %*\r\n', - ); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('uninstall removes gsd-check-update.cmd from hooks directory', () => { - const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); - assert.ok(fs.existsSync(cmdShimPath), 'pre-condition: .cmd shim exists before uninstall'); - - withCodexHome(tmpDir, () => uninstall(true, 'codex')); - - assert.ok( - !fs.existsSync(cmdShimPath), - `gsd-check-update.cmd must be removed from disk on uninstall — orphaned .cmd shim would cause stale hook references. Path: ${cmdShimPath}`, - ); - }); -}); - -// ─── Step 6: Upgrade path — existing win32 hooks.json with node-runner command ─ - -describe('#3426 upgrade: reinstall on win32 migrates existing "node script.js" to .cmd shim', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-3426-upgrade-'); - fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true }); - fs.writeFileSync( - path.join(tmpDir, 'hooks', 'gsd-check-update.js'), - '#!/usr/bin/env node\nconsole.log("ok");\n', - ); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('replaces old "node.exe script.js" command with .cmd shim on win32 reinstall', () => { - const managedHookPath = path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); - // Pre-existing stale hooks.json with node-runner command (v1.42.3 shape) - const staleLegacyCommand = `"C:/Program Files/nodejs/node.exe" "${managedHookPath}"`; - fs.writeFileSync( - path.join(tmpDir, 'hooks.json'), - JSON.stringify({ - SessionStart: [{ hooks: [{ type: 'command', command: staleLegacyCommand }] }], - }, null, 2), - ); - - const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; - ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'win32', - }); - - const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8')); - // #1348: hooks.json is now always written in nested { hooks: { ... } } shape - const commands = hookHandlersForEvent(hooksJson, 'SessionStart') - .map((h) => h && h.command) - .filter((c) => typeof c === 'string'); - - const gsdCmds = commands.filter((c) => c.includes('gsd-check-update')); - // Exactly one managed hook after migration — no duplicates - assert.equal(gsdCmds.length, 1, `must have exactly 1 gsd-check-update command after migration, got: ${JSON.stringify(gsdCmds)}`); - - // Must be the .cmd shim - assert.ok( - gsdCmds[0].includes('.cmd'), - `migrated command must reference .cmd shim, got: ${gsdCmds[0]}`, - ); - }); -}); diff --git a/tests/bug-3427-3433-codex-install-shape.test.cjs b/tests/bug-3427-3433-codex-install-shape.test.cjs deleted file mode 100644 index 1a35dd1f9..000000000 --- a/tests/bug-3427-3433-codex-install-shape.test.cjs +++ /dev/null @@ -1,163 +0,0 @@ -'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 crypto = require('node:crypto'); -const { execFileSync } = require('node:child_process'); - -const { install, uninstall, parseTomlToObject } = require('../bin/install.js'); -const { createTempDir, cleanup, parseFrontmatter } = 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 prev = process.env.CODEX_HOME; - process.env.CODEX_HOME = codexHome; - try { - return fn(); - } finally { - if (prev == null) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = prev; - } -} - -function extractSessionStartCommandsFromHooksJson(value) { - if (!value || typeof value !== 'object' || Array.isArray(value)) return []; - const table = (value.hooks && typeof value.hooks === 'object' && !Array.isArray(value.hooks)) - ? value.hooks - : value; - const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; - return sessionStart.flatMap((entry) => { - const hooks = entry && Array.isArray(entry.hooks) ? entry.hooks : []; - return hooks.map((h) => h && h.command).filter((cmd) => typeof cmd === 'string'); - }); -} - -describe('#3427 + #3433 — Codex installer avoids duplicate skills and mixed hook representation', { 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-3427-3433-'); - codexHome = path.join(tmpRoot, '.codex'); - fs.mkdirSync(codexHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpRoot); - }); - - test('regenerates managed gsd-* skill copies and preserves unrelated user skills (#3562 reverses prior #3427/#3433 behaviour)', () => { - // Stale legacy body — fresh install must overwrite this so Codex sees the - // current SKILL.md, not whatever was last on disk. - const legacySkillBody = '# old managed\n'; - fs.mkdirSync(path.join(codexHome, 'skills', 'gsd-help'), { recursive: true }); - fs.writeFileSync(path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md'), legacySkillBody); - const legacyHash = crypto.createHash('sha256').update(legacySkillBody).digest('hex'); - fs.writeFileSync(path.join(codexHome, 'gsd-file-manifest.json'), JSON.stringify({ - version: 1, - files: { - 'skills/gsd-help/SKILL.md': legacyHash, - }, - }, null, 2)); - - fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true }); - fs.writeFileSync(path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'), '# user skill\n'); - - withCodexHome(codexHome, () => install(true, 'codex')); - - const skillsDir = path.join(codexHome, 'skills'); - const entries = fs.existsSync(skillsDir) - ? fs.readdirSync(skillsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) - : []; - - // #3562: $gsd-* commands are discoverable only when skills/gsd-*/SKILL.md - // exists. The installer must regenerate (not remove) the managed gsd-* - // directories. - assert.equal(entries.includes('gsd-help'), true); - const refreshedBody = fs.readFileSync(path.join(skillsDir, 'gsd-help', 'SKILL.md'), 'utf8'); - assert.notEqual(refreshedBody, legacySkillBody, 'stale legacy body must be overwritten'); - const frontmatter = parseFrontmatter(refreshedBody); - assert.equal(frontmatter.name, 'gsd-help', 'refreshed SKILL.md frontmatter must declare name: gsd-help'); - - // Unrelated user skills are preserved — the regen scope is `gsd-*` only. - assert.equal(entries.includes('custom-user-skill'), true); - }); - - test('stores managed SessionStart update hook in hooks.json and removes inline gsd hook from config.toml', () => { - const configToml = [ - '[features]', - 'codex_hooks = true', - '', - '[[hooks.SessionStart]]', - '[[hooks.SessionStart.hooks]]', - 'type = "command"', - 'command = "node /tmp/legacy/.codex/hooks/gsd-check-update.js"', - '', - ].join('\n'); - fs.writeFileSync(path.join(codexHome, 'config.toml'), configToml); - - fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({ - SessionStart: [ - { - hooks: [ - { type: 'command', command: 'node "/Users/example/bin/user-hook.js"' }, - ], - }, - ], - }, null, 2)); - - withCodexHome(codexHome, () => install(true, 'codex')); - - const parsedToml = parseTomlToObject(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8')); - const tomlSessionStart = parsedToml.hooks?.SessionStart ?? []; - const tomlCommands = tomlSessionStart.flatMap((entry) => - (Array.isArray(entry?.hooks) ? entry.hooks : []).map((hook) => hook.command).filter((cmd) => typeof cmd === 'string') - ); - assert.equal(tomlCommands.some((cmd) => cmd.includes('gsd-check-update.js')), false); - - const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); - const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson); - const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update')); - - assert.equal(gsdCommands.length, 1); - assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true); - }); - - test('uninstall removes managed SessionStart hook from hooks.json but preserves user hooks', () => { - const hooksDir = path.join(codexHome, 'hooks'); - fs.mkdirSync(hooksDir, { recursive: true }); - fs.writeFileSync(path.join(hooksDir, 'gsd-check-update.js'), '// managed hook\n'); - const managedHookPath = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); - - fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({ - SessionStart: [ - { - hooks: [ - { type: 'command', command: `node "${managedHookPath}"` }, - { type: 'command', command: 'node "/Users/example/bin/user-hook.js"' }, - ], - }, - ], - }, null, 2)); - - withCodexHome(codexHome, () => uninstall(true, 'codex')); - - const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); - const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson); - // On Windows the managed hook is the .cmd shim path; on POSIX it is the .js node-runner command. - // Either way the managed hook is gone after uninstall — only the user hook remains. - const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update')); - - assert.equal(gsdCommands.length, 0); - assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true); - }); -}); diff --git a/tests/bug-3562-codex-install-skill-surface.test.cjs b/tests/bug-3562-codex-install-skill-surface.test.cjs deleted file mode 100644 index 35261dab5..000000000 --- a/tests/bug-3562-codex-install-skill-surface.test.cjs +++ /dev/null @@ -1,120 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Regression test for bug #3562 — Codex global install must create a - * discoverable $gsd-* skill surface. - * - * Codex CLI 0.130.0 (the version in the issue report) does NOT auto-discover - * commands from gsd-core/workflows/*.md or agents/*.md. It only registers - * commands from skills//SKILL.md. Prior installer logic ("Codex now - * discovers official skills from .agents/skills") was based on an assumption - * that does not match the shipping Codex CLI behavior, leaving users with - * workflows on disk and no $gsd-* entrypoints after `npx @opengsd/gsd-core - * --codex --global`. - * - * Fix: re-wire copyCommandsAsCodexSkills() back into the install dispatch path - * so the same skill-shape that Claude / Copilot / Antigravity / Cursor / - * Windsurf / Augment / Trae installs produce is also produced for Codex. - */ - -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 { install } = require('../bin/install.js'); -const { createTempDir, cleanup, parseFrontmatter } = 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 prev = process.env.CODEX_HOME; - process.env.CODEX_HOME = codexHome; - try { - return fn(); - } finally { - if (prev == null) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = prev; - } -} - -describe('#3562 — Codex install produces discoverable $gsd-* skill surface', { 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-3562-'); - codexHome = path.join(tmpRoot, '.codex'); - fs.mkdirSync(codexHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpRoot); - }); - - test('global install creates skills/gsd-help/SKILL.md', () => { - withCodexHome(codexHome, () => install(true, 'codex')); - - const skillPath = path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md'); - assert.ok( - fs.existsSync(skillPath), - `Codex install must create ${skillPath} so $gsd-help is discoverable. ` + - 'Without this, Codex CLI 0.130.0 does not expose any $gsd-* command.', - ); - }); - - test('SKILL.md content has frontmatter expected by Codex skill discovery', () => { - withCodexHome(codexHome, () => install(true, 'codex')); - - const skillPath = path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md'); - assert.ok(fs.existsSync(skillPath), 'precondition: SKILL.md exists'); - - const content = fs.readFileSync(skillPath, 'utf8'); - const frontmatter = parseFrontmatter(content); - assert.equal(frontmatter.name, 'gsd-help', 'SKILL.md frontmatter must declare name: gsd-help so $gsd-help resolves'); - }); - - test('multiple core $gsd-* skills are produced (not just gsd-help)', () => { - withCodexHome(codexHome, () => install(true, 'codex')); - - const skillsDir = path.join(codexHome, 'skills'); - assert.ok(fs.existsSync(skillsDir), 'skills/ directory must exist after install'); - - const gsdSkills = fs - .readdirSync(skillsDir, { withFileTypes: true }) - .filter((e) => e.isDirectory() && e.name.startsWith('gsd-')) - .map((e) => e.name); - - // Lower bound — exact count depends on the current command surface. The - // commands/gsd/ directory holds dozens of *.md files; expecting more than - // 10 generated skills is a conservative floor that catches "we generated - // nothing" or "we only generated one accidentally" regressions. - assert.ok( - gsdSkills.length >= 10, - `Expected >= 10 generated gsd-* skill directories, found ${gsdSkills.length}: ${gsdSkills.join(', ')}`, - ); - }); - - test('install preserves existing user skills (does not remove unrelated dirs)', () => { - fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true }); - fs.writeFileSync( - path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'), - '---\nname: custom-user-skill\n---\n# user skill\n', - ); - - withCodexHome(codexHome, () => install(true, 'codex')); - - const userSkill = path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'); - assert.ok( - fs.existsSync(userSkill), - 'Codex install must preserve existing non-gsd user skill directories', - ); - }); -}); diff --git a/tests/bug-3566-codex-hooks-feature-canonical-key.test.cjs b/tests/bug-3566-codex-hooks-feature-canonical-key.test.cjs deleted file mode 100644 index 1d422b337..000000000 --- a/tests/bug-3566-codex-hooks-feature-canonical-key.test.cjs +++ /dev/null @@ -1,202 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Regression tests for bug #3566 — Codex installer must emit canonical - * [features].hooks (not the legacy [features].codex_hooks). - * - * Codex itself marks `codex_hooks` as a `legacy_key` in - * codex-rs/features/src/legacy.rs. The canonical current feature flag is - * `hooks`. The GSD installer was still writing `codex_hooks` on every fresh - * install / reinstall, leaving deprecated config behind. This file pins: - * - * 1. Fresh install writes canonical `[features].hooks = true` and never - * emits `codex_hooks` (section, root-dotted, or block-fallback forms). - * 2. Reinstall over a GSD-owned section-form legacy - * `[features].codex_hooks = true` migrates forward to - * `[features].hooks = true` (legacy line removed); user-owned legacy - * entries are preserved per #2760. - * 3. Reinstall over a GSD-owned root-dotted legacy - * `features.codex_hooks = true` migrates forward to - * `features.hooks = true`; user-owned legacy entries are preserved. - * 4. Reinstall over a user-owned `[features].hooks = true` (no GSD - * ownership marker) preserves the user line; no double-write, no - * ownership stamp. - * 5. The `hasEnabledCodexHooksFeature` recognizer treats both canonical - * `hooks` AND legacy `codex_hooks` as "enabled" so existing installs - * keep working across the migration window. - * 6. Uninstall removes either GSD-owned `hooks` or GSD-owned legacy - * `codex_hooks`; user-owned `hooks` is preserved. - * - * All assertions use parseTomlToObject — never substring-match on raw TOML - * text (per RULESET.TESTS.no-source-grep). The product surface is the - * parsed config shape, not the file's lexical layout. - */ - -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 { install, uninstall, parseTomlToObject } = require('../bin/install.js'); -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 prev = process.env.CODEX_HOME; - process.env.CODEX_HOME = codexHome; - try { - return fn(); - } finally { - if (prev == null) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = prev; - } -} - -function readConfig(codexHome) { - const text = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); - return { text, parsed: parseTomlToObject(text) }; -} - -function featuresHooks(parsed) { - return parsed?.features?.hooks; -} - -function featuresCodexHooks(parsed) { - return parsed?.features?.codex_hooks; -} - -describe('#3566 — Codex feature flag is canonical "hooks" (not legacy "codex_hooks")', { 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-3566-'); - codexHome = path.join(tmpRoot, '.codex'); - fs.mkdirSync(codexHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpRoot); - }); - - test('fresh install writes [features].hooks = true and never emits codex_hooks', () => { - withCodexHome(codexHome, () => install(true, 'codex')); - const { parsed } = readConfig(codexHome); - - assert.strictEqual( - featuresHooks(parsed), - true, - 'fresh install must write canonical [features].hooks = true', - ); - assert.strictEqual( - featuresCodexHooks(parsed), - undefined, - 'fresh install must NOT write legacy [features].codex_hooks', - ); - }); - - test('install over a pre-existing legacy [features].codex_hooks line preserves it (user-owned, #2760 defensive)', () => { - // A user who hand-wrote `codex_hooks = true` keeps the legacy key. - // Codex itself maps it via the runtime legacy_key alias, so this is - // forward-compatible without GSD rewriting user-authored content. - const legacy = [ - '[features]', - 'codex_hooks = true', - '', - ].join('\n'); - fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy); - - withCodexHome(codexHome, () => install(true, 'codex')); - const { parsed } = readConfig(codexHome); - - assert.strictEqual( - featuresCodexHooks(parsed), - true, - 'user-owned legacy codex_hooks line must be preserved verbatim', - ); - }); - - test('install over a pre-existing legacy root-dotted features.codex_hooks line preserves it', () => { - const legacy = 'features.codex_hooks = true\n'; - fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy); - - withCodexHome(codexHome, () => install(true, 'codex')); - const { parsed } = readConfig(codexHome); - - assert.strictEqual( - featuresCodexHooks(parsed), - true, - 'user-owned root-dotted legacy line must be preserved verbatim', - ); - }); - - test('reinstall preserves user-owned [features].hooks = true (no GSD ownership marker)', () => { - const userOwned = [ - '[features]', - 'hooks = true', - '', - ].join('\n'); - fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned); - - withCodexHome(codexHome, () => install(true, 'codex')); - const { parsed } = readConfig(codexHome); - - assert.strictEqual( - featuresHooks(parsed), - true, - 'user-owned hooks=true must be preserved', - ); - }); - - test('uninstall removes GSD-owned canonical hooks line but preserves user-owned hooks', () => { - // Phase 1: fresh GSD install — writes GSD-owned hooks line. - withCodexHome(codexHome, () => install(true, 'codex')); - const { parsed: afterInstall } = readConfig(codexHome); - assert.strictEqual( - featuresHooks(afterInstall), - true, - 'precondition: install wrote canonical hooks', - ); - - withCodexHome(codexHome, () => uninstall(true, 'codex')); - const configPath = path.join(codexHome, 'config.toml'); - if (!fs.existsSync(configPath)) { - // Uninstall may delete config.toml entirely when nothing user-owned - // remains — that is the strongest possible "feature flag removed" - // signal and counts as success. - return; - } - const { parsed: afterUninstall } = readConfig(codexHome); - assert.notStrictEqual( - featuresHooks(afterUninstall), - true, - 'uninstall must remove GSD-owned canonical hooks line', - ); - }); - - test('uninstall preserves user-owned hooks=true when GSD never owned it', () => { - const userOwned = [ - '[features]', - 'hooks = true', - '', - ].join('\n'); - fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned); - - withCodexHome(codexHome, () => uninstall(true, 'codex')); - const { parsed } = readConfig(codexHome); - - assert.strictEqual( - featuresHooks(parsed), - true, - 'uninstall must NOT touch a hooks line GSD never claimed ownership of (#2760 defensive principle)', - ); - }); -}); diff --git a/tests/bug-3571-configuration-manifest-install-path.test.cjs b/tests/bug-3571-configuration-manifest-install-path.test.cjs deleted file mode 100644 index 376c1cfc2..000000000 --- a/tests/bug-3571-configuration-manifest-install-path.test.cjs +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Regression test for #3571: configuration.cjs used the source - * checkout sdk/shared path only, which breaks installed gsd-tools.cjs because - * runtime installs copy gsd-core/ but not sdk/. - */ - -'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 REPO_ROOT = path.join(__dirname, '..'); -const CONFIGURATION_CJS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'configuration.cjs'); -const SHARED_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared'); - -const { install } = require('../bin/install.js'); - -const { createTempDir, cleanup } = require('./helpers.cjs'); -const makeTmpDir = () => createTempDir('gsd-3571-'); - -function silenceConsole(fn) { - const original = { - log: console.log, - warn: console.warn, - error: console.error, - }; - console.log = () => {}; - console.warn = () => {}; - console.error = () => {}; - try { - return fn(); - } finally { - console.log = original.log; - console.warn = original.warn; - console.error = original.error; - } -} - -describe('bug #3571: configuration generated manifests resolve in install layout', () => { - let tmpRoot; - let savedHome; - let savedUserProfile; - let savedExplicitConfigDir; - - beforeEach(() => { - tmpRoot = makeTmpDir(); - savedHome = process.env.HOME; - // On Windows, os.homedir() reads USERPROFILE; install() resolves via it. - savedUserProfile = process.env.USERPROFILE; - savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; - delete process.env.GSD_EXPLICIT_CONFIG_DIR; - }); - - afterEach(() => { - process.env.HOME = savedHome; - if (savedUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = savedUserProfile; - if (savedExplicitConfigDir === undefined) { - delete process.env.GSD_EXPLICIT_CONFIG_DIR; - } else { - process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; - } - cleanup(tmpRoot); - }); - - test('co-located bin/shared manifests let configuration.cjs load without sdk/shared', () => { - const gsdBinDir = path.join(tmpRoot, '.codex', 'gsd-core', 'bin'); - const gsdLibDir = path.join(gsdBinDir, 'lib'); - const gsdSharedDir = path.join(gsdBinDir, 'shared'); - fs.mkdirSync(gsdLibDir, { recursive: true }); - fs.mkdirSync(gsdSharedDir, { recursive: true }); - - const installedCjs = path.join(gsdLibDir, 'configuration.cjs'); - fs.copyFileSync(CONFIGURATION_CJS, installedCjs); - fs.copyFileSync( - path.join(SHARED_DIR, 'config-defaults.manifest.json'), - path.join(gsdSharedDir, 'config-defaults.manifest.json') - ); - fs.copyFileSync( - path.join(SHARED_DIR, 'config-schema.manifest.json'), - path.join(gsdSharedDir, 'config-schema.manifest.json') - ); - - delete require.cache[installedCjs]; - let mod; - assert.doesNotThrow(() => { - mod = require(installedCjs); - }, 'installed configuration.cjs must not require ~/.codex/sdk/shared'); - - assert.ok(mod.VALID_CONFIG_KEYS.has('workflow.plan_review_convergence')); - }); - - test('post-install: install() copies configuration manifests to co-located bin/shared', () => { - process.env.HOME = tmpRoot; - process.env.USERPROFILE = tmpRoot; - - silenceConsole(() => { - install(true, 'codex'); - }); - - const sharedDir = path.join(tmpRoot, '.codex', 'gsd-core', 'bin', 'shared'); - for (const fileName of ['config-defaults.manifest.json', 'config-schema.manifest.json']) { - const installedManifest = path.join(sharedDir, fileName); - assert.ok(fs.existsSync(installedManifest), `${fileName} must be copied to ${sharedDir}`); - assert.doesNotThrow(() => { - JSON.parse(fs.readFileSync(installedManifest, 'utf8')); - }, `${fileName} must be valid JSON`); - } - - const installedCjs = path.join( - tmpRoot, - '.codex', - 'gsd-core', - 'bin', - 'lib', - 'configuration.cjs' - ); - - delete require.cache[installedCjs]; - assert.doesNotThrow(() => { - require(installedCjs); - }, 'post-install configuration.cjs must load from co-located manifests'); - }); -}); diff --git a/tests/bug-3582-codex-skills-materialized.test.cjs b/tests/bug-3582-codex-skills-materialized.test.cjs deleted file mode 100644 index a7a797a82..000000000 --- a/tests/bug-3582-codex-skills-materialized.test.cjs +++ /dev/null @@ -1,313 +0,0 @@ -/** - * Regression test for bug #3582 — Codex install must materialize the skill - * surface under `~/.codex/skills//SKILL.md`. - * - * Background: GSD 1.42.2 reported the user-visible failure - * > Skipped Codex skill-copy generation (Codex discovers official skills directly) - * which left users with a "successful" install but no routable `$gsd-*` - * entrypoints in Codex CLI 0.130.0. Codex CLI does NOT auto-discover - * commands from `~/.codex/gsd-core/workflows/*.md` or `agents/*.md`; - * it only registers slash commands derived from `~/.codex/skills//SKILL.md`. - * The "Codex discovers official skills directly" assumption was wrong. - * - * The current installer (#3562 / current main) calls - * `copyCommandsAsCodexSkills()` to materialize one SKILL.md per - * commands/gsd/*.md, with Claude-flavored command frontmatter rewritten - * into Codex skill frontmatter and the `` body - * produced by `getCodexSkillAdapterHeader()`. - * - * This test locks the install contract so the 1.42.2 regression cannot - * silently come back. It asserts the full expected skill-name set - * (deepStrictEqual, not just count), the full adapter block (using - * the exported `getCodexSkillAdapterHeader` IR as the expected value, - * not raw substring search), and the success/skip log invariant. - */ -// allow-test-rule: source-text-is-the-product -// This assertion validates the generated adapter block that is shipped to -// users in SKILL.md; matching exact emitted text is the contract under test. - -'use strict'; - -// GSD_TEST_MODE neutralizes side-effecting branches (auto-detection, etc.). -// Must be set BEFORE requiring bin/install.js; scoped to module load only -// so downstream tests don't see it. Mirrors the bug-2760 codex harness. -const previousGsdTestMode = process.env.GSD_TEST_MODE; -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const { install, getCodexSkillAdapterHeader } = require('../bin/install.js'); -const { parseFrontmatter, createTempDir, cleanup } = require('./helpers.cjs'); - -if (previousGsdTestMode === undefined) { - delete process.env.GSD_TEST_MODE; -} else { - process.env.GSD_TEST_MODE = previousGsdTestMode; -} - -const ROOT = path.join(__dirname, '..'); -const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); - -// Strip ANSI color codes so log assertions don't depend on TTY detection. -function stripAnsi(s) { - // 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 - return s.replace(/\x1b\[[0-9;]*m/g, ''); -} - -function assertNoBareGsdToolsInvocation(content, label) { - const patterns = [ - /(^|\n)[ \t]*gsd-tools\s/, - /\$\(\s*gsd-tools\s/, - /`\s*gsd-tools\s/, - /(?:&&|\|\||[;|])\s*gsd-tools\s/, - ]; - for (const pattern of patterns) { - assert.doesNotMatch( - content, - pattern, - `${label} must not contain a command-position bare gsd-tools invocation`, - ); - } -} - -/** - * Walk commands/gsd/**\/*.md and return the set of skill names the installer - * is contractually obligated to produce. Naming rule mirrors - * `copyCommandsAsCodexSkills` in bin/install.js: nested dirs collapse to - * `gsd--` with the .md stripped. - */ -function expectedSkillNames() { - const names = new Set(); - function recurse(dir, prefix) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (entry.isDirectory()) { - recurse(path.join(dir, entry.name), `${prefix}-${entry.name}`); - } else if (entry.name.endsWith('.md')) { - const base = entry.name.slice(0, -3); - names.add(`${prefix}-${base}`); - } - } - } - recurse(COMMANDS_DIR, 'gsd'); - return names; -} - -/** - * Run a Codex global install into a temp CODEX_HOME and capture stdout/stderr. - * Cleans up codexHome on throw so a partial-install failure never leaks - * temp directories. - */ -function runCodexInstallCaptured() { - const codexHome = createTempDir('gsd-3582-codex-'); - const logs = []; - const warnings = []; - const origLog = console.log; - const origWarn = console.warn; - console.log = (...a) => { logs.push(a.join(' ')); }; - console.warn = (...a) => { warnings.push(a.join(' ')); }; - - const previousCodexHome = process.env.CODEX_HOME; - const previousCwd = process.cwd(); - process.env.CODEX_HOME = codexHome; - process.env.GSD_TEST_MODE = '1'; - try { - process.chdir(ROOT); - install(true, 'codex'); - return { codexHome, logs, warnings }; - } catch (err) { - // Always reclaim the temp dir if install throws — otherwise the - // describe-level afterEach can't see codexHome and it leaks. - try { cleanup(codexHome); } catch { /* best-effort */ } - throw err; - } finally { - process.chdir(previousCwd); - console.log = origLog; - console.warn = origWarn; - if (previousCodexHome === undefined) { - delete process.env.CODEX_HOME; - } else { - process.env.CODEX_HOME = previousCodexHome; - } - if (previousGsdTestMode === undefined) { - delete process.env.GSD_TEST_MODE; - } else { - process.env.GSD_TEST_MODE = previousGsdTestMode; - } - } -} - -// concurrency:false — harness mutates console.* / process.env / process.cwd(). -// Matches the convention used by tests/bug-3562-codex-install-skill-surface.test.cjs. -describe('bug-3582: Codex global install materializes the skill surface', { concurrency: false }, () => { - let installRun; - - beforeEach(() => { - installRun = runCodexInstallCaptured(); - }); - - afterEach(() => { - if (installRun && installRun.codexHome) { - cleanup(installRun.codexHome); - } - }); - - test('writes the exact expected set of gsd-*/SKILL.md skills (deepEqual on name set)', () => { - const skillsDir = path.join(installRun.codexHome, 'skills'); - assert.ok( - fs.existsSync(skillsDir), - `Codex install must create ${skillsDir} (the 1.42.2 regression skipped this entirely)`, - ); - - const actualNames = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) - .map(e => e.name); - - // deepStrictEqual on the sorted full set — not just count — so a - // partial install that drops a real command and substitutes a bogus - // same-count `gsd-*` directory cannot pass. - const expected = [...expectedSkillNames()].sort(); - assert.deepStrictEqual( - [...actualNames].sort(), - expected, - `installed Codex skills must exactly match commands/gsd/**/*.md (one skill per command)`, - ); - - // Every skill dir contains a non-empty SKILL.md file. Empty dirs or - // empty SKILL.md bodies would defeat Codex's slash-command - // registration as silently as the 1.42.2 "skipped" branch did. - for (const name of actualNames) { - const skillMd = path.join(skillsDir, name, 'SKILL.md'); - const stat = fs.statSync(skillMd); - assert.ok(stat.isFile(), `${skillMd} must be a regular file`); - assert.ok(stat.size > 0, `${skillMd} must not be empty`); - } - }); - - test('SKILL.md frontmatter declares hyphen-form name matching the directory', () => { - const skillsDir = path.join(installRun.codexHome, 'skills'); - const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) - .map(e => e.name); - - for (const name of skillDirs) { - const content = fs.readFileSync( - path.join(skillsDir, name, 'SKILL.md'), - 'utf-8', - ); - // Uses the shared `parseFrontmatter` from tests/helpers.cjs per the - // CONTRIBUTING.md "tests parse, never grep" convention. - const fm = parseFrontmatter(content); - assert.strictEqual( - fm.name, - name, - `SKILL.md name field must match directory name for ${name} (got ${JSON.stringify(fm.name)})`, - ); - assert.ok( - typeof fm.description === 'string' && fm.description.length > 0, - `SKILL.md description must be a non-empty string for ${name}`, - ); - } - }); - - test('SKILL.md body contains the full block produced by the exported builder', () => { - // Structural check against the production builder's output — NOT a - // raw substring grep on the rendered file. `getCodexSkillAdapterHeader` - // is the typed IR exported by bin/install.js (#3582 PR #3609 codex - // review); the file on disk must contain its full output verbatim - // (open tag, body, closing ``). A truncated, - // empty, or missing-closing-tag adapter cannot satisfy this assertion. - const skillsDir = path.join(installRun.codexHome, 'skills'); - const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) - .map(e => e.name); - - for (const name of skillDirs) { - const expectedAdapter = getCodexSkillAdapterHeader(name); - // Sanity: the builder itself must produce a closed block for the - // assertion below to be meaningful. - assert.ok( - expectedAdapter.startsWith(''), - `getCodexSkillAdapterHeader(${name}) must start with the opening tag`, - ); - assert.ok( - expectedAdapter.trimEnd().endsWith(''), - `getCodexSkillAdapterHeader(${name}) must end with the closing tag`, - ); - - const content = fs.readFileSync( - path.join(skillsDir, name, 'SKILL.md'), - 'utf-8', - ); - assert.ok( - content.includes(expectedAdapter), - `${name}/SKILL.md must contain the full adapter block produced by getCodexSkillAdapterHeader(${name}); Codex routes $${name} via this exact body`, - ); - } - }); - - test('representative skills named in the issue report are present', () => { - // The bug report and triage explicitly named these. Locking them as a - // representative set so a future dispatch / filter / profile change - // cannot drop just the commands the original user was trying to run. - const representative = [ - 'gsd-map-codebase', // the literal command from the bug report - 'gsd-execute-phase', - 'gsd-plan-phase', - 'gsd-new-project', - 'gsd-health', - ]; - const skillsDir = path.join(installRun.codexHome, 'skills'); - for (const name of representative) { - const skillMd = path.join(skillsDir, name, 'SKILL.md'); - assert.ok( - fs.existsSync(skillMd), - `${name}/SKILL.md must exist after Codex install (was unrouteable in 1.42.2)`, - ); - } - }); - - test('installed Codex skills do not ask agents to run bare gsd-tools commands', () => { - const skillsDir = path.join(installRun.codexHome, 'skills'); - const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) - .map(e => e.name); - - for (const name of skillDirs) { - const content = fs.readFileSync( - path.join(skillsDir, name, 'SKILL.md'), - 'utf-8', - ); - assertNoBareGsdToolsInvocation(content, `${name}/SKILL.md`); - } - }); - - test('installer success log mentions skills/ — never claims success while skipping', () => { - // The 1.42.2 user-visible failure mode was a successful install that - // printed "Skipped Codex skill-copy generation (Codex discovers - // official skills directly)" while leaving the user with no - // entrypoints. Lock that the broken strings can NEVER coexist with a - // success indicator. Current main prints "✓ Installed N skills". - const cleanLogs = installRun.logs.map(stripAnsi); - const cleanWarnings = installRun.warnings.map(stripAnsi); - const allOutput = [...cleanLogs, ...cleanWarnings].join('\n'); - - assert.ok( - !/Skipped Codex skill-copy generation/i.test(allOutput), - `installer must never print "Skipped Codex skill-copy generation" (1.42.2 failure). Output:\n${allOutput}`, - ); - assert.ok( - !/Codex discovers official skills directly/i.test(allOutput), - `installer must never claim "Codex discovers official skills directly" (1.42.2 incorrect assumption). Output:\n${allOutput}`, - ); - - // Positive proof — at least one log line acknowledges the skills install. - const hasSkillsInstalledLog = cleanLogs.some(line => /Installed\s+\d+\s+skills\s+to\s+skills\//.test(line)); - assert.ok( - hasSkillsInstalledLog, - `installer must print a success line of the form "Installed N skills to skills/". Logs:\n${cleanLogs.join('\n')}`, - ); - }); -}); diff --git a/tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs b/tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs deleted file mode 100644 index 8c5d730f0..000000000 --- a/tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs +++ /dev/null @@ -1,408 +0,0 @@ -'use strict'; - -/** - * Regression for bug #376 — Claude-installed hook JS files ship with raw - * /gsd: command literals because the hook-copy loop in install.js had - * no /gsd: → /gsd- rewrite for the claude runtime. - * - * Fix: the `.js` branch of the hook-copy loop now applies - * `content.replace(/gsd:/gi, 'gsd-')` when - * `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` is true (covers - * claude, qwen, hermes). - * - * Test plan: - * 1. Claude install to tmp prefix — installed .js hook files must contain - * no user-facing /gsd: literals (// comment occurrences exempted). - * 2. Cursor install regression — still rewrites correctly (pre-existing - * branch must remain intact). - * 3. Source files in hooks/ must be byte-identical before and after both - * installs (install-time rewrite only, no in-tree mutation). - */ - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, before, after } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { execFileSync } = require('node:child_process'); -const { cleanup } = require('./helpers.cjs'); - -const REPO_ROOT = path.resolve(__dirname, '..'); -const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js'); -const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); -const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); - -/** - * Ensure hooks/dist is populated before any suite that reads it. - * hooks/dist/ is gitignored and only produced by `npm run build:hooks`. - * In CI the scoped/windows test jobs do NOT run build:hooks before running - * tests, so the first test that needs hooks/dist would fail. This mirrors - * the pattern used in bug-3357-codex-legacy-hooks-json-migration.test.cjs. - */ -function ensureHooksDist() { - if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Run `node install.js <...args>` from cwd. - * GSD_TEST_MODE is cleared so the install() main block executes. - */ -function runInstall(cwd, args) { - const env = { ...process.env }; - delete env.GSD_TEST_MODE; - execFileSync(process.execPath, [INSTALL_PATH, ...args], { - cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - env, - timeout: 60000, - }); -} - -/** - * Return an array of { rel, path } for all .js files under dir. - */ -function findJsFiles(dir) { - const results = []; - function walk(d) { - for (const entry of fs.readdirSync(d, { withFileTypes: true })) { - const full = path.join(d, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith('.js') || entry.name.endsWith('.cjs')) { - results.push({ rel: path.relative(dir, full), full }); - } - } - } - walk(dir); - return results; -} - -/** - * Split a JS file's lines into comment and non-comment buckets. - * A line is treated as a comment if it starts with optional whitespace - * followed by // (single-line comment). Block comments are not checked - * since none of the hook files use them for command refs. - */ -function nonCommentLines(content) { - return content.split('\n').filter(line => !/^\s*\/\//.test(line)); -} - -/** - * Return lines (from nonCommentLines) that contain a user-facing /gsd: ref. - */ -function colonRefs(content) { - return nonCommentLines(content).filter(line => /\/gsd:/.test(line)); -} - -// --------------------------------------------------------------------------- -// Prerequisite: hooks/dist must exist (built by `npm run build:hooks`) -// --------------------------------------------------------------------------- -describe('bug #376 — prerequisite: hooks/dist is present', () => { - before(() => { - // hooks/dist is gitignored; build it on demand so this test is - // deterministic in CI scoped/windows jobs that don't pre-run build:hooks. - ensureHooksDist(); - }); - - test('hooks/dist directory exists (run npm run build:hooks if missing)', () => { - assert.ok( - fs.existsSync(HOOKS_DIST_DIR), - `hooks/dist not found at ${HOOKS_DIST_DIR}. Run: npm run build:hooks`, - ); - }); - - test('hooks/dist contains at least one .js hook file with a /gsd: literal', () => { - const jsFiles = findJsFiles(HOOKS_DIST_DIR); - assert.ok(jsFiles.length > 0, 'hooks/dist must contain .js files'); - - const withColonRef = jsFiles.filter(({ full }) => { - const content = fs.readFileSync(full, 'utf-8'); - return colonRefs(content).length > 0; - }); - - assert.ok( - withColonRef.length > 0, - 'Expected at least one hooks/dist .js file with a non-comment /gsd: literal ' + - '— this confirms the test is guarding a real regression surface. ' + - `Files checked: ${jsFiles.map(f => f.rel).join(', ')}`, - ); - }); -}); - -// --------------------------------------------------------------------------- -// Suite 1 — Claude install: no /gsd: colon refs in installed .js hook files -// --------------------------------------------------------------------------- -describe('bug #376 — Suite 1: Claude install rewrites /gsd: → /gsd- in hook .js files', () => { - let tmpDir; - - before(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-376-claude-')); - runInstall(tmpDir, ['--claude', '--local', '--no-sdk']); - }); - - after(() => { - cleanup(tmpDir); - }); - - test('1a: hooks/ directory is created by the Claude local install', () => { - const hooksDir = path.join(tmpDir, '.claude', 'hooks'); - assert.ok( - fs.existsSync(hooksDir), - `hooks/ must be created at ${hooksDir} by Claude local install`, - ); - }); - - test('1b: installed .js hook files contain no user-facing /gsd: colon refs', () => { - const hooksDir = path.join(tmpDir, '.claude', 'hooks'); - if (!fs.existsSync(hooksDir)) { - // If hooks/ wasn't created (hooks/dist missing at install time), skip gracefully - return; - } - - const jsFiles = findJsFiles(hooksDir); - assert.ok(jsFiles.length > 0, 'At least one .js hook file must be installed'); - - const offenders = []; - for (const { rel, full } of jsFiles) { - const content = fs.readFileSync(full, 'utf-8'); - const badLines = colonRefs(content); - if (badLines.length > 0) { - offenders.push({ rel, lines: badLines }); - } - } - - assert.deepEqual( - offenders, - [], - 'Installed Claude hook .js files must not contain /gsd: colon refs ' + - '(non-comment occurrences). The install-time rewriter must replace these with /gsd-. ' + - 'Offenders: ' + JSON.stringify(offenders, null, 2), - ); - }); - - test('1c: installed .js hook files DO contain the hyphen form /gsd- (rewrite happened)', () => { - const hooksDir = path.join(tmpDir, '.claude', 'hooks'); - if (!fs.existsSync(hooksDir)) return; - - const jsFiles = findJsFiles(hooksDir); - const withHyphen = jsFiles.filter(({ full }) => { - const content = fs.readFileSync(full, 'utf-8'); - return /\/gsd-/.test(content); - }); - - assert.ok( - withHyphen.length > 0, - 'At least one installed .js hook file must contain /gsd- (confirming rewrite ran). ' + - `Files checked: ${jsFiles.map(f => f.rel).join(', ')}`, - ); - }); -}); - -// --------------------------------------------------------------------------- -// Suite 2 — Cursor install regression: /gsd: → /gsd- still works (pre-existing) -// -// Note: Cursor installs its own hooks (gsd-cursor-session-start.js and -// gsd-cursor-post-tool.js) via the cursor-hooks-json installSurface (issue #777). -// It does NOT install the bundled Claude-style hooks/dist files (no gsd-session-state.sh -// etc.). The Cursor /gsd: rewrite applies in `copyWithPathReplacement` to JS files -// under the agent/skill tree (.cursor/gsd-core/*.js etc). We verify that Cursor's -// installed .js files under .cursor/ have no /gsd: colon refs, and that the hooks/ -// directory contains only the Cursor-specific managed hooks. -// --------------------------------------------------------------------------- -describe('bug #376 — Suite 2: Cursor install still rewrites /gsd: → /gsd- (regression)', () => { - let tmpDir; - - before(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-376-cursor-')); - runInstall(tmpDir, ['--cursor', '--local', '--no-sdk']); - }); - - after(() => { - cleanup(tmpDir); - }); - - test('2a: .cursor/ directory is created by the Cursor local install', () => { - const cursorDir = path.join(tmpDir, '.cursor'); - assert.ok( - fs.existsSync(cursorDir), - `Cursor install must create .cursor/ directory at ${cursorDir}`, - ); - }); - - test('2b: Cursor-installed .js files contain no user-facing /gsd: colon refs', () => { - const cursorDir = path.join(tmpDir, '.cursor'); - if (!fs.existsSync(cursorDir)) return; - - // Infrastructure files whose /gsd: occurrences are intentional implementation - // details — NOT user-facing command references that Cursor would invoke. - // - // scripts/fix-slash-commands.cjs is the slash-command rewriter engine, required - // by gsd-core/bin/lib/command-roster.cjs on ALL runtimes (including Cursor). - // It must be installed verbatim and must NOT be content-rewritten: it needs to - // emit `/gsd:${cmd}` for non-Cursor runtimes, and its /gsd: strings are internal - // implementation/docs (transform patterns, regex literals, template literals), - // not commands a Cursor user would type. Rewriting it would corrupt the transformer. - const INFRA_BASENAMES = new Set(['fix-slash-commands.cjs']); - - const jsFiles = findJsFiles(cursorDir); - // Cursor may not install any .js files depending on what agent/skill content exists; - // if none, skip gracefully. - if (jsFiles.length === 0) return; - - const offenders = []; - for (const { rel, full } of jsFiles) { - // Skip infrastructure files whose /gsd: strings are intentional (see above). - if (INFRA_BASENAMES.has(path.basename(full))) continue; - const content = fs.readFileSync(full, 'utf-8'); - const badLines = colonRefs(content); - if (badLines.length > 0) { - offenders.push({ rel, lines: badLines }); - } - } - - assert.deepEqual( - offenders, - [], - 'Cursor-installed .js files must not contain /gsd: colon refs. ' + - 'The existing Cursor branch in copyWithPathReplacement must still apply /gsd:/gi → gsd- rewrite. ' + - 'Offenders: ' + JSON.stringify(offenders, null, 2), - ); - }); - - test('2c: Cursor install creates a hooks/ directory with only Cursor-specific managed hooks', () => { - // Since issue #777, Cursor installs gsd-cursor-session-start.js and - // gsd-cursor-post-tool.js into /hooks/. These are Cursor-native - // hooks — NOT the bundled Claude-style hooks (no gsd-session-state.sh etc.). - // Verify: hooks/ exists AND does NOT contain any Claude-bundled hooks. - const hooksDir = path.join(tmpDir, '.cursor', 'hooks'); - assert.ok( - fs.existsSync(hooksDir), - 'Cursor install must create a hooks/ directory for its managed hook scripts (#777)', - ); - const CLAUDE_BUNDLED_HOOKS = ['gsd-session-state.sh', 'gsd-context-monitor.js', 'gsd-statusline.js']; - for (const hook of CLAUDE_BUNDLED_HOOKS) { - assert.strictEqual( - fs.existsSync(path.join(hooksDir, hook)), - false, - `Cursor hooks/ must NOT contain Claude-bundled hook ${hook} — only Cursor-native hooks are installed`, - ); - } - // The two Cursor-specific managed hooks must be present. - assert.ok( - fs.existsSync(path.join(hooksDir, 'gsd-cursor-session-start.js')), - 'gsd-cursor-session-start.js must be installed in .cursor/hooks/ (#777)', - ); - assert.ok( - fs.existsSync(path.join(hooksDir, 'gsd-cursor-post-tool.js')), - 'gsd-cursor-post-tool.js must be installed in .cursor/hooks/ (#777)', - ); - }); -}); - -// --------------------------------------------------------------------------- -// Suite 3 — Source files in hooks/ are untouched -// --------------------------------------------------------------------------- -describe('bug #376 — Suite 3: hooks/ source files are unchanged by install', () => { - let snapshotBefore; - - before(() => { - // Ensure hooks/dist is built before snapshotting; it may be absent in CI - // scoped/windows jobs that don't pre-run build:hooks (#777 fix). - ensureHooksDist(); - // Snapshot hooks/dist JS files before any install in this suite - snapshotBefore = {}; - if (fs.existsSync(HOOKS_DIST_DIR)) { - for (const { rel, full } of findJsFiles(HOOKS_DIST_DIR)) { - snapshotBefore[rel] = fs.readFileSync(full, 'utf-8'); - } - } - }); - - test('3a: hooks/dist .js source files still contain /gsd: literals (not mutated)', () => { - // The source must remain in colon form — the rewrite is install-time only - const jsFiles = findJsFiles(HOOKS_DIST_DIR); - const withColonRef = jsFiles.filter(({ full }) => { - const content = fs.readFileSync(full, 'utf-8'); - return colonRefs(content).length > 0; - }); - - // We know from the prerequisite suite that at least one file had a colon ref; - // if the source was mutated by install, this would now be zero. - assert.ok( - withColonRef.length > 0, - 'hooks/dist .js files must still contain /gsd: literals after install — ' + - 'the install-time rewrite must NOT modify the source tree. ' + - `Files that still have colon refs: ${withColonRef.map(f => f.rel).join(', ')}`, - ); - }); - - test('3b: hooks/dist .js source file contents match pre-test snapshot (byte-identical)', () => { - if (Object.keys(snapshotBefore).length === 0) { - // hooks/dist was absent before; skip - return; - } - - for (const [rel, before] of Object.entries(snapshotBefore)) { - const full = path.join(HOOKS_DIST_DIR, rel); - const after = fs.readFileSync(full, 'utf-8'); - assert.strictEqual( - after, - before, - `hooks/dist/${rel} was mutated by install — install must only rewrite the installed copy, not the source`, - ); - } - }); -}); - -// --------------------------------------------------------------------------- -// Suite 4 — Pure-function: shouldNormalizeHyphenNamespaceInAgentBody covers claude -// --------------------------------------------------------------------------- -describe('bug #376 — Suite 4: shouldNormalizeHyphenNamespaceInAgentBody covers claude', () => { - const install = require(INSTALL_PATH); - - test('4a: shouldNormalizeHyphenNamespaceInAgentBody is exported', () => { - assert.strictEqual( - typeof install.shouldNormalizeHyphenNamespaceInAgentBody, - 'function', - 'install.js must export shouldNormalizeHyphenNamespaceInAgentBody', - ); - }); - - test('4b: claude is in the hyphen-namespace set', () => { - assert.strictEqual( - install.shouldNormalizeHyphenNamespaceInAgentBody('claude'), - true, - 'claude must be a hyphen-namespace runtime', - ); - }); - - test('4c: qwen is in the hyphen-namespace set', () => { - assert.strictEqual( - install.shouldNormalizeHyphenNamespaceInAgentBody('qwen'), - true, - ); - }); - - test('4d: hermes is in the hyphen-namespace set', () => { - assert.strictEqual( - install.shouldNormalizeHyphenNamespaceInAgentBody('hermes'), - true, - ); - }); - - test('4e: gemini is NOT in the hyphen-namespace set', () => { - assert.strictEqual( - install.shouldNormalizeHyphenNamespaceInAgentBody('gemini'), - false, - 'gemini intentionally keeps colon namespace and must not be in the hyphen set', - ); - }); -}); diff --git a/tests/bug-3808-codex-adapter-text-mode-fallback.test.cjs b/tests/bug-3808-codex-adapter-text-mode-fallback.test.cjs deleted file mode 100644 index 1ad63e281..000000000 --- a/tests/bug-3808-codex-adapter-text-mode-fallback.test.cjs +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Regression test for bug #3808. - * - * When Codex runs in Default mode, `request_user_input` is reported as - * unavailable. The Codex skill adapter must tell the agent to activate the - * workflow's built-in TEXT_MODE mechanism (`--text` flag) rather than either: - * (a) silently picking a default value — the #3018 failure mode, or - * (b) ad-hoc plain-text fallback that bypasses the workflow's own branching. - * - * Workflows (e.g. plan-phase.md) already have TEXT_MODE logic: - * "Set TEXT_MODE=true if `--text` is present in $ARGUMENTS OR text_mode - * from init JSON is true." - * The adapter must tell the agent to USE that mechanism when - * `request_user_input` is unavailable instead of inventing its own fallback - * or silently continuing with defaults. - * - * Test design: mirrors the typed-semantic-flag pattern from bug #3018 so that - * prose rewording doesn't break tests as long as the semantics stay correct. - */ - -'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 INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); -const { getCodexSkillAdapterHeader } = INSTALL; - -/** - * Extract the "Execute mode fallback" section text from the adapter header. - * Returns null if the section is missing. Section runs from the - * "Execute mode fallback:" label up to the next heading or tag. - */ -function extractExecuteModeFallback(header) { - const m = header.match(/Execute mode fallback:\s*\n([\s\S]*?)(?=\n##\s|\n<\/codex_skill_adapter>)/); - return m ? m[1].trim() : null; -} - -/** - * Parse the Execute-mode-fallback section into a typed semantic-flag record. - * - * Flags for bug #3808 (TEXT_MODE activation): - * activatesTextMode — does the prose tell the agent to activate TEXT_MODE / use --text? - * instructsStop — does the prose tell the agent to stop/halt/wait? - * presentsPlainText — does the prose mention plain-text / numbered-list presentation? - * silentlyPicksDefaults — (anti-pattern) does the prose instruct silent-default picking? - */ -function parseExecuteModeFallbackFor3808(section) { - if (!section || typeof section !== 'string') { - return { - ok: false, - sectionLength: 0, - activatesTextMode: false, - instructsStop: false, - presentsPlainText: false, - silentlyPicksDefaults: false, - }; - } - - const lower = section.toLowerCase(); - - // (a) TEXT_MODE activation — adapter must tell the agent to use the workflow's - // built-in text mode mechanism when request_user_input is unavailable. - // Accept either: explicit "--text" flag mention OR "text_mode" / "text mode" - // paired with context showing it is being SET/ACTIVATED (not just referenced). - const mentionsTextFlag = section.includes('--text'); - const mentionsTextModeOn = /text_mode\s*=\s*true|set\s+text_mode|activate\s+text.?mode|enable\s+text.?mode|text.?mode.*active|text.?mode.*on\b/i.test(section); - const activatesTextMode = mentionsTextFlag || mentionsTextModeOn; - - // (b) STOP/WAIT directive — the agent must halt instead of proceeding silently. - const instructsStop = /\b(stop|halt|wait)\b/.test(lower); - - // (c) Plain-text fallback presentation. - const presentsPlainText = /plain.?text|numbered list/.test(lower); - - // Anti-pattern guard — the prose that caused #3018 and resurfaces in #3808. - const silentlyPicksDefaults = /pick (a |the )?(reasonable|sensible|sane) default/i.test(section); - - return { - ok: true, - sectionLength: section.length, - activatesTextMode, - instructsStop, - presentsPlainText, - silentlyPicksDefaults, - }; -} - -describe('bug #3808: codex skill adapter activates TEXT_MODE when request_user_input is unavailable', () => { - const SKILL_NAMES = ['gsd-plan-phase', 'gsd-discuss-phase', 'gsd-execute-phase', 'gsd-verify-work']; - - test('getCodexSkillAdapterHeader is exported', () => { - assert.equal(typeof getCodexSkillAdapterHeader, 'function'); - }); - - test('Execute mode fallback section exists for all key skills', () => { - for (const skillName of SKILL_NAMES) { - const header = getCodexSkillAdapterHeader(skillName); - const section = extractExecuteModeFallback(header); - assert.ok(section !== null && section.length > 0, - `${skillName}: Execute mode fallback section must exist and have content`); - } - }); - - for (const skillName of SKILL_NAMES) { - test(`${skillName}: fallback activates TEXT_MODE (--text flag or text_mode=true) when request_user_input is unavailable`, () => { - const header = getCodexSkillAdapterHeader(skillName); - const section = extractExecuteModeFallback(header); - const parsed = parseExecuteModeFallbackFor3808(section); - assert.equal(parsed.activatesTextMode, true, - `${skillName}: fallback must instruct the agent to activate TEXT_MODE (mention --text flag or text_mode=true/active) when request_user_input is unavailable (#3808). Section was:\n${section}`); - }); - - test(`${skillName}: fallback instructs STOP/WAIT (not silent continuation)`, () => { - const header = getCodexSkillAdapterHeader(skillName); - const section = extractExecuteModeFallback(header); - const parsed = parseExecuteModeFallbackFor3808(section); - assert.equal(parsed.instructsStop, true, - `${skillName}: fallback must include stop/halt/wait instruction. Section was:\n${section}`); - }); - - test(`${skillName}: fallback does NOT contain silent-default anti-pattern`, () => { - const header = getCodexSkillAdapterHeader(skillName); - const section = extractExecuteModeFallback(header); - const parsed = parseExecuteModeFallbackFor3808(section); - assert.equal(parsed.silentlyPicksDefaults, false, - `${skillName}: regression — fallback must NOT instruct the agent to pick defaults autonomously (#3018 / #3808). Section was:\n${section}`); - }); - } - - test('typed semantic-record snapshot for gsd-plan-phase — full contract', () => { - const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-plan-phase')); - const parsed = parseExecuteModeFallbackFor3808(section); - assert.deepStrictEqual( - { - ok: parsed.ok, - activatesTextMode: parsed.activatesTextMode, - instructsStop: parsed.instructsStop, - presentsPlainText: parsed.presentsPlainText, - silentlyPicksDefaults: parsed.silentlyPicksDefaults, - }, - { - ok: true, - activatesTextMode: true, - instructsStop: true, - presentsPlainText: true, - silentlyPicksDefaults: false, - }, - `gsd-plan-phase: full TEXT_MODE fallback contract violated (#3808). Section was:\n${section}`, - ); - }); -}); diff --git a/tests/bug-410-install-defaults-test-mode-guard.test.cjs b/tests/bug-410-install-defaults-test-mode-guard.test.cjs deleted file mode 100644 index 0d5061ed7..000000000 --- a/tests/bug-410-install-defaults-test-mode-guard.test.cjs +++ /dev/null @@ -1,293 +0,0 @@ -'use strict'; - -/** - * Bug #410: finishInstall writes ~/.gsd/defaults.json for non-Claude runtimes - * without a GSD_TEST_MODE guard, polluting the real developer home directory - * during test runs. - * - * The opencode permission-config write a few lines above already carries the - * GSD_TEST_MODE guard (added for #130) — this test covers the un-fixed sibling - * (the resolve_model_ids: "omit" write). - */ - -const { test, describe } = require('node:test'); -const { cleanup } = require('./helpers.cjs'); -const assert = require('node:assert/strict'); -const path = require('node:path'); -const os = require('node:os'); -const fs = require('node:fs'); - -const ROOT = path.join(__dirname, '..'); - -// Point HOME at a temp dir so the defaults.json write can't reach the real -// ~/.gsd/ even if the guard is missing. -// On Windows, os.homedir() reads USERPROFILE (not HOME). Set both so -// finishInstall's path.join(os.homedir(), '.gsd') resolves into FAKE_HOME -// on every platform. Node docs: https://nodejs.org/docs/latest-v22.x/api/os.html#oshomedir -const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-410-test-')); -process.env.HOME = FAKE_HOME; -process.env.USERPROFILE = FAKE_HOME; - -// The path that finishInstall would write to for a non-Claude runtime. -const GSD_DIR = path.join(FAKE_HOME, '.gsd'); -const DEFAULTS_PATH = path.join(GSD_DIR, 'defaults.json'); - -// Set GSD_TEST_MODE before requiring install.js so any module-level guards -// also see the flag. -process.env.GSD_TEST_MODE = '1'; - -const installModule = require(path.join(ROOT, 'bin', 'install.js')); - -// A synthetic settingsPath that won't exist — finishInstall should cope. -const SETTINGS_PATH = path.join(FAKE_HOME, `gsd-test-settings-${process.pid}.json`); - -function callFinishInstallForRuntime(runtime) { - const original = console.log; - console.log = () => {}; - try { - installModule.finishInstall( - SETTINGS_PATH, - {}, // empty settings - null, // statuslineCommand - false, // shouldInstallStatusline - runtime, - true, // isGlobal - null, // configDir - ); - } finally { - console.log = original; - } -} - -describe('Bug #410: finishInstall non-Claude runtime + GSD_TEST_MODE side-effect guard', () => { - test('defaults.json is NOT written for opencode runtime under GSD_TEST_MODE', () => { - assert.equal( - fs.existsSync(DEFAULTS_PATH), - false, - 'defaults.json should not exist before finishInstall call', - ); - - callFinishInstallForRuntime('opencode'); - - assert.equal( - fs.existsSync(DEFAULTS_PATH), - false, - `defaults.json must NOT be created under GSD_TEST_MODE; found at ${DEFAULTS_PATH}`, - ); - }); - - test('defaults.json is NOT written for gemini runtime under GSD_TEST_MODE', () => { - // Reset in case previous test left artifacts (it shouldn't). - assert.equal( - fs.existsSync(DEFAULTS_PATH), - false, - 'defaults.json should not exist before gemini test', - ); - - callFinishInstallForRuntime('gemini'); - - assert.equal( - fs.existsSync(DEFAULTS_PATH), - false, - `defaults.json must NOT be created under GSD_TEST_MODE for gemini; found at ${DEFAULTS_PATH}`, - ); - }); - - test('defaults.json IS written for opencode runtime when GSD_TEST_MODE is unset', () => { - // Temporarily unset GSD_TEST_MODE to verify the user-facing path still works. - const saved = process.env.GSD_TEST_MODE; - delete process.env.GSD_TEST_MODE; - try { - callFinishInstallForRuntime('opencode'); - assert.equal( - fs.existsSync(DEFAULTS_PATH), - true, - `defaults.json must be written for non-Claude runtime when GSD_TEST_MODE is unset`, - ); - // Verify the written content is correct. - const contents = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal(contents.resolve_model_ids, 'omit', 'resolve_model_ids must be "omit"'); - } finally { - // Restore GSD_TEST_MODE and clean up the written file. - process.env.GSD_TEST_MODE = saved; - cleanup(DEFAULTS_PATH); - try { fs.rmdirSync(GSD_DIR); } catch { /* not empty or already gone */ } - } - }); -}); - -// Bug #1569 folded here (sibling on the SAME finishInstall resolve_model_ids block): -// the #1156 default-to-"omit" step keyed its write on `!== "omit"`, so an explicit -// `resolve_model_ids: true` opt-in (resolveModelInternal returns full materialized -// model IDs) was silently clobbered across all 14 non-Claude runtimes. The fix -// preserves `true` and only defaults absent/falsy → "omit". Reuses the #410 harness. - -describe('Bug #1569: non-Claude finishInstall preserves explicit resolve_model_ids:true', () => { - function seedDefaults(obj) { - fs.mkdirSync(GSD_DIR, { recursive: true }); - fs.writeFileSync(DEFAULTS_PATH, JSON.stringify(obj, null, 2) + '\n', 'utf8'); - } - - function withUserPath(fn) { - const saved = process.env.GSD_TEST_MODE; - delete process.env.GSD_TEST_MODE; - try { - return fn(); - } finally { - process.env.GSD_TEST_MODE = saved; - } - } - - test('explicit resolve_model_ids:true survives a codex global install (the reported case)', () => { - withUserPath(() => { - seedDefaults({ runtime: 'codex', model_profile: 'balanced', resolve_model_ids: true }); - callFinishInstallForRuntime('codex'); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal( - after.resolve_model_ids, - true, - 'explicit resolve_model_ids:true must be preserved across a codex install, not clobbered to "omit"', - ); - }); - }); - - // The clobber guard is runtime-agnostic (`runtime !== 'claude'`); parameterize - // across a representative slice of non-Claude runtimes. - for (const runtime of ['codex', 'opencode', 'gemini']) { - test(`explicit resolve_model_ids:true survives a ${runtime} global install`, () => { - withUserPath(() => { - seedDefaults({ runtime, resolve_model_ids: true }); - callFinishInstallForRuntime(runtime); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal( - after.resolve_model_ids, - true, - `explicit resolve_model_ids:true must be preserved for ${runtime}`, - ); - }); - }); - } - - test('absent resolve_model_ids still defaults to "omit" (preserves #1156 intent)', () => { - withUserPath(() => { - seedDefaults({ runtime: 'codex' }); - callFinishInstallForRuntime('codex'); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal( - after.resolve_model_ids, - 'omit', - 'absent resolve_model_ids must still default to "omit" for non-Claude runtimes', - ); - }); - }); - - test('explicit resolve_model_ids:false still defaults to "omit"', () => { - withUserPath(() => { - seedDefaults({ runtime: 'codex', resolve_model_ids: false }); - callFinishInstallForRuntime('codex'); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal(after.resolve_model_ids, 'omit', 'false must still be normalized to "omit"'); - }); - }); - - test('non-canonical resolve_model_ids values (0, "", "yes", {}) default to "omit" — no Claude alias leak (#1569 codex review)', () => { - // The domain is true/false/"omit"/absent. Any OTHER value is malformed; the safe - // non-Claude default is "omit" (don't leak Claude aliases the runtime can't resolve). - withUserPath(() => { - for (const bad of [0, '', 'yes', {}]) { - seedDefaults({ runtime: 'codex', resolve_model_ids: bad }); - callFinishInstallForRuntime('codex'); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal( - after.resolve_model_ids, - 'omit', - `non-canonical resolve_model_ids:${JSON.stringify(bad)} must default to "omit", not pass through`, - ); - } - }); - }); - - test('already-"omit" is left unchanged (idempotent, no rewrite churn)', () => { - withUserPath(() => { - seedDefaults({ runtime: 'codex', resolve_model_ids: 'omit' }); - const beforeMtime = fs.statSync(DEFAULTS_PATH).mtimeMs; - // fs mtime resolution can be coarse; wait briefly so an accidental rewrite is detectable. - const start = Date.now(); - while (Date.now() - start < 20) { /* spin briefly */ } - callFinishInstallForRuntime('codex'); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - const afterMtime = fs.statSync(DEFAULTS_PATH).mtimeMs; - assert.equal(after.resolve_model_ids, 'omit'); - assert.equal( - afterMtime, - beforeMtime, - 'defaults.json must not be rewritten when resolve_model_ids is already "omit" (idempotent)', - ); - }); - }); - - test('claude runtime never touches resolve_model_ids (cross-runtime parity)', () => { - withUserPath(() => { - seedDefaults({ runtime: 'claude', resolve_model_ids: true }); - callFinishInstallForRuntime('claude'); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal( - after.resolve_model_ids, - true, - 'claude install must never rewrite resolve_model_ids', - ); - }); - }); - - test('malformed defaults.json does not crash — still defaults to "omit"', () => { - withUserPath(() => { - fs.mkdirSync(GSD_DIR, { recursive: true }); - fs.writeFileSync(DEFAULTS_PATH, '{ not valid json }', 'utf8'); - // Must not throw. - callFinishInstallForRuntime('codex'); - const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); - assert.equal( - after.resolve_model_ids, - 'omit', - 'malformed defaults.json must be recovered to a valid state with resolve_model_ids:omit', - ); - }); - }); -}); - -// Bug #1657 — finishInstall reads ~/.gsd/defaults.json with JSON.parse but did not -// validate the result is a plain object. A valid-JSON-but-non-object value (null, [], -// 42, "str") bypassed the catch and flowed through, leaving the malformed file on disk -// unrecovered (and, for null, throwing a TypeError swallowed by the outer try/catch). -// Folded into the owning install-defaults test (no new top-level bug-NNNN file). -describe('Bug #1657: finishInstall recovers a malformed (non-object) defaults.json', () => { - function seedDefaultsRaw(raw) { - fs.mkdirSync(GSD_DIR, { recursive: true }); - fs.writeFileSync(DEFAULTS_PATH, raw, 'utf8'); - } - function runAndRead(runtime) { - const saved = process.env.GSD_TEST_MODE; - delete process.env.GSD_TEST_MODE; - const log = console.log; console.log = () => {}; - let threw = null; - try { - installModule.finishInstall(SETTINGS_PATH, {}, null, false, runtime, true, null); - } catch (e) { threw = e.message; } finally { console.log = log; process.env.GSD_TEST_MODE = saved; } - let after = null; - try { after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); } catch (e) { after = 'UNPARSEABLE: ' + e.message; } - return { threw, after }; - } - - for (const [label, raw] of [['null', 'null'], ['array', '[]'], ['number', '42'], ['string', '"oops"']]) { - test(`seed ${label} (${raw}) recovers to a valid object with resolve_model_ids:omit`, () => { - seedDefaultsRaw(raw); - const { threw, after } = runAndRead('codex'); - assert.equal(threw, null, `must not throw for seed ${label} (got: ${threw})`); - assert.equal( - after !== null && typeof after === 'object' && !Array.isArray(after) && after.resolve_model_ids === 'omit', - true, - `seed ${label} must recover to { resolve_model_ids: 'omit' }, got: ${JSON.stringify(after)}`, - ); - }); - } -}); diff --git a/tests/bug-442-config-dir-equals-in-path.test.cjs b/tests/bug-442-config-dir-equals-in-path.test.cjs deleted file mode 100644 index 1bf000113..000000000 --- a/tests/bug-442-config-dir-equals-in-path.test.cjs +++ /dev/null @@ -1,99 +0,0 @@ -'use strict'; -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); - -// parseConfigDirArg is not exported directly from bin/install.js (it closes -// over the module-level `args` array). We expose a pure seam here: -// parseConfigDirFromArgs(args) that mirrors the function's logic so we can -// test the equals-form parsing without spawning a child process. -// -// The implementation under test is inlined below (RED: before the fix it will -// reproduce the truncation bug). Once the fix lands, we swap in the real -// implementation via require. - -/** - * Pure seam that replicates the equals-form parse logic from bin/install.js. - * We import it via a thin wrapper so that the function can be tested without - * executing the entire install script. - * - * During RED the bug is: `split('=')[1]` drops everything after the second `=`. - */ -const { parseConfigDirFromArgs } = require('../bin/install.js'); - -describe('bug-442: --config-dir= equals-form path parsing', () => { - // ── Happy-path: single = in path ───────────────────────────────────────── - test('--config-dir= with one = in value returns full value', () => { - const result = parseConfigDirFromArgs(['--config-dir=/tmp/gsd=a']); - assert.equal(result, '/tmp/gsd=a'); - }); - - // ── Happy-path: multiple = in path ─────────────────────────────────────── - test('--config-dir= with multiple = in value returns full value', () => { - const result = parseConfigDirFromArgs(['--config-dir=/tmp/a=b=c']); - assert.equal(result, '/tmp/a=b=c'); - }); - - // ── Short form -c= ──────────────────────────────────────────────────────── - test('-c= with = in value returns full value', () => { - const result = parseConfigDirFromArgs(['-c=/tmp/gsd=a']); - assert.equal(result, '/tmp/gsd=a'); - }); - - test('-c= with multiple = in value returns full value', () => { - const result = parseConfigDirFromArgs(['-c=/tmp/a=b=c']); - assert.equal(result, '/tmp/a=b=c'); - }); - - // ── Contract: empty value ───────────────────────────────────────────────── - // --config-dir= (no value after the =) → returns empty string ''. - // The caller (parseConfigDirArg) treats '' as missing and errors; the seam - // itself should faithfully return '' rather than null/undefined so the - // caller can make the error decision. - test('--config-dir= with no value returns empty string', () => { - const result = parseConfigDirFromArgs(['--config-dir=']); - assert.equal(result, ''); - }); - - test('-c= with no value returns empty string', () => { - const result = parseConfigDirFromArgs(['-c=']); - assert.equal(result, ''); - }); - - // ── Space-separated form is unaffected (regression guard) ───────────────── - test('--config-dir space-separated still returns the path', () => { - const result = parseConfigDirFromArgs(['--config-dir', '/tmp/gsd=a']); - assert.equal(result, '/tmp/gsd=a'); - }); - - test('-c space-separated still returns the path', () => { - const result = parseConfigDirFromArgs(['-c', '/tmp/gsd=a']); - assert.equal(result, '/tmp/gsd=a'); - }); - - // ── No config-dir flag → null ───────────────────────────────────────────── - test('returns null when no --config-dir flag is present', () => { - const result = parseConfigDirFromArgs(['--global', '--claude']); - assert.equal(result, null); - }); - - // ── Negative matrix (CLI edge cases) ───────────────────────────────────── - // Flag-looking value after space form: next arg starts with - → null (no - // valid value; the real function would process.exit but the seam returns null - // so tests stay in-process). - test('space form with next arg being a flag returns null (flag-looking value)', () => { - const result = parseConfigDirFromArgs(['--config-dir', '--other-flag']); - assert.equal(result, null); - }); - - // Equals form where value is a path with no = (plain path, no regression) - test('--config-dir= without any = in path still works', () => { - const result = parseConfigDirFromArgs(['--config-dir=/tmp/plain']); - assert.equal(result, '/tmp/plain'); - }); - - // Flag appears after other args (positional ordering should not matter) - test('--config-dir= flag after other args is parsed correctly', () => { - const result = parseConfigDirFromArgs(['--global', '--config-dir=/tmp/a=b', '--claude']); - assert.equal(result, '/tmp/a=b'); - }); -}); diff --git a/tests/bug-505-remove-dead-sdk-verification.test.cjs b/tests/bug-505-remove-dead-sdk-verification.test.cjs deleted file mode 100644 index b3a0df922..000000000 --- a/tests/bug-505-remove-dead-sdk-verification.test.cjs +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Regression guard for #505: dead SDK-shim verification subsystem removed. - * - * Post-ADR-0174 the `@opengsd/gsd-sdk` package was retired; `sdk/` no longer - * ships. `installSdkIfNeeded` and all functions it transitively called are - * dead code with no live callers. This test asserts: - * - * 1. All removed symbols are NO LONGER exported from bin/install.js. - * 2. The two live stale-standalone-SDK helpers (detectStaleStandaloneSdk, - * formatStaleStandaloneSdkWarning) are STILL exported as functions — they - * handle a real user-facing condition (#3406) and MUST NOT be removed. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); - -const inst = require('../bin/install.js'); - -describe('bug #505: dead SDK verification subsystem removed from bin/install.js', () => { - // ---------------------------------------------------------------- - // Dead symbols — must NOT be exported after removal - // ---------------------------------------------------------------- - const deadSymbols = [ - 'installSdkIfNeeded', - 'classifySdkInstall', - 'buildSdkFailFastReport', - 'renderSdkFailFastReport', - 'buildGsdSdkVersionMismatchReport', - 'renderGsdSdkVersionMismatchReport', - 'readGsdSdkVersion', - 'parseGsdSdkVersion', - 'findGsdSdkOnPath', - 'isGsdSdkOnPath', - 'isLegacyGsdSdkShim', - 'trySelfLinkGsdSdk', - 'trySelfLinkGsdSdkWindows', - 'filterNpxFromPath', - 'getUserShellPath', - 'getUserShellWindowsPersistentPath', - ]; - - for (const sym of deadSymbols) { - test(`dead symbol '${sym}' is not exported`, () => { - assert.equal( - typeof inst[sym], - 'undefined', - `'${sym}' should have been removed (post #505 dead-code removal) but is still exported as ${typeof inst[sym]}`, - ); - }); - } - - // ---------------------------------------------------------------- - // The stale-standalone-SDK helpers (detectStaleStandaloneSdk, - // formatStaleStandaloneSdkWarning) and the gsd-sdk shim contract surface - // (buildWindowsShimTriple, formatSdkPathDiagnostic) that #505 kept were - // removed when the gsd-sdk shim itself was retired (#191). Their absence is - // covered by the dead-symbol assertions above. - // ---------------------------------------------------------------- -}); diff --git a/tests/bug-570-codex-leak-scanner.test.cjs b/tests/bug-570-codex-leak-scanner.test.cjs deleted file mode 100644 index 74a38b76a..000000000 --- a/tests/bug-570-codex-leak-scanner.test.cjs +++ /dev/null @@ -1,142 +0,0 @@ -// allow-test-rule: source-text-is-the-product -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Regression tests for issue #570 — three related sub-bugs in the Codex leak - * scanner and supporting infrastructure. - * - * SUB-BUG A: scanForLeakedPaths recursively scans the entire targetDir, - * including pre-existing unrelated files that contain ~/.claude references. - * Fix: scan only files listed in gsd-file-manifest.json. - * - * SUB-BUG B: convertClaudeToCodexMarkdown replaces "~/.claude/" (with trailing - * slash) but NOT bare "~/.claude" (no slash). The scanner regex - * /(?:~|\$HOME)\/\.claude\b/ matches without trailing slash. - * Fix: add bare word-boundary replacement. - * - * SUB-BUG C: writeManifest checks file.endsWith('.md') for the agents/ - * directory. Codex installs .toml agent files, so they are invisible to the - * manifest and thus to any manifest-based scan fix. - * Fix: also check .toml. - */ - -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 { - install, - convertClaudeCommandToCodexSkill, -} = require('../bin/install.js'); -const { createTempDir, cleanup, captureConsole } = 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 prev = process.env.CODEX_HOME; - process.env.CODEX_HOME = codexHome; - try { - return fn(); - } finally { - if (prev == null) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = prev; - } -} - -describe('#570 — Codex leak scanner sub-bugs', { 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-570-'); - codexHome = path.join(tmpRoot, '.codex'); - fs.mkdirSync(codexHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpRoot); - }); - - // SUB-BUG B - test('convertClaudeToCodexMarkdown replaces bare ~/.claude (no trailing slash)', () => { - // convertClaudeToCodexMarkdown is not exported directly; exercise it via - // convertClaudeCommandToCodexSkill which calls it internally. - const input = 'configDir = ~/.claude\npath = ~/.claude/hooks/\ndir = $HOME/.claude'; - const out = convertClaudeCommandToCodexSkill(input, 'gsd-test'); - - assert.ok( - !/(?:~|\$HOME)\/\.claude\b/.test(out), - `Expected no leaked ~/.claude reference after conversion, got:\n${out}`, - ); - }); - - // SUB-BUG C - test('writeManifest includes .toml agent files for Codex', () => { - withCodexHome(codexHome, () => install(true, 'codex')); - - const agentsDir = path.join(codexHome, 'agents'); - // Not the shared listAgentFiles() helper: this reads the INSTALLED Codex - // dest dir and filters .toml (not source .md), so its semantics differ. - // Confirm that Codex actually wrote .toml agent files — if none exist the - // test is vacuous and we should fail loudly. - const tomlFiles = fs.existsSync(agentsDir) - ? fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-') && f.endsWith('.toml')) - : []; - assert.ok( - tomlFiles.length > 0, - `Precondition: Codex install must write at least one gsd-*.toml in agents/; found none in ${agentsDir}`, - ); - - const manifestPath = path.join(codexHome, 'gsd-file-manifest.json'); - assert.ok( - fs.existsSync(manifestPath), - `gsd-file-manifest.json must exist after install; not found at ${manifestPath}`, - ); - - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - const manifestKeys = Object.keys(manifest.files || {}); - - const tomlManifestKeys = manifestKeys.filter( - (k) => k.startsWith('agents/gsd-') && k.endsWith('.toml'), - ); - assert.ok( - tomlManifestKeys.length > 0, - `Expected at least one 'agents/gsd-*.toml' key in manifest.files, but found none.\n` + - `agents/ toml files on disk: ${tomlFiles.join(', ')}\n` + - `All manifest keys (agents/): ${manifestKeys.filter((k) => k.startsWith('agents/')).join(', ')}`, - ); - }); - - // SUB-BUG A - test('scanForLeakedPaths does not warn for pre-existing unrelated files in ~/.codex', () => { - // Write a pre-existing file with ~/.claude references BEFORE install. - const memoriesDir = path.join(codexHome, 'memories'); - fs.mkdirSync(memoriesDir, { recursive: true }); - const preExistingFile = path.join(memoriesDir, 'raw_memories.md'); - fs.writeFileSync( - preExistingFile, - '# Old memories\nI used to work in ~/.claude and $HOME/.claude regularly.\n', - ); - - let captured; - withCodexHome(codexHome, () => { - captured = captureConsole(() => install(true, 'codex')); - }); - - const combinedOutput = captured.stderr; - - assert.ok( - !combinedOutput.includes('memories/raw_memories.md'), - `scanForLeakedPaths must not warn about pre-existing unrelated file memories/raw_memories.md.\n` + - `Actual warnings:\n${combinedOutput}`, - ); - }); -}); diff --git a/tests/bug-704-codex-launcher-path-corruption.test.cjs b/tests/bug-704-codex-launcher-path-corruption.test.cjs deleted file mode 100644 index b39f3f564..000000000 --- a/tests/bug-704-codex-launcher-path-corruption.test.cjs +++ /dev/null @@ -1,418 +0,0 @@ -// allow-test-rule: source-text-is-the-product -'use strict'; - -/** - * Regression test for issue #704: - * "v1.3.1 global install ships literal $gsd-core launcher paths in workflows" - * - * ROOT CAUSE: `convertSlashCommandsToCodexSkillMentions` had a regex - * /(? { - test('convertClaudeCommandToCodexSkill does not corrupt ${VAR}/gsd-core/ or $(cmd)/gsd-* paths', () => { - // Minimal fixture with the launcher snippet and command-substitution patterns - // that were being corrupted (#704). - const input = [ - '---', - 'description: Test skill', - '---', - '', - '```bash', - '_GSD_SHIM_NAME="gsd-tools.cjs"', - '_GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"', - 'GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"', - 'if [ -f "$GSD_TOOLS" ]; then', - ' gsd_run() { node "$GSD_TOOLS" "$@"; }', - 'elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then', - ' GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', - ' gsd_run() { node "$GSD_TOOLS" "$@"; }', - 'elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then', - ' GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', - ' gsd_run() { node "$GSD_TOOLS" "$@"; }', - 'fi', - '# Command-substitution path form (reapply-patches pattern)', - 'candidate="$(expand_home "$KILO_CONFIG_DIR")/gsd-local-patches"', - '```', - ].join('\n'); - - const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704'); - - // Shell-context corruption patterns from issue #704: - // - `}$gsd-*` from shell variable expressions `${VAR}/gsd-*` - // - `)$gsd-*` from command-substitution paths `$(cmd)/gsd-*` - const shellCorruptionPatterns = [ - { pattern: '}' + BAD_TOKEN, description: 'shell-variable }$gsd-core' }, - { pattern: ')$gsd-local', description: 'command-substitution )$gsd-local-patches' }, - ]; - for (const { pattern, description } of shellCorruptionPatterns) { - assert.ok( - !output.includes(pattern), - `Codex skill conversion must not produce "${pattern}" (${description}). ` + - `Offending fragment: ${ - output.includes(pattern) - ? output.substring(output.indexOf(pattern) - 50, output.indexOf(pattern) + 80) - : '(not found)' - }`, - ); - } - - // The correct path forms must be preserved — the canonical launcher path - // (RUNTIME_ROOT_PATH) must survive Codex conversion intact. - assert.ok( - output.includes(RUNTIME_ROOT_PATH), - `Expected canonical launcher path "${RUNTIME_ROOT_PATH}" to appear in the converted output. ` + - `Got:\n${output.substring(0, 500)}`, - ); - assert.ok( - output.includes(')/gsd-local-patches'), - `Expected ")/gsd-local-patches" to appear in the converted output. ` + - `Got:\n${output.substring(0, 500)}`, - ); - }); - - test('convertClaudeCommandToCodexSkill preserves all shell path forms (}, ) closers)', () => { - // All these paths appear after a shell-closing character (} or )) and must - // NOT be converted to $gsd-* by the Codex slash-command converter. - const shellPaths = [ - // Shell variable expression forms (} closer) - { path: '"${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' }, - { path: '"${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' }, - { path: '"$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' }, - // Command-substitution forms () closer) — reapply-patches pattern - { path: 'candidate="$(expand_home "$KILO_CONFIG_DIR")/gsd-local-patches"', corruptedForm: ')$gsd-local' }, - { path: 'candidate="$(dirname "$(expand_home "$OPENCODE_CONFIG")")/gsd-local-patches"', corruptedForm: ')$gsd-local' }, - ]; - - for (const { path: p, corruptedForm } of shellPaths) { - const input = `---\ndescription: Test\n---\n\n\`\`\`bash\n${p}\n\`\`\``; - const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704-paths'); - assert.ok( - !output.includes(corruptedForm), - `Path "${p}" was corrupted to contain "${corruptedForm}" after Codex conversion.\n` + - `Got:\n${output}`, - ); - } - }); - - test('convertClaudeCommandToCodexSkill still converts legitimate /gsd- slash mentions', () => { - // Slash-command mentions (not preceded by }) should still be converted - const input = [ - '---', - 'description: Test', - '---', - '', - 'Use /gsd-discuss-phase to start a discussion.', - 'Or use /gsd-plan-phase for planning.', - 'Also: /gsd:capture --backlog adds items.', - ].join('\n'); - - const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704-cmds'); - - assert.ok( - output.includes('$gsd-discuss-phase'), - 'Expected /gsd-discuss-phase to be converted to $gsd-discuss-phase', - ); - assert.ok( - output.includes('$gsd-plan-phase'), - 'Expected /gsd-plan-phase to be converted to $gsd-plan-phase', - ); - assert.ok( - output.includes('$gsd-capture'), - 'Expected /gsd:capture to be converted to $gsd-capture', - ); - }); - - test('actual shipped workflow files: shell-variable launcher paths contain no $gsd-core', () => { - // Walk gsd-core/workflows/ and assert that no file produces $gsd-core - // inside a shell variable expansion context after Codex conversion. - // - // NOTE: The backtick-wrapped prose-path case (`/gsd-core/workflows/update.md`) - // was a pre-existing gap with the #704 lookbehind fix and is now addressed by - // the positive-boundary regex introduced in #712. That case is covered by the - // "#712" describe block below. - // - // We probe for the specific shell-context pattern from the issue report: - // BAD: ${_GSD_RUNTIME_ROOT}$gsd-core/bin/ - // GOOD: ${_GSD_RUNTIME_ROOT}/gsd-core/bin/ - const workflowsDir = path.join(__dirname, '..', 'gsd-core', 'workflows'); - if (!fs.existsSync(workflowsDir)) { - // If the directory doesn't exist, skip gracefully (non-standard layout) - return; - } - - const files = fs.readdirSync(workflowsDir) - .filter((f) => f.endsWith('.md')) - .map((f) => path.join(workflowsDir, f)); - - assert.ok(files.length > 0, 'Expected at least one workflow .md file'); - - // Shell-context corruption patterns from issue #704: - // - `}$gsd-*`: closing brace from `${VAR}/gsd-*` shell variable expressions - // - `)$gsd-*`: closing paren from `$(cmd)/gsd-*` command substitutions - const SHELL_CORRUPTION_RE = /[})](\$gsd-[a-z])/; - - const offending = []; - for (const file of files) { - const content = fs.readFileSync(file, 'utf8'); - const skillName = `gsd-${path.basename(file, '.md')}`; - const converted = convertClaudeCommandToCodexSkill(content, skillName); - const match = converted.match(SHELL_CORRUPTION_RE); - if (match) { - const idx = converted.indexOf(match[0]); - offending.push({ - file: path.relative(workflowsDir, file), - context: converted.substring(Math.max(0, idx - 40), idx + 80), - }); - } - } - - assert.deepStrictEqual( - offending, - [], - `Found shell-context path corruption ([})]$gsd-*) in Codex-converted workflow files (#704):\n` + - offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'), - ); - }); - - test('commands/gsd/*.md: shell-variable launcher paths contain no $gsd-core', () => { - // Walk commands/gsd/ and assert that no command file produces the shell-context - // }$gsd-core corruption — since commands also go through - // convertClaudeCommandToCodexSkill when installed globally for Codex. - const commandsDir = path.join(__dirname, '..', 'commands', 'gsd'); - if (!fs.existsSync(commandsDir)) return; - - const files = fs.readdirSync(commandsDir) - .filter((f) => f.endsWith('.md')) - .map((f) => path.join(commandsDir, f)); - - assert.ok(files.length > 0, 'Expected at least one command .md file'); - - const SHELL_CORRUPTION_RE = /[})](\$gsd-[a-z])/; - - const offending = []; - for (const file of files) { - const content = fs.readFileSync(file, 'utf8'); - const skillName = `gsd-${path.basename(file, '.md')}`; - const converted = convertClaudeCommandToCodexSkill(content, skillName); - const match = converted.match(SHELL_CORRUPTION_RE); - if (match) { - const idx = converted.indexOf(match[0]); - offending.push({ - file: path.relative(commandsDir, file), - context: converted.substring(Math.max(0, idx - 40), idx + 80), - }); - } - } - - assert.deepStrictEqual( - offending, - [], - `Found shell-context path corruption ([})]$gsd-*) in Codex-converted command files (#704):\n` + - offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'), - ); - }); -}); - -describe('#712: positive-boundary slash-command conversion', () => { - // Tests call convertSlashCommandsToCodexSkillMentions directly so the regex - // is exercised in isolation — no frontmatter wrapping, no ADAPTER_CLOSE - // stripping, no .claude→.codex rewrite masking the result. - - // ── MUST-NOT-CONVERT (negative) cases ───────────────────────────────────── - // These inputs must be returned UNCHANGED — no $gsd-* substitution. - - test('backtick-wrapped path: `/gsd-core/workflows/update.md` is NOT converted (THE new fix)', () => { - const input = 'See `/gsd-core/workflows/update.md` for details.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.strictEqual( - result, - input, - `Expected backtick-wrapped path to be unchanged. Got: ${result}`, - ); - }); - - test('backtick-wrapped path deeper: `/gsd-pi/bin/foo.cjs` is NOT converted', () => { - const input = 'Run `/gsd-pi/bin/foo.cjs` directly.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.strictEqual( - result, - input, - `Expected deep backtick-wrapped path to be unchanged. Got: ${result}`, - ); - }); - - test('shell var expansion: ${_GSD_RUNTIME_ROOT}/gsd-core/bin/x is NOT converted (regression guard)', () => { - const input = 'PATH="${_GSD_RUNTIME_ROOT}/gsd-core/bin/x"'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - !result.includes('$gsd-core'), - `Expected no $gsd-core substitution in shell var path. Got: ${result}`, - ); - assert.ok( - result.includes('/gsd-core/bin/x'), - `Expected original path to be preserved. Got: ${result}`, - ); - }); - - test('command substitution: $(expand_home ~/.claude)/gsd-local-patches is NOT converted (regression guard)', () => { - const input = 'candidate="$(expand_home ~/.claude)/gsd-local-patches"'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - !result.includes(')$gsd-local'), - `Expected no )$gsd-local substitution. Got: ${result}`, - ); - assert.ok( - result.includes(')/gsd-local-patches'), - `Expected original path to be preserved. Got: ${result}`, - ); - }); - - test('plain path segment: bin/gsd-tools.cjs is NOT converted', () => { - const input = 'node bin/gsd-tools.cjs --help'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.strictEqual( - result, - input, - `Expected plain path segment to be unchanged. Got: ${result}`, - ); - }); - - test('plain path segment: .claude/gsd-core/agents — /gsd-core portion is NOT slash-command converted', () => { - // Tests the regex in isolation: the .claude→.codex path rewrite that happens - // inside convertClaudeToCodexMarkdown does NOT run here. We assert directly - // that the slash-command regex leaves /gsd-core after the slash intact — - // i.e. the `e` in `/gsd-core` is NOT treated as a command boundary. - const input = 'Look in .claude/gsd-core/agents for the agent files.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - !result.includes('$gsd-core'), - `Expected no $gsd-core substitution in .claude/gsd-core path. Got: ${result}`, - ); - assert.ok( - result.includes('/gsd-core/agents'), - `Expected /gsd-core/agents to remain as a path segment. Got: ${result}`, - ); - }); - - // ── MUST-CONVERT (positive) cases ───────────────────────────────────────── - // These inputs contain legitimate /gsd- mentions that MUST be converted. - - test('space-preceded prose: Use /gsd-discuss-phase to start. → $gsd-discuss-phase', () => { - const input = 'Use /gsd-discuss-phase to start.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - result.includes('$gsd-discuss-phase'), - `Expected /gsd-discuss-phase to be converted. Got: ${result}`, - ); - assert.ok( - !result.includes('/gsd-discuss-phase'), - `Expected original /gsd-discuss-phase to be replaced. Got: ${result}`, - ); - }); - - test('backtick-WRAPPED MENTION (single segment): Run `/gsd-execute-phase` now → `$gsd-execute-phase`', () => { - // A backtick-wrapped COMMAND (single segment, no path continuation) MUST - // still be converted — this guards against a naive whitespace-only fix. - const input = 'Run `/gsd-execute-phase` now.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - result.includes('`$gsd-execute-phase`'), - `Expected backtick-wrapped command to be converted to \`$gsd-execute-phase\`. Got: ${result}`, - ); - assert.ok( - !result.includes('`/gsd-execute-phase`'), - `Expected original \`/gsd-execute-phase\` to be replaced. Got: ${result}`, - ); - }); - - test('parenthetical/backtick list like CONTEXT.md:59: (`/gsd-plan-phase`, `/gsd-progress`) → converted', () => { - const input = 'Available commands: (`/gsd-plan-phase`, `/gsd-progress`) — pick one.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - result.includes('`$gsd-plan-phase`'), - `Expected /gsd-plan-phase to be converted. Got: ${result}`, - ); - assert.ok( - result.includes('`$gsd-progress`'), - `Expected /gsd-progress to be converted. Got: ${result}`, - ); - }); - - test('start-of-string: /gsd-manager runs → $gsd-manager runs (exercises the ^ branch of lookbehind)', () => { - // This case is IMPOSSIBLE to test through the frontmatter-wrapping pipeline - // (the body always has preceding chars). Direct call exercises the ^ branch. - const input = '/gsd-manager runs the pipeline.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - result.includes('$gsd-manager'), - `Expected /gsd-manager to be converted. Got: ${result}`, - ); - assert.ok( - !result.includes('/gsd-manager'), - `Expected original /gsd-manager to be replaced. Got: ${result}`, - ); - }); - - test('double-quote wrapped: "/gsd-resume" → "$gsd-resume"', () => { - const input = 'Call "/gsd-resume" to continue.'; - const result = convertSlashCommandsToCodexSkillMentions(input); - assert.ok( - result.includes('"$gsd-resume"'), - `Expected "/gsd-resume" to be converted to "$gsd-resume". Got: ${result}`, - ); - assert.ok( - !result.includes('"/gsd-resume"'), - `Expected original "/gsd-resume" to be replaced. Got: ${result}`, - ); - }); - - // ── End-to-end: headline #712 bug through the real install pipeline ──────── - - test('end-to-end: backtick-wrapped path `/gsd-core/workflows/update.md` survives full Codex install pipeline', () => { - // Uses convertClaudeCommandToCodexSkill (same pattern as #704 tests above) - // to prove the real install path does not corrupt prose references to repo paths. - const input = [ - '---', - 'description: Test', - '---', - '', - 'See `/gsd-core/workflows/update.md` for the update workflow.', - ].join('\n'); - - const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-712-e2e'); - - assert.ok( - !output.includes('$gsd-core'), - `Expected no $gsd-core in converted output. Got:\n${output}`, - ); - assert.ok( - output.includes('/gsd-core/workflows/update.md'), - `Expected backtick-wrapped path to survive conversion. Got:\n${output}`, - ); - }); -}); diff --git a/tests/bug-782-cline-skills-emission.test.cjs b/tests/bug-782-cline-skills-emission.test.cjs deleted file mode 100644 index bd111ec7d..000000000 --- a/tests/bug-782-cline-skills-emission.test.cjs +++ /dev/null @@ -1,652 +0,0 @@ -'use strict'; -/** - * Regression tests for bug #782 — Cline skills emission. - * - * gsd now emits skills to ~/.cline/skills//SKILL.md for Cline >= v3.48. - * Skills discovery: https://docs.cline.bot/customization/skills - * - * (a) Converter unit test: convertClaudeCommandToClineSkill - * (b) Integration test: installRuntimeArtifacts for cline writes SKILL.md files - * (c) .clinerules/gsd.md still written by the install path (#787 dir form) - * (d) Idempotency: running install twice leaves skills + .clinerules/ intact - * (e) Full install() global: both skills AND .clinerules/gsd.md are written - */ - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); - -const { - convertClaudeCommandToClineSkill, - convertClaudeToCliineMarkdown, - install, - _applyRuntimeRewrites, -} = require('../bin/install.js'); - -const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); - -const { - resolveRuntimeArtifactLayout, -} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - -const { - loadSkillsManifest, - resolveProfile, -} = require('../gsd-core/bin/lib/install-profiles.cjs'); - -const { nestedSkillPath } = require('./helpers/nested-layout.cjs'); - -const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); -const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); -const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); - -// ─── (a) Converter unit test ───────────────────────────────────────────────── - -const SAMPLE_COMMAND = `--- -name: gsd:execute-phase -description: Execute all tasks in the current phase using Cline tools. -allowed-tools: - - Read - - Write - - Bash ---- - -## Objective - -Run all tasks in the current phase. - -See ~/.claude/skills/gsd-help/SKILL.md for reference. -Use \`/gsd-help\` or Claude Code for details. -`; - -// A command that exercises all three Claude-specific frontmatter fields that -// must NOT leak into the emitted Cline SKILL.md. -const RICH_COMMAND = `--- -name: gsd:validate-phase -description: Retroactively audit and fill Nyquist validation gaps for a completed phase -argument-hint: "[phase number]" -agent: researcher -allowed-tools: - - Read - - Write - - Edit - - Bash - - Glob - - Grep - - Agent - - AskUserQuestion ---- - -## Objective - -Audit Nyquist validation coverage. See ~/.claude/skills/gsd-help/SKILL.md for reference. -Use Claude Code for details. -`; - -/** - * Extract frontmatter block (between --- delimiters) from output. - * Returns the raw text between the first --- and the closing ---. - * Uses \r?\n to handle both LF and CRLF line endings (Windows parity). - */ -function parseFrontmatter(text) { - const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); - return m ? m[1] : null; -} - -describe('convertClaudeCommandToClineSkill — unit', () => { - test('emits frontmatter with name: gsd-', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - const nameMatch = result.match(/^name:\s*(.+)$/m); - assert.ok(nameMatch, 'frontmatter must contain name field'); - assert.ok(nameMatch[1].includes('gsd-execute-phase'), 'name must start with gsd-execute-phase'); - }); - - test('emits non-empty description in frontmatter', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'frontmatter must contain description field'); - assert.ok(descMatch[1].trim().length > 0, 'description must not be empty'); - }); - - test('body uses .cline/ paths not .claude/', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - // The body reference to ~/.claude/ should be rewritten to ~/.cline/ - assert.ok(!result.includes('~/.claude/skills'), 'body must not contain ~/.claude/skills'); - assert.ok(result.includes('.cline/skills'), 'body must contain .cline/skills'); - }); - - test('body replaces "Claude Code" with "Cline"', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - assert.ok(!result.includes('Claude Code'), 'Claude Code must be replaced with Cline'); - assert.ok(result.includes('Cline'), 'result must contain Cline branding'); - }); - - test('no stray .claude/ paths in frontmatter or body', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - // Should not contain .claude/ anywhere (except inside CLAUDE.md→.clinerules rewrites - // but those are already handled by convertClaudeToCliineMarkdown) - assert.ok(!result.includes('/.claude/'), 'no /.claude/ paths in output'); - }); - - // ── Fix 1 (code-review): frontmatter must be ONLY name + description ────── - - test('frontmatter emits ONLY name and description — no allowed-tools (SAMPLE_COMMAND)', () => { - const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); - const fm = parseFrontmatter(result); - assert.ok(fm !== null, 'result must have YAML frontmatter'); - assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools'); - assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint'); - assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:'); - }); - - test('frontmatter emits ONLY name and description — no allowed-tools/argument-hint/agent (RICH_COMMAND)', () => { - const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); - const fm = parseFrontmatter(result); - assert.ok(fm !== null, 'result must have YAML frontmatter'); - assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools'); - assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint'); - assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:'); - }); - - test('name == gsd-validate-phase for RICH_COMMAND', () => { - const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); - const nameMatch = result.match(/^name:\s*(.+)$/m); - assert.ok(nameMatch, 'must have name field'); - // yamlIdentifier may quote the value; strip surrounding quotes for comparison - const nameVal = nameMatch[1].replace(/^['"]|['"]$/g, '').trim(); - assert.strictEqual(nameVal, 'gsd-validate-phase', `name must be gsd-validate-phase, got: ${nameVal}`); - }); - - test('description is non-empty and <= 1024 chars for RICH_COMMAND', () => { - const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'must have description field'); - const desc = descMatch[1].replace(/^['"]|['"]$/g, '').trim(); - assert.ok(desc.length > 0, 'description must be non-empty'); - assert.ok(desc.length <= 1024, `description must be <= 1024 chars, got ${desc.length}`); - }); - - test('description truncated to <=1024 chars when source description is very long', () => { - const longDesc = 'A'.repeat(2000); - const longDescCommand = `---\nname: gsd:test\ndescription: ${longDesc}\n---\n\nBody text.\n`; - const result = convertClaudeCommandToClineSkill(longDescCommand, 'gsd-test'); - const descMatch = result.match(/^description:\s*'?(.*?)'?$/m); - assert.ok(descMatch, 'must have description field'); - // The raw description value (unquoted) should be <=1024 chars - // The result string after the --- block will have the quoted form; check raw length - // by checking the whole result doesn't have the full 2000-char string - assert.ok(!result.includes('A'.repeat(1025)), 'description must be truncated to 1024 chars'); - }); - - test('returns content unchanged when source has no frontmatter', () => { - const noFm = 'Just a body, no frontmatter here.\n'; - const result = convertClaudeCommandToClineSkill(noFm, 'gsd-test'); - assert.strictEqual(result, noFm, 'content without frontmatter must be returned unchanged'); - }); - - test('RICH_COMMAND body uses .cline/ paths and Cline branding', () => { - const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); - assert.ok(!result.includes('~/.claude/'), 'body must not contain ~/.claude/'); - assert.ok(result.includes('.cline/'), 'body must contain .cline/ paths'); - assert.ok(!result.includes('Claude Code'), 'body must not contain "Claude Code"'); - assert.ok(result.includes('Cline'), 'body must reference Cline'); - }); -}); - -// ─── (b) + (c) + (d) Integration tests ──────────────────────────────────────── - -describe('installRuntimeArtifacts — cline skills emission', () => { - test('cline global: writes gsd-prefixed skill dirs under skills/', (t) => { - const configDir = createTempDir('gsd-cline-skills-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const layout = resolveRuntimeArtifactLayout('cline', configDir, 'global'); - const skillsKind = layout.kinds.find(k => k.kind === 'skills'); - assert.ok(skillsKind, 'cline must have a skills kind after #782'); - - const skillsDir = path.join(configDir, skillsKind.destSubpath); - assert.ok(fs.existsSync(skillsDir), 'skills/ directory must be created'); - - const helpSkillDir = path.join(skillsDir, `${skillsKind.prefix}help`); - assert.ok( - fs.existsSync(path.join(helpSkillDir, 'SKILL.md')), - `gsd-help/SKILL.md must exist under ${skillsKind.destSubpath}/` - ); - }); - - test('cline global: SKILL.md has valid cline frontmatter (name + description)', (t) => { - const configDir = createTempDir('gsd-cline-fm-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const skillsDir = path.join(configDir, 'skills'); - const helpSkill = path.join(skillsDir, 'gsd-help', 'SKILL.md'); - assert.ok(fs.existsSync(helpSkill), 'gsd-help/SKILL.md must exist'); - - const content = fs.readFileSync(helpSkill, 'utf8'); - // Must have YAML frontmatter - assert.ok(content.startsWith('---'), 'SKILL.md must start with YAML frontmatter'); - assert.ok(content.includes('name:'), 'frontmatter must have name field'); - assert.ok(content.includes('description:'), 'frontmatter must have description field'); - // name must be gsd-help - const nameMatch = content.match(/^name:\s*(.+)$/m); - assert.ok(nameMatch, 'must have name field'); - assert.ok(nameMatch[1].includes('gsd-help'), `name must include gsd-help, got: ${nameMatch[1]}`); - }); - - test('cline global: SKILL.md uses .cline/ paths not .claude/', (t) => { - const configDir = createTempDir('gsd-cline-paths-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const skillsDir = path.join(configDir, 'skills'); - // Check all installed skill files for stray .claude/ references - const skills = fs.readdirSync(skillsDir).filter(n => n.startsWith('gsd-')); - assert.ok(skills.length > 0, 'at least one gsd- skill must be installed'); - - for (const skillName of skills) { - const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); - if (!fs.existsSync(skillFile)) continue; - const content = fs.readFileSync(skillFile, 'utf8'); - assert.ok( - !content.includes('~/.claude/'), - `${skillName}/SKILL.md must not contain ~/.claude/ — found stray path` - ); - assert.ok( - !content.includes('/.claude/'), - `${skillName}/SKILL.md must not contain /.claude/ — found stray path` - ); - } - }); - - test('cline global: skill count matches resolved profile', (t) => { - const configDir = createTempDir('gsd-cline-count-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const skillsDir = path.join(configDir, 'skills'); - const count = fs.readdirSync(skillsDir) - .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) - .length; - - if (RESOLVED_CORE.skills !== '*') { - assert.strictEqual(count, RESOLVED_CORE.skills.size, - `installed skill count (${count}) must match profile size (${RESOLVED_CORE.skills.size})`); - } else { - assert.ok(count > 0, 'must install at least 1 skill'); - } - }); -}); - -describe('installRuntimeArtifacts — cline idempotency', () => { - test('cline: running install twice leaves skills intact (idempotency)', (t) => { - const configDir = createTempDir('gsd-cline-idempotent-'); - t.after(() => cleanup(configDir)); - - // First install - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const skillsDir = path.join(configDir, 'skills'); - const countAfterFirst = fs.readdirSync(skillsDir) - .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) - .length; - - // Second install (upgrade over existing) - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); - - const countAfterSecond = fs.readdirSync(skillsDir) - .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) - .length; - - assert.strictEqual(countAfterFirst, countAfterSecond, - `skill count must be stable across installs: first=${countAfterFirst} second=${countAfterSecond}`); - }); -}); - -// ─── (e) Full install() global — coexistence regression ─────────────────────── -// -// Issue #782 explicitly requires that a global Cline install writes BOTH: -// - skills//SKILL.md (skills for Cline >= v3.48) -// - .clinerules/gsd.md (rules dir form introduced by #787) -// -// installRuntimeArtifacts() tests cover skills in isolation; this test exercises -// the FULL install() code path to ensure neither artifact is silently dropped. - -describe('install() global cline — coexistence: skills AND .clinerules', () => { - let tmpGlobalDir; - let originalClineConfigDir; - - beforeEach(() => { - originalClineConfigDir = process.env.CLINE_CONFIG_DIR; - tmpGlobalDir = createTempDir('gsd-cline-global-'); - // Redirect CLINE_CONFIG_DIR to the temp dir so install() never touches ~/.cline - process.env.CLINE_CONFIG_DIR = tmpGlobalDir; - }); - - afterEach(() => { - if (originalClineConfigDir !== undefined) { - process.env.CLINE_CONFIG_DIR = originalClineConfigDir; - } else { - delete process.env.CLINE_CONFIG_DIR; - } - cleanup(tmpGlobalDir); - }); - - test('global cline install writes at least one gsd-* SKILL.md under skills/', () => { - captureConsole(() => install(true, 'cline')); - - const skillsDir = path.join(tmpGlobalDir, 'skills'); - assert.ok( - fs.existsSync(skillsDir), - `skills/ directory must exist under ${tmpGlobalDir} after global cline install` - ); - - // full profile: gsd-help is nested under gsd-ns-manage/skills/help/SKILL.md - const helpSkillFile = nestedSkillPath(skillsDir, 'gsd-', 'help'); - assert.ok( - fs.existsSync(helpSkillFile), - `${path.relative(tmpGlobalDir, helpSkillFile)} must exist under ${tmpGlobalDir} — skills emission broken for global cline` - ); - }); - - test('global cline install writes .clinerules/gsd.md to the global config dir', () => { - captureConsole(() => install(true, 'cline')); - - // For a global Cline install, targetDir = getGlobalDir('cline') = CLINE_CONFIG_DIR. - // The cline-rules surface (#787) writes the .clinerules/ DIRECTORY form: - // .clinerules/gsd.md (rule file) - // .clinerules/hooks/PreToolUse (lifecycle hook) - const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md'); - assert.ok( - fs.existsSync(clinerulesMd), - `.clinerules/gsd.md must exist at ${clinerulesMd} — coexistence with skills broken for global cline (#782+#787)` - ); - }); - - test('global cline .clinerules/gsd.md contains GSD instructions', () => { - captureConsole(() => install(true, 'cline')); - - // #787 dir form: rule content lives in .clinerules/gsd.md, not a flat .clinerules file - const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md'); - assert.ok(fs.existsSync(clinerulesMd), '.clinerules/gsd.md must exist'); - const content = fs.readFileSync(clinerulesMd, 'utf8'); - assert.ok( - content.includes('GSD') || content.includes('gsd'), - '.clinerules/gsd.md must reference GSD' - ); - }); -}); - -// ─── Fix 3 regression: converter rewrites bare ~/.claude and CLAUDE_CONFIG_DIR ── -// -// convertClaudeToCliineMarkdown must also handle bare ~/.claude (no trailing -// slash) and the CLAUDE_CONFIG_DIR env-var name. surface.md contains these; -// the emitted Cline SKILL.md must contain no such stale Claude refs. - -describe('convertClaudeToCliineMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (Fix 3)', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - - test('no bare ~/.claude in converted surface.md', () => { - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToCliineMarkdown(raw); - // ~/.claude followed by a word-boundary (not a /) must be gone - assert.ok( - !/~\/\.claude\b/.test(result), - 'converted surface.md must not contain bare ~/.claude' - ); - }); - - test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToCliineMarkdown(raw); - assert.ok( - !result.includes('CLAUDE_CONFIG_DIR'), - 'converted surface.md must not contain CLAUDE_CONFIG_DIR' - ); - }); - - test('CLAUDE_CONFIG_DIR rewritten to CLINE_CONFIG_DIR', () => { - const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; - const result = convertClaudeToCliineMarkdown(input); - assert.ok(result.includes('CLINE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must become CLINE_CONFIG_DIR'); - assert.ok(!result.includes('CLAUDE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must be gone'); - }); - - test('bare ~/.claude rewritten to ~/.cline', () => { - const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; - const result = convertClaudeToCliineMarkdown(input); - assert.ok(!result.includes('~/.claude'), 'bare ~/.claude must be rewritten'); - assert.ok(result.includes('~/.cline'), 'must rewrite to ~/.cline'); - }); - - test('installRuntimeArtifacts cline global: gsd-surface SKILL.md has no bare ~/.claude or CLAUDE_CONFIG_DIR', (t) => { - const configDir = createTempDir('gsd-cline-surface-fix3-'); - t.after(() => cleanup(configDir)); - - const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest( - path.join(__dirname, '..', 'commands', 'gsd') - ); - const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({ - modes: ['full'], manifest: MANIFEST_FULL, - }); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL); - - // full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md - const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface'); - assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist for full profile`); - - const content = fs.readFileSync(surfaceSkill, 'utf8'); - assert.ok( - !/~\/\.claude\b/.test(content), - 'gsd-surface SKILL.md must not contain bare ~/.claude (Fix 3)' - ); - assert.ok( - !content.includes('CLAUDE_CONFIG_DIR'), - 'gsd-surface SKILL.md must not contain CLAUDE_CONFIG_DIR (Fix 3)' - ); - }); -}); - -// ─── Fix 1 regression: custom CLINE_CONFIG_DIR → embedded paths use custom dir ── -// -// _applyRuntimeRewrites for cline must rewrite ~/.cline/ → pathPrefix. -// For default global installs, pathPrefix = "$HOME/.cline/" (unchanged). -// For custom installs (CLINE_CONFIG_DIR=/custom), pathPrefix = "/custom/" and -// all embedded ~/.cline/ refs in SKILL.md must become /custom/... - -describe('_applyRuntimeRewrites — cline custom-dir embedded path (Fix 1)', () => { - test('default pathPrefix ($HOME/.cline/) leaves ~/.cline refs as $HOME/.cline', () => { - const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n'; - const result = _applyRuntimeRewrites(content, 'cline', '$HOME/.cline/'); - assert.ok(result.includes('$HOME/.cline/'), 'default prefix must map ~/.cline/ to $HOME/.cline/'); - assert.ok(!result.includes('~/.cline'), 'no tilde form should remain after rewrite'); - }); - - test('custom pathPrefix rewrites ~/.cline/ → custom path in SKILL.md body', () => { - const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n'; - const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/'); - assert.ok(result.includes('/custom/cline-dir/'), 'custom prefix must appear in output'); - assert.ok(!result.includes('~/.cline'), 'no tilde cline form should remain after custom rewrite'); - }); - - test('custom pathPrefix rewrites residual ~/.claude/ safety net', () => { - const content = 'Residual: ~/.claude/skills\n'; - const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/'); - assert.ok(result.includes('/custom/cline-dir/'), 'safety-net ~/.claude/ also rewritten to custom prefix'); - assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ should remain'); - }); - - test('installRuntimeArtifacts cline with CLINE_CONFIG_DIR custom: SKILL.md embeds custom path', (t) => { - const configDir = createTempDir('gsd-cline-custom-dir-'); - t.after(() => cleanup(configDir)); - - const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest( - path.join(__dirname, '..', 'commands', 'gsd') - ); - const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({ - modes: ['full'], manifest: MANIFEST_FULL, - }); - - installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL); - - // gsd-surface SKILL.md references config paths; with a custom configDir - // (not under $HOME), pathPrefix will be the absolute custom path. - // full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md - const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface'); - assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist`); - - const content = fs.readFileSync(surfaceSkill, 'utf8'); - // With a custom dir (path under /tmp, not ~/.cline), the output must NOT - // contain ~/.cline/ or $HOME/.cline/ — it must embed the actual configDir path. - assert.ok( - !content.includes('~/.cline/'), - `gsd-surface SKILL.md must not contain ~/.cline/ when configDir=${configDir} (Fix 1)` - ); - // The custom path must appear somewhere in the file - // (configDir is a /tmp/... path so pathPrefix = configDir+'/'). - // Production normalizes backslashes to forward slashes via - // path.resolve(configDir).replace(/\\/g, '/'), so compare against that - // form — otherwise this assertion fails on Windows where mkdtempSync - // returns a backslash path (e.g. C:\Users\...) but the emitted content - // already has forward slashes (C:/Users/...). - const expectedPath = path.resolve(configDir).replace(/\\/g, '/'); - assert.ok( - content.includes(expectedPath), - `gsd-surface SKILL.md must embed custom configDir path ${expectedPath} (Fix 1)` - ); - }); -}); - -// ─── Fix 4 regression: description truncation is code-point-aware ──────────── -// -// Naive UTF-16 slicing (`str.slice(0, 1021)`) can split a surrogate pair when -// the cut falls between the high and low surrogate of a multibyte character -// (e.g. emoji U+1F600, which is encoded as two UTF-16 code units). The fix -// uses Array.from() to split by code point, guaranteeing that the truncated -// value never contains a lone surrogate. - -describe('convertClaudeCommandToClineSkill — code-point-aware truncation (Fix 4)', () => { - /** - * Build a frontmatter+body command string whose description is: - * - exactly `prefixLen` ASCII chars - * - followed by `emojiCount` repetitions of '😀' (U+1F600, 2 UTF-16 units) - * - total UTF-16 length is prefixLen + emojiCount * 2 - */ - function makeEmojiCommand(prefixLen, emojiCount) { - const desc = 'A'.repeat(prefixLen) + '😀'.repeat(emojiCount); - return `---\nname: gsd:emoji-test\ndescription: ${desc}\n---\n\nBody.\n`; - } - - test('emitted description is <= 1024 code points when source overflows', () => { - // 1020 ASCII chars + 4 emoji = 1020 + 8 UTF-16 units = 1028 UTF-16 units > 1024. - // Code-point count = 1020 + 4 = 1024 — exactly at the boundary BEFORE adding '...'. - // After truncation to 1021 code points + '...' → 1024 code points total. - const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate - const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); - - // Extract raw description value (strip surrounding YAML quotes if present) - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'emitted SKILL.md must have a description field'); - const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); - - const codePoints = Array.from(rawDesc); - assert.ok( - codePoints.length <= 1024, - `emitted description must be <= 1024 code points, got ${codePoints.length}` - ); - }); - - test('emitted description ends with "..." when truncated', () => { - const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate - const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); - - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'emitted SKILL.md must have a description field'); - const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); - - assert.ok(rawDesc.endsWith('...'), `truncated description must end with "...", got: ${rawDesc.slice(-10)}`); - }); - - test('emitted description has no lone surrogate (no split emoji)', () => { - // Place emojis exactly at positions 1021–1025 (code points) so that a naive - // UTF-16 slice at 1021 code units would cut inside the second emoji's surrogate pair. - // 1019 ASCII chars + 6 emoji = 1025 code points (>1024, triggers truncation). - // UTF-16 length = 1019 + 12 = 1031. Naive slice(0,1021) yields 1019 ASCII + - // the HIGH surrogate of emoji[0] — a lone surrogate. - const cmd = makeEmojiCommand(1019, 6); - const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); - - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'emitted SKILL.md must have a description field'); - const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); - - // Verify no lone surrogate: every char's code point must be outside [0xD800, 0xDFFF]. - const hasLoneSurrogate = [...rawDesc].some(c => { - const cp = c.codePointAt(0); - return cp >= 0xD800 && cp <= 0xDFFF; - }); - assert.ok(!hasLoneSurrogate, 'emitted description must not contain a lone surrogate'); - - // Also round-trip through Buffer to confirm the string is valid UTF-8 encodable. - assert.doesNotThrow( - () => Buffer.from(rawDesc, 'utf8').toString('utf8'), - 'emitted description must round-trip through Buffer without error' - ); - }); - - test('short description (<= 1024 code points) is not truncated', () => { - // 10 ASCII + 5 emoji = 15 code points — well under the limit. - const cmd = makeEmojiCommand(10, 5); - const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); - - const descMatch = result.match(/^description:\s*(.+)$/m); - assert.ok(descMatch, 'emitted SKILL.md must have a description field'); - const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); - - assert.ok(!rawDesc.endsWith('...'), 'short description must NOT be truncated with "..."'); - // Must contain the original emoji characters intact - assert.ok(rawDesc.includes('😀'), 'short description must preserve emoji characters'); - }); -}); - -// ─── Fix 2 regression: cline local scope emits no skills ───────────────────── -// -// resolveRuntimeArtifactLayout('cline', dir, 'local') must return 0 kinds. -// installRuntimeArtifacts('cline', dir, 'local') must not write any skills. - -describe('resolveRuntimeArtifactLayout — cline scope-aware (Fix 2)', () => { - test('cline local: kinds.length === 0 (no skills for local scope)', () => { - const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'local'); - assert.strictEqual(layout.kinds.length, 0, 'cline local must have 0 kinds'); - }); - - test('cline global: kinds.length === 1 (skills kind)', () => { - const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'global'); - assert.strictEqual(layout.kinds.length, 1, 'cline global must have 1 skills kind'); - assert.strictEqual(layout.kinds[0].kind, 'skills'); - }); - - test('installRuntimeArtifacts cline local: no skills/ dir created', (t) => { - const configDir = createTempDir('gsd-cline-local-noskills-'); - t.after(() => cleanup(configDir)); - - assert.doesNotThrow(() => installRuntimeArtifacts('cline', configDir, 'local', RESOLVED_CORE)); - const skillsDir = path.join(configDir, 'skills'); - assert.ok( - !fs.existsSync(skillsDir), - `skills/ must NOT be created for cline local install (Fix 2), but found ${skillsDir}` - ); - }); -}); diff --git a/tests/bug-851-codex-quick-adapter-agent-type-fallback.test.cjs b/tests/bug-851-codex-quick-adapter-agent-type-fallback.test.cjs deleted file mode 100644 index 63d8456e2..000000000 --- a/tests/bug-851-codex-quick-adapter-agent-type-fallback.test.cjs +++ /dev/null @@ -1,215 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Tests assert on text in bin/install.js (Codex adapter header prose) — -// the adapter text IS the product loaded by Codex agents at runtime. - -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); -const src = fs.readFileSync(INSTALL_JS, 'utf8'); - -// Helper: extract Section C from the raw source text. -// Anchors on the heading and ends at . -function getSectionC() { - const headingIdx = src.indexOf('## C. Task() → spawn_agent Mapping'); - assert.ok(headingIdx >= 0, 'Section C heading must exist in bin/install.js'); - const closeTag = src.indexOf('', headingIdx); - assert.ok(closeTag >= 0, 'Section C must be followed by '); - return src.slice(headingIdx, closeTag); -} - -describe('bug #851: Codex adapter documents multi_agent_v1 schema limitation and fallback', () => { - - // (a) Schema-detection step: the adapter must require the agent to inspect - // spawn_agent's parameter schema BEFORE deciding how to dispatch. - test('(a) schema-detection: adapter requires inspecting spawn_agent schema before dispatching', () => { - const sectionC = getSectionC(); - - // Must name BOTH schema variants so the agent knows what to look for - assert.ok( - sectionC.includes('multi_agent_v1'), - 'Section C must name the multi_agent_v1 schema to identify the limited form', - ); - assert.ok( - sectionC.includes('multi_agent_v2') || sectionC.includes('agent_type-capable'), - 'Section C must name the typed schema (multi_agent_v2 or agent_type-capable) as the capable form', - ); - - // Must instruct schema inspection before spawning - assert.ok( - sectionC.includes('tool_search') || sectionC.includes('inspect') || sectionC.includes('schema'), - 'Section C must instruct the agent to inspect the spawn_agent schema (via tool_search or similar)', - ); - - // All three requirements together (AND): - assert.ok( - sectionC.includes('multi_agent_v1') && - (sectionC.includes('multi_agent_v2') || sectionC.includes('agent_type-capable')) && - (sectionC.includes('tool_search') || sectionC.includes('inspect') || sectionC.includes('schema')), - 'Section C must require schema-detection: name both schema variants AND instruct inspection before spawning', - ); - }); - - // (b) Active-config-root resolution: the TOML path must describe how to - // resolve the config root (honoring $CODEX_HOME / --config-dir / --local), - // not imply a single fixed path. - test('(b) active-config-root: fallback TOML path resolves the active Codex config root', () => { - const sectionC = getSectionC(); - - // Must mention the agents/.toml relative path - assert.ok( - sectionC.includes('agents/.toml'), - 'Section C must reference agents/.toml for the TOML extraction step', - ); - - // Must describe dynamic config-root resolution (at least two of the three - // override mechanisms, plus the word "config" to anchor context) - const mentionsCodexHome = sectionC.includes('$CODEX_HOME') || sectionC.includes('CODEX_HOME'); - const mentionsConfigDir = sectionC.includes('--config-dir') || sectionC.includes('config-dir'); - const mentionsLocal = sectionC.includes('--local') || sectionC.includes('.codex') || sectionC.includes('local'); - const mentionsConfigRoot = sectionC.includes('config root') || sectionC.includes('config.toml') || sectionC.includes('config directory'); - - assert.ok( - mentionsCodexHome, - 'Section C fallback must mention $CODEX_HOME for config-root resolution', - ); - assert.ok( - mentionsConfigDir, - 'Section C fallback must mention --config-dir for config-root resolution', - ); - assert.ok( - mentionsLocal, - 'Section C fallback must mention --local / .codex for config-root resolution', - ); - assert.ok( - mentionsConfigRoot, - 'Section C fallback must describe the concept of an active config root (config.toml or config root/directory)', - ); - - // AND: all four required elements together - assert.ok( - mentionsCodexHome && mentionsConfigDir && mentionsLocal && mentionsConfigRoot, - 'Section C fallback must describe active-config-root resolution: $CODEX_HOME + --config-dir + --local + config-root concept (AND logic)', - ); - - // Must NOT contain the literal ~/.codex/ (would be rewritten by _applyRuntimeRewrites - // and cause bug-3582 to diverge) - assert.ok( - !sectionC.includes('~/.codex/'), - 'Section C must NOT contain the literal ~/.codex/ substring (breaks bug-3582 materialization test)', - ); - }); - - // (c) "NOT equivalent" label: the workaround must be explicitly labeled as - // not equivalent to typed gsd-planner/gsd-executor execution. - test('(c) not-equivalent label: generic-agent workaround is labeled as NOT equivalent to typed dispatch', () => { - const sectionC = getSectionC(); - - // Must name at least one typed agent - const namesTypedAgent = - sectionC.includes('gsd-planner') || - sectionC.includes('gsd-executor') || - sectionC.includes('typed GSD agent') || - sectionC.includes('typed gsd-'); - - // Must contain explicit "not equivalent" / "NOT equivalent" / negation language - const hasNotEquivalent = - sectionC.toLowerCase().includes('not equivalent') || - sectionC.includes('NOT equivalent') || - sectionC.includes('is NOT possible'); - - // Must name the workaround as a workaround, not a first-class path - const hasWorkaroundLabel = - sectionC.includes('workaround') || - sectionC.includes('fallback'); - - assert.ok( - namesTypedAgent, - 'Section C must name at least one typed GSD agent (gsd-planner, gsd-executor, or "typed GSD agent")', - ); - assert.ok( - hasNotEquivalent, - 'Section C must contain explicit "not equivalent" / "NOT equivalent" language for the generic-agent path', - ); - assert.ok( - hasWorkaroundLabel, - 'Section C must label the generic-agent path as a workaround or fallback', - ); - - // AND: all three together - assert.ok( - namesTypedAgent && hasNotEquivalent && hasWorkaroundLabel, - 'Section C must AND: name a typed agent + label it NOT equivalent + call the generic path a workaround/fallback', - ); - }); - - // (d) Fail-closed rule: when typed dispatch is mandatory, the adapter must - // instruct the agent to fail closed and report the limitation, not silently degrade. - test('(d) fail-closed: adapter requires failing closed when typed dispatch is mandatory', () => { - const sectionC = getSectionC(); - - const hasFailClosed = - sectionC.includes('fail closed') || - sectionC.includes('fail-closed') || - sectionC.includes('fail_closed'); - - const hasReportLimitation = - sectionC.includes('schema limitation') || - sectionC.includes('report') || - sectionC.includes('not silently') || - sectionC.includes('silently degrading') || - sectionC.includes('silently'); - - const hasMandatoryContext = - sectionC.includes('mandatory') || - sectionC.includes('required') || - sectionC.includes('worktree isolation') || - sectionC.includes('isolation'); - - assert.ok( - hasFailClosed, - 'Section C must instruct fail-closed behavior (the phrase "fail closed" or equivalent)', - ); - assert.ok( - hasReportLimitation, - 'Section C must instruct reporting the schema limitation rather than silently degrading', - ); - assert.ok( - hasMandatoryContext, - 'Section C must identify a context where typed dispatch is mandatory (e.g. worktree isolation)', - ); - - // AND: all three together - assert.ok( - hasFailClosed && hasReportLimitation && hasMandatoryContext, - 'Section C must AND: instruct fail-closed + report limitation + identify mandatory-typed-dispatch contexts', - ); - }); - - // Regression guard: typed mapping for capable schema must still be present. - test('adapter still documents typed agent_type spawn for sessions that support it', () => { - const sectionC = getSectionC(); - - assert.ok( - sectionC.includes('agent_type-capable') || sectionC.includes('multi_agent_v2'), - 'Section C must still document the typed schema (agent_type-capable / multi_agent_v2)', - ); - assert.ok( - sectionC.includes('spawn_agent(agent_type=') || sectionC.includes('agent_type="X"'), - 'Section C must still show a typed spawn_agent(agent_type=...) example for capable sessions', - ); - }); - - // Regression guard: deferred tool discovery must remain (bug-279 contract). - test('adapter deferred tool discovery instruction is preserved', () => { - // The pre-existing bug-279 contract must remain intact - assert.ok( - src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), - 'Adapter must still instruct deferred tool discovery via tool_search before deciding to run inline', - ); - }); -}); diff --git a/tests/bug-977-fnm-multishell-path.test.cjs b/tests/bug-977-fnm-multishell-path.test.cjs deleted file mode 100644 index 65ea2331a..000000000 --- a/tests/bug-977-fnm-multishell-path.test.cjs +++ /dev/null @@ -1,190 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Bug #977: `resolveNodeRunner()` bakes an ephemeral fnm multishell shim path - * (e.g. `C:/Users/u/AppData/Local/fnm_multishells/_/node.exe`) into - * managed `.js` hook commands. fnm cleans up these per-shell-session directories - * when the shell exits, so the captured path later points at nothing — every - * managed hook fails to spawn until reinstall. - * - * Fix: when `normalizeNodePath` detects a path matching the fnm multishell - * directory pattern (`fnm_multishells//node(\.exe)?$`), it probes a stable - * alias path derived from `FNM_DIR` or `APPDATA` env vars (with injected - * `existsSync` for testability) and returns the first that exists. Falls back to - * the raw execPath if no stable alias is found. - * - * All assertions go against exported function return values — no source-grep. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); -const { normalizeNodePath, resolveNodeRunner } = INSTALL; - -// ─── Synthetic paths used across tests ─────────────────────────────────────── - -const EPHEMERAL_FNM_WIN = 'C:/Users/u/AppData/Local/fnm_multishells/15600_1781041703752/node.exe'; -const EPHEMERAL_FNM_WIN_BACKSLASH = 'C:\\Users\\u\\AppData\\Local\\fnm_multishells\\15600_1781041703752\\node.exe'; -const FNM_DIR_WIN = 'C:/Users/u/AppData/Roaming/fnm'; -const APPDATA_WIN = 'C:/Users/u/AppData/Roaming'; -const STABLE_FNM_DIR_NODE = `${FNM_DIR_WIN}/aliases/default/node.exe`; -const STABLE_APPDATA_NODE = `${APPDATA_WIN}/fnm/aliases/default/node.exe`; - -// ─── normalizeNodePath — fnm multishell ephemeral path → stable alias ──────── - -describe('Bug #977: normalizeNodePath — fnm multishell path with FNM_DIR → stable alias', () => { - test('forward-slash Windows ephemeral path + FNM_DIR set + alias exists → stable FNM_DIR alias', () => { - const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { - env: { FNM_DIR: FNM_DIR_WIN }, - existsSync: p => p === STABLE_FNM_DIR_NODE, - }); - assert.equal( - result, - STABLE_FNM_DIR_NODE, - `expected stable FNM_DIR alias, got: ${result}`, - ); - }); - - test('backslash Windows ephemeral path + FNM_DIR set + alias exists → stable FNM_DIR alias', () => { - const result = normalizeNodePath(EPHEMERAL_FNM_WIN_BACKSLASH, { - env: { FNM_DIR: FNM_DIR_WIN }, - existsSync: p => p === STABLE_FNM_DIR_NODE, - }); - assert.equal( - result, - STABLE_FNM_DIR_NODE, - `expected stable FNM_DIR alias, got: ${result}`, - ); - }); - - test('FNM_DIR alias does not exist → falls through to APPDATA alias → returns APPDATA alias', () => { - const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { - env: { FNM_DIR: FNM_DIR_WIN, APPDATA: APPDATA_WIN }, - existsSync: p => p === STABLE_APPDATA_NODE, // FNM_DIR alias absent, APPDATA alias present - }); - assert.equal( - result, - STABLE_APPDATA_NODE, - `expected stable APPDATA alias, got: ${result}`, - ); - }); - - test('no alias exists → returns raw execPath unchanged (graceful fallback)', () => { - const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { - env: { FNM_DIR: FNM_DIR_WIN, APPDATA: APPDATA_WIN }, - existsSync: () => false, // nothing exists - }); - assert.equal( - result, - EPHEMERAL_FNM_WIN, - `expected raw execPath fallback, got: ${result}`, - ); - }); - - test('no FNM_DIR or APPDATA in env → returns raw execPath unchanged', () => { - const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { - env: {}, - existsSync: () => false, - }); - assert.equal( - result, - EPHEMERAL_FNM_WIN, - `expected raw execPath fallback, got: ${result}`, - ); - }); -}); - -// ─── normalizeNodePath — non-fnm paths are NOT affected by the new branch ──── - -describe('Bug #977: normalizeNodePath — non-fnm paths are unaffected (no regression to existing behavior)', () => { - test('NVM path is unchanged', () => { - const nvm = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node'; - assert.equal(normalizeNodePath(nvm), nvm); - }); - - test('Intel Homebrew Cellar path still maps to stable symlink', () => { - assert.equal( - normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node'), - '/usr/local/bin/node', - ); - }); - - test('Apple Silicon Homebrew Cellar path still maps to stable symlink', () => { - assert.equal( - normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node'), - '/opt/homebrew/bin/node', - ); - }); - - test('regular Windows nodejs path is unchanged', () => { - const win = 'C:\\Program Files\\nodejs\\node.exe'; - assert.equal(normalizeNodePath(win), win); - }); - - test('empty string is returned as-is', () => { - assert.equal(normalizeNodePath(''), ''); - }); - - test('null is returned as-is', () => { - assert.equal(normalizeNodePath(null), null); - }); -}); - -// ─── normalizeNodePath — already-stable fnm alias path is not re-processed ─── - -describe('Bug #977: normalizeNodePath — already-stable fnm alias path passes through unchanged', () => { - test('stable FNM_DIR alias path is returned as-is', () => { - assert.equal( - normalizeNodePath(STABLE_FNM_DIR_NODE), - STABLE_FNM_DIR_NODE, - ); - }); -}); - -// ─── normalizeNodePath — false-positive guard: non-numeric id must NOT remap ── - -describe('Bug #977: normalizeNodePath — non-ephemeral fnm_multishells path is not remapped', () => { - test('non-numeric id segment (e.g. custom-dir) returns raw execPath unchanged even when alias exists', () => { - const nonEphemeral = 'C:/Users/u/AppData/Local/fnm_multishells/custom-dir/node.exe'; - const stableAlias = 'C:/Users/u/AppData/Roaming/fnm/aliases/default/node.exe'; - const result = normalizeNodePath(nonEphemeral, { - env: { FNM_DIR: 'C:/Users/u/AppData/Roaming/fnm' }, - // existsSync returns true for the alias to prove the regex — not the existsSync — is the guard - existsSync: p => p === stableAlias, - }); - assert.equal( - result, - nonEphemeral, - `expected raw execPath (non-ephemeral id must not be remapped), got: ${result}`, - ); - }); -}); - -// ─── resolveNodeRunner — opts pass-through ──────────────────────────────────── - -describe('Bug #977: resolveNodeRunner — passes opts through to normalizeNodePath', () => { - test('fnm multishell execPath is resolved to stable alias via injected opts', () => { - const orig = process.execPath; - try { - Object.defineProperty(process, 'execPath', { - value: EPHEMERAL_FNM_WIN, - configurable: true, - }); - const runner = resolveNodeRunner({ - env: { FNM_DIR: FNM_DIR_WIN }, - existsSync: p => p === STABLE_FNM_DIR_NODE, - }); - assert.equal( - runner, - JSON.stringify(STABLE_FNM_DIR_NODE), - `expected stable FNM_DIR alias quoted, got: ${runner}`, - ); - } finally { - Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); - } - }); -}); diff --git a/tests/bug-983-trae-windsurf-claude-path-leak.test.cjs b/tests/bug-983-trae-windsurf-claude-path-leak.test.cjs deleted file mode 100644 index 64bd4e2e0..000000000 --- a/tests/bug-983-trae-windsurf-claude-path-leak.test.cjs +++ /dev/null @@ -1,338 +0,0 @@ -// allow-test-rule: source-text-is-the-product -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Regression tests for issue #983 — Trae and Windsurf converters leak - * unreplaced bare `~/.claude` / `$HOME/.claude` references. - * - * Both converters rewrote only trailing-slash `.claude/` forms, so bare - * home-path references (configDir = ~/.claude, $HOME/.claude) survived - * conversion and pointed users at the wrong config dir. - * - * Fix: add bare word-boundary replacements mirroring Cline (#782) and - * Codex (#570) precedent, with a negative lookahead to preserve `.claude-plugin`. - */ - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const { - convertClaudeToWindsurfMarkdown, - convertClaudeToTraeMarkdown, - _applyRuntimeRewrites, -} = require('../bin/install.js'); - -// ─── Windsurf converter bare-form tests ───────────────────────────────────── - -describe('convertClaudeToWindsurfMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (#983)', () => { - test('bare ~/.claude rewritten to ~/.windsurf (#1615: workspace dir is now .windsurf)', () => { - const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; - const result = convertClaudeToWindsurfMarkdown(input); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - `bare ~/.claude must be rewritten; got: ${result}`, - ); - assert.ok(result.includes('~/.windsurf'), 'must rewrite to ~/.windsurf'); - }); - - test('$HOME/.claude rewritten to $HOME/.windsurf (#1615: workspace dir is now .windsurf)', () => { - const input = 'RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'; - const result = convertClaudeToWindsurfMarkdown(input); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - `bare $HOME/.claude must be rewritten; got: ${result}`, - ); - assert.ok(result.includes('$HOME/.windsurf'), 'must rewrite to $HOME/.windsurf'); - }); - - test('CLAUDE_CONFIG_DIR rewritten to WINDSURF_CONFIG_DIR', () => { - const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; - const result = convertClaudeToWindsurfMarkdown(input); - assert.ok( - result.includes('WINDSURF_CONFIG_DIR'), - 'CLAUDE_CONFIG_DIR must become WINDSURF_CONFIG_DIR', - ); - assert.ok( - !result.includes('CLAUDE_CONFIG_DIR'), - 'CLAUDE_CONFIG_DIR must be gone', - ); - }); - - test('.claude-plugin is NOT corrupted (preserved as-is)', () => { - const input = 'The .claude-plugin/plugin.json manifest enables plugin install.'; - const result = convertClaudeToWindsurfMarkdown(input); - assert.ok( - result.includes('.claude-plugin'), - `.claude-plugin must be preserved; got: ${result}`, - ); - assert.ok( - !result.includes('.windsurf-plugin'), - `.windsurf-plugin must not appear; got: ${result}`, - ); - }); - - test('no bare ~/.claude in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToWindsurfMarkdown(raw); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - 'converted surface.md must not contain bare ~/.claude', - ); - }); - - test('no $HOME/.claude in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToWindsurfMarkdown(raw); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - 'converted surface.md must not contain bare $HOME/.claude', - ); - }); - - test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToWindsurfMarkdown(raw); - assert.ok( - !result.includes('CLAUDE_CONFIG_DIR'), - 'converted surface.md must not contain CLAUDE_CONFIG_DIR', - ); - }); -}); - -// ─── Trae converter bare-form tests ───────────────────────────────────────── - -describe('convertClaudeToTraeMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (#983)', () => { - test('bare ~/.claude rewritten to ~/.trae', () => { - const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; - const result = convertClaudeToTraeMarkdown(input); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - `bare ~/.claude must be rewritten; got: ${result}`, - ); - assert.ok(result.includes('~/.trae'), 'must rewrite to ~/.trae'); - }); - - test('$HOME/.claude rewritten to $HOME/.trae', () => { - const input = 'RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'; - const result = convertClaudeToTraeMarkdown(input); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - `bare $HOME/.claude must be rewritten; got: ${result}`, - ); - assert.ok(result.includes('$HOME/.trae'), 'must rewrite to $HOME/.trae'); - }); - - test('CLAUDE_CONFIG_DIR rewritten to TRAE_CONFIG_DIR', () => { - const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; - const result = convertClaudeToTraeMarkdown(input); - assert.ok( - result.includes('TRAE_CONFIG_DIR'), - 'CLAUDE_CONFIG_DIR must become TRAE_CONFIG_DIR', - ); - assert.ok( - !result.includes('CLAUDE_CONFIG_DIR'), - 'CLAUDE_CONFIG_DIR must be gone', - ); - }); - - test('.claude-plugin is NOT corrupted (preserved as-is)', () => { - const input = 'The .claude-plugin/plugin.json manifest enables plugin install.'; - const result = convertClaudeToTraeMarkdown(input); - assert.ok( - result.includes('.claude-plugin'), - `.claude-plugin must be preserved; got: ${result}`, - ); - assert.ok( - !result.includes('.trae-plugin'), - `.trae-plugin must not appear; got: ${result}`, - ); - }); - - test('no bare ~/.claude in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToTraeMarkdown(raw); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - 'converted surface.md must not contain bare ~/.claude', - ); - }); - - test('no $HOME/.claude in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToTraeMarkdown(raw); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - 'converted surface.md must not contain bare $HOME/.claude', - ); - }); - - test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { - const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); - const raw = fs.readFileSync(surfacePath, 'utf8'); - const result = convertClaudeToTraeMarkdown(raw); - assert.ok( - !result.includes('CLAUDE_CONFIG_DIR'), - 'converted surface.md must not contain CLAUDE_CONFIG_DIR', - ); - }); -}); - -// ─── _applyRuntimeRewrites install-path tests (windsurf) ──────────────────── -// -// These tests exercise the ACTUAL install path that causes the user-facing leak. -// The converter functions are called at stage time to produce a Windsurf-branded -// copy, but _applyRuntimeRewrites is the path that runs at INSTALL time and -// rewrites any surviving ~/.claude / $HOME/.claude refs in the staged files. -// -// FAIL-BEFORE proof: prior to this PR, windsurf used /~\/\.claude\b/ which -// fires on "~/.claude-plugin" because \b matches between 'e' and '-'. Running -// the test below against the old regex (`\b`) would: -// - let bare $HOME/.claude survive (it used only /~\/\.claude\b/, missing $HOME form), AND -// - corrupt "~/.claude-plugin" → "~/.windsurf-plugin". -// Both assertions in the test below would fail on the old code. -// -// PASS-AFTER: the fix changes to (?![\w-]) so: -// - bare ~/.claude / $HOME/.claude (not followed by word-char or hyphen) → rewritten -// - ~/.claude-plugin preserved (the '-' after 'e' is in [\w-]) -// -// NOTE on pathPrefix choice: we use '~/.windsurf/' (a simple home-relative -// prefix) rather than '$HOME/.codeium/windsurf/' so that the corruption of -// '~/.claude-plugin' → '~/.windsurf-plugin' is directly detectable via -// result.includes('.windsurf-plugin'). -describe('_applyRuntimeRewrites(windsurf) — install-path bare-form + .claude-plugin (#983)', () => { - // Use ~/ prefix (local-style) so that the .windsurf-plugin corruption is - // directly detectable as a substring of the result. - const WINDSURF_PATH_PREFIX = '~/.windsurf/'; - - // Compound content: covers every form the fix must handle. - // IMPORTANT: we use ~/.claude-plugin (home-relative form) to exercise the - // corruption that the old \b regex caused. The \b fires between 'e' and '-', - // so ~/.claude-plugin → ~/.windsurf-plugin under the old code. That would - // break the preservation assertion below. The (?![\w-]) fix prevents this. - const COMPOUND_INPUT = [ - 'Config dir: ~/.claude', - 'Also: $HOME/.claude', - 'Slash form: ~/.claude/skills/foo.md', - 'Plugin installed at: ~/.claude-plugin/plugin.json', - 'Env var: CLAUDE_CONFIG_DIR', - ].join('\n'); - - test('bare ~/.claude rewritten to ~/.windsurf (no trailing slash)', () => { - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - `bare ~/.claude must be gone; got:\n${result}`, - ); - assert.ok( - result.includes('~/.windsurf'), - `must contain normalized pathPrefix; got:\n${result}`, - ); - }); - - test('bare $HOME/.claude rewritten to ~/.windsurf (install-path normalizes both home forms)', () => { - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - `bare $HOME/.claude must be gone; got:\n${result}`, - ); - }); - - test('zero surviving bare ~/.claude or $HOME/.claude refs in compound input', () => { - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - const bareClaudePattern = /(?:~|\$HOME)\/\.claude(?![\w-])/; - assert.ok( - !bareClaudePattern.test(result), - `no bare ~/.claude / $HOME/.claude must survive; got:\n${result}`, - ); - }); - - test('~/.claude-plugin is NOT corrupted to ~/.windsurf-plugin — was the \\b corruption', () => { - // FAIL-BEFORE: old /~\/\.claude\b/ rewrote ~/.claude-plugin → ~/.windsurf-plugin - // because \b fires between 'e' and '-'. - // PASS-AFTER: (?![\w-]) sees '-' and skips the match, preserving ~/.claude-plugin. - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - assert.ok( - result.includes('~/.claude-plugin'), - `~/.claude-plugin must be preserved; got:\n${result}`, - ); - assert.ok( - !result.includes('~/.windsurf-plugin'), - `~/.windsurf-plugin must NOT appear (was the \\b corruption); got:\n${result}`, - ); - }); - - test('slash form ~/.claude/ is also rewritten (pre-existing coverage)', () => { - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - assert.ok( - !result.includes('~/.claude/'), - `slash form ~/.claude/ must be gone; got:\n${result}`, - ); - }); - - test('CLAUDE_CONFIG_DIR is NOT rewritten by _applyRuntimeRewrites (converter responsibility)', () => { - // _applyRuntimeRewrites does NOT handle CLAUDE_CONFIG_DIR for windsurf; - // that rewrite is done by convertClaudeToWindsurfMarkdown at stage time. - // This test documents the boundary and guards against scope creep. - const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); - assert.ok( - result.includes('CLAUDE_CONFIG_DIR'), - 'CLAUDE_CONFIG_DIR is not rewritten by _applyRuntimeRewrites — that is converter scope', - ); - }); -}); - -// ─── _applyRuntimeRewrites install-path tests (trae) ──────────────────────── -// -// Trae had bare-form handling before this PR (via \b) and the converter uses -// (?![\w-]). The pre-existing \b in _applyRuntimeRewrites DOES corrupt -// .claude-plugin → .trae-plugin (known limitation, out of scope for #983). -// We document this here but do NOT assert preservation for trae, and we do NOT -// fix the pre-existing trae \b lines (that would be a separate concern). -// -// What we DO assert: trae bare ~/.claude / $HOME/.claude refs are rewritten -// (the install path cleans them), which is the core #983 fix for trae. -describe('_applyRuntimeRewrites(trae) — install-path bare-form (#983)', () => { - const TRAE_PATH_PREFIX = '$HOME/.trae/'; - - const TRAE_INPUT = [ - 'Config dir: ~/.claude', - 'Also: $HOME/.claude', - 'Slash form: ~/.claude/skills/foo.md', - // Note: .claude-plugin is intentionally omitted from assertions here because - // the pre-existing trae case uses \b which corrupts it (known limitation, - // out of scope for #983 — do not fix here). - ].join('\n'); - - test('bare ~/.claude rewritten to $HOME/.trae (trae install path)', () => { - const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); - assert.ok( - !/~\/\.claude(?![\w-])/.test(result), - `bare ~/.claude must be gone; got:\n${result}`, - ); - }); - - test('bare $HOME/.claude rewritten to $HOME/.trae (trae install path)', () => { - const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); - assert.ok( - !/\$HOME\/\.claude(?![\w-])/.test(result), - `bare $HOME/.claude must be gone; got:\n${result}`, - ); - }); - - test('slash form ~/.claude/ also rewritten (trae install path)', () => { - const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); - assert.ok( - !result.includes('~/.claude/'), - `slash form ~/.claude/ must be gone; got:\n${result}`, - ); - }); -}); diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index f87290bba..0d75f6ee4 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -2733,3 +2733,5522 @@ describe('cleanupCodexSkillMetadataSidecars (#1326)', () => { } }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2698-crlf-install.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2698-crlf-install (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #2698) +// Workflow .md / agent .md / command .md / reference .md files — their text +// IS what the runtime loads. Testing text content tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Regression test for #2698: CRLF line endings break agent-block strip regexes + * + * The legacy `gsd-update-check` hook migration in bin/install.js uses two + * separate .replace() calls: + * 1. LF-only regex: /\n# GSD Hooks\n\[\[hooks\]\]\nevent = ...\n/ + * 2. CRLF-only regex: /\r\n# GSD Hooks\r\n\[\[hooks\]\]\r\nevent = ...\r\n/ + * + * These patterns fail when config.toml has mixed line endings — e.g. the + * "# GSD Hooks" header uses LF but the body uses CRLF, or vice versa. This + * can happen when the file is created cross-platform (Windows/Linux), when + * editors convert only part of the file, or when a previous GSD version wrote + * the block with different EOL than the file's dominant EOL. + * + * Fix: consolidate to a single \r?\n-aware regex that handles LF, CRLF, and + * any mix in a single pass, making the migration robust regardless of the + * platform the file was last written on. + * + * Test approach: write a `.codex/config.toml` with a stale gsd-update-check + * block that uses mixed line endings (header in LF, body in CRLF), then run + * install() and assert the stale block is gone. + * + * Note: The local Codex install writes to `.codex/` in the current directory. + * Tests `process.chdir(tmpDir)` and write fixtures to `tmpDir/.codex/`. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, before, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +const { install, GSD_CODEX_MARKER } = require(INSTALL_SRC); +const { cleanup } = require('./helpers.cjs'); + +// Ensure hooks/dist/ is populated before install tests +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { + encoding: 'utf-8', + stdio: 'pipe', + }); +}); + +describe('#2698: CRLF stale gsd-update-check block is removed on Codex reinstall', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-crlf-install-2698-')); + }); + + afterEach(() => { + // Use the shared 5s Windows-EBUSY retry budget instead of inline 1s. + cleanup(tmpDir); + }); + + // Helper: pre-populate .codex/config.toml with a GSD marker + stale hooks block + // using the given line ending for the stale hooks block header, and a potentially + // different EOL for the hooks body. This exercises the cross-platform mixed scenario. +function writeCodexConfigWithStaleHooks(dir, headerEol, bodyEol) { + // Build the stale block with header EOL for the "# GSD Hooks" line, but body EOL + // for the content lines (simulates a file edited by two different platforms). + const staleBlock = [ + '# GSD Hooks', // line that starts the stale section + '[[hooks]]', + 'event = "SessionStart"', + 'command = "node /old/path/gsd-update-check.js"', + ].join(bodyEol); + + // Put the stale block in user content BEFORE the GSD marker. The GSD marker area + // will be regenerated by mergeCodexConfig during install(); the stale block in + // the user area is what the hooks migration must remove. + const content = [ + '[features]', + 'codex_hooks = true', + '', + ].join(headerEol) + headerEol + staleBlock + headerEol + headerEol + GSD_CODEX_MARKER + headerEol; + + const codexDir = path.join(dir, '.codex'); + fs.mkdirSync(codexDir, { recursive: true }); + const configPath = path.join(codexDir, 'config.toml'); + fs.writeFileSync(configPath, content, 'utf-8'); + return configPath; + } + + function readHooksSessionStartCommands(codexHome) { + const hooksPath = path.join(codexHome, 'hooks.json'); + if (!fs.existsSync(hooksPath)) return []; + const raw = fs.readFileSync(hooksPath, 'utf8').trim(); + if (!raw) return []; + const parsed = JSON.parse(raw); + const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) + ? parsed.hooks + : parsed; + const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; + return sessionStart.flatMap((entry) => [ + ...(typeof entry?.command === 'string' ? [entry.command] : []), + ...(Array.isArray(entry?.hooks) + ? entry.hooks.map((hook) => hook && hook.command).filter((cmd) => typeof cmd === 'string') + : []), + ]); + } + + test('LF config.toml: stale gsd-update-check block removed on reinstall', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + writeCodexConfigWithStaleHooks(tmpDir, '\n', '\n'); + install(false, 'codex'); + + const configPath = path.join(tmpDir, '.codex', 'config.toml'); + const content = fs.readFileSync(configPath, 'utf-8'); + + assert.ok( + !content.includes('gsd-update-check'), + 'Stale gsd-update-check entry must be removed from LF config.toml (#2698)' + ); + const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex')); + assert.equal( + hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')), + true, + 'New gsd-check-update hook must appear in hooks.json after reinstall' + ); + }); + + test('CRLF config.toml: stale gsd-update-check block removed on reinstall', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + writeCodexConfigWithStaleHooks(tmpDir, '\r\n', '\r\n'); + install(false, 'codex'); + + const configPath = path.join(tmpDir, '.codex', 'config.toml'); + const content = fs.readFileSync(configPath, 'utf-8'); + + assert.ok( + !content.includes('gsd-update-check'), + 'Stale gsd-update-check entry must be removed from CRLF config.toml (#2698)' + ); + const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex')); + assert.equal( + hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')), + true, + 'New gsd-check-update hook must appear in hooks.json after reinstall' + ); + }); + + test('mixed-EOL config.toml: stale block with LF header but CRLF body removed on reinstall', (t) => { + // This is the primary failure case: header line uses LF but the body uses CRLF. + // The old LF-only regex requires all-\n separators; the old CRLF-only regex requires + // all-\r\n separators. Neither matches a block with mixed endings, so the stale + // block survives reinstall with the old code (#2698). + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // headerEol='\n' (file dominant), bodyEol='\r\n' (hook block from another platform) + writeCodexConfigWithStaleHooks(tmpDir, '\n', '\r\n'); + install(false, 'codex'); + + const configPath = path.join(tmpDir, '.codex', 'config.toml'); + const content = fs.readFileSync(configPath, 'utf-8'); + + assert.ok( + !content.includes('gsd-update-check'), + [ + 'Stale gsd-update-check block with mixed LF/CRLF endings must be removed (#2698).', + 'Old code used two separate LF-only and CRLF-only regexes; neither matched mixed content.', + 'Fix consolidates to a single \\r?\\n-aware regex.', + ].join(' ') + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2760-codex-install-defensive.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2760-codex-install-defensive (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression: issue #2760 — Codex install path corrupts existing config.toml. + * + * Three defects, three fixes (defensive triple): + * + * Defect 3 (confirmed real) — Hooks AoT downgrade. When the user already has + * `[[hooks.SessionStart]]` (namespaced AoT) entries in their config, GSD + * used to append a `[[hooks]]` (top-level AoT) block that confuses + * round-trip writers and produces a config Codex refuses to load. + * Fix: detect the user's preferred shape and emit GSD's hook in the same + * namespaced form so both coexist cleanly. + * + * Defects 1+2 (defensive) — Strip-step robustness. Pre-existing legacy + * `[agents]` (single-bracket) and `[[agents]]` (sequence) blocks are + * invalid in current Codex schema and break Codex even though GSD now + * emits the correct `[agents.]` struct form. Fix: install-time + * stripping always purges these forms regardless of GSD marker presence + * so reinstall self-heals files where the marker was edited out or never + * existed (third-party tools). + * + * Fix 3 (defensive) — Post-write validation. Parse the bytes we are about + * to commit, assert they match Codex's expected schema (no bare/sequence + * `agents`, no bare `hooks.`); on failure, restore the pre-install + * backup and abort so the user never gets a broken Codex CLI. + */ + +// Scope GSD_TEST_MODE to module load only — restore prior value (or unset) so +// downstream tests in the same node process never see test-only behaviour +// leak through (#2760 CR4 finding 5). +const previousGsdTestMode = process.env.GSD_TEST_MODE; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { + install, + validateCodexConfigSchema, + hasUserNamespacedAotHooks, + parseTomlToObject, +} = require('../bin/install.js'); + +const { cleanup } = require('./helpers.cjs'); + +if (previousGsdTestMode === undefined) { + delete process.env.GSD_TEST_MODE; +} else { + process.env.GSD_TEST_MODE = previousGsdTestMode; +} + +function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) { + const previousCodeHome = process.env.CODEX_HOME; + const previousCwd = process.cwd(); + process.env.CODEX_HOME = codexHome; + try { + process.chdir(cwd); + return install(true, 'codex'); + } finally { + process.chdir(previousCwd); + if (previousCodeHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previousCodeHome; + } + } +} + +function readCodexConfig(codexHome) { + return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); +} + +function writeCodexConfig(codexHome, content) { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); +} + +function readCodexHooksJson(codexHome) { + const hooksPath = path.join(codexHome, 'hooks.json'); + if (!fs.existsSync(hooksPath)) return {}; + const raw = fs.readFileSync(hooksPath, 'utf8').trim(); + if (!raw) return {}; + return JSON.parse(raw); +} + +function readHooksSessionStartCommands(codexHome) { + const parsed = readCodexHooksJson(codexHome); + const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) + ? parsed.hooks + : parsed; + const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; + return sessionStart.flatMap((entry) => + (Array.isArray(entry?.hooks) ? entry.hooks : []) + .map((hook) => hook && hook.command) + .filter((cmd) => typeof cmd === 'string') + ); +} + +describe('#2760 defect 3 — Hooks AoT preservation across install/uninstall/reinstall', () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-d3-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('fresh install emits the two-level nested AoT schema (#2773)', () => { + // Codex 0.124.0+ requires [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]] + // with type = "command". Neither the flat [[hooks]] + event field form nor + // the single-block [[hooks.SessionStart]] form without .hooks is accepted. + writeCodexConfig(codexHome, ''); + runCodexInstall(codexHome); + const content = readCodexConfig(codexHome); + const parsed = parseTomlToObject(content); + + const sessionStartCommands = readHooksSessionStartCommands(codexHome); + const managed = sessionStartCommands.filter((cmd) => /gsd-check-update/.test(cmd)); + assert.equal(managed.length, 1, 'hooks.json must contain exactly one managed gsd-check-update command'); + assert.ok( + !parsed.hooks || !Array.isArray(parsed.hooks.SessionStart), + 'config.toml should not carry managed SessionStart hooks for GSD' + ); + }); + + test('preserves user [[hooks.SessionStart]] entries and registers managed GSD handler in hooks.json', () => { + // Users may have their own [[hooks.SessionStart]] entries using the new schema. + // GSD must append its own two-level block without disturbing theirs. + const userConfig = [ + '[[hooks.SessionStart]]', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "echo first user hook"', + '', + '[[hooks.SessionStart]]', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "echo second user hook"', + '', + ].join('\n'); + writeCodexConfig(codexHome, userConfig); + + runCodexInstall(codexHome); + const afterInstall = readCodexConfig(codexHome); + const parsed = parseTomlToObject(afterInstall); + + assert.ok( + parsed.hooks && Array.isArray(parsed.hooks.SessionStart), + 'hooks.SessionStart must remain an array-of-tables after install' + ); + + // Collect all handler commands across all event entries. + const allCommands = parsed.hooks.SessionStart.flatMap((entry) => + Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command) : [] + ); + + assert.ok( + allCommands.includes('echo first user hook'), + 'first user hook preserved: ' + JSON.stringify(allCommands) + ); + assert.ok( + allCommands.includes('echo second user hook'), + 'second user hook preserved: ' + JSON.stringify(allCommands) + ); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + assert.ok( + hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)), + 'GSD handler must appear in hooks.json SessionStart entries: ' + JSON.stringify(hooksJsonCommands) + ); + assert.ok(!Array.isArray(parsed.hooks), 'no flat [[hooks]] entries'); + }); + + test('reinstall replaces flat [[hooks]] + event form with nested schema', () => { + // Upgrade path: user has a config written by GSD 1.38.x (flat [[hooks]] form). + const legacyConfig = [ + '[features]', + 'codex_hooks = true', + '', + '# GSD Hooks', + '[[hooks]]', + 'event = "SessionStart"', + 'command = "node /old/path/to/gsd-check-update.js"', + '', + ].join('\n'); + writeCodexConfig(codexHome, legacyConfig); + + runCodexInstall(codexHome); + const content = readCodexConfig(codexHome); + const parsed = parseTomlToObject(content); + + // Old flat form must be gone. + assert.ok(!Array.isArray(parsed.hooks), 'flat [[hooks]] must be stripped on upgrade'); + // Only one GSD hook entry must exist (no duplication) in hooks.json. + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); + assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed handler after upgrade'); + }); + + test('reinstall replaces single-block [[hooks.SessionStart]] (no .hooks sub-table) with nested schema', () => { + // Upgrade path: user has a config written by the PR #2802 shape — + // [[hooks.SessionStart]] without a nested [[hooks.SessionStart.hooks]] sub-table. + const prBranchConfig = [ + '[features]', + 'codex_hooks = true', + '', + '# GSD Hooks', + '[[hooks.SessionStart]]', + 'command = "node /old/path/to/gsd-check-update.js"', + '', + ].join('\n'); + writeCodexConfig(codexHome, prBranchConfig); + + runCodexInstall(codexHome); + const content = readCodexConfig(codexHome); + parseTomlToObject(content); + + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); + assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed handler after upgrade from PR-#2802-shape'); + }); + + test('reinstall is idempotent: correct nested schema is stripped and re-emitted cleanly', () => { + writeCodexConfig(codexHome, ''); + runCodexInstall(codexHome); + runCodexInstall(codexHome); // second install + readCodexConfig(codexHome); + + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); + assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed SessionStart handler after double install'); + }); +}); + +describe('#2760 fix 2 — Strip purges invalid legacy [agents] / [[agents]] regardless of marker', () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f2-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('strips bare [agents] single-bracket block (no GSD marker, arbitrary user keys)', () => { + writeCodexConfig(codexHome, [ + '[agents]', + 'default = "custom-agent"', + 'extra_key = "value"', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + const content = readCodexConfig(codexHome); + const parsed = parseTomlToObject(content); + + // Bare [agents] would have left { default, extra_key } as scalar leaves + // on parsed.agents. After strip + struct emit, every key under agents + // must itself be a table (the gsd-* struct form). + assert.ok( + parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents), + 'agents must be a table-of-tables in parsed structure, got: ' + typeof parsed.agents + ); + assert.equal(parsed.agents.default, undefined, 'bare [agents] default key must be stripped'); + assert.equal(parsed.agents.extra_key, undefined, 'bare [agents] extra_key must be stripped'); + const gsdAgents = Object.keys(parsed.agents).filter((k) => k.startsWith('gsd-')); + assert.ok( + gsdAgents.length > 0 && gsdAgents.every((k) => typeof parsed.agents[k] === 'object'), + 'agents.gsd-* struct form must be present: ' + JSON.stringify(Object.keys(parsed.agents)) + ); + + // User's unrelated [model] section preserved structurally. + assert.ok( + parsed.model && parsed.model.name === 'o3', + 'unrelated user [model] section preserved with name = "o3", got: ' + JSON.stringify(parsed.model) + ); + }); + + test('strips [[agents]] sequence-form block without GSD marker (third-party / marker-edited-out)', () => { + writeCodexConfig(codexHome, [ + '[[agents]]', + 'name = "user-helper"', + 'description = "third-party agent"', + '', + '[[agents]]', + 'name = "another-helper"', + 'description = "second one"', + '', + '[projects."/tmp/x"]', + 'trust_level = "trusted"', + '', + ].join('\n')); + + runCodexInstall(codexHome); + const content = readCodexConfig(codexHome); + const parsed = parseTomlToObject(content); + + // [[agents]] sequence form would parse to Array — after strip it must be + // a table-of-tables with gsd-* struct keys. + assert.ok( + parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents), + 'agents must be a table-of-tables in parsed structure (sequence form must be stripped), got: ' + + (Array.isArray(parsed.agents) ? 'array' : typeof parsed.agents) + ); + const gsdAgents = Object.keys(parsed.agents).filter((k) => k.startsWith('gsd-')); + assert.ok( + gsdAgents.length > 0, + 'agents.gsd-* struct form must be present: ' + JSON.stringify(Object.keys(parsed.agents)) + ); + + // User's unrelated [projects."/tmp/x"] section preserved structurally. + assert.ok( + parsed.projects && parsed.projects['/tmp/x'] && parsed.projects['/tmp/x'].trust_level === 'trusted', + 'unrelated user [projects."/tmp/x"] section preserved with trust_level = "trusted", got: ' + + JSON.stringify(parsed.projects) + ); + }); +}); + +// concurrency: false — the third test mutates installModule.__codexSchemaValidator, +// a module-level test seam. Other tests in this file (and in bug-2153, etc.) +// also call runCodexInstall() and would observe the injected validator if +// node:test ran them in parallel. Serializing this describe block keeps the +// seam mutation invisible to siblings. +describe('#2760 fix 3 — Post-write Codex schema validation', { concurrency: false }, () => { + test('passes a clean config produced by GSD install', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f3a-')); + try { + const codexHome = path.join(tmpDir, 'codex-home'); + runCodexInstall(codexHome); + const content = readCodexConfig(codexHome); + const result = validateCodexConfigSchema(content); + assert.equal(result.ok, true, 'GSD-emitted config passes schema validation'); + } finally { + cleanup(tmpDir); + } + }); + + test('rejects bare [agents] and bare [hooks.SessionStart] in arbitrary content', () => { + const bareAgents = [ + '[agents]', + 'default = "x"', + '', + ].join('\n'); + const bareHooks = [ + '[hooks.SessionStart]', + 'command = "x"', + '', + ].join('\n'); + const sequenceAgents = [ + '[[agents]]', + 'name = "x"', + '', + ].join('\n'); + + assert.equal(validateCodexConfigSchema(bareAgents).ok, false, 'bare [agents] rejected'); + assert.equal(validateCodexConfigSchema(bareHooks).ok, false, 'bare [hooks.SessionStart] rejected'); + assert.equal(validateCodexConfigSchema(sequenceAgents).ok, false, '[[agents]] sequence rejected'); + }); + + test('aborts install and restores pre-install backup when post-write validation fails', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f3b-')); + const installModule = require('../bin/install.js'); + try { + const codexHome = path.join(tmpDir, 'codex-home'); + // Pre-install file the user wants protected. + const preInstall = [ + '# user file', + '[model]', + 'name = "o3"', + '', + ].join('\n'); + writeCodexConfig(codexHome, preInstall); + + // Force the post-write validator to fail via the documented test seam. + // This simulates the writer producing legacy-form output that Codex + // would reject — install MUST abort, restore the pre-install bytes, + // and surface a clear error. + installModule.__codexSchemaValidator = () => ({ + ok: false, + reason: 'simulated invalid output for test', + }); + + let threw = false; + try { + runCodexInstall(codexHome); + } catch (e) { + threw = true; + assert.match( + e.message, + /post-write Codex schema validation failed/, + 'thrown error names the validation failure' + ); + assert.match(e.message, /simulated invalid output for test/, 'thrown error includes reason'); + } + assert.equal(threw, true, 'install threw when validator failed'); + + const afterInstall = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + assert.equal( + afterInstall, + preInstall, + 'pre-install file restored verbatim after validation failure' + ); + } finally { + delete installModule.__codexSchemaValidator; + cleanup(tmpDir); + } + }); +}); + +describe('#2760 — hasUserNamespacedAotHooks helper', () => { + test('detects [[hooks.SessionStart]] AoT entries', () => { + const content = [ + '[[hooks.SessionStart]]', + 'command = "x"', + '', + ].join('\n'); + assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), true); + }); + + test('returns false when only top-level [[hooks]] entries exist', () => { + const content = [ + '[[hooks]]', + 'event = "SessionStart"', + 'command = "x"', + '', + ].join('\n'); + assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), false); + }); + + test('returns false when only single-bracket [hooks.SessionStart] exists', () => { + const content = [ + '[hooks.SessionStart]', + 'command = "x"', + '', + ].join('\n'); + assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), false); + }); +}); + +// concurrency: false — these tests monkey-patch fs.writeFileSync, a global +// shared with every other suite running in parallel. Serializing prevents +// stray writes from sibling tests landing in the stub. +describe('#2760 fix 4 — Write-failure rollback (atomic write + snapshot restore)', { concurrency: false }, () => { + let tmpDir; + let codexHome; + let originalWriteFileSync; + // #2760 CR5 finding 5 — symmetric snapshot/restore for fs.renameSync. The + // first test below monkey-patches renameSync; without a beforeEach/afterEach + // pair, only the local `finally` restores it, which is fragile to future + // edits that add early-return paths. + let originalRenameSync; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f4-')); + codexHome = path.join(tmpDir, 'codex-home'); + originalWriteFileSync = fs.writeFileSync; + originalRenameSync = fs.renameSync; + }); + + afterEach(() => { + fs.renameSync = originalRenameSync; + fs.writeFileSync = originalWriteFileSync; + cleanup(tmpDir); + }); + + test('pre-install config bytes survive when fs.renameSync throws over configPath', () => { + const preInstall = [ + '# user file', + '[model]', + 'name = "o3"', + '', + ].join('\n'); + writeCodexConfig(codexHome, preInstall); + + // After fs is restored we'll re-read the file. Capture the byte buffer + // exactly so the comparison is bit-for-bit. + const preInstallBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); + + const configPath = path.join(codexHome, 'config.toml'); + const tempPattern = new RegExp('^' + configPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.tmp-'); + + // Stub: allow writes to atomic temp files (which renameSync overwrites + // the target, never truncating it directly) but throw on any direct + // write to the canonical configPath. This simulates either: + // (a) an older code path doing a non-atomic write, or + // (b) a downstream module bypassing atomicWriteFileSync. + // Either way the snapshot must be restored. We let the temp write go + // through, then make renameSync throw to simulate the partial write + // never landing. + // #2760 CR5 finding 5 — fs.renameSync is restored by the suite-level + // afterEach; no local finally needed. + fs.renameSync = (src, dst) => { + if (dst === configPath) { + throw new Error('simulated rename failure mid-install'); + } + return originalRenameSync(src, dst); + }; + + let threw = false; + let thrownErr = null; + try { + runCodexInstall(codexHome); + } catch (e) { + threw = true; + thrownErr = e; + assert.ok(/rename failure|simulated|post-write/.test(e.message), + 'thrown error must surface the simulated failure or its post-write wrapper: ' + e.message); + } + // #2760 CR5 finding 4 — tighten contract per finding #1: ALL pre-write + // and write failures must be fatal. This test previously accepted either + // throw OR warn — sibling tests already require throw, so lock parity. + assert.equal(threw, true, 'rename failure must be fatal: ' + (thrownErr && thrownErr.message)); + + const afterBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); + assert.deepStrictEqual( + afterBytes, + preInstallBytes, + 'pre-install config.toml bytes must survive a mid-install write/rename failure' + ); + + // And the parsed structure of the surviving file must still be the + // user's [model] section, not a half-written GSD block. + const parsed = parseTomlToObject(afterBytes.toString('utf8')); + assert.equal(parsed.model && parsed.model.name, 'o3', + 'surviving file must still be the user pre-install content'); + assert.equal(parsed.agents, undefined, + 'no GSD agents block may have leaked into the surviving file'); + + // No stray .tmp-* siblings left behind in the codex home. + const stray = fs.readdirSync(codexHome).filter((f) => tempPattern.test(path.join(codexHome, f))); + assert.equal(stray.length, 0, + 'atomic write must clean up its temp file on failure: ' + stray.join(', ')); + }); + + test('pre-install config bytes survive when fs.writeFileSync throws on the .tmp- target', () => { + const preInstall = [ + '# user file', + '[model]', + 'name = "o3"', + '', + ].join('\n'); + writeCodexConfig(codexHome, preInstall); + + const preInstallBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); + const configPath = path.join(codexHome, 'config.toml'); + const tempPattern = new RegExp('^' + configPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.tmp-'); + + // Stub: fault writes targeting the atomic temp file (the pre-rename branch + // of atomicWriteFileSync). Other writes (agent .toml files in CODEX_HOME) + // pass through. This exercises the failure path where the temp write itself + // throws, not the rename — the case the prior test left untested. + // #2760 CR5 finding 5 — fs.writeFileSync is restored by the suite-level + // afterEach (via originalWriteFileSync); no local finally needed. + const captured = originalWriteFileSync; + fs.writeFileSync = function patchedWriteFileSync(target, data, options) { + if (typeof target === 'string' && tempPattern.test(target)) { + throw new Error('simulated writeFileSync failure on .tmp- target'); + } + return captured.call(this, target, data, options); + }; + + let threw = false; + try { + runCodexInstall(codexHome); + } catch (e) { + threw = true; + assert.ok(/simulated writeFileSync failure|post-write Codex install failed|pre-write/.test(e.message), + 'thrown error must surface the simulated failure or its post-write wrapper: ' + e.message); + } + // Per #2760 CR4 finding 1 / CR5 finding 1, write failures must abort install (not warn). + assert.equal(threw, true, 'install must throw when atomic temp-write fails'); + + const afterBytes = fs.readFileSync(path.join(codexHome, 'config.toml')); + assert.deepStrictEqual( + afterBytes, + preInstallBytes, + 'pre-install config.toml bytes must survive a temp-write failure' + ); + + const parsed = parseTomlToObject(afterBytes.toString('utf8')); + assert.equal(parsed.model && parsed.model.name, 'o3', + 'surviving file must still be the user pre-install content'); + assert.equal(parsed.agents, undefined, + 'no GSD agents block may have leaked into the surviving file'); + + const stray = fs.readdirSync(codexHome).filter((f) => tempPattern.test(path.join(codexHome, f))); + assert.equal(stray.length, 0, + 'atomic write must clean up its temp file on failure: ' + stray.join(', ')); + }); +}); + +// concurrency: false — these tests rely on the same install path and module- +// level pre-install snapshot that the fix-3/fix-4 suites exercise. Serializing +// keeps state mutations from leaking across parallel siblings. +describe('#2760 CR4 finding 2 — Legacy flat [[hooks]] block migrates to namespaced AoT on reinstall', { concurrency: false }, () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr4-f2-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('pre-install legacy flat [[hooks]] gsd-check-update + user namespaced [[hooks.SessionStart]] → post-install converges on namespaced AoT', () => { + // Reproduce the upgrade scenario: + // - User has [[hooks.SessionStart]] entry of their own (signal that GSD + // should emit in the namespaced shape). + // - A previous GSD install left the legacy flat [[hooks]] managed block + // for gsd-check-update. The pre-CR4 strip step would short-circuit + // the namespaced emit and leave the user stuck in the mixed layout. + const userPlusLegacy = [ + '[[hooks.SessionStart]]', + 'command = "echo user hook"', + '', + '# GSD Hooks', + '[[hooks]]', + 'event = "SessionStart"', + 'command = "node /old/path/hooks/gsd-check-update.js"', + '', + ].join('\n'); + writeCodexConfig(codexHome, userPlusLegacy); + + runCodexInstall(codexHome); + const afterInstall = readCodexConfig(codexHome); + const parsed = parseTomlToObject(afterInstall); + + // After CR4 finding 2: the legacy flat [[hooks]] managed block is stripped + // and the GSD entry is re-emitted in the namespaced AoT shape so the two + // forms do not coexist. + assert.ok( + parsed.hooks && Array.isArray(parsed.hooks.SessionStart), + 'hooks.SessionStart must be an array-of-tables, got: ' + + (parsed.hooks ? typeof parsed.hooks.SessionStart : 'no hooks table') + ); + + // Migration now handles stale [[hooks.SessionStart]] entries with handler + // fields at event-entry level (pre-#2773 shape), promoting them to the + // two-level nested form. Every entry must carry a .hooks sub-array after + // migration, so collect from nested handlers only. + assert.ok( + parsed.hooks.SessionStart.every((entry) => Array.isArray(entry.hooks)), + 'every hooks.SessionStart entry must use nested [[hooks.SessionStart.hooks]] handlers after migration' + ); + const allSessionStartCommands = parsed.hooks.SessionStart.flatMap((entry) => + entry.hooks.map((h) => h.command).filter(Boolean) + ); + assert.ok( + allSessionStartCommands.includes('echo user hook'), + 'user [[hooks.SessionStart]] entry preserved: ' + JSON.stringify(allSessionStartCommands) + ); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + assert.ok( + hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)), + 'GSD entry must appear in hooks.json SessionStart entries: ' + + JSON.stringify(hooksJsonCommands) + ); + + // The legacy top-level [[hooks]] AoT must NOT coexist with the namespaced + // form after migration. parseTomlToObject distinguishes via Array.isArray. + assert.ok( + !Array.isArray(parsed.hooks) || parsed.hooks.length === 0, + 'no top-level [[hooks]] AoT entries may remain after legacy migration: ' + + JSON.stringify(parsed.hooks) + ); + + // No duplicate gsd-check-update entries — exactly one managed entry. + const gsdEntries = hooksJsonCommands.filter((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)); + assert.equal(gsdEntries.length, 1, + 'exactly one gsd-check-update entry after migration, got: ' + gsdEntries.length); + }); +}); + +describe('#2760 CR4 finding 3 / #3245 — parseTomlToObject handles edge-case value types (floats accepted; dates/trailing-garbage rejected)', () => { + // #3245 inverts the float-rejection requirement: Codex CLI's serde schema + // requires f64 for tool_timeout_sec/startup_timeout_sec, so GSD's parser + // must now ACCEPT floats. The original guard (from #2760 CR4 finding 3) was + // "don't silently truncate 0.5 to integer 0" — that goal is still met + // because we parse the full float as a JS Number (not truncate to prefix). + test('accepts TOML floats (timeout = 0.5) — #3245 fix', () => { + const content = [ + '[server]', + 'timeout = 0.5', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.server.timeout, 0.5, + 'float values must be accepted as JS Number (not truncated to 0) — #3245'); + }); + + test('rejects date values (created = 1979-05-27)', () => { + const content = [ + '[meta]', + 'created = 1979-05-27', + '', + ].join('\n'); + assert.throws( + () => parseTomlToObject(content), + /unsupported TOML value|trailing bytes/, + 'date values must be rejected, not silently truncated' + ); + }); + + test('rejects trailing garbage after a string value (key = "x" junk)', () => { + const content = [ + '[section]', + 'key = "x" junk', + '', + ].join('\n'); + assert.throws( + () => parseTomlToObject(content), + /trailing bytes/, + 'trailing bytes after a complete value must be rejected' + ); + }); + + test('accepts trailing whitespace and # comment after a value', () => { + const content = [ + '[section]', + 'key = "x" # an inline comment', + 'flag = true', + 'count = 7 ', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.equal(parsed.section.key, 'x'); + assert.equal(parsed.section.flag, true); + assert.equal(parsed.section.count, 7); + }); +}); + +// concurrency: false — see the fix-3 suite above for the same rationale. +describe('#2760 CR4 finding 1 — atomicWriteFileSync failure aborts install (post-write fatal)', { concurrency: false }, () => { + let tmpDir; + let codexHome; + let originalRenameSync; + let originalConsoleLog; + let consoleOutput; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr4-f1-')); + codexHome = path.join(tmpDir, 'codex-home'); + originalRenameSync = fs.renameSync; + originalConsoleLog = console.log; + consoleOutput = []; + console.log = (...args) => { consoleOutput.push(args.join(' ')); }; + }); + + afterEach(() => { + fs.renameSync = originalRenameSync; + console.log = originalConsoleLog; + cleanup(tmpDir); + }); + + test('install throws and never prints "Done!" when atomicWriteFileSync fails on configPath', () => { + const preInstall = [ + '# user file', + '[model]', + 'name = "o3"', + '', + ].join('\n'); + writeCodexConfig(codexHome, preInstall); + + const configPath = path.join(codexHome, 'config.toml'); + // Only fault the hook-block atomic rename — earlier writes to config.toml + // happen via mergeCodexConfig (agent-block emit). We want to exercise the + // post-write Codex install branch specifically. Detect by reading the temp + // file's contents and only faulting when the hook block is present. + fs.renameSync = (src, dst) => { + if (dst === configPath) { + let isHookWrite = false; + try { + const data = fs.readFileSync(src, 'utf8'); + isHookWrite = /GSD codex_hooks ownership/.test(data); + } catch (_) { /* ignore */ } + if (isHookWrite) { + throw new Error('simulated rename failure'); + } + } + return originalRenameSync(src, dst); + }; + + let threw = false; + let thrownMessage = ''; + try { + runCodexInstall(codexHome); + } catch (e) { + threw = true; + thrownMessage = e.message; + } + + assert.equal(threw, true, 'install must throw when atomic write fails'); + assert.match( + thrownMessage, + /post-write Codex install failed/, + 'thrown error must use the post-write prefix so the outer catch treats it as fatal' + ); + + // Critical: install must NOT have printed any "Done!" success banner. + const printedDone = consoleOutput.some( + (line) => typeof line === 'string' && /Done!/i.test(line) + ); + assert.equal(printedDone, false, + 'install must NOT print "Done!" after a write failure: ' + JSON.stringify(consoleOutput.filter((l) => /Done|✓/.test(l)))); + + // And the user's pre-install bytes are intact (snapshot restore). + const after = fs.readFileSync(configPath, 'utf8'); + assert.equal(after, preInstall, 'pre-install bytes preserved after fatal abort'); + }); +}); + +// concurrency: false — patches module.exports.__codexSchemaValidator, a +// shared test seam. Serializing prevents stray patches from sibling tests. +describe('#2760 CR5 finding 1 — pre-write failures abort install (outer catch fatal)', { concurrency: false }, () => { + let tmpDir; + let codexHome; + let originalConsoleLog; + let consoleOutput; + const installModule = require('../bin/install.js'); + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr5-f1-')); + codexHome = path.join(tmpDir, 'codex-home'); + originalConsoleLog = console.log; + consoleOutput = []; + console.log = (...args) => { consoleOutput.push(args.join(' ')); }; + }); + + afterEach(() => { + console.log = originalConsoleLog; + delete installModule.__codexSchemaValidator; + cleanup(tmpDir); + }); + + test('pre-write throw (validator throws, not returns {ok:false}) is fatal and restores snapshot', () => { + // A validator that THROWS (vs returning {ok:false}) bypasses the + // validation branch and exits the inner try via the catch at the outer + // level. Pre-CR5, that catch downgraded to console.warn and let the + // install print "Done!" with no Codex hooks. Post-CR5 it must rethrow. + const preInstall = [ + '# user file', + '[model]', + 'name = "o3"', + '', + ].join('\n'); + writeCodexConfig(codexHome, preInstall); + + installModule.__codexSchemaValidator = () => { + throw new Error('synthetic validator-throw simulating a pre-write helper failure'); + }; + + let threw = false; + let thrownMsg = ''; + try { + runCodexInstall(codexHome); + } catch (e) { + threw = true; + thrownMsg = e.message; + } + + assert.equal(threw, true, + 'install must rethrow when a pre-write step throws (CR5 finding 1)'); + assert.match(thrownMsg, /pre-write|synthetic validator-throw/, + 'thrown error must surface the pre-write wrapper or original message: ' + thrownMsg); + + const printedDone = consoleOutput.some( + (line) => typeof line === 'string' && /Done!/i.test(line) + ); + assert.equal(printedDone, false, + 'install must NOT print "Done!" after a pre-write failure: ' + + JSON.stringify(consoleOutput.filter((l) => /Done|✓/.test(l)))); + + // Pre-install bytes intact (snapshot restored). + const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + assert.equal(after, preInstall, + 'pre-install bytes must survive a pre-write helper throw'); + }); +}); + +describe('#2760 CR5 finding 2 — parseTomlToObject rejects duplicate keys and shape-mismatched headers', () => { + test('rejects duplicate scalar key in same table ([a]\\nx=1\\nx=2)', () => { + const content = [ + '[a]', + 'x = 1', + 'x = 2', + '', + ].join('\n'); + assert.throws( + () => parseTomlToObject(content), + /duplicate key/, + 'real TOML 1.0 rejects duplicate keys in the same table' + ); + }); + + test('rejects duplicate scalar key in root table', () => { + const content = [ + 'x = 1', + 'x = 2', + '', + ].join('\n'); + assert.throws( + () => parseTomlToObject(content), + /duplicate key/, + 'duplicate root-table keys must be rejected' + ); + }); + + test('rejects re-declared [a] table header ([a] then [a] again)', () => { + const content = [ + '[a]', + 'x = 1', + '', + '[a]', + 'y = 2', + '', + ].join('\n'); + assert.throws( + () => parseTomlToObject(content), + /duplicate or shape-mismatched table header/, + 'real TOML 1.0 rejects re-declaring the same [a] header twice' + ); + }); + + test('rejects [[arr]] then [arr] for same path (array-of-tables → table)', () => { + const content = [ + '[[arr]]', + 'x = 1', + '', + '[arr]', + 'y = 2', + '', + ].join('\n'); + assert.throws( + () => parseTomlToObject(content), + /duplicate or shape-mismatched table header/, + 'cannot redeclare an array-of-tables path as a plain table' + ); + }); + + test('accepts repeated [[arr]] (genuine array-of-tables)', () => { + const content = [ + '[[arr]]', + 'x = 1', + '', + '[[arr]]', + 'x = 2', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.ok(Array.isArray(parsed.arr)); + assert.strictEqual(parsed.arr.length, 2); + assert.strictEqual(parsed.arr[0].x, 1); + assert.strictEqual(parsed.arr[1].x, 2); + }); + + test('accepts disjoint nested headers (not duplicates)', () => { + const content = [ + '[a.b]', + 'x = 1', + '', + '[a.c]', + 'y = 2', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.a.b.x, 1); + assert.strictEqual(parsed.a.c.y, 2); + }); +}); + +// concurrency: false — drives the same install pipeline as the other f-suites. +describe('#2760 CR5 finding 3 — migration emits namespaced AoT (no flat/namespaced mixing)', { concurrency: false }, () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr5-f3-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('user has [[hooks.AfterTool]] AND legacy [hooks.SessionStart] → post-install both namespaced, no flat AoT', () => { + // Reproduces the mixed-form scenario from finding 3: + // - User pre-config has both a namespaced AoT entry [[hooks.AfterTool]] + // AND a legacy single-bracket [hooks.SessionStart]. + // - Pre-CR5 migration converts the legacy section to flat [[hooks]] + // with event="SessionStart", leaving a mixed flat+namespaced layout. + // - Post-CR5 migration emits [[hooks.SessionStart]] directly so both + // of the user's hooks coexist in the namespaced shape, and the + // GSD-managed entry converges on namespaced too. + const userPlusLegacy = [ + '[[hooks.AfterTool]]', + 'command = "x"', + '', + '[hooks.SessionStart]', + 'command = "y"', + '', + ].join('\n'); + writeCodexConfig(codexHome, userPlusLegacy); + + runCodexInstall(codexHome); + const after = readCodexConfig(codexHome); + const parsed = parseTomlToObject(after); + + // The pre-existing [[hooks.AfterTool]] entry is preserved. + assert.ok( + parsed.hooks && Array.isArray(parsed.hooks.AfterTool), + 'pre-existing [[hooks.AfterTool]] must remain a namespaced AoT array' + ); + // AfterTool was in [[hooks.AfterTool]] with command at event-entry level + // (pre-#2773 stale namespaced AoT shape). Migration now promotes these to + // the two-level nested form, so every entry must have a .hooks sub-array. + assert.ok( + parsed.hooks.AfterTool.every((e) => Array.isArray(e.hooks)), + 'every AfterTool entry must use nested [[hooks.AfterTool.hooks]] handlers after migration' + ); + const afterToolCommands = parsed.hooks.AfterTool.flatMap((e) => + e.hooks.map((h) => h.command).filter(Boolean) + ); + assert.ok( + afterToolCommands.includes('x'), + 'user AfterTool entry must be preserved: ' + JSON.stringify(afterToolCommands) + ); + + // The migrated SessionStart entry is now namespaced AoT with nested .hooks sub-table. + assert.ok( + parsed.hooks && Array.isArray(parsed.hooks.SessionStart), + 'migrated SessionStart must be namespaced AoT (not flat [[hooks]])' + ); + // After migration, [hooks.SessionStart] map-format is promoted to nested AoT. + // Command lives in [[hooks.SessionStart.hooks]][0].command (nested schema). + assert.ok( + parsed.hooks.SessionStart.every((e) => Array.isArray(e.hooks)), + 'every SessionStart entry must use nested [[hooks.SessionStart.hooks]] handlers after migration' + ); + const ssCommands = parsed.hooks.SessionStart.flatMap((e) => + e.hooks.map((h) => h.command).filter(Boolean) + ); + assert.ok( + ssCommands.includes('y'), + 'user SessionStart command "y" must be preserved in namespaced array: ' + + JSON.stringify(ssCommands) + ); + // GSD's managed gsd-check-update entry also lives in the namespaced array. + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + assert.ok( + hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)), + 'managed gsd-check-update entry must appear in hooks.json SessionStart entries: ' + + JSON.stringify(hooksJsonCommands) + ); + + // No flat top-level [[hooks]] AoT may remain. + assert.ok( + !Array.isArray(parsed.hooks) || parsed.hooks.length === 0, + 'no flat top-level [[hooks]] AoT entries may remain after migration: ' + + JSON.stringify(parsed.hooks) + ); + + // No synthetic event field on the migrated SessionStart entries — the + // namespace IS the event. + for (const entry of parsed.hooks.SessionStart) { + assert.equal(entry.event, undefined, + 'no synthetic event field — namespace [[hooks.SessionStart]] encodes the event: ' + + JSON.stringify(entry)); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-279-codex-agent-mapping.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-279-codex-agent-mapping (consolidation epic #1969 B1 #1970)", () => { +'use strict'; +// allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); +const src = fs.readFileSync(INSTALL_JS, 'utf8'); + +describe('bug #279: Codex adapter documents Agent() and deferred tool discovery', () => { + test('adapter mapping section includes explicit Agent(...) -> spawn_agent mapping', () => { + assert.ok( + /Task\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src) && + /Agent\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src), + 'Codex adapter must explicitly map both Task(...) and Agent(...) to spawn_agent', + ); + }); + + test('adapter includes deferred tool_search discovery guidance before inline fallback', () => { + assert.ok( + src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), + 'Codex adapter must instruct deferred tool discovery via tool_search before deciding to run inline', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3017-codex-hook-absolute-node.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3017-codex-hook-absolute-node (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #3017: Codex SessionStart hook still emits bare `node` after #3002. + * + * PR #3002 fixed #2979 for settings.json-based managed JS hooks (Claude + * Code, Gemini, Antigravity) by routing through buildHookCommand() → + * resolveNodeRunner(), which emits the absolute Node binary path. But the + * Codex install path writes its SessionStart hook directly into a + * config.toml string, bypassing both helpers: + * + * command = "node ${updateCheckScript}" + * + * Under a GUI/minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) where node + * is not resolvable, the hook fails with `/bin/sh: node: command not + * found` (exit 127). The same failure mode #2979 was meant to fix — + * just on the codex toml branch instead of the settings.json branch. + * + * The fix exposes two pure helpers and tests them as typed records, + * not by grepping install.js content: + * + * buildCodexHookBlock(targetDir, { absoluteRunner }) → toml string + * - emits `command = " "` so the + * hook resolves under minimal PATH. + * - returns null when absoluteRunner is null (caller skips with warn, + * matching settings.json branch behavior). + * + * rewriteLegacyCodexHookBlock(tomlContent, absoluteRunner) → { content, changed } + * - rewrites an existing bare-node managed-hook command on reinstall + * (matches the rewriteLegacyManagedNodeHookCommands shape from #3002). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); +const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs')); +const { buildCodexHookBlock, rewriteLegacyCodexHookBlock, resolveNodeRunner } = INSTALL; +const { projectCodexHookTomlCommand } = projection; + +/** + * Parse the toml hook block into a typed record so tests can assert on + * the structured shape (what's the runner, what's the hook path, what's + * the type) rather than substring-matching the toml text. + */ +function parseCodexHookBlock(block) { + if (!block) return { ok: false, reason: 'empty' }; + // The block always carries the "# GSD Hooks" marker, the AoT tables, + // a type=command, and a command=" " line. + const hasMarker = /^# GSD Hooks$/m.test(block); + const hasEvent = /^\[\[hooks\.SessionStart\]\]$/m.test(block); + const hasHandler = /^\[\[hooks\.SessionStart\.hooks\]\]$/m.test(block); + const typeMatch = block.match(/^type\s*=\s*"([^"]+)"$/m); + // command = " " — runner may itself be a quoted absolute path. + // Match the whole RHS as one toml double-quoted string, then split into runner + hookpath. + const cmdLine = block.match(/^command\s*=\s*"((?:[^"\\]|\\.)*)"$/m); + if (!cmdLine) return { ok: false, reason: 'no command line' }; + const cmdValue = cmdLine[1]; + // Inside the command value, the runner is either a quoted string (escaped \" in toml) + // or a bare token, followed by a space and the hook path (quoted). + // toml escapes interior " as \", so the cmdValue contains literal \" sequences. + const cmdParsed = cmdValue.match(/^(\\".+?\\"|node|bash|\S+)\s+\\"([^\\]+)\\"\s*$/); + return { + ok: true, + hasMarker, + hasEvent, + hasHandler, + type: typeMatch ? typeMatch[1] : null, + command: cmdValue, + runner: cmdParsed ? cmdParsed[1] : null, + hookPath: cmdParsed ? cmdParsed[2] : null, + }; +} + +// Strip the toml-escape (\") and JSON-quote (") layers from the parsed +// runner token to compare against the raw absolute path the caller +// supplied. parsed.runner round-trips through TWO escape layers: +// 1. JSON.stringify in resolveNodeRunner adds outer "..." quotes +// 2. toml escapes the interior " to \" inside the command field +// After both, parsed.runner ends in `\"` and starts with `\"`. +function unescapeRunner(token) { + if (!token) return token; + let t = token.replace(/^\\"/, '').replace(/\\"$/, ''); + if (t.startsWith('"') && t.endsWith('"')) t = t.slice(1, -1); + return t; +} + +describe('Bug #3017 / #3440: Codex hook projection seam', () => { + test('projectCodexHookTomlCommand renders escaped command value from shared projection module', () => { + const commandValue = projectCodexHookTomlCommand({ + absoluteRunner: '"/usr/local/bin/node"', + scriptPath: '/tmp/codex-test/.codex/hooks/gsd-check-update.js', + platform: 'linux', + }); + assert.equal( + commandValue, + '\\"/usr/local/bin/node\\" \\"/tmp/codex-test/.codex/hooks/gsd-check-update.js\\"', + ); + }); +}); + +describe('Bug #3017: buildCodexHookBlock emits absolute node runner', () => { + test('exported as a function', () => { + assert.equal(typeof buildCodexHookBlock, 'function'); + }); + + test('emits the EXACT absolute node runner the caller supplied (#3022 CR)', () => { + const targetDir = '/tmp/codex-test/.codex'; + const expectedRunnerPath = '/usr/local/bin/node'; + const absoluteRunner = `"${expectedRunnerPath}"`; + const block = buildCodexHookBlock(targetDir, { absoluteRunner }); + const parsed = parseCodexHookBlock(block); + assert.equal(parsed.ok, true, `parse failed: ${block}`); + assert.equal(parsed.hasMarker, true, '# GSD Hooks marker present'); + assert.equal(parsed.hasEvent, true, '[[hooks.SessionStart]] AoT entry present'); + assert.equal(parsed.hasHandler, true, '[[hooks.SessionStart.hooks]] handler entry present'); + assert.equal(parsed.type, 'command', 'handler is type=command'); + // Strict: parsed runner must match the supplied absolute path EXACTLY + // (after stripping toml/JSON escape layers). A loose substring like + // '/node' would let an unrelated absolute token containing '/node' + // pass — e.g. '/Users/x/notnode/foo'. + assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath, + `parsed runner must equal supplied absolute path: got ${parsed.runner}, want ${expectedRunnerPath}`); + // On Windows, path.resolve prepends the current drive letter ("D:") to + // the POSIX-shaped fixture path. Accept either form. + const expectedHookSuffix = '/tmp/codex-test/.codex/hooks/gsd-check-update.js'; + assert.ok( + parsed.hookPath === expectedHookSuffix || + parsed.hookPath.replace(/^[A-Za-z]:/, '') === expectedHookSuffix, + `hook path equality, got: ${parsed.hookPath}, want suffix: ${expectedHookSuffix}`, + ); + }); + + test('returns null when absoluteRunner is null (caller skips registration)', () => { + const block = buildCodexHookBlock('/tmp/x/.codex', { absoluteRunner: null }); + assert.equal(block, null, + 'must return null on missing runner so caller can warn-and-skip instead of writing a broken hook'); + }); + + test('integrates with resolveNodeRunner() in the live process — runner equals resolved node runner (#3022 CR)', () => { + const runner = resolveNodeRunner(); + assert.ok(runner, 'resolveNodeRunner returns a usable value in this test env'); + const block = buildCodexHookBlock('/tmp/x/.codex', { absoluteRunner: runner }); + const parsed = parseCodexHookBlock(block); + assert.equal(parsed.ok, true); + // Strict canonical-runner equality: the parsed runner (after stripping + // toml + JSON escape layers) must be exactly the normalized runner that + // resolveNodeRunner selected. Homebrew Cellar execPath values intentionally + // normalize to the stable Homebrew symlink (#3181). + const expected = JSON.parse(runner); + assert.equal(unescapeRunner(parsed.runner), expected, + `parsed runner must equal resolveNodeRunner(), got: ${parsed.runner}, want: ${expected}`); + }); +}); + +describe('Bug #3017: rewriteLegacyCodexHookBlock migrates bare-node on reinstall', () => { + test('exported as a function', () => { + assert.equal(typeof rewriteLegacyCodexHookBlock, 'function'); + }); + + test('rewrites a bare-node managed-hook command to the absolute runner', () => { + const before = [ + '[model]', + 'name = "o3"', + '', + '# GSD Hooks', + '[[hooks.SessionStart]]', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "node /Users/x/.codex/hooks/gsd-check-update.js"', + '', + ].join('\n'); + const expectedRunnerPath = '/usr/local/bin/node'; + const runner = `"${expectedRunnerPath}"`; + const result = rewriteLegacyCodexHookBlock(before, runner); + assert.equal(result.changed, true, 'must report change=true'); + // The migrated command must use the EXACT absolute runner the caller + // supplied (#3022 CR — was previously asserting a loose '/node' + // substring which let unrelated absolute paths pass). + const parsed = parseCodexHookBlock(result.content); + assert.equal(parsed.ok, true); + assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath, + `runner must equal supplied absolute path: ${parsed.runner}`); + assert.equal(parsed.hookPath, '/Users/x/.codex/hooks/gsd-check-update.js'); + // Non-GSD content (the [model] block) must be preserved verbatim. + assert.ok(result.content.includes('[model]')); + assert.ok(result.content.includes('name = "o3"')); + }); + + test('decodes TOML-escaped quoted script paths before projection', () => { + const before = [ + '# GSD Hooks', + '[[hooks.SessionStart]]', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "node \\"C:\\\\Users\\\\x\\\\.codex\\\\hooks\\\\gsd-check-update.js\\""', + '', + ].join('\n'); + const runner = '"/usr/local/bin/node"'; + const result = rewriteLegacyCodexHookBlock(before, runner, { platform: 'win32' }); + assert.equal(result.changed, true); + const parsed = parseCodexHookBlock(result.content); + assert.equal(parsed.ok, true, 'hook block must parse correctly'); + const expected = projectCodexHookTomlCommand({ + absoluteRunner: runner, + scriptPath: 'C:\\Users\\x\\.codex\\hooks\\gsd-check-update.js', + platform: 'win32', + }); + assert.equal(parsed.command, expected, + 'rewritten command must project from decoded Windows path (not TOML-escaped token text)'); + assert.equal(unescapeRunner(parsed.runner), '/usr/local/bin/node', + 'runner must equal supplied absolute path'); + assert.equal(parsed.hookPath, 'C:/Users/x/.codex/hooks/gsd-check-update.js', + 'hook path must equal decoded Windows path after projection normalization'); + }); + + test('does NOT touch a managed-hook entry that already uses an absolute runner', () => { + const already = [ + '# GSD Hooks', + '[[hooks.SessionStart]]', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "\\"/usr/local/bin/node\\" /Users/x/.codex/hooks/gsd-check-update.js"', + '', + ].join('\n'); + const result = rewriteLegacyCodexHookBlock(already, '"/usr/local/bin/node"'); + assert.equal(result.changed, false); + assert.equal(result.content, already); + }); + + test('does NOT touch user-authored bare-node hooks (filename not in managed allowlist)', () => { + const userOwned = [ + '[[hooks.SessionStart]]', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "node /home/me/my-custom-codex-hook.js"', + '', + ].join('\n'); + const result = rewriteLegacyCodexHookBlock(userOwned, '"/usr/local/bin/node"'); + assert.equal(result.changed, false, + 'user-authored hooks must be left alone; only managed gsd-* hooks are migrated'); + assert.equal(result.content, userOwned); + }); + + test('returns content unchanged when absoluteRunner is null', () => { + const before = 'command = "node /path/to/gsd-check-update.js"'; + const result = rewriteLegacyCodexHookBlock(before, null); + assert.equal(result.changed, false); + assert.equal(result.content, before); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3018-codex-discuss-fallback.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3018-codex-discuss-fallback (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for bug #3018. + * + * @jon-hendry: running `$gsd-discuss-phase 81` in Codex Default mode (where + * `request_user_input` is rejected) caused the agent to pick "reasonable + * defaults" and proceed straight into writing CONTEXT.md / DISCUSSION-LOG.md + * checkpoints — without ever surfacing the questions to the user. The + * generated Codex skill adapter explicitly told it to do that: + * + * "When `request_user_input` is rejected (Execute mode), present a + * plain-text numbered list and pick a reasonable default." + * + * Discuss-mode is the wrong place for that fallback. The contract should be: + * stop, render the questions as plain text, wait for the user's answer. + * Defaults may only be picked when the user has authorized non-interactive + * mode (--auto / --all) or has explicitly approved them. + * + * Test design (#3027 CR follow-up): instead of grepping the prose with + * regex, parse the fallback section into a typed semantic-flag record and + * assert on those booleans. This adheres to CONTRIBUTING.md "no-source-grep" + * — the test names a behavioral invariant, the parser walks the prose + * once and exposes the invariants as named flags, and the prose can be + * reworded freely as long as the flags stay true. + */ + +'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 INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); +const { getCodexSkillAdapterHeader } = INSTALL; + +/** + * Extract the "Execute mode fallback" section text from the adapter header. + * Returns null if the section is missing. Section runs from the + * "Execute mode fallback:" label up to the next heading or tag. + */ +function extractExecuteModeFallback(header) { + const m = header.match(/Execute mode fallback:\s*\n([\s\S]*?)(?=\n##\s|\n<\/codex_skill_adapter>)/); + return m ? m[1].trim() : null; +} + +/** + * Parse the Execute-mode-fallback section into a typed semantic-flag + * record. Each flag answers a single behavioral question that the #3018 + * fix is contractually required to encode in the prose. Tests assert on + * the booleans, not the wording — so the prose can evolve without test + * churn as long as the semantics stay correct. + * + * The flags are derived from a single pass over the section text: each + * one looks for any of a small set of synonym phrases that a correct + * implementation would use. The negative anti-pattern flag + * (`silentlyPicksDefaults`) is the regression guard — the prose under + * #3018 told the agent to "pick a reasonable default" autonomously, + * which is exactly what this fix removes. + */ +function parseExecuteModeFallback(section) { + if (!section || typeof section !== 'string') { + return { + ok: false, + sectionLength: 0, + instructsStop: false, + presentsPlainTextQuestions: false, + namesPermissionPath: false, + forbidsWritingArtifactsBeforeAnswer: false, + silentlyPicksDefaults: false, + }; + } + const lower = section.toLowerCase(); + // (a) STOP/WAIT directive — the agent must halt instead of proceeding. + const instructsStop = /\b(stop|halt|wait)\b/.test(lower); + // (b) Plain-text fallback presentation — the agent must surface the + // questions in some inspectable form (numbered list / plain text). + const presentsPlainTextQuestions = /plain.?text|numbered list/.test(lower); + // (c) Permission path that DOES allow defaults — must name at least + // one (--auto / --all / explicit user approval / autonomous workflow). + const namesPermissionPath = + /--auto|--all/.test(section) || + /explicit(ly)? (approv|authoriz|consent)/i.test(section) || + /user (has )?approv|user (has )?authoriz|user (has )?consent/i.test(section) || + /autonomous (lifecycle|workflow|paths?)/i.test(section); + // (d) Artifact-write ban — the agent must not produce workflow files + // (CONTEXT.md, DISCUSSION-LOG.md, PLAN.md, checkpoints) before the + // user answers or one of the permission-path conditions applies. + // Require BOTH a "do not write" intent AND a named artifact class so + // generic "do not write" prose elsewhere can't satisfy the flag. + const forbidsWriteIntent = /do not write|don'?t write|must not write|shall not write/i.test(section); + const namesArtifactClass = /artifact|checkpoint|context\.md|discussion.?log|plan\.md/i.test(section); + const forbidsWritingArtifactsBeforeAnswer = forbidsWriteIntent && namesArtifactClass; + // Anti-pattern guard — the prose that caused #3018. This MUST be false. + const silentlyPicksDefaults = /pick (a |the )?(reasonable|sensible|sane) default/i.test(section); + return { + ok: true, + sectionLength: section.length, + instructsStop, + presentsPlainTextQuestions, + namesPermissionPath, + forbidsWritingArtifactsBeforeAnswer, + silentlyPicksDefaults, + }; +} + +describe('bug #3018: codex skill adapter encodes the discuss-mode fallback contract', () => { + test('exports the adapter generator', () => { + assert.equal(typeof getCodexSkillAdapterHeader, 'function'); + }); + + test('Execute mode fallback section exists and has content', () => { + const header = getCodexSkillAdapterHeader('gsd-discuss-phase'); + const section = extractExecuteModeFallback(header); + const parsed = parseExecuteModeFallback(section); + assert.equal(parsed.ok, true, `section must parse, got header:\n${header}`); + assert.ok(parsed.sectionLength > 0, 'section must be non-empty'); + }); + + test('fallback instructs STOP/WAIT (not silent continuation)', () => { + const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); + const parsed = parseExecuteModeFallback(section); + assert.equal(parsed.instructsStop, true, + `must instruct stop/halt/wait — section was:\n${section}`); + }); + + test('fallback prescribes plain-text question presentation', () => { + const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); + const parsed = parseExecuteModeFallback(section); + assert.equal(parsed.presentsPlainTextQuestions, true, + `must mention plain-text / numbered-list presentation — section was:\n${section}`); + }); + + test('fallback names a permission path under which defaults ARE allowed (--auto / --all / explicit approval / autonomous)', () => { + const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); + const parsed = parseExecuteModeFallback(section); + assert.equal(parsed.namesPermissionPath, true, + `must name at least one permission path — section was:\n${section}`); + }); + + test('fallback forbids writing workflow artifacts before user answers', () => { + const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); + const parsed = parseExecuteModeFallback(section); + assert.equal(parsed.forbidsWritingArtifactsBeforeAnswer, true, + `must encode write-ban + named artifact class — section was:\n${section}`); + }); + + test('fallback does NOT contain the #3018 anti-pattern ("pick a reasonable default")', () => { + const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); + const parsed = parseExecuteModeFallback(section); + assert.equal(parsed.silentlyPicksDefaults, false, + `regression — fallback must NOT instruct the agent to pick defaults autonomously, section was:\n${section}`); + }); + + test('all four positive flags + the negative anti-pattern flag — typed-record snapshot', () => { + // Single assertion that the whole semantic record matches the contract. + // If any flag flips, the test fails with a structured diff naming the + // exact invariant that broke. + const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase')); + const parsed = parseExecuteModeFallback(section); + const semanticContract = { + ok: parsed.ok, + instructsStop: parsed.instructsStop, + presentsPlainTextQuestions: parsed.presentsPlainTextQuestions, + namesPermissionPath: parsed.namesPermissionPath, + forbidsWritingArtifactsBeforeAnswer: parsed.forbidsWritingArtifactsBeforeAnswer, + silentlyPicksDefaults: parsed.silentlyPicksDefaults, + }; + assert.deepStrictEqual(semanticContract, { + ok: true, + instructsStop: true, + presentsPlainTextQuestions: true, + namesPermissionPath: true, + forbidsWritingArtifactsBeforeAnswer: true, + silentlyPicksDefaults: false, + }, `discuss-mode fallback contract violated — section was:\n${section}`); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3245-codex-toml-floats.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3245-codex-toml-floats (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression: issue #3245 — Codex install rejects valid TOML floats. + * + * Two defects, two fixes: + * + * Defect 1 — parseTomlValue rejects TOML floats (e.g. tool_timeout_sec = 20.0). + * Codex CLI's serde schema requires f64 for tool_timeout_sec / startup_timeout_sec + * (integers fail with "invalid type: integer"). GSD's strict-integer-only parser + * was the inverse of what Codex requires — any float triggers the rejection branch. + * Fix: extend parseTomlValue to accept TOML 1.0 float literals and return them as + * JS Number. The merged config.toml preserves the float form verbatim so + * round-trip writes don't coerce 20.0 → 20. + * + * Defect 2 — Partial rollback leaves install in hybrid state. + * restoreCodexSnapshot only knew about config.toml, but skills/, agents/, and VERSION + * are written earlier in the install sequence. A post-install validation failure + * aborts with new agent text on disk, config.toml reverted, and .tmp files + * potentially orphaned. + * Fix: capture pre-install state of skills/, agents/, and VERSION before any + * Codex-specific mutation, and extend the rollback to cover all of them. + */ + +// GSD_TEST_MODE must be set before require('../bin/install.js') so the module +// skips the main CLI entry point and exports its internals. +const previousGsdTestMode = process.env.GSD_TEST_MODE; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, before, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); +const { cleanup } = require('./helpers.cjs'); + +const { parseTomlToObject, validateCodexConfigSchema, install } = require('../bin/install.js'); +const installModule = require('../bin/install.js'); + +if (previousGsdTestMode === undefined) { + delete process.env.GSD_TEST_MODE; +} else { + process.env.GSD_TEST_MODE = previousGsdTestMode; +} + +// Ensure hooks/dist/ is populated — mirrors the pattern used by codex-config.test.cjs. +const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +before(() => { + if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { + execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + } +}); + +function runCodexInstall(codexHome) { + const previousCodexHome = process.env.CODEX_HOME; + const previousCwd = process.cwd(); + process.env.CODEX_HOME = codexHome; + try { + process.chdir(path.join(__dirname, '..')); + return install(true, 'codex'); + } finally { + process.chdir(previousCwd); + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previousCodexHome; + } + } +} + +function writeCodexConfig(codexHome, content) { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); +} + +// --------------------------------------------------------------------------- +// Defect 1 — parseTomlValue must accept TOML floats +// --------------------------------------------------------------------------- + +describe('#3245 — parseTomlToObject accepts TOML floats', () => { + test('parses bare decimal float (20.0)', () => { + const content = [ + 'tool_timeout_sec = 20.0', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(typeof parsed.tool_timeout_sec, 'number', + 'tool_timeout_sec should be a JS number'); + assert.strictEqual(parsed.tool_timeout_sec, 20.0, + 'value must equal 20.0'); + }); + + test('parses startup_timeout_sec = 60.0', () => { + const content = [ + 'startup_timeout_sec = 60.0', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.startup_timeout_sec, 60.0); + }); + + test('parses positive exponent notation (1e10)', () => { + const content = [ + 'x = 1e10', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.x, 1e10); + }); + + test('parses negative exponent (1.5e-3)', () => { + const content = [ + 'x = 1.5e-3', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.ok(Math.abs(parsed.x - 1.5e-3) < 1e-15, 'must be approximately 1.5e-3'); + }); + + test('parses signed positive float (+1.0)', () => { + const content = [ + 'x = +1.0', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.x, 1.0); + }); + + test('parses signed negative float (-0.5)', () => { + const content = [ + 'x = -0.5', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.x, -0.5); + }); + + test('parses float with underscore separators (1_000.0)', () => { + const content = [ + 'x = 1_000.0', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.x, 1000.0); + }); + + test('integer (no decimal) still parses as integer', () => { + const content = [ + 'x = 42', + '', + ].join('\n'); + const parsed = parseTomlToObject(content); + assert.strictEqual(parsed.x, 42); + }); + + test('still rejects bare date (1979-05-27)', () => { + const content = [ + 'x = 1979-05-27', + '', + ].join('\n'); + assert.throws( + () => parseTomlToObject(content), + /unsupported TOML value/, + 'date literals must remain unsupported' + ); + }); + + test('still rejects bare time (07:32:00)', () => { + const content = [ + 'x = 07:32:00', + '', + ].join('\n'); + // With leading-zero rejection (CR4 fix) the parser stops at `0`, and + // `7:32:00` is "trailing bytes". Either error form is acceptable — the + // key invariant is that time literals are never silently accepted. + assert.throws( + () => parseTomlToObject(content), + /unsupported TOML value|trailing bytes/, + 'time literals must remain unsupported' + ); + }); + + test('still rejects hex literal (0x1A)', () => { + const content = [ + 'x = 0x1A', + '', + ].join('\n'); + // 0 is parsed, then 'x1A' is trailing garbage — rejected with "trailing bytes" + // or "unsupported value" depending on where the parser catches it. + assert.throws( + () => parseTomlToObject(content), + /trailing bytes|unsupported (TOML value|value)/, + 'hex literals must remain unsupported' + ); + }); + + test('validateCodexConfigSchema passes a config with tool_timeout_sec = 20.0', () => { + const content = [ + '[model]', + 'name = "o3"', + '', + 'tool_timeout_sec = 20.0', + 'startup_timeout_sec = 60.0', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, true, + 'schema validation must pass for a config containing TOML floats: ' + result.reason); + }); +}); + +// --------------------------------------------------------------------------- +// Defect 1 — full install must succeed and preserve float verbatim +// --------------------------------------------------------------------------- + +// concurrency: false — drives the live install pipeline (shared CODEX_HOME env, +// process.chdir). Serialise to prevent stray mutations across parallel siblings. +describe('#3245 — install succeeds with TOML float in pre-existing config', { concurrency: false }, () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-float-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('install completes when config.toml contains tool_timeout_sec = 20.0', () => { + // Floats at the root level (before any table header) — this is where Codex + // CLI reads tool_timeout_sec / startup_timeout_sec according to its serde schema. + const preInstall = [ + 'tool_timeout_sec = 20.0', + 'startup_timeout_sec = 60.0', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n'); + writeCodexConfig(codexHome, preInstall); + + // Must not throw — pre-#3245 this threw "unsupported TOML value … floats … not supported". + assert.doesNotThrow( + () => runCodexInstall(codexHome), + 'install must not throw when config.toml contains TOML floats' + ); + + // The merged config.toml must still contain the float values at root scope. + const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + const parsed = parseTomlToObject(after); + assert.strictEqual(parsed.tool_timeout_sec, 20.0, + 'tool_timeout_sec must be preserved as a number after install'); + assert.strictEqual(parsed.startup_timeout_sec, 60.0, + 'startup_timeout_sec must be preserved as a number after install'); + }); + + test('post-install config round-trips tool_timeout_sec as numeric 20', () => { + const preInstall = [ + 'tool_timeout_sec = 20.0', + '', + ].join('\n'); + writeCodexConfig(codexHome, preInstall); + + runCodexInstall(codexHome); + + const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + // The value must survive round-trip as a float-compatible representation. + // Parse structurally — don't grep for the literal string "20.0". + const parsed = parseTomlToObject(after); + assert.strictEqual(parsed.tool_timeout_sec, 20, + 'tool_timeout_sec must round-trip as numeric 20 (=== 20.0 in JS)'); + }); +}); + +// --------------------------------------------------------------------------- +// CR round-4 finding — TOML 1.0 disallows leading zeros in integer part +// --------------------------------------------------------------------------- +// +// TOML 1.0 §2: integer literals follow decimal-integer rules, which disallow +// leading zeros except the value `0` itself. `01`, `01.5`, `00e2`, `+01.0` +// are therefore invalid. The `parseTomlValue` integer-part regex is tightened +// from `\d(?:_?\d)*` to `(0|[1-9](?:_?\d)*)`. + +describe('#3245 CR4 — parseTomlValue rejects leading zeros in float integer part', () => { + function parseValue(raw) { + // Wrap in a minimal TOML assignment so parseTomlToObject drives the test. + return parseTomlToObject(`x = ${raw}`).x; + } + + function assertRejects(raw, label) { + let threw = false; + try { parseValue(raw); } catch (_) { threw = true; } + assert.strictEqual(threw, true, `expected rejection for ${label}: ${raw}`); + } + + function assertAccepts(raw, expected, label) { + let val; + let threw = false; + try { val = parseValue(raw); } catch (e) { threw = true; } + assert.strictEqual(threw, false, `expected acceptance for ${label}: ${raw}`); + if (expected !== undefined) { + assert.ok(Math.abs(val - expected) < 1e-12, `${label}: expected ${expected}, got ${val}`); + } + } + + // --- rejection cases: leading zeros in the integer part --- + + test('rejects 01 (leading zero on bare integer)', () => assertRejects('01', '01')); + test('rejects 00 (double-zero bare integer)', () => assertRejects('00', '00')); + test('rejects 01.5 (leading zero before decimal point)', () => assertRejects('01.5', '01.5')); + test('rejects 00.5 (double-zero before decimal)', () => assertRejects('00.5', '00.5')); + test('rejects +01 (leading zero with sign)', () => assertRejects('+01', '+01')); + test('rejects -01 (negative leading zero)', () => assertRejects('-01', '-01')); + test('rejects 00e2 (leading zero with exponent)', () => assertRejects('00e2', '00e2')); + test('rejects +01.0 (leading zero in positive float)', () => assertRejects('+01.0', '+01.0')); + test('rejects -01.0 (leading zero in negative float)', () => assertRejects('-01.0', '-01.0')); + test('rejects 01.5e10 (leading zero, decimal, and exponent)', () => assertRejects('01.5e10', '01.5e10')); + + // --- acceptance cases: valid TOML 1.0 numeric forms --- + + test('accepts 0 (single zero)', () => assertAccepts('0', 0, 'single zero')); + test('accepts 0.5 (zero before decimal)', () => assertAccepts('0.5', 0.5, 'zero.decimal')); + test('accepts 0.0 (zero.zero)', () => assertAccepts('0.0', 0.0, 'zero.zero')); + test('accepts 0e1 (zero with exponent)', () => assertAccepts('0e1', 0, '0e1')); + test('accepts +0.5 (positive zero-decimal)', () => assertAccepts('+0.5', 0.5, '+0.5')); + test('accepts -0.5 (negative zero-decimal)', () => assertAccepts('-0.5', -0.5, '-0.5')); + test('accepts 1 (single non-zero digit)', () => assertAccepts('1', 1, '1')); + test('accepts 12 (two digits)', () => assertAccepts('12', 12, '12')); + test('accepts 1.5 (simple float)', () => assertAccepts('1.5', 1.5, '1.5')); + test('accepts 1_000 (underscored integer)', () => assertAccepts('1_000', 1000, '1_000')); + test('accepts 1_000.5 (underscored float)', () => assertAccepts('1_000.5', 1000.5, '1_000.5')); + test('accepts +1.5 (positive float)', () => assertAccepts('+1.5', 1.5, '+1.5')); + test('accepts -2.0 (negative float)', () => assertAccepts('-2.0', -2.0, '-2.0')); + test('accepts 1.5e-3 (float with negative exponent)', () => assertAccepts('1.5e-3', 1.5e-3, '1.5e-3')); + test('accepts 1.05e10 (fractional part may start with zero)', () => assertAccepts('1.05e10', 1.05e10, '1.05e10')); +}); + +// --------------------------------------------------------------------------- +// Defect 2 — idempotent rollback covers skills, agents, VERSION +// --------------------------------------------------------------------------- + +// concurrency: false — patches module.exports.__codexSchemaValidator and drives +// the install pipeline. Serialise to prevent cross-test pollution. +describe('#3245 — idempotent rollback reverts skills/, agents/, and VERSION', { concurrency: false }, () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-rollback-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + delete installModule.__codexSchemaValidator; + cleanup(tmpDir); + }); + + test('validation failure rolls back skills/, agents/, and VERSION to pre-install state', () => { + // Start from a clean codexHome with no pre-existing GSD content — the dirs + // do not exist yet. After a failed install they must be absent (or contain + // only what was there before, i.e. nothing). + fs.mkdirSync(codexHome, { recursive: true }); + + // Force schema validation to fail so we can observe the rollback without + // needing a genuinely broken config. + installModule.__codexSchemaValidator = () => ({ + ok: false, + reason: 'simulated failure for #3245 rollback test', + }); + + let threw = false; + try { + runCodexInstall(codexHome); + } catch (_) { + threw = true; + } + assert.strictEqual(threw, true, 'install must throw when validation fails'); + + // skills/ — GSD writes gsd-* subdirs here. All must be absent after rollback. + const skillsDir = path.join(codexHome, 'skills'); + if (fs.existsSync(skillsDir)) { + const gsdSkills = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); + assert.strictEqual( + gsdSkills.length, + 0, + 'rollback must remove all gsd-* skill directories: ' + gsdSkills.map(e => e.name).join(', ') + ); + } + + // agents/ — GSD writes gsd-*.md and gsd-*.toml here. All must be absent. + // Not the shared listAgentFiles() helper: reads the INSTALLED Codex dest + // dir and is .toml-inclusive, so its semantics differ from the source roster. + const agentsDir = path.join(codexHome, 'agents'); + if (fs.existsSync(agentsDir)) { + const gsdAgents = fs.readdirSync(agentsDir) + .filter(f => f.startsWith('gsd-') && (f.endsWith('.md') || f.endsWith('.toml'))); + assert.strictEqual( + gsdAgents.length, + 0, + 'rollback must remove all gsd-* agent files: ' + gsdAgents.join(', ') + ); + } + + // VERSION — GSD writes gsd-core/VERSION. Must be absent (wasn't there before). + const versionPath = path.join(codexHome, 'gsd-core', 'VERSION'); + assert.strictEqual( + fs.existsSync(versionPath), + false, + 'rollback must remove the VERSION file written during install' + ); + }); + + test('rollback is safe when fired before any snapshots were captured (very early failure)', () => { + // If the validator is injected before ANY install writes happen, the rollback + // must not throw — it should be idempotent when nothing was written yet. + fs.mkdirSync(codexHome, { recursive: true }); + + installModule.__codexSchemaValidator = () => ({ + ok: false, + reason: 'very early simulated failure', + }); + + // The install must throw (validation failure), but the rollback that runs + // internally must not throw — it must be idempotent when nothing was written. + let threw = false; + try { + runCodexInstall(codexHome); + } catch (_) { + threw = true; + } + assert.strictEqual(threw, true, 'install must throw when validation fails (very early failure)'); + // Rollback removes all gsd-* skill dirs it wrote. Even if skills/ was + // created during the install, no gsd-* dirs should survive after rollback. + const skillsDir = path.join(codexHome, 'skills'); + const remainingGsdSkills = fs.existsSync(skillsDir) + ? fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name.startsWith('gsd-')) + .map((e) => e.name) + : []; + assert.deepStrictEqual( + remainingGsdSkills, + [], + 'rollback must remove all gsd-* skill dirs even when fired after minimal writes' + ); + }); + + test('rollback does not remove pre-existing user skills that GSD did not write', () => { + // If the user has a custom skill dir (not gsd-*) it must survive rollback. + const skillsDir = path.join(codexHome, 'skills'); + const userSkill = path.join(skillsDir, 'my-custom-skill'); + fs.mkdirSync(userSkill, { recursive: true }); + fs.writeFileSync(path.join(userSkill, 'SKILL.md'), '# Custom\n', 'utf8'); + + installModule.__codexSchemaValidator = () => ({ + ok: false, + reason: 'simulated failure — user skill must survive', + }); + + let threw = false; + try { runCodexInstall(codexHome); } catch (_) { threw = true; } + assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (user-skill-survives scenario)'); + + assert.strictEqual( + fs.existsSync(path.join(userSkill, 'SKILL.md')), + true, + 'pre-existing non-gsd-* skill must survive rollback' + ); + }); + + test('rollback removes orphaned atomic-write temp files', () => { + // Any .tmp-- files created during aborted atomic writes + // must be cleaned up by the rollback so targetDir is not left with stray + // temp files consuming disk space. + fs.mkdirSync(codexHome, { recursive: true }); + + installModule.__codexSchemaValidator = () => ({ + ok: false, + reason: 'simulated failure for temp-file cleanup test', + }); + + let threw = false; + try { runCodexInstall(codexHome); } catch (_) { threw = true; } + assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (temp-file cleanup scenario)'); + + // Scan for any *.tmp-* files left in codexHome after rollback. + const tmpPattern = /\.tmp-\d+-\d+$/; + function findTmpFiles(dir) { + if (!fs.existsSync(dir)) return []; + const results = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findTmpFiles(full)); + } else if (tmpPattern.test(entry.name)) { + results.push(full); + } + } + return results; + } + const stray = findTmpFiles(codexHome); + assert.strictEqual( + stray.length, + 0, + 'rollback must clean up orphaned atomic-write temp files: ' + stray.join(', ') + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3285-codex-hooks-state-allowed.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3285-codex-hooks-state-allowed (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression: issue #3285 — Codex install fails when config.toml contains + * hooks.state entries. + * + * Root cause: validateCodexConfigSchema walks every `hooks.*` table section + * and asserts array-of-tables (AoT) shape, without distinguishing the + * `hooks.state.*` namespace (Codex-managed per-hook trust persistence, a + * regular table) from `hooks.` (event handlers like SessionStart, + * which DO require AoT shape via [[hooks.SessionStart]]). + * + * Fix: add a carve-out so that any table whose path starts with `hooks.state` + * is validated as a regular table (not AoT). All `hooks.` paths still + * require AoT. + */ + +// GSD_TEST_MODE must be set before require('../bin/install.js') so the module +// skips the main CLI entry point and exports its internals. +const previousGsdTestMode = process.env.GSD_TEST_MODE; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const { validateCodexConfigSchema, install } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +if (previousGsdTestMode === undefined) { + delete process.env.GSD_TEST_MODE; +} else { + process.env.GSD_TEST_MODE = previousGsdTestMode; +} + +// Ensure hooks/dist/ is populated — mirrors the pattern used by codex-config.test.cjs. +const { before, beforeEach, afterEach } = require('node:test'); +const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +before(() => { + if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { + execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + } +}); + +// --------------------------------------------------------------------------- +// Validator unit tests (no install, just validateCodexConfigSchema) +// --------------------------------------------------------------------------- + +describe('#3285 — validateCodexConfigSchema: hooks.state is a regular table (not AoT)', () => { + test('bare [hooks.state] table header passes validation', () => { + const content = [ + '[hooks.state]', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, true, + 'bare [hooks.state] must be allowed (regular-table namespace): ' + result.reason); + }); + + test('bare [hooks.state.] table header passes validation', () => { + // Mirrors the exact shape Codex CLI 0.130.0+ writes for per-hook trust entries. + // The key contains slashes and colons — must be quoted in TOML. + const content = [ + '[hooks.state]', + '', + "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", + 'enabled = true', + 'trusted_hash = "sha256:abc123"', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, true, + 'bare [hooks.state.] with trust fields must be allowed: ' + result.reason); + }); + + test('hooks.state alongside [[hooks.SessionStart]] AoT both pass', () => { + // The real-world fixture: user has both Codex trust state AND GSD-managed + // event hooks in the same config.toml. + const content = [ + '[hooks.state]', + '', + "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", + 'enabled = true', + 'trusted_hash = "sha256:abc123"', + '', + '[[hooks.SessionStart]]', + '', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "/usr/local/bin/gsd-check-update"', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, true, + 'mixed hooks.state (regular table) + [[hooks.SessionStart]] (AoT) must pass: ' + result.reason); + }); + + test('[[hooks.SessionStart]] AoT still requires array-of-tables shape', () => { + // Regression guard: the fix must NOT relax AoT requirements for event hooks. + // [hooks.SessionStart] (single-bracket) must still fail. + const content = [ + '[hooks.SessionStart]', + 'type = "command"', + 'command = "/some/command"', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, false, + '[hooks.SessionStart] bare table (not AoT) must still be rejected'); + assert.ok( + result.reason.includes('hooks.SessionStart'), + 'rejection reason must mention hooks.SessionStart, got: ' + result.reason + ); + }); + + test('hooks.state object in parsed structure does not trigger non-array rejection', () => { + // The parsed-object check loops over Object.entries(parsed.hooks) and + // asserts !Array.isArray(value) → error. hooks.state is an object, not + // an array. The fix must skip hooks.state in that loop too. + const content = [ + '[hooks.state]', + '', + "[hooks.state.'some-key']", + 'enabled = true', + 'trusted_hash = "sha256:deadbeef"', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, true, + 'parsed hooks.state object must not trigger "hooks.state must be an array" rejection: ' + result.reason); + }); + + test('multiple hooks.state sub-keys all pass validation', () => { + const content = [ + '[hooks.state]', + '', + "[hooks.state.'/project/a/.codex/hooks.json:pre_tool_use:0:0']", + 'enabled = true', + 'trusted_hash = "sha256:aaa"', + '', + "[hooks.state.'/project/b/.codex/hooks.json:pre_tool_use:0:0']", + 'enabled = false', + 'trusted_hash = "sha256:bbb"', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, true, + 'multiple hooks.state sub-keys must all pass: ' + result.reason); + }); + + test('[[hooks.state]] AoT form is rejected', () => { + // hooks.state must be a regular table — array-of-tables shape is invalid. + const content = [ + '[[hooks.state]]', + 'enabled = true', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, false, + '[[hooks.state]] (AoT) must be rejected'); + assert.ok( + result.reason.includes('hooks.state'), + 'rejection reason must mention hooks.state, got: ' + result.reason + ); + }); + + test('[[hooks.state.foo]] AoT sub-key form is rejected', () => { + // hooks.state.* sub-keys must be regular tables — AoT sub-key shape is invalid. + const content = [ + '[[hooks.state.foo]]', + 'enabled = true', + '', + ].join('\n'); + const result = validateCodexConfigSchema(content); + assert.strictEqual(result.ok, false, + '[[hooks.state.foo]] (AoT sub-key) must be rejected'); + assert.ok( + result.reason.includes('hooks.state'), + 'rejection reason must mention hooks.state, got: ' + result.reason + ); + }); +}); + +// --------------------------------------------------------------------------- +// Full install integration test +// --------------------------------------------------------------------------- + +describe('#3285 — install succeeds when config.toml contains hooks.state entries', { concurrency: false }, () => { + let tmpDir; + let codexHome; + + function writeCodexConfig(content) { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); + } + + function runCodexInstall() { + const previousCodexHome = process.env.CODEX_HOME; + const previousCwd = process.cwd(); + process.env.CODEX_HOME = codexHome; + try { + process.chdir(path.join(__dirname, '..')); + return install(true, 'codex'); + } finally { + process.chdir(previousCwd); + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previousCodexHome; + } + } + } + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3285-')); + codexHome = path.join(tmpDir, 'codex-home'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('install does not throw when config.toml contains hooks.state trust entries', () => { + // This is the exact failure scenario reported in #3285. + const preInstall = [ + '[hooks.state]', + '', + "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", + 'enabled = true', + 'trusted_hash = "sha256:abc123def456"', + '', + ].join('\n'); + writeCodexConfig(preInstall); + + assert.doesNotThrow( + () => runCodexInstall(), + 'install must not throw when config.toml contains hooks.state trust entries' + ); + }); + + test('hooks.state entries are preserved in post-install config.toml', () => { + const preInstall = [ + '[hooks.state]', + '', + "[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']", + 'enabled = true', + 'trusted_hash = "sha256:abc123def456"', + '', + ].join('\n'); + writeCodexConfig(preInstall); + + runCodexInstall(); + + const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + // Verify structurally: the trust hash key must survive the install. + // Do NOT grep for the literal string — parse the TOML structure. + const { parseTomlToObject } = require('../bin/install.js'); + const parsed = parseTomlToObject(after); + assert.ok( + parsed.hooks && typeof parsed.hooks.state === 'object' && parsed.hooks.state !== null, + 'post-install config.toml must have hooks.state as an object' + ); + // Verify the actual trust entry survives — not just that hooks.state is an object. + const trustKey = "/home/user/.codex/hooks.json:pre_tool_use:0:0"; + assert.ok( + parsed.hooks.state[trustKey] != null, + `post-install must preserve the original trust entry for key: ${trustKey}` + ); + assert.strictEqual( + parsed.hooks.state[trustKey].enabled, + true, + 'preserved trust entry must have enabled = true' + ); + assert.strictEqual( + parsed.hooks.state[trustKey].trusted_hash, + 'sha256:abc123def456', + 'preserved trust entry must have the original trusted_hash' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3346-codex-aot-toml-key.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3346-codex-aot-toml-key (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression: issue #3346 — Codex install fails on Windows when the legacy + * Codex `[hooks]` config uses a `:::` location tuple + * as the table key (with the actual event name carried in an `event = "..."` + * body field). `migrateCodexHooksMapFormat` re-emitted the location tuple + * verbatim as the leaf TOML key, producing a header like + * + * [[hooks."C:\Users\helen\.codex\config.toml:session_start:0:0"]] + * + * which Codex 0.124.0+ refuses to load (the leaf key segment is supposed to + * be the event name, not a diagnostic location identifier). + * + * Expected behaviour: when the legacy `[hooks.]` body declares an + * `event = "..."` field, the migrator must use that event name as the leaf + * TOML key for the emitted `[[hooks.]]` two-level nested AoT block. + * + * Test discipline: parse the migrated TOML with the project's own + * `parseTomlToObject` and assert on the resulting object shape — never + * grep the raw string. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + migrateCodexHooksMapFormat, + parseTomlToObject, +} = require('../bin/install.js'); + +describe('#3346 — Codex AoT hooks migration emits event-name leaf key, not location tuple', () => { + test('legacy [hooks.""] with event="..." body migrates to [[hooks.]]', () => { + // Pre-install fixture: a legacy `[hooks.]` block whose key is + // a `:::` location identifier. The actual + // event name lives in the body as `event = "session_start"`. + const legacy = [ + '[hooks."C:\\\\Users\\\\helen\\\\.codex\\\\config.toml:session_start:0:0"]', + 'event = "session_start"', + 'command = "echo hi"', + '', + ].join('\n'); + + const migrated = migrateCodexHooksMapFormat(legacy); + const parsed = parseTomlToObject(migrated); + + // The migrated hooks object must be keyed by the event name, not by the + // location tuple. This is the core assertion of #3346. + assert.ok(parsed.hooks, 'migrated TOML must define a hooks table'); + assert.deepEqual( + Object.keys(parsed.hooks), + ['session_start'], + `migrated hooks must be keyed by event name only; got: ${JSON.stringify(Object.keys(parsed.hooks))}` + ); + + // The handler body must survive the migration and live under the two-level + // nested AoT shape (hooks.[0].hooks[0].command). + const eventEntries = parsed.hooks.session_start; + assert.ok(Array.isArray(eventEntries) && eventEntries.length >= 1, + 'hooks.session_start must be an array of tables'); + const handlers = eventEntries[0].hooks; + assert.ok(Array.isArray(handlers) && handlers.length >= 1, + 'hooks.session_start[0].hooks must be an array of handler tables'); + assert.equal(handlers[0].command, 'echo hi', + 'handler command must be preserved through migration'); + assert.equal(handlers[0].type, 'command', + 'handler type must default to "command" when no explicit type given'); + assert.equal(handlers[0].event, undefined, + 'handler body must not retain legacy `event` field after migration'); + }); + + test('legacy [hooks.""] with explicit type and event survives migration cleanly', () => { + // Same as above but with an explicit `type` field — the migrator must not + // duplicate it when re-emitting the handler. + const legacy = [ + '[hooks."/home/user/.codex/config.toml:tool_call_pre:5:0"]', + 'event = "tool_call_pre"', + 'type = "command"', + 'command = "node /path/to/hook.js"', + '', + ].join('\n'); + + const migrated = migrateCodexHooksMapFormat(legacy); + const parsed = parseTomlToObject(migrated); + + assert.deepEqual( + Object.keys(parsed.hooks), + ['tool_call_pre'], + 'leaf key must be the event name from the `event = "..."` body field' + ); + const handler = parsed.hooks.tool_call_pre[0].hooks[0]; + assert.equal(handler.command, 'node /path/to/hook.js'); + assert.equal(handler.type, 'command'); + assert.equal(handler.event, undefined, + 'handler body must not retain legacy `event` field after migration'); + }); + + test('legacy [hooks.] without location-tuple key continues to work unchanged', () => { + // Regression guard: the fix must not break the canonical legacy-map case + // ([hooks.] with handler-fields-only body, no `event` key). + const legacy = [ + '[hooks.session_start]', + 'command = "echo hi"', + '', + ].join('\n'); + + const migrated = migrateCodexHooksMapFormat(legacy); + const parsed = parseTomlToObject(migrated); + + assert.deepEqual( + Object.keys(parsed.hooks), + ['session_start'], + 'bare-event legacy shape must continue to migrate to event-named leaf key' + ); + assert.equal(parsed.hooks.session_start[0].hooks[0].command, 'echo hi'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3360-codex-execute-phase-worktrees.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3360-codex-execute-phase-worktrees (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for bug #3360. + * + * Codex does not have a direct equivalent of Claude Code's + * `Agent(... isolation="worktree")`. The execute-phase workflow must fail + * closed for Codex + workflow.use_worktrees=true instead of spawning + * workspace-write executors in the main checkout. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const EXECUTE_PHASE = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md'); +const { getCodexSkillAdapterHeader } = require('../bin/install.js'); + +function parseWorkflowSteps(content) { + return [...content.matchAll(/]*>([\s\S]*?)<\/step>/g)] + .map((match) => { + const body = match[2]; + return { + name: match[1], + // After #3797 architectural fix, callsites use gsd_run + readsRuntimeConfig: body.includes('RUNTIME=$(gsd_run query config-get runtime --default claude'), + // #1521: guard generalized from Codex-specific to all non-Claude runtimes + codexWorktreeGuard: body.includes('git worktree isolation') && body.includes('unsupported on runtime'), + worktreeDispatchGuidance: body.includes('isolation="worktree"'), + }; + }); +} + +function executePhaseWorktreeContract(content) { + const steps = parseWorkflowSteps(content); + const initializeIndex = steps.findIndex((step) => step.name === 'initialize'); + const firstWorktreeDispatchIndex = steps.findIndex((step) => step.worktreeDispatchGuidance); + assert.notEqual(initializeIndex, -1, 'workflow must have an initialize step'); + assert.notEqual(firstWorktreeDispatchIndex, -1, 'workflow must still document worktree dispatch guidance'); + + const initialize = steps[initializeIndex]; + return { + initializeReadsRuntimeConfig: initialize.readsRuntimeConfig, + initializeHasCodexWorktreeGuard: initialize.codexWorktreeGuard, + guardStepPrecedesWorktreeDispatch: initializeIndex <= firstWorktreeDispatchIndex, + }; +} + +describe('#3360 — Codex execute-phase fails closed for unsupported worktree isolation', () => { + test('execute-phase reads runtime before worktree dispatch and blocks Codex worktree mode', () => { + const workflow = fs.readFileSync(EXECUTE_PHASE, 'utf8'); + const contract = executePhaseWorktreeContract(workflow); + + assert.deepEqual(contract, { + initializeReadsRuntimeConfig: true, + initializeHasCodexWorktreeGuard: true, + guardStepPrecedesWorktreeDispatch: true, + }); + }); + + test('Codex adapter documents that worktree isolation has no direct spawn_agent mapping', () => { + const header = getCodexSkillAdapterHeader('gsd-execute-phase'); + assert.match(header, /isolation="worktree"/); + assert.match(header, /no direct Codex mapping/i); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3426-codex-windows-hooks.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3426-codex-windows-hooks (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +/** + * Bug #3426 — Codex on Windows: SessionStart/PostToolUse hooks fail with exit code 1 + * + * After PRs #3396/#3397 fixed bare-bash and quote-escaping issues, a new failure + * mode appeared on v1.42.3+: + * + * Failed with non-blocking status code: + * C:/Program Files/Git/bin/bash.exe: C:/Program Files/Git/bin/bash.exe: cannot execute binary file + * + * Root cause: Codex on Windows runs hook commands from a PowerShell/cmd + * execution environment (see install.js comment at buildHookCommand). The + * command string written to hooks.json was: + * + * "C:/Program Files/nodejs/node.exe" "C:/path/.codex/hooks/gsd-check-update.js" + * + * When Codex's hook runner passes this to its subprocess spawner, the quoted + * path resolves through Git Bash (MSYS), which then tries to POSIX-exec + * node.exe — a Windows PE binary — via the MSYS exec layer. The MSYS exec + * path calls execvp() on the PE binary directly, which fails with ENOEXEC, + * reported as "cannot execute binary file". The "bash.exe: bash.exe:" prefix + * appears because the error propagates through the bash.exe process that Codex + * uses as its hook-dispatch shell. + * + * Fix: on Windows, write a .cmd shim (using the same buildWindowsShimTriple + * IR pattern as gsd-sdk.cmd) and put the .cmd path as the hooks.json command. + * cmd.exe executes .cmd files natively via CreateProcess — no POSIX exec layer, + * no MSYS shebang walk. + * + * Test strategy: + * - Assert on the typed IR returned by buildCodexHookWindowsShimIR — not on + * rendered .cmd text (per CONTRIBUTING.md L558-L565 IR-first discipline). + * - Counter-tests confirm darwin/linux paths are unchanged. + * + * NOTE: Windows wall-clock verification depends on Docker matrix Windows + * runners. Local test exercises the generator IR shape only. + */ + +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 INSTALL = require('../bin/install.js'); +const PROJECTION = require('../gsd-core/bin/lib/shell-command-projection.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { + buildCodexHookWindowsShimIR, + ensureCodexHooksJsonSessionStart, + resolveNodeRunner, + uninstall, +} = INSTALL; + +const { projectManagedHookCommand } = PROJECTION; + +/** + * Extract hook handler objects for `eventName` from a hooks.json object. + * Handles both the legacy top-level shape { SessionStart: [...] } and the + * canonical nested shape { hooks: { SessionStart: [...] } } (bug #1348). + */ +function hookHandlersForEvent(hooksJson, eventName) { + if (!hooksJson || typeof hooksJson !== 'object') return []; + const table = + hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks) + ? hooksJson.hooks + : hooksJson; + if (!Array.isArray(table[eventName])) return []; + return table[eventName].flatMap((e) => Array.isArray(e && e.hooks) ? e.hooks : []); +} + +// ─── Step 1: Export surface check ──────────────────────────────────────────── + +describe('#3426 — export surface: buildCodexHookWindowsShimIR must be exported', () => { + test('buildCodexHookWindowsShimIR is a function', () => { + assert.equal(typeof buildCodexHookWindowsShimIR, 'function', + 'buildCodexHookWindowsShimIR must be exported from bin/install.js'); + }); + + test('ensureCodexHooksJsonSessionStart is a function', () => { + assert.equal(typeof ensureCodexHooksJsonSessionStart, 'function', + 'ensureCodexHooksJsonSessionStart must be exported from bin/install.js'); + }); +}); + +// ─── Step 2: Typed IR shape for Windows Codex hook shim ────────────────────── + +describe('#3426 — buildCodexHookWindowsShimIR: typed IR (not rendered text)', () => { + const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js'; + const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"'; + + test('returns typed IR with invocation, cmdPath, and render factory', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + // IR shape assertion — per CONTRIBUTING.md L558 IR-first discipline + assert.ok(ir && typeof ir === 'object', 'must return an object'); + assert.ok(typeof ir.invocation === 'object', 'must have invocation record'); + assert.ok(typeof ir.cmdPath === 'string', 'must have cmdPath string'); + assert.ok(typeof ir.hookCommand === 'string', 'must have hookCommand string (written to hooks.json)'); + assert.ok(typeof ir.render === 'object', 'must have render factory'); + assert.ok(typeof ir.render.cmd === 'function', 'must have render.cmd() factory'); + }); + + test('invocation.target equals the resolved script path', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + // invocation.target is the JS file being wrapped — same IR contract as buildWindowsShimTriple + assert.ok( + ir.invocation.target.includes('gsd-check-update.js'), + `invocation.target must reference the hook script, got: ${ir.invocation.target}`, + ); + }); + + test('invocation.interpreter is the node runner (not bash)', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + // The shim must invoke node, never bash — bash is not a valid Codex hook runner on Windows + const interp = ir.invocation.interpreter; + assert.ok( + typeof interp === 'string' && (interp.includes('node') || interp === 'node'), + `invocation.interpreter must be a node path, not bash. Got: ${interp}`, + ); + assert.ok( + !interp.toLowerCase().includes('bash'), + `invocation.interpreter must NOT be bash — bash is the source of the #3426 failure. Got: ${interp}`, + ); + }); + + test('cmdPath ends with .cmd extension', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + assert.ok( + ir.cmdPath.endsWith('.cmd'), + `cmdPath must end with .cmd for cmd.exe native execution, got: ${ir.cmdPath}`, + ); + }); + + test('hookCommand is the .cmd path (not a "runner script.js" string)', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + // The hook command written to hooks.json must be the .cmd path, not "node.exe script.js" + // because cmd.exe executes .cmd natively without POSIX exec layer + assert.ok( + ir.hookCommand.includes('.cmd'), + `hookCommand must reference the .cmd shim, got: ${ir.hookCommand}`, + ); + // hookCommand must NOT contain bash — this was the failure mode + assert.ok( + !ir.hookCommand.toLowerCase().includes('bash'), + `hookCommand must NOT reference bash, got: ${ir.hookCommand}`, + ); + }); + + test('returns null when absoluteRunnerToken is null (caller skips registration)', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, null); + assert.equal(ir, null, + 'must return null when runner is unavailable so caller can warn-and-skip'); + }); +}); + +// ─── Step 2b: Typed IR — eol / quoting / passthroughArgs ───────────────────── +// Per CONTRIBUTING.md L558-L565: assert on the typed IR, not on rendered text. +// These assertions cover the three bug-critical render semantics that +// text-matching tests would miss (silent EOL/quoting/passthrough regressions). + +describe('#3426 — buildCodexHookWindowsShimIR: typed IR eol / quoting / passthroughArgs', () => { + const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js'; + const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"'; + + test('eol.cmd is CRLF (\\r\\n) — canonical for cmd.exe .cmd files', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + assert.ok(ir && typeof ir.eol === 'object', 'IR must expose an eol field'); + assert.strictEqual( + ir.eol.cmd, + '\r\n', + 'eol.cmd must be CRLF (\\r\\n) — LF-only .cmd files risk silent parse failures on some Windows versions', + ); + }); + + test('invocation.target has no shell-metachar leakage (clean absolute path)', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + const target = ir.invocation.target; + assert.ok(typeof target === 'string' && target.length > 0, 'invocation.target must be a non-empty string'); + // The target stored in the IR is the raw unquoted path — quoting happens at + // render time. A metachar in the raw value means the IR is already corrupted. + assert.ok( + !target.includes('"') && !target.includes("'") && !target.includes('`'), + `invocation.target must be the raw path without shell quoting, got: ${target}`, + ); + assert.ok( + target.endsWith('.js'), + `invocation.target must resolve to the .js script, got: ${target}`, + ); + }); + + test('passthroughArgs is true — shim forwards all args via %*', () => { + const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER); + assert.strictEqual( + ir.passthroughArgs, + true, + 'passthroughArgs must be true: the .cmd shim must forward all arguments to the node script via %*', + ); + }); +}); + +// ─── Step 3: Counter-test — non-Windows platforms use node-runner command ──── + +describe('#3426 counter-test: darwin/linux Codex paths use node-runner command (not .cmd shim)', () => { + test('projectManagedHookCommand on darwin emits node-runner command, not .cmd', () => { + const runner = resolveNodeRunner() || '"/usr/local/bin/node"'; + const cmd = projectManagedHookCommand({ + absoluteRunner: runner, + scriptPath: '/Users/me/.codex/hooks/gsd-check-update.js', + runtime: 'codex', + platform: 'darwin', + }); + assert.ok(typeof cmd === 'string', 'must return a string on darwin'); + assert.ok(!cmd.endsWith('.cmd'), 'darwin command must NOT reference a .cmd shim'); + assert.ok( + cmd.includes('gsd-check-update.js'), + `darwin command must reference the .js hook directly, got: ${cmd}`, + ); + }); + + test('projectManagedHookCommand on linux emits node-runner command, not .cmd', () => { + const runner = resolveNodeRunner() || '"/usr/local/bin/node"'; + const cmd = projectManagedHookCommand({ + absoluteRunner: runner, + scriptPath: '/home/me/.codex/hooks/gsd-check-update.js', + runtime: 'codex', + platform: 'linux', + }); + assert.ok(typeof cmd === 'string', 'must return a string on linux'); + assert.ok(!cmd.endsWith('.cmd'), 'linux command must NOT reference a .cmd shim'); + assert.ok( + cmd.includes('gsd-check-update.js'), + `linux command must reference the .js hook directly, got: ${cmd}`, + ); + }); +}); + +// ─── Step 4: Integration — ensureCodexHooksJsonSessionStart on win32 writes .cmd shim ── + +describe('#3426 integration: ensureCodexHooksJsonSessionStart on win32 writes .cmd shim', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-3426-'); + fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true }); + // Stub the hook file that must exist for the hook to be registered + fs.writeFileSync( + path.join(tmpDir, 'hooks', 'gsd-check-update.js'), + '#!/usr/bin/env node\nconsole.log("ok");\n', + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('win32: hooks.json command references .cmd shim (not "node.exe script.js")', () => { + const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; + + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'win32', + }); + + assert.ok(result.wrote || result.changed, 'must write hooks.json on win32'); + + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must exist after install'); + + const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + // #1348: hooks.json is now always written in nested { hooks: { ... } } shape + const commands = hookHandlersForEvent(hooksJson, 'SessionStart') + .map((h) => h && h.command) + .filter((c) => typeof c === 'string'); + + assert.ok(commands.length > 0, 'must have at least one SessionStart hook command'); + + const cmd = commands.find((c) => c.includes('gsd-check-update')); + assert.ok(cmd, 'must have a gsd-check-update hook command'); + + // KEY ASSERTION: on win32, the command must reference a .cmd file — not bash + assert.ok( + cmd.includes('.cmd'), + `win32 hook command must reference a .cmd shim to avoid bash.exe exec failure (#3426). Got: ${cmd}`, + ); + assert.ok( + !cmd.toLowerCase().includes('bash'), + `win32 hook command must NOT reference bash.exe — this was the #3426 failure. Got: ${cmd}`, + ); + }); + + test('win32: .cmd shim file is written to the hooks directory', () => { + const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; + + ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'win32', + }); + + const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); + assert.ok( + fs.existsSync(cmdShimPath), + `win32: .cmd shim must be written at ${cmdShimPath}`, + ); + // File must be non-empty — structure check only (IR-first discipline) + const size = fs.statSync(cmdShimPath).size; + assert.ok(size > 0, '.cmd shim must have non-zero content'); + }); + + test('non-Windows (darwin): hooks.json command is "node.exe script.js" (no .cmd shim)', () => { + const fakeRunner = '"/usr/local/bin/node"'; + + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'darwin', + }); + + assert.ok(result.wrote || result.changed, 'must write hooks.json on darwin'); + + const hooksJson = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'), + ); + // #1348: hooks.json is now always written in nested { hooks: { ... } } shape + const commands = hookHandlersForEvent(hooksJson, 'SessionStart') + .map((h) => h && h.command) + .filter((c) => typeof c === 'string'); + + const cmd = commands.find((c) => c.includes('gsd-check-update')); + assert.ok(cmd, 'must have a gsd-check-update hook command on darwin'); + + // Counter-test: darwin must NOT use a .cmd shim + assert.ok( + !cmd.endsWith('.cmd'), + `darwin hook command must NOT reference a .cmd shim, got: ${cmd}`, + ); + assert.ok( + cmd.includes('gsd-check-update.js'), + `darwin hook command must reference the .js file directly, got: ${cmd}`, + ); + + // .cmd shim must NOT be written on darwin + const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); + assert.ok( + !fs.existsSync(cmdShimPath), + 'darwin must NOT write a .cmd shim', + ); + }); + + test('non-Windows (linux): same as darwin — no .cmd shim', () => { + const fakeRunner = '"/usr/local/bin/node"'; + + ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + + const hooksJson = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'), + ); + // #1348: hooks.json is now always written in nested { hooks: { ... } } shape + const commands = hookHandlersForEvent(hooksJson, 'SessionStart') + .map((h) => h && h.command) + .filter((c) => typeof c === 'string'); + + const cmd = commands.find((c) => c.includes('gsd-check-update')); + assert.ok(cmd, 'linux must have a gsd-check-update hook command'); + assert.ok(!cmd.endsWith('.cmd'), 'linux must NOT use a .cmd shim'); + + const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); + assert.ok(!fs.existsSync(cmdShimPath), 'linux must NOT write a .cmd shim'); + }); +}); + +// ─── Step 5: Uninstall cleanup — .cmd shim removed from disk ───────────────── + +describe('#3426 uninstall: gsd-check-update.cmd is removed from hooks dir on uninstall', () => { + let tmpDir; + + function withCodexHome(dir, fn) { + const prev = process.env.CODEX_HOME; + process.env.CODEX_HOME = dir; + try { return fn(); } + finally { + if (prev == null) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prev; + } + } + + beforeEach(() => { + tmpDir = createTempDir('gsd-3426-uninstall-'); + fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true }); + // Write the .js hook (required by install) and a pre-existing .cmd shim + fs.writeFileSync( + path.join(tmpDir, 'hooks', 'gsd-check-update.js'), + '#!/usr/bin/env node\nconsole.log("ok");\n', + ); + fs.writeFileSync( + path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'), + '@ECHO OFF\r\n@SETLOCAL\r\n@"C:/node.exe" "C:/path/gsd-check-update.js" %*\r\n', + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('uninstall removes gsd-check-update.cmd from hooks directory', () => { + const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'); + assert.ok(fs.existsSync(cmdShimPath), 'pre-condition: .cmd shim exists before uninstall'); + + withCodexHome(tmpDir, () => uninstall(true, 'codex')); + + assert.ok( + !fs.existsSync(cmdShimPath), + `gsd-check-update.cmd must be removed from disk on uninstall — orphaned .cmd shim would cause stale hook references. Path: ${cmdShimPath}`, + ); + }); +}); + +// ─── Step 6: Upgrade path — existing win32 hooks.json with node-runner command ─ + +describe('#3426 upgrade: reinstall on win32 migrates existing "node script.js" to .cmd shim', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-3426-upgrade-'); + fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'hooks', 'gsd-check-update.js'), + '#!/usr/bin/env node\nconsole.log("ok");\n', + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('replaces old "node.exe script.js" command with .cmd shim on win32 reinstall', () => { + const managedHookPath = path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); + // Pre-existing stale hooks.json with node-runner command (v1.42.3 shape) + const staleLegacyCommand = `"C:/Program Files/nodejs/node.exe" "${managedHookPath}"`; + fs.writeFileSync( + path.join(tmpDir, 'hooks.json'), + JSON.stringify({ + SessionStart: [{ hooks: [{ type: 'command', command: staleLegacyCommand }] }], + }, null, 2), + ); + + const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; + ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'win32', + }); + + const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8')); + // #1348: hooks.json is now always written in nested { hooks: { ... } } shape + const commands = hookHandlersForEvent(hooksJson, 'SessionStart') + .map((h) => h && h.command) + .filter((c) => typeof c === 'string'); + + const gsdCmds = commands.filter((c) => c.includes('gsd-check-update')); + // Exactly one managed hook after migration — no duplicates + assert.equal(gsdCmds.length, 1, `must have exactly 1 gsd-check-update command after migration, got: ${JSON.stringify(gsdCmds)}`); + + // Must be the .cmd shim + assert.ok( + gsdCmds[0].includes('.cmd'), + `migrated command must reference .cmd shim, got: ${gsdCmds[0]}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3427-3433-codex-install-shape.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3427-3433-codex-install-shape (consolidation epic #1969 B1 #1970)", () => { +'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 crypto = require('node:crypto'); +const { execFileSync } = require('node:child_process'); + +const { install, uninstall, parseTomlToObject } = require('../bin/install.js'); +const { createTempDir, cleanup, parseFrontmatter } = 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 prev = process.env.CODEX_HOME; + process.env.CODEX_HOME = codexHome; + try { + return fn(); + } finally { + if (prev == null) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prev; + } +} + +function extractSessionStartCommandsFromHooksJson(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return []; + const table = (value.hooks && typeof value.hooks === 'object' && !Array.isArray(value.hooks)) + ? value.hooks + : value; + const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; + return sessionStart.flatMap((entry) => { + const hooks = entry && Array.isArray(entry.hooks) ? entry.hooks : []; + return hooks.map((h) => h && h.command).filter((cmd) => typeof cmd === 'string'); + }); +} + +describe('#3427 + #3433 — Codex installer avoids duplicate skills and mixed hook representation', { 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-3427-3433-'); + codexHome = path.join(tmpRoot, '.codex'); + fs.mkdirSync(codexHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpRoot); + }); + + test('regenerates managed gsd-* skill copies and preserves unrelated user skills (#3562 reverses prior #3427/#3433 behaviour)', () => { + // Stale legacy body — fresh install must overwrite this so Codex sees the + // current SKILL.md, not whatever was last on disk. + const legacySkillBody = '# old managed\n'; + fs.mkdirSync(path.join(codexHome, 'skills', 'gsd-help'), { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md'), legacySkillBody); + const legacyHash = crypto.createHash('sha256').update(legacySkillBody).digest('hex'); + fs.writeFileSync(path.join(codexHome, 'gsd-file-manifest.json'), JSON.stringify({ + version: 1, + files: { + 'skills/gsd-help/SKILL.md': legacyHash, + }, + }, null, 2)); + + fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'), '# user skill\n'); + + withCodexHome(codexHome, () => install(true, 'codex')); + + const skillsDir = path.join(codexHome, 'skills'); + const entries = fs.existsSync(skillsDir) + ? fs.readdirSync(skillsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) + : []; + + // #3562: $gsd-* commands are discoverable only when skills/gsd-*/SKILL.md + // exists. The installer must regenerate (not remove) the managed gsd-* + // directories. + assert.equal(entries.includes('gsd-help'), true); + const refreshedBody = fs.readFileSync(path.join(skillsDir, 'gsd-help', 'SKILL.md'), 'utf8'); + assert.notEqual(refreshedBody, legacySkillBody, 'stale legacy body must be overwritten'); + const frontmatter = parseFrontmatter(refreshedBody); + assert.equal(frontmatter.name, 'gsd-help', 'refreshed SKILL.md frontmatter must declare name: gsd-help'); + + // Unrelated user skills are preserved — the regen scope is `gsd-*` only. + assert.equal(entries.includes('custom-user-skill'), true); + }); + + test('stores managed SessionStart update hook in hooks.json and removes inline gsd hook from config.toml', () => { + const configToml = [ + '[features]', + 'codex_hooks = true', + '', + '[[hooks.SessionStart]]', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + 'command = "node /tmp/legacy/.codex/hooks/gsd-check-update.js"', + '', + ].join('\n'); + fs.writeFileSync(path.join(codexHome, 'config.toml'), configToml); + + fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({ + SessionStart: [ + { + hooks: [ + { type: 'command', command: 'node "/Users/example/bin/user-hook.js"' }, + ], + }, + ], + }, null, 2)); + + withCodexHome(codexHome, () => install(true, 'codex')); + + const parsedToml = parseTomlToObject(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8')); + const tomlSessionStart = parsedToml.hooks?.SessionStart ?? []; + const tomlCommands = tomlSessionStart.flatMap((entry) => + (Array.isArray(entry?.hooks) ? entry.hooks : []).map((hook) => hook.command).filter((cmd) => typeof cmd === 'string') + ); + assert.equal(tomlCommands.some((cmd) => cmd.includes('gsd-check-update.js')), false); + + const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); + const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson); + const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update')); + + assert.equal(gsdCommands.length, 1); + assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true); + }); + + test('uninstall removes managed SessionStart hook from hooks.json but preserves user hooks', () => { + const hooksDir = path.join(codexHome, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, 'gsd-check-update.js'), '// managed hook\n'); + const managedHookPath = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); + + fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({ + SessionStart: [ + { + hooks: [ + { type: 'command', command: `node "${managedHookPath}"` }, + { type: 'command', command: 'node "/Users/example/bin/user-hook.js"' }, + ], + }, + ], + }, null, 2)); + + withCodexHome(codexHome, () => uninstall(true, 'codex')); + + const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')); + const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson); + // On Windows the managed hook is the .cmd shim path; on POSIX it is the .js node-runner command. + // Either way the managed hook is gone after uninstall — only the user hook remains. + const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update')); + + assert.equal(gsdCommands.length, 0); + assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3562-codex-install-skill-surface.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3562-codex-install-skill-surface (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Regression test for bug #3562 — Codex global install must create a + * discoverable $gsd-* skill surface. + * + * Codex CLI 0.130.0 (the version in the issue report) does NOT auto-discover + * commands from gsd-core/workflows/*.md or agents/*.md. It only registers + * commands from skills//SKILL.md. Prior installer logic ("Codex now + * discovers official skills from .agents/skills") was based on an assumption + * that does not match the shipping Codex CLI behavior, leaving users with + * workflows on disk and no $gsd-* entrypoints after `npx @opengsd/gsd-core + * --codex --global`. + * + * Fix: re-wire copyCommandsAsCodexSkills() back into the install dispatch path + * so the same skill-shape that Claude / Copilot / Antigravity / Cursor / + * Windsurf / Augment / Trae installs produce is also produced for Codex. + */ + +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 { install } = require('../bin/install.js'); +const { createTempDir, cleanup, parseFrontmatter } = 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 prev = process.env.CODEX_HOME; + process.env.CODEX_HOME = codexHome; + try { + return fn(); + } finally { + if (prev == null) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prev; + } +} + +describe('#3562 — Codex install produces discoverable $gsd-* skill surface', { 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-3562-'); + codexHome = path.join(tmpRoot, '.codex'); + fs.mkdirSync(codexHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpRoot); + }); + + test('global install creates skills/gsd-help/SKILL.md', () => { + withCodexHome(codexHome, () => install(true, 'codex')); + + const skillPath = path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md'); + assert.ok( + fs.existsSync(skillPath), + `Codex install must create ${skillPath} so $gsd-help is discoverable. ` + + 'Without this, Codex CLI 0.130.0 does not expose any $gsd-* command.', + ); + }); + + test('SKILL.md content has frontmatter expected by Codex skill discovery', () => { + withCodexHome(codexHome, () => install(true, 'codex')); + + const skillPath = path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md'); + assert.ok(fs.existsSync(skillPath), 'precondition: SKILL.md exists'); + + const content = fs.readFileSync(skillPath, 'utf8'); + const frontmatter = parseFrontmatter(content); + assert.equal(frontmatter.name, 'gsd-help', 'SKILL.md frontmatter must declare name: gsd-help so $gsd-help resolves'); + }); + + test('multiple core $gsd-* skills are produced (not just gsd-help)', () => { + withCodexHome(codexHome, () => install(true, 'codex')); + + const skillsDir = path.join(codexHome, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ directory must exist after install'); + + const gsdSkills = fs + .readdirSync(skillsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name.startsWith('gsd-')) + .map((e) => e.name); + + // Lower bound — exact count depends on the current command surface. The + // commands/gsd/ directory holds dozens of *.md files; expecting more than + // 10 generated skills is a conservative floor that catches "we generated + // nothing" or "we only generated one accidentally" regressions. + assert.ok( + gsdSkills.length >= 10, + `Expected >= 10 generated gsd-* skill directories, found ${gsdSkills.length}: ${gsdSkills.join(', ')}`, + ); + }); + + test('install preserves existing user skills (does not remove unrelated dirs)', () => { + fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true }); + fs.writeFileSync( + path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'), + '---\nname: custom-user-skill\n---\n# user skill\n', + ); + + withCodexHome(codexHome, () => install(true, 'codex')); + + const userSkill = path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'); + assert.ok( + fs.existsSync(userSkill), + 'Codex install must preserve existing non-gsd user skill directories', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3566-codex-hooks-feature-canonical-key.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3566-codex-hooks-feature-canonical-key (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Regression tests for bug #3566 — Codex installer must emit canonical + * [features].hooks (not the legacy [features].codex_hooks). + * + * Codex itself marks `codex_hooks` as a `legacy_key` in + * codex-rs/features/src/legacy.rs. The canonical current feature flag is + * `hooks`. The GSD installer was still writing `codex_hooks` on every fresh + * install / reinstall, leaving deprecated config behind. This file pins: + * + * 1. Fresh install writes canonical `[features].hooks = true` and never + * emits `codex_hooks` (section, root-dotted, or block-fallback forms). + * 2. Reinstall over a GSD-owned section-form legacy + * `[features].codex_hooks = true` migrates forward to + * `[features].hooks = true` (legacy line removed); user-owned legacy + * entries are preserved per #2760. + * 3. Reinstall over a GSD-owned root-dotted legacy + * `features.codex_hooks = true` migrates forward to + * `features.hooks = true`; user-owned legacy entries are preserved. + * 4. Reinstall over a user-owned `[features].hooks = true` (no GSD + * ownership marker) preserves the user line; no double-write, no + * ownership stamp. + * 5. The `hasEnabledCodexHooksFeature` recognizer treats both canonical + * `hooks` AND legacy `codex_hooks` as "enabled" so existing installs + * keep working across the migration window. + * 6. Uninstall removes either GSD-owned `hooks` or GSD-owned legacy + * `codex_hooks`; user-owned `hooks` is preserved. + * + * All assertions use parseTomlToObject — never substring-match on raw TOML + * text (per RULESET.TESTS.no-source-grep). The product surface is the + * parsed config shape, not the file's lexical layout. + */ + +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 { install, uninstall, parseTomlToObject } = require('../bin/install.js'); +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 prev = process.env.CODEX_HOME; + process.env.CODEX_HOME = codexHome; + try { + return fn(); + } finally { + if (prev == null) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prev; + } +} + +function readConfig(codexHome) { + const text = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + return { text, parsed: parseTomlToObject(text) }; +} + +function featuresHooks(parsed) { + return parsed?.features?.hooks; +} + +function featuresCodexHooks(parsed) { + return parsed?.features?.codex_hooks; +} + +describe('#3566 — Codex feature flag is canonical "hooks" (not legacy "codex_hooks")', { 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-3566-'); + codexHome = path.join(tmpRoot, '.codex'); + fs.mkdirSync(codexHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpRoot); + }); + + test('fresh install writes [features].hooks = true and never emits codex_hooks', () => { + withCodexHome(codexHome, () => install(true, 'codex')); + const { parsed } = readConfig(codexHome); + + assert.strictEqual( + featuresHooks(parsed), + true, + 'fresh install must write canonical [features].hooks = true', + ); + assert.strictEqual( + featuresCodexHooks(parsed), + undefined, + 'fresh install must NOT write legacy [features].codex_hooks', + ); + }); + + test('install over a pre-existing legacy [features].codex_hooks line preserves it (user-owned, #2760 defensive)', () => { + // A user who hand-wrote `codex_hooks = true` keeps the legacy key. + // Codex itself maps it via the runtime legacy_key alias, so this is + // forward-compatible without GSD rewriting user-authored content. + const legacy = [ + '[features]', + 'codex_hooks = true', + '', + ].join('\n'); + fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy); + + withCodexHome(codexHome, () => install(true, 'codex')); + const { parsed } = readConfig(codexHome); + + assert.strictEqual( + featuresCodexHooks(parsed), + true, + 'user-owned legacy codex_hooks line must be preserved verbatim', + ); + }); + + test('install over a pre-existing legacy root-dotted features.codex_hooks line preserves it', () => { + const legacy = 'features.codex_hooks = true\n'; + fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy); + + withCodexHome(codexHome, () => install(true, 'codex')); + const { parsed } = readConfig(codexHome); + + assert.strictEqual( + featuresCodexHooks(parsed), + true, + 'user-owned root-dotted legacy line must be preserved verbatim', + ); + }); + + test('reinstall preserves user-owned [features].hooks = true (no GSD ownership marker)', () => { + const userOwned = [ + '[features]', + 'hooks = true', + '', + ].join('\n'); + fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned); + + withCodexHome(codexHome, () => install(true, 'codex')); + const { parsed } = readConfig(codexHome); + + assert.strictEqual( + featuresHooks(parsed), + true, + 'user-owned hooks=true must be preserved', + ); + }); + + test('uninstall removes GSD-owned canonical hooks line but preserves user-owned hooks', () => { + // Phase 1: fresh GSD install — writes GSD-owned hooks line. + withCodexHome(codexHome, () => install(true, 'codex')); + const { parsed: afterInstall } = readConfig(codexHome); + assert.strictEqual( + featuresHooks(afterInstall), + true, + 'precondition: install wrote canonical hooks', + ); + + withCodexHome(codexHome, () => uninstall(true, 'codex')); + const configPath = path.join(codexHome, 'config.toml'); + if (!fs.existsSync(configPath)) { + // Uninstall may delete config.toml entirely when nothing user-owned + // remains — that is the strongest possible "feature flag removed" + // signal and counts as success. + return; + } + const { parsed: afterUninstall } = readConfig(codexHome); + assert.notStrictEqual( + featuresHooks(afterUninstall), + true, + 'uninstall must remove GSD-owned canonical hooks line', + ); + }); + + test('uninstall preserves user-owned hooks=true when GSD never owned it', () => { + const userOwned = [ + '[features]', + 'hooks = true', + '', + ].join('\n'); + fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned); + + withCodexHome(codexHome, () => uninstall(true, 'codex')); + const { parsed } = readConfig(codexHome); + + assert.strictEqual( + featuresHooks(parsed), + true, + 'uninstall must NOT touch a hooks line GSD never claimed ownership of (#2760 defensive principle)', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3582-codex-skills-materialized.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3582-codex-skills-materialized (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for bug #3582 — Codex install must materialize the skill + * surface under `~/.codex/skills//SKILL.md`. + * + * Background: GSD 1.42.2 reported the user-visible failure + * > Skipped Codex skill-copy generation (Codex discovers official skills directly) + * which left users with a "successful" install but no routable `$gsd-*` + * entrypoints in Codex CLI 0.130.0. Codex CLI does NOT auto-discover + * commands from `~/.codex/gsd-core/workflows/*.md` or `agents/*.md`; + * it only registers slash commands derived from `~/.codex/skills//SKILL.md`. + * The "Codex discovers official skills directly" assumption was wrong. + * + * The current installer (#3562 / current main) calls + * `copyCommandsAsCodexSkills()` to materialize one SKILL.md per + * commands/gsd/*.md, with Claude-flavored command frontmatter rewritten + * into Codex skill frontmatter and the `` body + * produced by `getCodexSkillAdapterHeader()`. + * + * This test locks the install contract so the 1.42.2 regression cannot + * silently come back. It asserts the full expected skill-name set + * (deepStrictEqual, not just count), the full adapter block (using + * the exported `getCodexSkillAdapterHeader` IR as the expected value, + * not raw substring search), and the success/skip log invariant. + */ +// allow-test-rule: source-text-is-the-product (see #3582) +// This assertion validates the generated adapter block that is shipped to +// users in SKILL.md; matching exact emitted text is the contract under test. + +'use strict'; + +// GSD_TEST_MODE neutralizes side-effecting branches (auto-detection, etc.). +// Must be set BEFORE requiring bin/install.js; scoped to module load only +// so downstream tests don't see it. Mirrors the bug-2760 codex harness. +const previousGsdTestMode = process.env.GSD_TEST_MODE; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const { install, getCodexSkillAdapterHeader } = require('../bin/install.js'); +const { parseFrontmatter, createTempDir, cleanup } = require('./helpers.cjs'); + +if (previousGsdTestMode === undefined) { + delete process.env.GSD_TEST_MODE; +} else { + process.env.GSD_TEST_MODE = previousGsdTestMode; +} + +const ROOT = path.join(__dirname, '..'); +const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); + +// Strip ANSI color codes so log assertions don't depend on TTY detection. +function stripAnsi(s) { + // 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 + return s.replace(/\x1b\[[0-9;]*m/g, ''); +} + +function assertNoBareGsdToolsInvocation(content, label) { + const patterns = [ + /(^|\n)[ \t]*gsd-tools\s/, + /\$\(\s*gsd-tools\s/, + /`\s*gsd-tools\s/, + /(?:&&|\|\||[;|])\s*gsd-tools\s/, + ]; + for (const pattern of patterns) { + assert.doesNotMatch( + content, + pattern, + `${label} must not contain a command-position bare gsd-tools invocation`, + ); + } +} + +/** + * Walk commands/gsd/**\/*.md and return the set of skill names the installer + * is contractually obligated to produce. Naming rule mirrors + * `copyCommandsAsCodexSkills` in bin/install.js: nested dirs collapse to + * `gsd--` with the .md stripped. + */ +function expectedSkillNames() { + const names = new Set(); + function recurse(dir, prefix) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + recurse(path.join(dir, entry.name), `${prefix}-${entry.name}`); + } else if (entry.name.endsWith('.md')) { + const base = entry.name.slice(0, -3); + names.add(`${prefix}-${base}`); + } + } + } + recurse(COMMANDS_DIR, 'gsd'); + return names; +} + +/** + * Run a Codex global install into a temp CODEX_HOME and capture stdout/stderr. + * Cleans up codexHome on throw so a partial-install failure never leaks + * temp directories. + */ +function runCodexInstallCaptured() { + const codexHome = createTempDir('gsd-3582-codex-'); + const logs = []; + const warnings = []; + const origLog = console.log; + const origWarn = console.warn; + console.log = (...a) => { logs.push(a.join(' ')); }; + console.warn = (...a) => { warnings.push(a.join(' ')); }; + + const previousCodexHome = process.env.CODEX_HOME; + const previousCwd = process.cwd(); + process.env.CODEX_HOME = codexHome; + process.env.GSD_TEST_MODE = '1'; + try { + process.chdir(ROOT); + install(true, 'codex'); + return { codexHome, logs, warnings }; + } catch (err) { + // Always reclaim the temp dir if install throws — otherwise the + // describe-level afterEach can't see codexHome and it leaks. + try { cleanup(codexHome); } catch { /* best-effort */ } + throw err; + } finally { + process.chdir(previousCwd); + console.log = origLog; + console.warn = origWarn; + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previousCodexHome; + } + if (previousGsdTestMode === undefined) { + delete process.env.GSD_TEST_MODE; + } else { + process.env.GSD_TEST_MODE = previousGsdTestMode; + } + } +} + +// concurrency:false — harness mutates console.* / process.env / process.cwd(). +// Matches the convention used by tests/bug-3562-codex-install-skill-surface.test.cjs. +describe('bug-3582: Codex global install materializes the skill surface', { concurrency: false }, () => { + let installRun; + + beforeEach(() => { + installRun = runCodexInstallCaptured(); + }); + + afterEach(() => { + if (installRun && installRun.codexHome) { + cleanup(installRun.codexHome); + } + }); + + test('writes the exact expected set of gsd-*/SKILL.md skills (deepEqual on name set)', () => { + const skillsDir = path.join(installRun.codexHome, 'skills'); + assert.ok( + fs.existsSync(skillsDir), + `Codex install must create ${skillsDir} (the 1.42.2 regression skipped this entirely)`, + ); + + const actualNames = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) + .map(e => e.name); + + // deepStrictEqual on the sorted full set — not just count — so a + // partial install that drops a real command and substitutes a bogus + // same-count `gsd-*` directory cannot pass. + const expected = [...expectedSkillNames()].sort(); + assert.deepStrictEqual( + [...actualNames].sort(), + expected, + `installed Codex skills must exactly match commands/gsd/**/*.md (one skill per command)`, + ); + + // Every skill dir contains a non-empty SKILL.md file. Empty dirs or + // empty SKILL.md bodies would defeat Codex's slash-command + // registration as silently as the 1.42.2 "skipped" branch did. + for (const name of actualNames) { + const skillMd = path.join(skillsDir, name, 'SKILL.md'); + const stat = fs.statSync(skillMd); + assert.ok(stat.isFile(), `${skillMd} must be a regular file`); + assert.ok(stat.size > 0, `${skillMd} must not be empty`); + } + }); + + test('SKILL.md frontmatter declares hyphen-form name matching the directory', () => { + const skillsDir = path.join(installRun.codexHome, 'skills'); + const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) + .map(e => e.name); + + for (const name of skillDirs) { + const content = fs.readFileSync( + path.join(skillsDir, name, 'SKILL.md'), + 'utf-8', + ); + // Uses the shared `parseFrontmatter` from tests/helpers.cjs per the + // CONTRIBUTING.md "tests parse, never grep" convention. + const fm = parseFrontmatter(content); + assert.strictEqual( + fm.name, + name, + `SKILL.md name field must match directory name for ${name} (got ${JSON.stringify(fm.name)})`, + ); + assert.ok( + typeof fm.description === 'string' && fm.description.length > 0, + `SKILL.md description must be a non-empty string for ${name}`, + ); + } + }); + + test('SKILL.md body contains the full block produced by the exported builder', () => { + // Structural check against the production builder's output — NOT a + // raw substring grep on the rendered file. `getCodexSkillAdapterHeader` + // is the typed IR exported by bin/install.js (#3582 PR #3609 codex + // review); the file on disk must contain its full output verbatim + // (open tag, body, closing ``). A truncated, + // empty, or missing-closing-tag adapter cannot satisfy this assertion. + const skillsDir = path.join(installRun.codexHome, 'skills'); + const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) + .map(e => e.name); + + for (const name of skillDirs) { + const expectedAdapter = getCodexSkillAdapterHeader(name); + // Sanity: the builder itself must produce a closed block for the + // assertion below to be meaningful. + assert.ok( + expectedAdapter.startsWith(''), + `getCodexSkillAdapterHeader(${name}) must start with the opening tag`, + ); + assert.ok( + expectedAdapter.trimEnd().endsWith(''), + `getCodexSkillAdapterHeader(${name}) must end with the closing tag`, + ); + + const content = fs.readFileSync( + path.join(skillsDir, name, 'SKILL.md'), + 'utf-8', + ); + assert.ok( + content.includes(expectedAdapter), + `${name}/SKILL.md must contain the full adapter block produced by getCodexSkillAdapterHeader(${name}); Codex routes $${name} via this exact body`, + ); + } + }); + + test('representative skills named in the issue report are present', () => { + // The bug report and triage explicitly named these. Locking them as a + // representative set so a future dispatch / filter / profile change + // cannot drop just the commands the original user was trying to run. + const representative = [ + 'gsd-map-codebase', // the literal command from the bug report + 'gsd-execute-phase', + 'gsd-plan-phase', + 'gsd-new-project', + 'gsd-health', + ]; + const skillsDir = path.join(installRun.codexHome, 'skills'); + for (const name of representative) { + const skillMd = path.join(skillsDir, name, 'SKILL.md'); + assert.ok( + fs.existsSync(skillMd), + `${name}/SKILL.md must exist after Codex install (was unrouteable in 1.42.2)`, + ); + } + }); + + test('installed Codex skills do not ask agents to run bare gsd-tools commands', () => { + const skillsDir = path.join(installRun.codexHome, 'skills'); + const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) + .map(e => e.name); + + for (const name of skillDirs) { + const content = fs.readFileSync( + path.join(skillsDir, name, 'SKILL.md'), + 'utf-8', + ); + assertNoBareGsdToolsInvocation(content, `${name}/SKILL.md`); + } + }); + + test('installer success log mentions skills/ — never claims success while skipping', () => { + // The 1.42.2 user-visible failure mode was a successful install that + // printed "Skipped Codex skill-copy generation (Codex discovers + // official skills directly)" while leaving the user with no + // entrypoints. Lock that the broken strings can NEVER coexist with a + // success indicator. Current main prints "✓ Installed N skills". + const cleanLogs = installRun.logs.map(stripAnsi); + const cleanWarnings = installRun.warnings.map(stripAnsi); + const allOutput = [...cleanLogs, ...cleanWarnings].join('\n'); + + assert.ok( + !/Skipped Codex skill-copy generation/i.test(allOutput), + `installer must never print "Skipped Codex skill-copy generation" (1.42.2 failure). Output:\n${allOutput}`, + ); + assert.ok( + !/Codex discovers official skills directly/i.test(allOutput), + `installer must never claim "Codex discovers official skills directly" (1.42.2 incorrect assumption). Output:\n${allOutput}`, + ); + + // Positive proof — at least one log line acknowledges the skills install. + const hasSkillsInstalledLog = cleanLogs.some(line => /Installed\s+\d+\s+skills\s+to\s+skills\//.test(line)); + assert.ok( + hasSkillsInstalledLog, + `installer must print a success line of the form "Installed N skills to skills/". Logs:\n${cleanLogs.join('\n')}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3808-codex-adapter-text-mode-fallback.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3808-codex-adapter-text-mode-fallback (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for bug #3808. + * + * When Codex runs in Default mode, `request_user_input` is reported as + * unavailable. The Codex skill adapter must tell the agent to activate the + * workflow's built-in TEXT_MODE mechanism (`--text` flag) rather than either: + * (a) silently picking a default value — the #3018 failure mode, or + * (b) ad-hoc plain-text fallback that bypasses the workflow's own branching. + * + * Workflows (e.g. plan-phase.md) already have TEXT_MODE logic: + * "Set TEXT_MODE=true if `--text` is present in $ARGUMENTS OR text_mode + * from init JSON is true." + * The adapter must tell the agent to USE that mechanism when + * `request_user_input` is unavailable instead of inventing its own fallback + * or silently continuing with defaults. + * + * Test design: mirrors the typed-semantic-flag pattern from bug #3018 so that + * prose rewording doesn't break tests as long as the semantics stay correct. + */ + +'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 INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); +const { getCodexSkillAdapterHeader } = INSTALL; + +/** + * Extract the "Execute mode fallback" section text from the adapter header. + * Returns null if the section is missing. Section runs from the + * "Execute mode fallback:" label up to the next heading or tag. + */ +function extractExecuteModeFallback(header) { + const m = header.match(/Execute mode fallback:\s*\n([\s\S]*?)(?=\n##\s|\n<\/codex_skill_adapter>)/); + return m ? m[1].trim() : null; +} + +/** + * Parse the Execute-mode-fallback section into a typed semantic-flag record. + * + * Flags for bug #3808 (TEXT_MODE activation): + * activatesTextMode — does the prose tell the agent to activate TEXT_MODE / use --text? + * instructsStop — does the prose tell the agent to stop/halt/wait? + * presentsPlainText — does the prose mention plain-text / numbered-list presentation? + * silentlyPicksDefaults — (anti-pattern) does the prose instruct silent-default picking? + */ +function parseExecuteModeFallbackFor3808(section) { + if (!section || typeof section !== 'string') { + return { + ok: false, + sectionLength: 0, + activatesTextMode: false, + instructsStop: false, + presentsPlainText: false, + silentlyPicksDefaults: false, + }; + } + + const lower = section.toLowerCase(); + + // (a) TEXT_MODE activation — adapter must tell the agent to use the workflow's + // built-in text mode mechanism when request_user_input is unavailable. + // Accept either: explicit "--text" flag mention OR "text_mode" / "text mode" + // paired with context showing it is being SET/ACTIVATED (not just referenced). + const mentionsTextFlag = section.includes('--text'); + const mentionsTextModeOn = /text_mode\s*=\s*true|set\s+text_mode|activate\s+text.?mode|enable\s+text.?mode|text.?mode.*active|text.?mode.*on\b/i.test(section); + const activatesTextMode = mentionsTextFlag || mentionsTextModeOn; + + // (b) STOP/WAIT directive — the agent must halt instead of proceeding silently. + const instructsStop = /\b(stop|halt|wait)\b/.test(lower); + + // (c) Plain-text fallback presentation. + const presentsPlainText = /plain.?text|numbered list/.test(lower); + + // Anti-pattern guard — the prose that caused #3018 and resurfaces in #3808. + const silentlyPicksDefaults = /pick (a |the )?(reasonable|sensible|sane) default/i.test(section); + + return { + ok: true, + sectionLength: section.length, + activatesTextMode, + instructsStop, + presentsPlainText, + silentlyPicksDefaults, + }; +} + +describe('bug #3808: codex skill adapter activates TEXT_MODE when request_user_input is unavailable', () => { + const SKILL_NAMES = ['gsd-plan-phase', 'gsd-discuss-phase', 'gsd-execute-phase', 'gsd-verify-work']; + + test('getCodexSkillAdapterHeader is exported', () => { + assert.equal(typeof getCodexSkillAdapterHeader, 'function'); + }); + + test('Execute mode fallback section exists for all key skills', () => { + for (const skillName of SKILL_NAMES) { + const header = getCodexSkillAdapterHeader(skillName); + const section = extractExecuteModeFallback(header); + assert.ok(section !== null && section.length > 0, + `${skillName}: Execute mode fallback section must exist and have content`); + } + }); + + for (const skillName of SKILL_NAMES) { + test(`${skillName}: fallback activates TEXT_MODE (--text flag or text_mode=true) when request_user_input is unavailable`, () => { + const header = getCodexSkillAdapterHeader(skillName); + const section = extractExecuteModeFallback(header); + const parsed = parseExecuteModeFallbackFor3808(section); + assert.equal(parsed.activatesTextMode, true, + `${skillName}: fallback must instruct the agent to activate TEXT_MODE (mention --text flag or text_mode=true/active) when request_user_input is unavailable (#3808). Section was:\n${section}`); + }); + + test(`${skillName}: fallback instructs STOP/WAIT (not silent continuation)`, () => { + const header = getCodexSkillAdapterHeader(skillName); + const section = extractExecuteModeFallback(header); + const parsed = parseExecuteModeFallbackFor3808(section); + assert.equal(parsed.instructsStop, true, + `${skillName}: fallback must include stop/halt/wait instruction. Section was:\n${section}`); + }); + + test(`${skillName}: fallback does NOT contain silent-default anti-pattern`, () => { + const header = getCodexSkillAdapterHeader(skillName); + const section = extractExecuteModeFallback(header); + const parsed = parseExecuteModeFallbackFor3808(section); + assert.equal(parsed.silentlyPicksDefaults, false, + `${skillName}: regression — fallback must NOT instruct the agent to pick defaults autonomously (#3018 / #3808). Section was:\n${section}`); + }); + } + + test('typed semantic-record snapshot for gsd-plan-phase — full contract', () => { + const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-plan-phase')); + const parsed = parseExecuteModeFallbackFor3808(section); + assert.deepStrictEqual( + { + ok: parsed.ok, + activatesTextMode: parsed.activatesTextMode, + instructsStop: parsed.instructsStop, + presentsPlainText: parsed.presentsPlainText, + silentlyPicksDefaults: parsed.silentlyPicksDefaults, + }, + { + ok: true, + activatesTextMode: true, + instructsStop: true, + presentsPlainText: true, + silentlyPicksDefaults: false, + }, + `gsd-plan-phase: full TEXT_MODE fallback contract violated (#3808). Section was:\n${section}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-570-codex-leak-scanner.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-570-codex-leak-scanner (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #570) +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Regression tests for issue #570 — three related sub-bugs in the Codex leak + * scanner and supporting infrastructure. + * + * SUB-BUG A: scanForLeakedPaths recursively scans the entire targetDir, + * including pre-existing unrelated files that contain ~/.claude references. + * Fix: scan only files listed in gsd-file-manifest.json. + * + * SUB-BUG B: convertClaudeToCodexMarkdown replaces "~/.claude/" (with trailing + * slash) but NOT bare "~/.claude" (no slash). The scanner regex + * /(?:~|\$HOME)\/\.claude\b/ matches without trailing slash. + * Fix: add bare word-boundary replacement. + * + * SUB-BUG C: writeManifest checks file.endsWith('.md') for the agents/ + * directory. Codex installs .toml agent files, so they are invisible to the + * manifest and thus to any manifest-based scan fix. + * Fix: also check .toml. + */ + +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 { + install, + convertClaudeCommandToCodexSkill, +} = require('../bin/install.js'); +const { createTempDir, cleanup, captureConsole } = 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 prev = process.env.CODEX_HOME; + process.env.CODEX_HOME = codexHome; + try { + return fn(); + } finally { + if (prev == null) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prev; + } +} + +describe('#570 — Codex leak scanner sub-bugs', { 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-570-'); + codexHome = path.join(tmpRoot, '.codex'); + fs.mkdirSync(codexHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpRoot); + }); + + // SUB-BUG B + test('convertClaudeToCodexMarkdown replaces bare ~/.claude (no trailing slash)', () => { + // convertClaudeToCodexMarkdown is not exported directly; exercise it via + // convertClaudeCommandToCodexSkill which calls it internally. + const input = 'configDir = ~/.claude\npath = ~/.claude/hooks/\ndir = $HOME/.claude'; + const out = convertClaudeCommandToCodexSkill(input, 'gsd-test'); + + assert.ok( + !/(?:~|\$HOME)\/\.claude\b/.test(out), + `Expected no leaked ~/.claude reference after conversion, got:\n${out}`, + ); + }); + + // SUB-BUG C + test('writeManifest includes .toml agent files for Codex', () => { + withCodexHome(codexHome, () => install(true, 'codex')); + + const agentsDir = path.join(codexHome, 'agents'); + // Not the shared listAgentFiles() helper: this reads the INSTALLED Codex + // dest dir and filters .toml (not source .md), so its semantics differ. + // Confirm that Codex actually wrote .toml agent files — if none exist the + // test is vacuous and we should fail loudly. + const tomlFiles = fs.existsSync(agentsDir) + ? fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-') && f.endsWith('.toml')) + : []; + assert.ok( + tomlFiles.length > 0, + `Precondition: Codex install must write at least one gsd-*.toml in agents/; found none in ${agentsDir}`, + ); + + const manifestPath = path.join(codexHome, 'gsd-file-manifest.json'); + assert.ok( + fs.existsSync(manifestPath), + `gsd-file-manifest.json must exist after install; not found at ${manifestPath}`, + ); + + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const manifestKeys = Object.keys(manifest.files || {}); + + const tomlManifestKeys = manifestKeys.filter( + (k) => k.startsWith('agents/gsd-') && k.endsWith('.toml'), + ); + assert.ok( + tomlManifestKeys.length > 0, + `Expected at least one 'agents/gsd-*.toml' key in manifest.files, but found none.\n` + + `agents/ toml files on disk: ${tomlFiles.join(', ')}\n` + + `All manifest keys (agents/): ${manifestKeys.filter((k) => k.startsWith('agents/')).join(', ')}`, + ); + }); + + // SUB-BUG A + test('scanForLeakedPaths does not warn for pre-existing unrelated files in ~/.codex', () => { + // Write a pre-existing file with ~/.claude references BEFORE install. + const memoriesDir = path.join(codexHome, 'memories'); + fs.mkdirSync(memoriesDir, { recursive: true }); + const preExistingFile = path.join(memoriesDir, 'raw_memories.md'); + fs.writeFileSync( + preExistingFile, + '# Old memories\nI used to work in ~/.claude and $HOME/.claude regularly.\n', + ); + + let captured; + withCodexHome(codexHome, () => { + captured = captureConsole(() => install(true, 'codex')); + }); + + const combinedOutput = captured.stderr; + + assert.ok( + !combinedOutput.includes('memories/raw_memories.md'), + `scanForLeakedPaths must not warn about pre-existing unrelated file memories/raw_memories.md.\n` + + `Actual warnings:\n${combinedOutput}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-704-codex-launcher-path-corruption.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-704-codex-launcher-path-corruption (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #704) +'use strict'; + +/** + * Regression test for issue #704: + * "v1.3.1 global install ships literal $gsd-core launcher paths in workflows" + * + * ROOT CAUSE: `convertSlashCommandsToCodexSkillMentions` had a regex + * /(? { + test('convertClaudeCommandToCodexSkill does not corrupt ${VAR}/gsd-core/ or $(cmd)/gsd-* paths', () => { + // Minimal fixture with the launcher snippet and command-substitution patterns + // that were being corrupted (#704). + const input = [ + '---', + 'description: Test skill', + '---', + '', + '```bash', + '_GSD_SHIM_NAME="gsd-tools.cjs"', + '_GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"', + 'GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"', + 'if [ -f "$GSD_TOOLS" ]; then', + ' gsd_run() { node "$GSD_TOOLS" "$@"; }', + 'elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then', + ' GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', + ' gsd_run() { node "$GSD_TOOLS" "$@"; }', + 'elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then', + ' GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', + ' gsd_run() { node "$GSD_TOOLS" "$@"; }', + 'fi', + '# Command-substitution path form (reapply-patches pattern)', + 'candidate="$(expand_home "$KILO_CONFIG_DIR")/gsd-local-patches"', + '```', + ].join('\n'); + + const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704'); + + // Shell-context corruption patterns from issue #704: + // - `}$gsd-*` from shell variable expressions `${VAR}/gsd-*` + // - `)$gsd-*` from command-substitution paths `$(cmd)/gsd-*` + const shellCorruptionPatterns = [ + { pattern: '}' + BAD_TOKEN, description: 'shell-variable }$gsd-core' }, + { pattern: ')$gsd-local', description: 'command-substitution )$gsd-local-patches' }, + ]; + for (const { pattern, description } of shellCorruptionPatterns) { + assert.ok( + !output.includes(pattern), + `Codex skill conversion must not produce "${pattern}" (${description}). ` + + `Offending fragment: ${ + output.includes(pattern) + ? output.substring(output.indexOf(pattern) - 50, output.indexOf(pattern) + 80) + : '(not found)' + }`, + ); + } + + // The correct path forms must be preserved — the canonical launcher path + // (RUNTIME_ROOT_PATH) must survive Codex conversion intact. + assert.ok( + output.includes(RUNTIME_ROOT_PATH), + `Expected canonical launcher path "${RUNTIME_ROOT_PATH}" to appear in the converted output. ` + + `Got:\n${output.substring(0, 500)}`, + ); + assert.ok( + output.includes(')/gsd-local-patches'), + `Expected ")/gsd-local-patches" to appear in the converted output. ` + + `Got:\n${output.substring(0, 500)}`, + ); + }); + + test('convertClaudeCommandToCodexSkill preserves all shell path forms (}, ) closers)', () => { + // All these paths appear after a shell-closing character (} or )) and must + // NOT be converted to $gsd-* by the Codex slash-command converter. + const shellPaths = [ + // Shell variable expression forms (} closer) + { path: '"${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' }, + { path: '"${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' }, + { path: '"$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' }, + // Command-substitution forms () closer) — reapply-patches pattern + { path: 'candidate="$(expand_home "$KILO_CONFIG_DIR")/gsd-local-patches"', corruptedForm: ')$gsd-local' }, + { path: 'candidate="$(dirname "$(expand_home "$OPENCODE_CONFIG")")/gsd-local-patches"', corruptedForm: ')$gsd-local' }, + ]; + + for (const { path: p, corruptedForm } of shellPaths) { + const input = `---\ndescription: Test\n---\n\n\`\`\`bash\n${p}\n\`\`\``; + const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704-paths'); + assert.ok( + !output.includes(corruptedForm), + `Path "${p}" was corrupted to contain "${corruptedForm}" after Codex conversion.\n` + + `Got:\n${output}`, + ); + } + }); + + test('convertClaudeCommandToCodexSkill still converts legitimate /gsd- slash mentions', () => { + // Slash-command mentions (not preceded by }) should still be converted + const input = [ + '---', + 'description: Test', + '---', + '', + 'Use /gsd-discuss-phase to start a discussion.', + 'Or use /gsd-plan-phase for planning.', + 'Also: /gsd:capture --backlog adds items.', + ].join('\n'); + + const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704-cmds'); + + assert.ok( + output.includes('$gsd-discuss-phase'), + 'Expected /gsd-discuss-phase to be converted to $gsd-discuss-phase', + ); + assert.ok( + output.includes('$gsd-plan-phase'), + 'Expected /gsd-plan-phase to be converted to $gsd-plan-phase', + ); + assert.ok( + output.includes('$gsd-capture'), + 'Expected /gsd:capture to be converted to $gsd-capture', + ); + }); + + test('actual shipped workflow files: shell-variable launcher paths contain no $gsd-core', () => { + // Walk gsd-core/workflows/ and assert that no file produces $gsd-core + // inside a shell variable expansion context after Codex conversion. + // + // NOTE: The backtick-wrapped prose-path case (`/gsd-core/workflows/update.md`) + // was a pre-existing gap with the #704 lookbehind fix and is now addressed by + // the positive-boundary regex introduced in #712. That case is covered by the + // "#712" describe block below. + // + // We probe for the specific shell-context pattern from the issue report: + // BAD: ${_GSD_RUNTIME_ROOT}$gsd-core/bin/ + // GOOD: ${_GSD_RUNTIME_ROOT}/gsd-core/bin/ + const workflowsDir = path.join(__dirname, '..', 'gsd-core', 'workflows'); + if (!fs.existsSync(workflowsDir)) { + // If the directory doesn't exist, skip gracefully (non-standard layout) + return; + } + + const files = fs.readdirSync(workflowsDir) + .filter((f) => f.endsWith('.md')) + .map((f) => path.join(workflowsDir, f)); + + assert.ok(files.length > 0, 'Expected at least one workflow .md file'); + + // Shell-context corruption patterns from issue #704: + // - `}$gsd-*`: closing brace from `${VAR}/gsd-*` shell variable expressions + // - `)$gsd-*`: closing paren from `$(cmd)/gsd-*` command substitutions + const SHELL_CORRUPTION_RE = /[})](\$gsd-[a-z])/; + + const offending = []; + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + const skillName = `gsd-${path.basename(file, '.md')}`; + const converted = convertClaudeCommandToCodexSkill(content, skillName); + const match = converted.match(SHELL_CORRUPTION_RE); + if (match) { + const idx = converted.indexOf(match[0]); + offending.push({ + file: path.relative(workflowsDir, file), + context: converted.substring(Math.max(0, idx - 40), idx + 80), + }); + } + } + + assert.deepStrictEqual( + offending, + [], + `Found shell-context path corruption ([})]$gsd-*) in Codex-converted workflow files (#704):\n` + + offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'), + ); + }); + + test('commands/gsd/*.md: shell-variable launcher paths contain no $gsd-core', () => { + // Walk commands/gsd/ and assert that no command file produces the shell-context + // }$gsd-core corruption — since commands also go through + // convertClaudeCommandToCodexSkill when installed globally for Codex. + const commandsDir = path.join(__dirname, '..', 'commands', 'gsd'); + if (!fs.existsSync(commandsDir)) return; + + const files = fs.readdirSync(commandsDir) + .filter((f) => f.endsWith('.md')) + .map((f) => path.join(commandsDir, f)); + + assert.ok(files.length > 0, 'Expected at least one command .md file'); + + const SHELL_CORRUPTION_RE = /[})](\$gsd-[a-z])/; + + const offending = []; + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + const skillName = `gsd-${path.basename(file, '.md')}`; + const converted = convertClaudeCommandToCodexSkill(content, skillName); + const match = converted.match(SHELL_CORRUPTION_RE); + if (match) { + const idx = converted.indexOf(match[0]); + offending.push({ + file: path.relative(commandsDir, file), + context: converted.substring(Math.max(0, idx - 40), idx + 80), + }); + } + } + + assert.deepStrictEqual( + offending, + [], + `Found shell-context path corruption ([})]$gsd-*) in Codex-converted command files (#704):\n` + + offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'), + ); + }); +}); + +describe('#712: positive-boundary slash-command conversion', () => { + // Tests call convertSlashCommandsToCodexSkillMentions directly so the regex + // is exercised in isolation — no frontmatter wrapping, no ADAPTER_CLOSE + // stripping, no .claude→.codex rewrite masking the result. + + // ── MUST-NOT-CONVERT (negative) cases ───────────────────────────────────── + // These inputs must be returned UNCHANGED — no $gsd-* substitution. + + test('backtick-wrapped path: `/gsd-core/workflows/update.md` is NOT converted (THE new fix)', () => { + const input = 'See `/gsd-core/workflows/update.md` for details.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.strictEqual( + result, + input, + `Expected backtick-wrapped path to be unchanged. Got: ${result}`, + ); + }); + + test('backtick-wrapped path deeper: `/gsd-pi/bin/foo.cjs` is NOT converted', () => { + const input = 'Run `/gsd-pi/bin/foo.cjs` directly.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.strictEqual( + result, + input, + `Expected deep backtick-wrapped path to be unchanged. Got: ${result}`, + ); + }); + + test('shell var expansion: ${_GSD_RUNTIME_ROOT}/gsd-core/bin/x is NOT converted (regression guard)', () => { + const input = 'PATH="${_GSD_RUNTIME_ROOT}/gsd-core/bin/x"'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + !result.includes('$gsd-core'), + `Expected no $gsd-core substitution in shell var path. Got: ${result}`, + ); + assert.ok( + result.includes('/gsd-core/bin/x'), + `Expected original path to be preserved. Got: ${result}`, + ); + }); + + test('command substitution: $(expand_home ~/.claude)/gsd-local-patches is NOT converted (regression guard)', () => { + const input = 'candidate="$(expand_home ~/.claude)/gsd-local-patches"'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + !result.includes(')$gsd-local'), + `Expected no )$gsd-local substitution. Got: ${result}`, + ); + assert.ok( + result.includes(')/gsd-local-patches'), + `Expected original path to be preserved. Got: ${result}`, + ); + }); + + test('plain path segment: bin/gsd-tools.cjs is NOT converted', () => { + const input = 'node bin/gsd-tools.cjs --help'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.strictEqual( + result, + input, + `Expected plain path segment to be unchanged. Got: ${result}`, + ); + }); + + test('plain path segment: .claude/gsd-core/agents — /gsd-core portion is NOT slash-command converted', () => { + // Tests the regex in isolation: the .claude→.codex path rewrite that happens + // inside convertClaudeToCodexMarkdown does NOT run here. We assert directly + // that the slash-command regex leaves /gsd-core after the slash intact — + // i.e. the `e` in `/gsd-core` is NOT treated as a command boundary. + const input = 'Look in .claude/gsd-core/agents for the agent files.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + !result.includes('$gsd-core'), + `Expected no $gsd-core substitution in .claude/gsd-core path. Got: ${result}`, + ); + assert.ok( + result.includes('/gsd-core/agents'), + `Expected /gsd-core/agents to remain as a path segment. Got: ${result}`, + ); + }); + + // ── MUST-CONVERT (positive) cases ───────────────────────────────────────── + // These inputs contain legitimate /gsd- mentions that MUST be converted. + + test('space-preceded prose: Use /gsd-discuss-phase to start. → $gsd-discuss-phase', () => { + const input = 'Use /gsd-discuss-phase to start.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + result.includes('$gsd-discuss-phase'), + `Expected /gsd-discuss-phase to be converted. Got: ${result}`, + ); + assert.ok( + !result.includes('/gsd-discuss-phase'), + `Expected original /gsd-discuss-phase to be replaced. Got: ${result}`, + ); + }); + + test('backtick-WRAPPED MENTION (single segment): Run `/gsd-execute-phase` now → `$gsd-execute-phase`', () => { + // A backtick-wrapped COMMAND (single segment, no path continuation) MUST + // still be converted — this guards against a naive whitespace-only fix. + const input = 'Run `/gsd-execute-phase` now.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + result.includes('`$gsd-execute-phase`'), + `Expected backtick-wrapped command to be converted to \`$gsd-execute-phase\`. Got: ${result}`, + ); + assert.ok( + !result.includes('`/gsd-execute-phase`'), + `Expected original \`/gsd-execute-phase\` to be replaced. Got: ${result}`, + ); + }); + + test('parenthetical/backtick list like CONTEXT.md:59: (`/gsd-plan-phase`, `/gsd-progress`) → converted', () => { + const input = 'Available commands: (`/gsd-plan-phase`, `/gsd-progress`) — pick one.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + result.includes('`$gsd-plan-phase`'), + `Expected /gsd-plan-phase to be converted. Got: ${result}`, + ); + assert.ok( + result.includes('`$gsd-progress`'), + `Expected /gsd-progress to be converted. Got: ${result}`, + ); + }); + + test('start-of-string: /gsd-manager runs → $gsd-manager runs (exercises the ^ branch of lookbehind)', () => { + // This case is IMPOSSIBLE to test through the frontmatter-wrapping pipeline + // (the body always has preceding chars). Direct call exercises the ^ branch. + const input = '/gsd-manager runs the pipeline.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + result.includes('$gsd-manager'), + `Expected /gsd-manager to be converted. Got: ${result}`, + ); + assert.ok( + !result.includes('/gsd-manager'), + `Expected original /gsd-manager to be replaced. Got: ${result}`, + ); + }); + + test('double-quote wrapped: "/gsd-resume" → "$gsd-resume"', () => { + const input = 'Call "/gsd-resume" to continue.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + result.includes('"$gsd-resume"'), + `Expected "/gsd-resume" to be converted to "$gsd-resume". Got: ${result}`, + ); + assert.ok( + !result.includes('"/gsd-resume"'), + `Expected original "/gsd-resume" to be replaced. Got: ${result}`, + ); + }); + + // ── End-to-end: headline #712 bug through the real install pipeline ──────── + + test('end-to-end: backtick-wrapped path `/gsd-core/workflows/update.md` survives full Codex install pipeline', () => { + // Uses convertClaudeCommandToCodexSkill (same pattern as #704 tests above) + // to prove the real install path does not corrupt prose references to repo paths. + const input = [ + '---', + 'description: Test', + '---', + '', + 'See `/gsd-core/workflows/update.md` for the update workflow.', + ].join('\n'); + + const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-712-e2e'); + + assert.ok( + !output.includes('$gsd-core'), + `Expected no $gsd-core in converted output. Got:\n${output}`, + ); + assert.ok( + output.includes('/gsd-core/workflows/update.md'), + `Expected backtick-wrapped path to survive conversion. Got:\n${output}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-851-codex-quick-adapter-agent-type-fallback.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-851-codex-quick-adapter-agent-type-fallback (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #851) +// Tests assert on text in bin/install.js (Codex adapter header prose) — +// the adapter text IS the product loaded by Codex agents at runtime. + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); +const src = fs.readFileSync(INSTALL_JS, 'utf8'); + +// Helper: extract Section C from the raw source text. +// Anchors on the heading and ends at . +function getSectionC() { + const headingIdx = src.indexOf('## C. Task() → spawn_agent Mapping'); + assert.ok(headingIdx >= 0, 'Section C heading must exist in bin/install.js'); + const closeTag = src.indexOf('', headingIdx); + assert.ok(closeTag >= 0, 'Section C must be followed by '); + return src.slice(headingIdx, closeTag); +} + +describe('bug #851: Codex adapter documents multi_agent_v1 schema limitation and fallback', () => { + + // (a) Schema-detection step: the adapter must require the agent to inspect + // spawn_agent's parameter schema BEFORE deciding how to dispatch. + test('(a) schema-detection: adapter requires inspecting spawn_agent schema before dispatching', () => { + const sectionC = getSectionC(); + + // Must name BOTH schema variants so the agent knows what to look for + assert.ok( + sectionC.includes('multi_agent_v1'), + 'Section C must name the multi_agent_v1 schema to identify the limited form', + ); + assert.ok( + sectionC.includes('multi_agent_v2') || sectionC.includes('agent_type-capable'), + 'Section C must name the typed schema (multi_agent_v2 or agent_type-capable) as the capable form', + ); + + // Must instruct schema inspection before spawning + assert.ok( + sectionC.includes('tool_search') || sectionC.includes('inspect') || sectionC.includes('schema'), + 'Section C must instruct the agent to inspect the spawn_agent schema (via tool_search or similar)', + ); + + // All three requirements together (AND): + assert.ok( + sectionC.includes('multi_agent_v1') && + (sectionC.includes('multi_agent_v2') || sectionC.includes('agent_type-capable')) && + (sectionC.includes('tool_search') || sectionC.includes('inspect') || sectionC.includes('schema')), + 'Section C must require schema-detection: name both schema variants AND instruct inspection before spawning', + ); + }); + + // (b) Active-config-root resolution: the TOML path must describe how to + // resolve the config root (honoring $CODEX_HOME / --config-dir / --local), + // not imply a single fixed path. + test('(b) active-config-root: fallback TOML path resolves the active Codex config root', () => { + const sectionC = getSectionC(); + + // Must mention the agents/.toml relative path + assert.ok( + sectionC.includes('agents/.toml'), + 'Section C must reference agents/.toml for the TOML extraction step', + ); + + // Must describe dynamic config-root resolution (at least two of the three + // override mechanisms, plus the word "config" to anchor context) + const mentionsCodexHome = sectionC.includes('$CODEX_HOME') || sectionC.includes('CODEX_HOME'); + const mentionsConfigDir = sectionC.includes('--config-dir') || sectionC.includes('config-dir'); + const mentionsLocal = sectionC.includes('--local') || sectionC.includes('.codex') || sectionC.includes('local'); + const mentionsConfigRoot = sectionC.includes('config root') || sectionC.includes('config.toml') || sectionC.includes('config directory'); + + assert.ok( + mentionsCodexHome, + 'Section C fallback must mention $CODEX_HOME for config-root resolution', + ); + assert.ok( + mentionsConfigDir, + 'Section C fallback must mention --config-dir for config-root resolution', + ); + assert.ok( + mentionsLocal, + 'Section C fallback must mention --local / .codex for config-root resolution', + ); + assert.ok( + mentionsConfigRoot, + 'Section C fallback must describe the concept of an active config root (config.toml or config root/directory)', + ); + + // AND: all four required elements together + assert.ok( + mentionsCodexHome && mentionsConfigDir && mentionsLocal && mentionsConfigRoot, + 'Section C fallback must describe active-config-root resolution: $CODEX_HOME + --config-dir + --local + config-root concept (AND logic)', + ); + + // Must NOT contain the literal ~/.codex/ (would be rewritten by _applyRuntimeRewrites + // and cause bug-3582 to diverge) + assert.ok( + !sectionC.includes('~/.codex/'), + 'Section C must NOT contain the literal ~/.codex/ substring (breaks bug-3582 materialization test)', + ); + }); + + // (c) "NOT equivalent" label: the workaround must be explicitly labeled as + // not equivalent to typed gsd-planner/gsd-executor execution. + test('(c) not-equivalent label: generic-agent workaround is labeled as NOT equivalent to typed dispatch', () => { + const sectionC = getSectionC(); + + // Must name at least one typed agent + const namesTypedAgent = + sectionC.includes('gsd-planner') || + sectionC.includes('gsd-executor') || + sectionC.includes('typed GSD agent') || + sectionC.includes('typed gsd-'); + + // Must contain explicit "not equivalent" / "NOT equivalent" / negation language + const hasNotEquivalent = + sectionC.toLowerCase().includes('not equivalent') || + sectionC.includes('NOT equivalent') || + sectionC.includes('is NOT possible'); + + // Must name the workaround as a workaround, not a first-class path + const hasWorkaroundLabel = + sectionC.includes('workaround') || + sectionC.includes('fallback'); + + assert.ok( + namesTypedAgent, + 'Section C must name at least one typed GSD agent (gsd-planner, gsd-executor, or "typed GSD agent")', + ); + assert.ok( + hasNotEquivalent, + 'Section C must contain explicit "not equivalent" / "NOT equivalent" language for the generic-agent path', + ); + assert.ok( + hasWorkaroundLabel, + 'Section C must label the generic-agent path as a workaround or fallback', + ); + + // AND: all three together + assert.ok( + namesTypedAgent && hasNotEquivalent && hasWorkaroundLabel, + 'Section C must AND: name a typed agent + label it NOT equivalent + call the generic path a workaround/fallback', + ); + }); + + // (d) Fail-closed rule: when typed dispatch is mandatory, the adapter must + // instruct the agent to fail closed and report the limitation, not silently degrade. + test('(d) fail-closed: adapter requires failing closed when typed dispatch is mandatory', () => { + const sectionC = getSectionC(); + + const hasFailClosed = + sectionC.includes('fail closed') || + sectionC.includes('fail-closed') || + sectionC.includes('fail_closed'); + + const hasReportLimitation = + sectionC.includes('schema limitation') || + sectionC.includes('report') || + sectionC.includes('not silently') || + sectionC.includes('silently degrading') || + sectionC.includes('silently'); + + const hasMandatoryContext = + sectionC.includes('mandatory') || + sectionC.includes('required') || + sectionC.includes('worktree isolation') || + sectionC.includes('isolation'); + + assert.ok( + hasFailClosed, + 'Section C must instruct fail-closed behavior (the phrase "fail closed" or equivalent)', + ); + assert.ok( + hasReportLimitation, + 'Section C must instruct reporting the schema limitation rather than silently degrading', + ); + assert.ok( + hasMandatoryContext, + 'Section C must identify a context where typed dispatch is mandatory (e.g. worktree isolation)', + ); + + // AND: all three together + assert.ok( + hasFailClosed && hasReportLimitation && hasMandatoryContext, + 'Section C must AND: instruct fail-closed + report limitation + identify mandatory-typed-dispatch contexts', + ); + }); + + // Regression guard: typed mapping for capable schema must still be present. + test('adapter still documents typed agent_type spawn for sessions that support it', () => { + const sectionC = getSectionC(); + + assert.ok( + sectionC.includes('agent_type-capable') || sectionC.includes('multi_agent_v2'), + 'Section C must still document the typed schema (agent_type-capable / multi_agent_v2)', + ); + assert.ok( + sectionC.includes('spawn_agent(agent_type=') || sectionC.includes('agent_type="X"'), + 'Section C must still show a typed spawn_agent(agent_type=...) example for capable sessions', + ); + }); + + // Regression guard: deferred tool discovery must remain (bug-279 contract). + test('adapter deferred tool discovery instruction is preserved', () => { + // The pre-existing bug-279 contract must remain intact + assert.ok( + src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), + 'Adapter must still instruct deferred tool discovery via tool_search before deciding to run inline', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-772-codex-hook-events.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-772-codex-hook-events (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Enhancement #772: Adopt new stable Codex hook events + commandWindows for + * Windows parity. + * + * Codex CLI (rust-v0.137.0) stabilised the full hook-event set. This suite + * asserts that a Codex install: + * + * (a) Registers the 3 new high-value hook events in hooks.json: + * - SubagentStart — inject context / GSD_AGENT_NAME awareness at subagent open + * - Stop — post-session context headroom tracking + * - PostToolUse — mirror the Claude Code PostToolUse context monitor + * + * (b) Emits `commandWindows` in the SessionStart hooks.json entry so that + * Windows users get the .cmd shim path and non-Windows users get the POSIX + * node runner command. Both fields are present in the same entry; Codex picks + * the right one per its HookHandlerConfig schema + * (codex-rs/config/src/hook_config.rs: commandWindows / command_windows alias). + * + * Note: UserPromptSubmit is NOT wired (same rationale as Qwen #788 — the + * gsd-prompt-guard handler exits unless tool_name is Write|Edit, so it would be + * a silent no-op for the UserPromptSubmit payload shape). + * + * Test strategy: + * - Test new event registration via ensureCodexHooksJsonEvent() directly + * (mirrors the #3426 pattern of testing ensureCodexHooksJsonSessionStart + * directly with a stub hook file — avoids full install() migration dance). + * - Test commandWindows via ensureCodexHooksJsonSessionStart() directly. + * - IR-first discipline: assert on the structured result, not rendered text. + * + * Verified hook event schema: + * https://github.com/openai/codex/blob/main/codex-rs/protocol/src/protocol.rs + * https://github.com/openai/codex/blob/main/codex/codex-rs/config/src/hook_config.rs + */ + +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 = require('../bin/install.js'); +const { + ensureCodexHooksJsonSessionStart, + ensureCodexHooksJsonEvent, + removeCodexHooksJsonEvent, + reconcileCodexHooksJsonEvent, +} = INSTALL; +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Extract all hook handler entries (full objects with type/command/etc.) for + * `eventName` from a hooks.json object (flat or nested-hooks shape). + */ +function hooksJsonHandlersForEvent(hooksJson, eventName) { + if (!hooksJson || typeof hooksJson !== 'object') return []; + const table = + hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks) + ? hooksJson.hooks + : hooksJson; + if (!Array.isArray(table[eventName])) return []; + return table[eventName].flatMap(entry => + Array.isArray(entry && entry.hooks) ? entry.hooks : [] + ); +} + +function readHooksJson(targetDir) { + const p = path.join(targetDir, 'hooks.json'); + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function stubHookFile(targetDir, hookName) { + const hooksDest = path.join(targetDir, 'hooks'); + fs.mkdirSync(hooksDest, { recursive: true }); + const dest = path.join(hooksDest, hookName); + if (!fs.existsSync(dest)) { + fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); + try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } + } +} + +// ─── Suite 1: ensureCodexHooksJsonEvent export surface ─────────────────────── + +describe('enh-772: export surface — new functions are exported', () => { + test('ensureCodexHooksJsonEvent is a function', () => { + assert.strictEqual(typeof ensureCodexHooksJsonEvent, 'function', + 'ensureCodexHooksJsonEvent must be exported from bin/install.js'); + }); + + test('removeCodexHooksJsonEvent is a function', () => { + assert.strictEqual(typeof removeCodexHooksJsonEvent, 'function', + 'removeCodexHooksJsonEvent must be exported from bin/install.js'); + }); + + test('reconcileCodexHooksJsonEvent is a function', () => { + assert.strictEqual(typeof reconcileCodexHooksJsonEvent, 'function', + 'reconcileCodexHooksJsonEvent must be exported from bin/install.js'); + }); +}); + +// ─── Suite 2: ensureCodexHooksJsonEvent registers new events ───────────────── + +describe('enh-772: ensureCodexHooksJsonEvent registers SubagentStart, Stop, PostToolUse', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-events-'); + stubHookFile(tmpDir, 'gsd-context-monitor.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { + test(`${eventName}: ensureCodexHooksJsonEvent writes hooks.json`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + const result = ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + assert.ok(result && result.path, `result must have path for ${eventName}`); + assert.ok(result.wrote || result.changed, + `ensureCodexHooksJsonEvent must write or change hooks.json for ${eventName}`); + assert.ok(fs.existsSync(path.join(tmpDir, 'hooks.json')), + `hooks.json must exist after registering ${eventName}`); + }); + + test(`${eventName}: hooks.json contains the event entry`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.ok(handlers.length > 0, + `Expected ${eventName} entry in hooks.json; got: ${JSON.stringify(hooksJson)}`); + }); + + test(`${eventName}: hook entry uses gsd-context-monitor`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.ok( + handlers.some(h => h.command && h.command.includes('gsd-context-monitor')), + `${eventName} hook must use gsd-context-monitor; got: ${JSON.stringify(handlers)}` + ); + }); + + test(`${eventName}: hook entry has type: 'command'`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor')); + assert.strictEqual(entry && entry.type, 'command', + `${eventName} hook entry must have type 'command'`); + }); + + test(`${eventName}: hook entry has timeout: 10`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor')); + assert.strictEqual(entry && entry.timeout, 10, + `${eventName} hook entry must have timeout 10`); + }); + } + + test('null absoluteRunner returns unchanged result without writing', () => { + const result = ensureCodexHooksJsonEvent(tmpDir, 'SubagentStart', { + absoluteRunner: null, + platform: 'linux', + }); + assert.strictEqual(result.changed, false, + 'null runner must return changed: false'); + assert.ok(!fs.existsSync(path.join(tmpDir, 'hooks.json')), + 'hooks.json must NOT be written when runner is null'); + }); +}); + +// ─── Suite 3: commandWindows parity in SessionStart ────────────────────────── + +describe('enh-772: commandWindows parity — ensureCodexHooksJsonSessionStart emits commandWindows', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-cmdwin-'); + stubHookFile(tmpDir, 'gsd-check-update.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // commandWindows is ONLY emitted on win32 platform (where the .cmd shim is also + // written). On POSIX platforms, commandWindows is omitted to avoid pointing Windows + // Codex at a non-existent .cmd file (the shim is only present after a native Windows + // install that runs buildCodexHookWindowsShimIR and atomicWriteFileSync). + + test('POSIX platform: commandWindows is NOT emitted (shim not written on POSIX)', () => { + const fakeRunner = '"/usr/local/bin/node"'; + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + assert.ok(result && result.wrote, 'must write hooks.json on linux'); + + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + assert.ok(handlers.length > 0, `Expected SessionStart handlers; got: ${JSON.stringify(hooksJson)}`); + + const entry = handlers[0]; + assert.ok( + entry.commandWindows === undefined, + `commandWindows must NOT be emitted on POSIX (shim not written); got: ${JSON.stringify(entry)}` + ); + }); + + test('POSIX platform: command references gsd-check-update.js (not .cmd)', () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + const entry = handlers[0]; + assert.ok( + entry.command && entry.command.includes('gsd-check-update'), + `POSIX command must reference gsd-check-update; got: ${entry.command}` + ); + assert.ok( + !entry.command.endsWith('.cmd') && !entry.command.endsWith('.cmd"'), + `POSIX command must not end with .cmd; got: ${entry.command}` + ); + }); + + test('null absoluteRunner: no commandWindows emitted, no write', () => { + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: null, + platform: 'linux', + }); + assert.strictEqual(result.changed, false, 'null runner must return changed: false'); + const hooksJson = readHooksJson(tmpDir); + if (hooksJson) { + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + for (const h of handlers) { + assert.ok(!h.commandWindows, + `commandWindows must not be present when runner is null; got: ${JSON.stringify(h)}`); + } + } + }); + + test('Windows platform: SessionStart hook is written with commandWindows pointing to .cmd shim', () => { + // On win32, both `command` and `commandWindows` use the .cmd shim path + // (because managedCommand = shimIR.hookCommand = .cmd path, and + // commandWindows = same .cmd path). This ensures Codex picks the .cmd + // on Windows regardless of which field it reads. + const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'win32', + }); + // The shim write and hooks.json write should succeed in the tmp dir. + if (result.wrote) { + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + assert.ok(handlers.length > 0, + `SessionStart must be registered on Windows path; got: ${JSON.stringify(hooksJson)}`); + const entry = handlers[0]; + assert.ok(typeof entry.commandWindows === 'string', + `commandWindows must be present on Windows path; got: ${JSON.stringify(entry)}`); + // commandWindows should reference the .cmd shim + assert.ok( + entry.commandWindows.includes('gsd-check-update') && entry.commandWindows.includes('.cmd'), + `commandWindows must reference gsd-check-update.cmd on win32; got: ${entry.commandWindows}` + ); + } + }); +}); + +// ─── Suite 4: idempotency ──────────────────────────────────────────────────── + +describe('enh-772: ensureCodexHooksJsonEvent is idempotent', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-idem-'); + stubHookFile(tmpDir, 'gsd-context-monitor.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { + test(`${eventName}: calling twice does not duplicate hook entries`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + const opts = { absoluteRunner: fakeRunner, platform: 'linux' }; + + ensureCodexHooksJsonEvent(tmpDir, eventName, opts); + ensureCodexHooksJsonEvent(tmpDir, eventName, opts); + + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.strictEqual(handlers.length, 1, + `${eventName} should have exactly 1 hook handler after idempotent re-register; got ${handlers.length}: ${JSON.stringify(handlers)}`); + }); + } +}); + +// ─── Suite 5: removeCodexHooksJsonEvent ────────────────────────────────────── + +describe('enh-772: removeCodexHooksJsonEvent removes managed entries', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-remove-'); + stubHookFile(tmpDir, 'gsd-context-monitor.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { + test(`${eventName}: removeCodexHooksJsonEvent removes the managed entry`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + + // Verify it was registered + let hooksJson = readHooksJson(tmpDir); + let handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.ok(handlers.length > 0, `${eventName} must be registered before removal`); + + // Remove + const result = removeCodexHooksJsonEvent(tmpDir, eventName); + assert.ok(result.changed || result.wrote, + `removeCodexHooksJsonEvent must change hooks.json for ${eventName}`); + + hooksJson = readHooksJson(tmpDir); + if (hooksJson) { + handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.strictEqual(handlers.length, 0, + `After removal, ${eventName} should have 0 handlers; got: ${JSON.stringify(handlers)}`); + } + }); + } +}); + +// ─── Suite 6: reconcileCodexHooksJsonEvent preserves user entries ───────────── + +describe('enh-772: reconcileCodexHooksJsonEvent preserves user-owned entries', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-preserve-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('user-owned SubagentStart entry is preserved when GSD entry is registered', () => { + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + const userEntry = { + hooks: [{ type: 'command', command: 'my-custom-hook.sh' }] + }; + fs.writeFileSync(hooksJsonPath, JSON.stringify({ + SubagentStart: [userEntry] + }, null, 2) + '\n'); + + reconcileCodexHooksJsonEvent(tmpDir, 'SubagentStart', { + managedCommand: '"/usr/local/bin/node" "/home/me/.codex/hooks/gsd-context-monitor.js"', + }); + + const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + const table = hooksJson.hooks || hooksJson; + const entries = Array.isArray(table.SubagentStart) ? table.SubagentStart : []; + // Should have 2 entries: user entry + GSD entry + assert.ok(entries.length >= 2, + `User entry must be preserved; got entries: ${JSON.stringify(entries)}`); + // User entry must still be present + const userEntryStillPresent = entries.some(e => + Array.isArray(e.hooks) && e.hooks.some(h => h.command === 'my-custom-hook.sh') + ); + assert.ok(userEntryStillPresent, + `User entry must survive GSD registration; entries: ${JSON.stringify(entries)}`); + }); +}); + }); +} diff --git a/tests/enh-1076-extended-hook-events-drive.test.cjs b/tests/enh-1076-extended-hook-events-drive.test.cjs deleted file mode 100644 index a7ae7799f..000000000 --- a/tests/enh-1076-extended-hook-events-drive.test.cjs +++ /dev/null @@ -1,427 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * ADR-857 phase 5f-3: extended hook event guards are driven by the - * extendedHookEvents descriptor field, not hardcoded runtime-name checks. - * - * Before this change: - * - SubagentStop/Stop/PreCompact were wired only when (isQwen || runtime==='claude') - * - FileChanged was wired only when (runtime === 'claude') - * - BeforeAgent/AfterAgent/BeforeModel were wired only when (isGemini) - * - * After this change: - * - All three guard blocks are driven purely by extendedEvents.includes(eventName) - * - Any runtime (or arbitrary string) that passes the right extendedHookEvents - * array gets exactly those events registered, regardless of its runtime name. - * - * This suite proves descriptor-drive by calling applySettingsJsonHooks directly - * with a controlled extendedHookEvents array and asserting on settings.hooks. - * No source-grep; purely behavioral. - */ - -const { test, describe, before } = 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 REPO_ROOT = path.resolve(__dirname, '..'); -const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); -const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); - -/** Idempotently ensure hooks/dist contains built .js files. */ -function ensureHooksDist() { - if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); - } -} - -before(() => { - ensureHooksDist(); -}); - -const { applySettingsJsonHooks } = require('../bin/install.js'); -const { cleanup } = require('./helpers.cjs'); - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -/** Return all hook commands registered under an event key. */ -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) - ); -} - -/** True if any hook is registered under eventName. */ -function hasHooksFor(settings, eventName) { - return hooksForEvent(settings, eventName).length > 0; -} - -/** - * Create a temporary directory with stub hook files so fs.existsSync guards pass. - * Returns the targetDir path. - */ -function createStubTargetDir() { - const tmpDir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-1076-')); - const hooksDir = path.join(tmpDir, 'hooks'); - fs.mkdirSync(hooksDir, { recursive: true }); - // Stubs for the hooks applySettingsJsonHooks existsSync-checks - const stubs = [ - 'gsd-check-update.js', - 'gsd-context-monitor.js', - 'gsd-prompt-guard.js', - 'gsd-read-guard.js', - 'gsd-read-injection-scanner.js', - 'gsd-config-reload.js', - 'gsd-workflow-guard.js', - 'gsd-worktree-path-guard.js', - 'gsd-validate-commit.sh', - 'gsd-session-state.sh', - 'gsd-phase-boundary.sh', - 'gsd-graphify-update.sh', - ]; - const hooksDistDir = path.join(REPO_ROOT, 'hooks', 'dist'); - for (const stub of stubs) { - const dest = path.join(hooksDir, stub); - const distSrc = path.join(hooksDistDir, stub); - if (fs.existsSync(distSrc)) { - fs.copyFileSync(distSrc, dest); - } else { - // Minimal stub so existsSync passes - const ext = path.extname(stub); - fs.writeFileSync(dest, ext === '.sh' ? '#!/bin/bash\n# stub\n' : '#!/usr/bin/env node\n// stub\n'); - } - try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } - } - return tmpDir; -} - -function cleanupDir(dir) { - cleanup(dir); -} - -/** - * Build the minimal opts bag for applySettingsJsonHooks. - * postToolEvent: 'PostToolUse' (default dialect). - * All commands: non-null strings so the "command truthy" guard passes. - */ -function buildOpts(targetDir, { runtime, extendedHookEvents }) { - const hookOpts = { platform: process.platform, runtime }; - const node = process.execPath; - return { - runtime, - isGlobal: true, - targetDir, - postToolEvent: 'PostToolUse', - hookEvents: undefined, // not the hookEvents dialect — we're testing extendedHookEvents - extendedHookEvents, - updateCheckCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-check-update.js')}"`, - contextMonitorCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-context-monitor.js')}"`, - promptGuardCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-prompt-guard.js')}"`, - readGuardCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-read-guard.js')}"`, - readInjectionScannerCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js')}"`, - configReloadCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-config-reload.js')}"`, - hookOpts, - localCmd: () => null, - localShellCmd: () => null, - }; -} - -// ─── Suite 1: claude shape (SubagentStop+Stop+PreCompact+FileChanged) ───────── - -describe('enh-1076 phase 5f-3: claude extendedHookEvents → SubagentStop/Stop/PreCompact/FileChanged', () => { - let targetDir; - let settings; - - before(() => { - targetDir = createStubTargetDir(); - settings = { hooks: {} }; - const opts = buildOpts(targetDir, { - runtime: 'claude', - extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged'], - }); - applySettingsJsonHooks(settings, opts); - }); - - test('SubagentStop is wired (descriptor-driven)', () => { - assert.ok( - hasHooksFor(settings, 'SubagentStop'), - `Expected SubagentStop hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('Stop is wired (descriptor-driven)', () => { - assert.ok( - hasHooksFor(settings, 'Stop'), - `Expected Stop hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('PreCompact is wired (descriptor-driven)', () => { - assert.ok( - hasHooksFor(settings, 'PreCompact'), - `Expected PreCompact hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('FileChanged is wired (descriptor-driven)', () => { - assert.ok( - hasHooksFor(settings, 'FileChanged'), - `Expected FileChanged hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('cleanup', () => { - cleanupDir(targetDir); - }); -}); - -// ─── Suite 2: qwen shape (SubagentStop+Stop+PreCompact, no FileChanged) ─────── - -describe('enh-1076 phase 5f-3: qwen extendedHookEvents → SubagentStop/Stop/PreCompact only', () => { - let targetDir; - let settings; - - before(() => { - targetDir = createStubTargetDir(); - settings = { hooks: {} }; - const opts = buildOpts(targetDir, { - runtime: 'qwen', - extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact'], - }); - applySettingsJsonHooks(settings, opts); - }); - - test('SubagentStop is wired', () => { - assert.ok(hasHooksFor(settings, 'SubagentStop')); - }); - - test('Stop is wired', () => { - assert.ok(hasHooksFor(settings, 'Stop')); - }); - - test('PreCompact is wired', () => { - assert.ok(hasHooksFor(settings, 'PreCompact')); - }); - - test('FileChanged is NOT wired (not in extendedHookEvents)', () => { - assert.strictEqual( - hasHooksFor(settings, 'FileChanged'), - false, - `FileChanged must NOT be wired for qwen shape; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('cleanup', () => { - cleanupDir(targetDir); - }); -}); - -// ─── Suite 3: gemini shape (BeforeAgent+AfterAgent+BeforeModel) ─────────────── - -describe('enh-1076 phase 5f-3: gemini extendedHookEvents → BeforeAgent/AfterAgent/BeforeModel', () => { - let targetDir; - let settings; - - before(() => { - targetDir = createStubTargetDir(); - settings = { hooks: {} }; - const opts = buildOpts(targetDir, { - runtime: 'gemini', - extendedHookEvents: ['BeforeAgent', 'AfterAgent', 'BeforeModel'], - }); - applySettingsJsonHooks(settings, opts); - }); - - test('BeforeAgent is wired', () => { - assert.ok( - hasHooksFor(settings, 'BeforeAgent'), - `Expected BeforeAgent hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('AfterAgent is wired', () => { - assert.ok(hasHooksFor(settings, 'AfterAgent')); - }); - - test('BeforeModel is wired', () => { - assert.ok(hasHooksFor(settings, 'BeforeModel')); - }); - - test('SubagentStop is NOT wired (not in extendedHookEvents)', () => { - assert.strictEqual( - hasHooksFor(settings, 'SubagentStop'), - false, - 'SubagentStop must NOT be wired for gemini shape' - ); - }); - - test('FileChanged is NOT wired (not in extendedHookEvents)', () => { - assert.strictEqual( - hasHooksFor(settings, 'FileChanged'), - false, - 'FileChanged must NOT be wired for gemini shape' - ); - }); - - test('cleanup', () => { - cleanupDir(targetDir); - }); -}); - -// ─── Suite 4: empty extendedHookEvents → none of the extended events ────────── - -describe('enh-1076 phase 5f-3: empty extendedHookEvents → no extended events wired', () => { - let targetDir; - let settings; - - before(() => { - targetDir = createStubTargetDir(); - settings = { hooks: {} }; - // Use runtime='someruntime' to prove it's the descriptor, not the name, that matters - const opts = buildOpts(targetDir, { - runtime: 'someruntime', - extendedHookEvents: [], - }); - applySettingsJsonHooks(settings, opts); - }); - - const EXTENDED_EVENTS = [ - 'SubagentStop', 'Stop', 'PreCompact', 'FileChanged', - 'BeforeAgent', 'AfterAgent', 'BeforeModel', - ]; - - for (const event of EXTENDED_EVENTS) { - test(`${event} is NOT wired when extendedHookEvents is empty`, () => { - assert.strictEqual( - hasHooksFor(settings, event), - false, - `${event} must not be wired when extendedHookEvents=[] (runtime=someruntime); hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - } - - test('cleanup', () => { - cleanupDir(targetDir); - }); -}); - -// ─── Suite 5: descriptor-drive is runtime-name-agnostic ─────────────────────── -// Pass an arbitrary runtime name ('hypothetical') with SubagentStop in its -// extendedHookEvents. This could NEVER have worked under the old hardcoded check. -// Under the new descriptor-driven guard it MUST work. - -describe('enh-1076 phase 5f-3: arbitrary runtime with SubagentStop in descriptor gets it wired', () => { - let targetDir; - let settings; - - before(() => { - targetDir = createStubTargetDir(); - settings = { hooks: {} }; - const opts = buildOpts(targetDir, { - runtime: 'hypothetical', // NOT 'claude' or 'qwen' — would have been skipped before - extendedHookEvents: ['SubagentStop'], - }); - applySettingsJsonHooks(settings, opts); - }); - - test('SubagentStop IS wired for a hypothetical runtime when descriptor includes it', () => { - assert.ok( - hasHooksFor(settings, 'SubagentStop'), - `SubagentStop must be wired via descriptor even for unknown runtime names; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('Stop is NOT wired (not in extendedHookEvents)', () => { - assert.strictEqual(hasHooksFor(settings, 'Stop'), false); - }); - - test('cleanup', () => { - cleanupDir(targetDir); - }); -}); - -// ─── Suite 6: hooksSurface drive (ADR-857 phase 5g drive 3) ────────────────── -// -// applySettingsJsonHooks is gated by opts.hooksSurface !== 'none'. -// - hooksSurface:'none' → entire body is skipped; no hooks written -// - hooksSurface:'settings-json'→ hooks are written (even for a runtime whose -// name was previously hardcoded to skip, e.g. 'opencode') -// -// This proves the skip is driven by the descriptor field, not the runtime name. - -describe('enh-1076 phase 5g drive 3: hooksSurface:none skips all hooks regardless of runtime', () => { - let targetDir; - let settings; - - before(() => { - targetDir = createStubTargetDir(); - settings = { hooks: {} }; - // 'claude' would normally write hooks, but hooksSurface:'none' must skip entirely. - const opts = { - ...buildOpts(targetDir, { runtime: 'claude', extendedHookEvents: ['SubagentStop'] }), - hooksSurface: 'none', - }; - applySettingsJsonHooks(settings, opts); - }); - - test('SessionStart is NOT written when hooksSurface is "none"', () => { - assert.strictEqual( - hasHooksFor(settings, 'SessionStart'), - false, - `SessionStart must not be written when hooksSurface="none"; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` - ); - }); - - test('PostToolUse is NOT written when hooksSurface is "none"', () => { - assert.strictEqual(hasHooksFor(settings, 'PostToolUse'), false); - }); - - test('PreToolUse is NOT written when hooksSurface is "none"', () => { - assert.strictEqual(hasHooksFor(settings, 'PreToolUse'), false); - }); - - test('cleanup', () => { - cleanupDir(targetDir); - }); -}); - -describe('enh-1076 phase 5g drive 3: hooksSurface:settings-json writes hooks even for previously-skipped runtime name', () => { - let targetDir; - let settings; - - before(() => { - targetDir = createStubTargetDir(); - settings = { hooks: {} }; - // 'opencode' previously was hardcoded to skip hooks; with descriptor drive it - // should write hooks whenever hooksSurface !== 'none'. - const opts = { - ...buildOpts(targetDir, { runtime: 'opencode', extendedHookEvents: [] }), - hooksSurface: 'settings-json', - }; - applySettingsJsonHooks(settings, opts); - }); - - test('SessionStart IS written with at least one command when hooksSurface is "settings-json" (even for opencode name)', () => { - // ensureHooksDist() in before() guarantees hooks/dist is built, so the - // existsSync guards inside applySettingsJsonHooks pass and commands are registered. - assert.ok( - settings.hooks && typeof settings.hooks === 'object', - `settings.hooks must be initialized when hooksSurface="settings-json"`, - ); - assert.ok( - hasHooksFor(settings, 'SessionStart'), - `settings.hooks.SessionStart must contain at least one registered command when hooksSurface="settings-json"; ` + - `keys: ${JSON.stringify(Object.keys(settings.hooks))}`, - ); - }); - - test('cleanup', () => { - cleanupDir(targetDir); - }); -}); diff --git a/tests/enh-1077-install-hook-events-dialect-drive.test.cjs b/tests/enh-1077-install-hook-events-dialect-drive.test.cjs deleted file mode 100644 index be9ddab85..000000000 --- a/tests/enh-1077-install-hook-events-dialect-drive.test.cjs +++ /dev/null @@ -1,429 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * ADR-857 phase 5f-2: hook-events dialect is driven from the registry descriptor. - * - * Before this change, postToolEvent and preToolEvent were hardcoded strings - * derived from runtime-name checks: - * - * (runtime === 'gemini' || runtime === 'antigravity') ? 'AfterTool' : 'PostToolUse' - * (runtime === 'gemini' || runtime === 'antigravity') ? 'BeforeTool' : 'PreToolUse' - * - * After phase 5f-2, both are driven by the registry descriptor's - * `hookEvents` field: hookEvents === 'gemini' → AfterTool/BeforeTool; - * any other value (or missing) → PostToolUse/PreToolUse. - * - * Equivalence (i.e. identical observable behaviour for all runtimes): - * hookEvents === 'gemini' iff runtime ∈ {gemini, antigravity} - * - * This suite asserts the equivalence and the registry-parity invariant: - * any runtime whose descriptor carries hookEvents='gemini' gets the - * AfterTool/BeforeTool dialect; all others get PostToolUse/PreToolUse. - */ - -const { test, describe, before, 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 { install } = require('../bin/install.js'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -// ─── hooks/dist build guard ─────────────────────────────────────────────────── -// -// hooks/dist/ is gitignored and only produced by `npm run build:hooks`. -// In CI the scoped/windows test jobs do NOT run build:hooks before running -// tests, so install() finds no hook files → event arrays come back empty → -// every "expected AfterTool/PostToolUse/BeforeTool/PreToolUse hooks" assertion -// fails. This mirrors the pattern in bug-376-claude-js-hook-gsd-rewriter.test.cjs. - -const REPO_ROOT = path.resolve(__dirname, '..'); -const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); -const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); - -/** - * Idempotently ensure hooks/dist contains built .js files. - * Runs build-hooks.js only when the directory is absent or empty of .js files. - */ -function ensureHooksDist() { - if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); - } -} - -before(() => { - ensureHooksDist(); -}); - -// ─── Registry lookup ────────────────────────────────────────────────────────── - -const REGISTRY_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'); -const registry = (() => { - try { return require(REGISTRY_PATH); } catch { return undefined; } -})(); - -/** - * Return the hookEvents dialect for a runtime ID from the live registry. - * Returns undefined when the registry is absent or the runtime has no descriptor. - */ -function registryHookEvents(runtimeId) { - return registry?.runtimes?.[runtimeId]?.runtime?.hookEvents; -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -/** Collect all hook commands registered under a settings event key. */ -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) - ); -} - -/** True if at least one hook is registered under eventName. */ -function hasHooksFor(settings, eventName) { - return hooksForEvent(settings, eventName).length > 0; -} - -// ─── Suite 1: Gemini-dialect runtimes use AfterTool/BeforeTool ─────────────── -// -// Registry runtimes with hookEvents='gemini': gemini, antigravity - -describe('enh-1077 phase 5f-2: gemini hookEvents dialect → AfterTool/BeforeTool', () => { - // ── gemini ── - - describe('gemini install uses AfterTool for post-tool hooks', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-1077-gemini-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const geminiDir = path.join(tmpDir, '.gemini'); - fs.mkdirSync(geminiDir, { recursive: true }); - const result = install(false, 'gemini'); - settings = result && result.settings; - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('registry confirms gemini hookEvents is "gemini"', () => { - // Parity assertion: if the registry changes, this test fails first. - const he = registryHookEvents('gemini'); - if (he !== undefined) { - assert.strictEqual(he, 'gemini', - 'Registry descriptor for gemini must declare hookEvents="gemini"'); - } - }); - - test('gemini install returns a settings object', () => { - assert.ok(settings !== null && typeof settings === 'object', - 'gemini install must return a non-null settings object'); - }); - - test('gemini install registers at least one hook under AfterTool (post-tool)', () => { - assert.ok(hasHooksFor(settings, 'AfterTool'), - `Expected AfterTool hooks on gemini; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); - }); - - test('gemini install does NOT register context-monitor under PostToolUse (wrong dialect)', () => { - const cmds = hooksForEvent(settings, 'PostToolUse'); - const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); - assert.strictEqual(hasMonitor, false, - `gemini must NOT use PostToolUse for context-monitor; got PostToolUse commands: ${JSON.stringify(cmds)}`); - }); - - test('gemini install registers at least one pre-tool hook (prompt-guard) under BeforeTool', () => { - const cmds = hooksForEvent(settings, 'BeforeTool'); - const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); - assert.ok(hasPromptGuard, - `Expected prompt-guard hook under BeforeTool on gemini; BeforeTool commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); - }); - - test('gemini install does NOT register prompt-guard under PreToolUse (wrong pre-tool dialect)', () => { - const cmds = hooksForEvent(settings, 'PreToolUse'); - const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); - assert.strictEqual(hasPromptGuard, false, - `gemini must NOT use PreToolUse for prompt-guard; got PreToolUse commands: ${JSON.stringify(cmds)}`); - }); - }); - - // ── antigravity ── - - describe('antigravity install uses AfterTool/BeforeTool (gemini dialect)', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-1077-antigrav-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const agDir = path.join(tmpDir, '.gemini', 'antigravity'); - fs.mkdirSync(agDir, { recursive: true }); - const result = install(false, 'antigravity'); - settings = result && result.settings; - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('registry confirms antigravity hookEvents is "gemini"', () => { - const he = registryHookEvents('antigravity'); - if (he !== undefined) { - assert.strictEqual(he, 'gemini', - 'Registry descriptor for antigravity must declare hookEvents="gemini"'); - } - }); - - test('antigravity install returns a settings object', () => { - assert.ok(settings !== null && typeof settings === 'object', - 'antigravity install must return a non-null settings object'); - }); - - test('antigravity install registers at least one hook under AfterTool', () => { - assert.ok(hasHooksFor(settings, 'AfterTool'), - `Expected AfterTool hooks on antigravity; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); - }); - - test('antigravity install does NOT register context-monitor under PostToolUse', () => { - const cmds = hooksForEvent(settings, 'PostToolUse'); - const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); - assert.strictEqual(hasMonitor, false, - `antigravity must NOT use PostToolUse for context-monitor; got: ${JSON.stringify(cmds)}`); - }); - - test('antigravity install registers at least one pre-tool hook (prompt-guard) under BeforeTool', () => { - const cmds = hooksForEvent(settings, 'BeforeTool'); - const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); - assert.ok(hasPromptGuard, - `Expected prompt-guard hook under BeforeTool on antigravity; BeforeTool commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); - }); - - test('antigravity install does NOT register prompt-guard under PreToolUse (wrong pre-tool dialect)', () => { - const cmds = hooksForEvent(settings, 'PreToolUse'); - const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); - assert.strictEqual(hasPromptGuard, false, - `antigravity must NOT use PreToolUse for prompt-guard; got PreToolUse commands: ${JSON.stringify(cmds)}`); - }); - }); -}); - -// ─── Suite 2: Claude-dialect runtimes use PostToolUse/PreToolUse ────────────── -// -// Registry runtimes with hookEvents='claude': claude, augment - -describe('enh-1077 phase 5f-2: claude hookEvents dialect → PostToolUse/PreToolUse', () => { - // ── claude ── - - describe('claude install uses PostToolUse for post-tool hooks', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-1077-claude-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const claudeDir = path.join(tmpDir, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - const result = install(false, 'claude'); - settings = result && result.settings; - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('registry confirms claude hookEvents is "claude"', () => { - const he = registryHookEvents('claude'); - if (he !== undefined) { - assert.strictEqual(he, 'claude', - 'Registry descriptor for claude must declare hookEvents="claude"'); - } - }); - - test('claude install returns a settings object', () => { - assert.ok(settings !== null && typeof settings === 'object', - 'claude install must return a non-null settings object'); - }); - - test('claude install registers at least one hook under PostToolUse', () => { - assert.ok(hasHooksFor(settings, 'PostToolUse'), - `Expected PostToolUse hooks on claude; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); - }); - - test('claude install does NOT register context-monitor under AfterTool (wrong dialect)', () => { - const cmds = hooksForEvent(settings, 'AfterTool'); - const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); - assert.strictEqual(hasMonitor, false, - `claude must NOT use AfterTool for context-monitor; got AfterTool commands: ${JSON.stringify(cmds)}`); - }); - - test('claude install registers at least one pre-tool hook (prompt-guard) under PreToolUse', () => { - const cmds = hooksForEvent(settings, 'PreToolUse'); - const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); - assert.ok(hasPromptGuard, - `Expected prompt-guard hook under PreToolUse on claude; PreToolUse commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); - }); - - test('claude install does NOT register prompt-guard under BeforeTool (wrong pre-tool dialect)', () => { - const cmds = hooksForEvent(settings, 'BeforeTool'); - const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); - assert.strictEqual(hasPromptGuard, false, - `claude must NOT use BeforeTool for prompt-guard; got BeforeTool commands: ${JSON.stringify(cmds)}`); - }); - }); - - // ── augment ── - - describe('augment install uses PostToolUse/PreToolUse (claude dialect)', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-1077-augment-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const augDir = path.join(tmpDir, '.augment'); - fs.mkdirSync(augDir, { recursive: true }); - const result = install(false, 'augment'); - settings = result && result.settings; - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('registry confirms augment hookEvents is "claude"', () => { - const he = registryHookEvents('augment'); - if (he !== undefined) { - assert.strictEqual(he, 'claude', - 'Registry descriptor for augment must declare hookEvents="claude"'); - } - }); - - test('augment install returns a settings object', () => { - assert.ok(settings !== null && typeof settings === 'object', - 'augment install must return a non-null settings object'); - }); - - test('augment install registers at least one hook under PostToolUse', () => { - assert.ok(hasHooksFor(settings, 'PostToolUse'), - `Expected PostToolUse hooks on augment; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); - }); - - test('augment install does NOT register context-monitor under AfterTool', () => { - const cmds = hooksForEvent(settings, 'AfterTool'); - const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); - assert.strictEqual(hasMonitor, false, - `augment must NOT use AfterTool for context-monitor; got: ${JSON.stringify(cmds)}`); - }); - - test('augment install registers at least one pre-tool hook (prompt-guard) under PreToolUse', () => { - const cmds = hooksForEvent(settings, 'PreToolUse'); - const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); - assert.ok(hasPromptGuard, - `Expected prompt-guard hook under PreToolUse on augment; PreToolUse commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); - }); - - test('augment install does NOT register prompt-guard under BeforeTool (wrong pre-tool dialect)', () => { - const cmds = hooksForEvent(settings, 'BeforeTool'); - const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); - assert.strictEqual(hasPromptGuard, false, - `augment must NOT use BeforeTool for prompt-guard; got BeforeTool commands: ${JSON.stringify(cmds)}`); - }); - }); -}); - -// ─── Suite 3: Registry-parity invariant ────────────────────────────────────── -// -// For every runtime in the registry that exposes a settings.json surface -// (i.e. hookEvents is defined), assert that the installed hook dialect matches -// the registry value. This is the generative-fix parity assertion -// (DEFECT.GENERATIVE-FIX): adding a new runtime with hookEvents to the -// registry automatically requires a passing install test for that runtime. - -describe('enh-1077 phase 5f-2: registry-parity — hookEvents descriptor drives install dialect', () => { - test('all registry runtimes with hookEvents use the matching install dialect', () => { - if (!registry || !registry.runtimes) { - // Registry absent — skip parity check (equivalence still verified above) - return; - } - - // Runtimes that have settings.json surfaces and a hookEvents descriptor - const SETTINGS_JSON_RUNTIMES = ['claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy']; - - const failures = []; - - for (const runtimeId of SETTINGS_JSON_RUNTIMES) { - const he = registryHookEvents(runtimeId); - if (he === undefined) continue; // no hookEvents in descriptor — skip - - const expectedPostEvent = he === 'gemini' ? 'AfterTool' : 'PostToolUse'; - const unexpectedPostEvent = he === 'gemini' ? 'PostToolUse' : 'AfterTool'; - const expectedPreEvent = he === 'gemini' ? 'BeforeTool' : 'PreToolUse'; - const unexpectedPreEvent = he === 'gemini' ? 'PreToolUse' : 'BeforeTool'; - - const previousCwd = process.cwd(); - const tmpDir = createTempDir(`gsd-1077-parity-${runtimeId}-`); - try { - process.chdir(tmpDir); - const result = install(false, runtimeId); - const settings = result && result.settings; - if (!settings) continue; // non-settings-json surface, skip - - // Post-tool event assertions - const hasExpected = hasHooksFor(settings, expectedPostEvent); - const hasUnexpected = hooksForEvent(settings, unexpectedPostEvent) - .some(c => c && c.includes('gsd-context-monitor')); - - if (!hasExpected) { - failures.push(`${runtimeId}: expected context-monitor hook under ${expectedPostEvent} (hookEvents=${he}), but none found`); - } - if (hasUnexpected) { - failures.push(`${runtimeId}: must NOT register context-monitor under ${unexpectedPostEvent}, but it was found`); - } - - // Pre-tool event assertions: prompt-guard must land under the dialect-correct key. - const preToolCmdsExpected = hooksForEvent(settings, expectedPreEvent); - const hasPromptGuardExpected = preToolCmdsExpected.some(c => c && c.includes('gsd-prompt-guard')); - const preToolCmdsUnexpected = hooksForEvent(settings, unexpectedPreEvent); - const hasPromptGuardUnexpected = preToolCmdsUnexpected.some(c => c && c.includes('gsd-prompt-guard')); - - if (!hasPromptGuardExpected) { - failures.push(`${runtimeId}: expected prompt-guard hook under ${expectedPreEvent} (hookEvents=${he}), but none found; ${expectedPreEvent} cmds: ${JSON.stringify(preToolCmdsExpected)}`); - } - if (hasPromptGuardUnexpected) { - failures.push(`${runtimeId}: must NOT register prompt-guard under ${unexpectedPreEvent} (hookEvents=${he}), but it was found`); - } - } finally { - process.chdir(previousCwd); - cleanup(tmpDir); - } - } - - assert.deepEqual(failures, [], - 'Registry-parity failures (hookEvents descriptor must drive install dialect):\n' + - failures.join('\n')); - }); -}); diff --git a/tests/enh-1510-rewrite-engine-helper-relocation.test.cjs b/tests/enh-1510-rewrite-engine-helper-relocation.test.cjs deleted file mode 100644 index 911b10a1c..000000000 --- a/tests/enh-1510-rewrite-engine-helper-relocation.test.cjs +++ /dev/null @@ -1,108 +0,0 @@ -'use strict'; - -// Enhancement #1510 (epic #1507, ADR-1508 Phase 1): behavior-preserving -// relocation of pure rewrite-engine helpers out of hand-authored bin/install.js. -// - getDirName -> gsd-core/bin/lib/runtime-name-policy.cjs -// - processAttribution -> gsd-core/bin/lib/runtime-artifact-conversion.cjs -// getCommitAttribution stays in install.js (impure install-time config I/O); the -// convertClaudeToAugmentMarkdown duplicate dedup is deferred to Phase 2's cleanup -// (entangled converter cluster; not required to unblock Phase 2). -// These tests exercise the REAL relocated functions at their new home (the -// generated .cjs) and assert install.js re-exports the SAME references -// (Hyrum: existing consumers import these names from bin/install.js). - -const { test, describe } = require('node:test'); -const assert = require('node:assert'); - -const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); -const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); -const installer = require('../bin/install.js'); - -// ── Slice A: getDirName relocated to runtime-name-policy ────────────────────── -describe('getDirName (relocated to runtime-name-policy)', () => { - const EXPECTED = { - claude: '.claude', - copilot: '.github', - opencode: '.opencode', - gemini: '.gemini', - kilo: '.kilo', - codex: '.codex', - antigravity: '.agents', - cursor: '.cursor', - windsurf: '.windsurf', - augment: '.augment', - trae: '.trae', - qwen: '.qwen', - hermes: '.hermes', - kimi: '.kimi-code', - codebuddy: '.codebuddy', - cline: '.cline', - }; - - for (const [runtime, dir] of Object.entries(EXPECTED)) { - test(`maps '${runtime}' to '${dir}'`, () => { - assert.strictEqual(runtimeNamePolicy.getDirName(runtime), dir); - }); - } - - test('falls back to .claude for an unknown runtime', () => { - assert.strictEqual(runtimeNamePolicy.getDirName('definitely-not-a-runtime'), '.claude'); - }); - - test('falls back to .claude for empty input', () => { - assert.strictEqual(runtimeNamePolicy.getDirName(''), '.claude'); - }); - - test('bin/install.js re-exports the SAME getDirName reference (no drift)', () => { - assert.strictEqual(installer.getDirName, runtimeNamePolicy.getDirName); - }); -}); - -// ── Slice B: processAttribution relocated to runtime-artifact-conversion ─────── -describe('processAttribution (relocated to runtime-artifact-conversion)', () => { - test('null removes the Co-Authored-By line and its preceding blank line', () => { - const input = 'Commit body line.\n\nCo-Authored-By: Someone '; - assert.strictEqual(conversion.processAttribution(input, null), 'Commit body line.'); - }); - - test('undefined leaves content unchanged', () => { - const input = 'Commit body.\n\nCo-Authored-By: Someone '; - assert.strictEqual(conversion.processAttribution(input, undefined), input); - }); - - test('a string replaces the attribution value', () => { - const input = 'Body\n\nCo-Authored-By: Old Name '; - assert.strictEqual( - conversion.processAttribution(input, 'New Name '), - 'Body\n\nCo-Authored-By: New Name ', - ); - }); - - test('escapes $ in the attribution to prevent backreference injection', () => { - const input = 'Body\n\nCo-Authored-By: x'; - // "$1" must survive literally, not be interpreted as a regex backreference. - assert.strictEqual( - conversion.processAttribution(input, 'A $1 B'), - 'Body\n\nCo-Authored-By: A $1 B', - ); - }); - - test('handles CRLF when removing (null)', () => { - const input = 'Body\r\n\r\nCo-Authored-By: Someone '; - assert.strictEqual(conversion.processAttribution(input, null), 'Body'); - }); - - test('replaces every Co-Authored-By line (global)', () => { - const input = 'Body\nCo-Authored-By: A \nCo-Authored-By: B '; - assert.strictEqual( - conversion.processAttribution(input, 'Z '), - 'Body\nCo-Authored-By: Z \nCo-Authored-By: Z ', - ); - }); - - test('bin/install.js re-exports the SAME processAttribution reference (no drift)', () => { - // processAttribution remains an explicit installer compatibility relay, so - // the export must keep pointing at the conversion module's implementation. - assert.strictEqual(installer.processAttribution, conversion.processAttribution); - }); -}); diff --git a/tests/enh-1511-rewrite-engine-relocation.test.cjs b/tests/enh-1511-rewrite-engine-relocation.test.cjs deleted file mode 100644 index 3dbfaafde..000000000 --- a/tests/enh-1511-rewrite-engine-relocation.test.cjs +++ /dev/null @@ -1,408 +0,0 @@ -'use strict'; -/** - * Tests for ADR-1508 Phase 2: rewrite engine relocation to runtime-artifact-conversion. - * Issue #1511 — verifies the deep public seam signatures and behavior. - * - * Tests are behavioral (no source-grep). All filesystem operations use tmp dirs. - */ - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { cleanup } = require('./helpers.cjs'); - -let conversion; -before(() => { - process.env['GSD_TEST_MODE'] = '1'; - conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); -}); - -// --------------------------------------------------------------------------- -// _computePathPrefix unit tests -// --------------------------------------------------------------------------- - -describe('_computePathPrefix', () => { - test('global under home → $HOME/... form', () => { - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: '/home/u/.cursor', - homeDir: '/home/u', - }); - assert.equal(prefix, '$HOME/.cursor/'); - }); - - test('non-global → resolvedTarget/ form', () => { - const prefix = conversion._computePathPrefix({ - isGlobal: false, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: '/project/.cursor', - homeDir: '/home/u', - }); - assert.equal(prefix, '/project/.cursor/'); - }); - - test('global opencode skips $HOME shorthand', () => { - // OpenCode uses ~/.config/opencode which breaks $HOME shorthand in content - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: true, - isWindowsHost: false, - resolvedTarget: '/home/u/.config/opencode', - homeDir: '/home/u', - }); - assert.equal(prefix, '/home/u/.config/opencode/'); - }); - - test('global target outside home → resolvedTarget/ form', () => { - const prefix = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: '/opt/custom-cursor', - homeDir: '/home/u', - }); - assert.equal(prefix, '/opt/custom-cursor/'); - }); - - test('isWindowsHost tripwire — Windows paths collapse to $HOME/ same as POSIX (no-op today)', () => { - // Documents CURRENT behavior: isWindowsHost is accepted but not branched on. - // Both win32=true and win32=false return '$HOME/.cursor/' for a home-relative target. - // If a future Windows-specific branch is added, this tripwire fails and forces - // an explicit decision about what to return on Windows. - const withWindows = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: true, - resolvedTarget: 'C:/Users/matte/.cursor', - homeDir: 'C:/Users/matte', - }); - const withoutWindows = conversion._computePathPrefix({ - isGlobal: true, - isOpencode: false, - isWindowsHost: false, - resolvedTarget: 'C:/Users/matte/.cursor', - homeDir: 'C:/Users/matte', - }); - assert.equal(withWindows, '$HOME/.cursor/'); - assert.strictEqual(withWindows, withoutWindows); - }); - - test('backslash-style resolvedTarget is normalized to forward slashes (#1615 regression)', () => { - // path.join on Windows produces backslashes; the returned prefix is - // substituted into markdown @-references which must use POSIX paths. - // Without normalization the backslashes leak into workflow file content - // and break substring checks on Windows CI. - const prefix = conversion._computePathPrefix({ - isGlobal: false, - isOpencode: false, - isWindowsHost: true, - resolvedTarget: 'C:\\Users\\runner\\AppData\\Local\\Temp\\gsd-1615-windsurf', - homeDir: 'C:\\Users\\runner', - }); - assert.strictEqual(prefix, 'C:/Users/runner/AppData/Local/Temp/gsd-1615-windsurf/'); - assert.ok(!prefix.includes('\\'), `prefix must not contain backslashes: ${prefix}`); - }); -}); - -// --------------------------------------------------------------------------- -// _applyRuntimeRewrites with injected attribution -// --------------------------------------------------------------------------- - -describe('_applyRuntimeRewrites — attribution injection', () => { - const PREFIX = '$HOME/.cursor/'; - - test('attribution=null removes Co-Authored-By line', () => { - const content = '# Hello\n\nSome text\n\nCo-Authored-By: Claude\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, null); - assert.ok(!result.includes('Co-Authored-By:'), 'Co-Authored-By should be removed'); - }); - - test('attribution=undefined leaves Co-Authored-By unchanged', () => { - const content = '# Hello\n\nCo-Authored-By: Claude\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, undefined); - assert.ok(result.includes('Co-Authored-By: Claude'), 'Co-Authored-By should be preserved when attribution=undefined'); - }); - - test('attribution=string replaces Co-Authored-By value', () => { - const content = '# Hello\n\nCo-Authored-By: OldName\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, 'NewName '); - assert.ok(result.includes('Co-Authored-By: NewName '), 'Co-Authored-By should be replaced'); - }); - - test('cursor runtime replaces ~/.claude/ paths', () => { - const content = 'See ~/.claude/skills/ for more info\n'; - const result = conversion._applyRuntimeRewrites(content, 'cursor', '/home/u/.cursor/', false, undefined); - assert.ok(result.includes('/home/u/.cursor/skills/'), 'cursor should replace ~/.claude/ with pathPrefix'); - }); -}); - -// --------------------------------------------------------------------------- -// rewriteStagedSkillBodies — behavioral filesystem test -// --------------------------------------------------------------------------- - -describe('rewriteStagedSkillBodies', () => { - test('rewrites .md files in-place for cursor runtime', () => { - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); - try { - // Create a skill dir with a SKILL.md referencing ~/.claude/skills/foo - // NOTE: the rewrite engine handles path replacement and attribution only. - // Bash→Shell conversion is done by the stage-1 skill converter, not the engine. - const skillDir = path.join(stagedDir, 'gsd-test-skill'); - fs.mkdirSync(skillDir, { recursive: true }); - const content = '# Test\n\nSee ~/.claude/skills/foo\n\nAlso ~/.cursor/skills/bar\n'; - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); - - // Call with injected homedir + platform for determinism - conversion.rewriteStagedSkillBodies(stagedDir, { - runtime: 'cursor', - configDir, - scope: 'global', - homedir: () => '/home/u', - platform: 'linux', - }); - - const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); - // cursor rewrites ~/.claude/ → pathPrefix - // configDir is a tmpdir, not under /home/u, so prefix = resolvedTarget + '/' - // Mirror the engine's backslash→slash normalization so the assertion holds on Windows. - const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); - assert.ok(result.includes(`${resolvedTarget}/skills/foo`), `Should replace ~/.claude/skills/ with ${resolvedTarget}/skills/`); - // cursor also rewrites ~/.cursor/ → pathPrefix - assert.ok(result.includes(`${resolvedTarget}/skills/bar`), `Should replace ~/.cursor/skills/ with ${resolvedTarget}/skills/`); - } finally { - cleanup(stagedDir); - cleanup(configDir); - } - }); - - test('with injected homedir: global under home uses $HOME prefix', () => { - // Real absolute path so Windows path.resolve does not re-root a POSIX literal onto a drive. - // The dir need not exist — the engine only string-processes it. - const HOME = path.resolve(os.tmpdir(), 'gsd-1511-fake-home'); - const configDir = path.join(HOME, '.cursor'); - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); - try { - const skillDir = path.join(stagedDir, 'gsd-help'); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'Use ~/.claude/skills/ here\n'); - - conversion.rewriteStagedSkillBodies(stagedDir, { - runtime: 'cursor', - configDir, - scope: 'global', - homedir: () => HOME, - platform: process.platform, - }); - - const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); - assert.ok(result.includes('$HOME/.cursor/skills/'), 'Should use $HOME shorthand when configDir is under homedir'); - } finally { - cleanup(stagedDir); - } - }); - - test('non-existent stagedDir is a no-op', () => { - assert.doesNotThrow(() => { - conversion.rewriteStagedSkillBodies('/nonexistent/dir', { - runtime: 'cursor', - configDir: '/tmp/fake', - scope: 'global', - }); - }); - }); -}); - -// --------------------------------------------------------------------------- -// rewriteStagedCommandBodies — returns temp dir, does not mutate source -// --------------------------------------------------------------------------- - -describe('rewriteStagedCommandBodies', () => { - test('returns a temp dir (not the source dir) with rewritten content', () => { - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-cmd-')); - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); - let tempDir; - try { - // NOTE: rewrite engine handles path replacement + attribution, NOT tool renames. - fs.writeFileSync(path.join(stagedDir, 'help.md'), '# Help\n\nSee ~/.claude/skills/\n\nSee ~/.cursor/skills/\n'); - - tempDir = conversion.rewriteStagedCommandBodies(stagedDir, { - runtime: 'cursor', - configDir, - scope: 'global', - homedir: () => '/home/u', - platform: 'linux', - }); - - assert.notEqual(tempDir, stagedDir, 'must return a different dir, never the source'); - assert.ok(fs.existsSync(tempDir), 'returned tempDir should exist'); - - const result = fs.readFileSync(path.join(tempDir, 'help.md'), 'utf8'); - // Source dir should be unchanged - const source = fs.readFileSync(path.join(stagedDir, 'help.md'), 'utf8'); - assert.ok(source.includes('~/.claude/skills/'), 'source file must not be mutated'); - // configDir is /tmp/... (not under /home/u), so prefix = resolvedTarget + '/' - const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); - assert.ok(result.includes(`${resolvedTarget}/skills/`), 'output should have cursor path rewrite applied'); - // ~/.cursor/ also rewrites to prefix - assert.ok(!result.includes('~/.cursor/'), 'output should have ~/.cursor/ replaced too'); - } finally { - cleanup(stagedDir); - cleanup(configDir); - if (tempDir && tempDir !== stagedDir) { - cleanup(tempDir); - } - } - }); - - test('non-existent stagedDir returns stagedDir unchanged (safe)', () => { - const result = conversion.rewriteStagedCommandBodies('/nonexistent/dir', { - runtime: 'cursor', - configDir: '/tmp/fake', - scope: 'global', - }); - assert.equal(result, '/nonexistent/dir', 'should return input path unchanged for missing dir'); - }); -}); - -// --------------------------------------------------------------------------- -// Error-path: applyRuntimeContentRewritesForCommandsInPlace must rm the tempDir -// on any exception and NOT leave an orphaned gsd-cmd-rewrites-* directory. -// --------------------------------------------------------------------------- - -describe('applyRuntimeContentRewritesForCommandsInPlace — error-path tempDir cleanup', () => { - test('rmSync is called on the tempDir when readFileSync throws (deterministic monkeypatch)', () => { - // Asserting the injected error propagates proves the throw happens AFTER the tempDir is - // created (the function creates tempDir, then reads .md), so the catch's rmSync cleanup - // is genuinely exercised — deterministic on every platform/uid. - const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-error-path-')); - fs.writeFileSync(path.join(stagedDir, 'x.md'), '# test\n'); - - const before = new Set( - fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')) - ); - - const origReadFileSync = fs.readFileSync; - let leaked = []; - try { - fs.readFileSync = () => { throw new Error('injected read failure'); }; - - assert.throws( - () => conversion.applyRuntimeContentRewritesForCommandsInPlace(stagedDir, 'cursor', '/tmp/x/', false), - /injected read failure/, - ); - - // Restore before any further fs use so the snapshot read is trustworthy. - fs.readFileSync = origReadFileSync; - - const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')); - leaked = after.filter(n => !before.has(n)); - assert.deepStrictEqual(leaked, [], `tempDir not cleaned up on error: ${leaked.join(',')}`); - } finally { - // Idempotent restore — guard against early-throw paths above. - fs.readFileSync = origReadFileSync; - // Clean up the staged dir created for this test. - cleanup(stagedDir); - // Clean up any genuinely leaked gsd-cmd-rewrites-* dirs so the runner stays clean. - for (const n of leaked) { - cleanup(path.join(os.tmpdir(), n)); - } - } - }); -}); - -// --------------------------------------------------------------------------- -// Guard: runtime-artifact-layout no longer exports getInstallExports -// --------------------------------------------------------------------------- - -describe('layout module no longer exports getInstallExports', () => { - test('getInstallExports is not on the layout module export', () => { - process.env['GSD_TEST_MODE'] = '1'; - const layout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); - assert.equal( - typeof layout.getInstallExports, - 'undefined', - 'getInstallExports should have been removed from runtime-artifact-layout exports (ADR-1508 Phase 2)', - ); - }); -}); - -// --------------------------------------------------------------------------- -// DEFECT.GENERATIVE-FIX: single-owner reference-identity guard (#1511) -// Proves install.js binds to the conversion module's implementation, not a -// duplicate local copy. If these fail, a duplicate body was re-introduced. -// --------------------------------------------------------------------------- - -describe('single-owner reference-identity guard (ADR-1508 / #1511 Phase 2)', () => { - let install; - let conversionCjs; - before(() => { - process.env['GSD_TEST_MODE'] = '1'; - install = require('../bin/install.js'); - conversionCjs = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); - }); - - test('install.computePathPrefix === conversion._computePathPrefix (single implementation)', () => { - assert.strictEqual( - install.computePathPrefix, - conversionCjs._computePathPrefix, - 'install.js must bind computePathPrefix from conversion (not a duplicate body)', - ); - }); - - test('install.applyRuntimeContentRewritesInPlace === conversion.applyRuntimeContentRewritesInPlace (single walk loop)', () => { - assert.strictEqual( - install.applyRuntimeContentRewritesInPlace, - conversionCjs.applyRuntimeContentRewritesInPlace, - 'install.js must bind applyRuntimeContentRewritesInPlace from conversion (not a duplicate walk loop)', - ); - }); - - test('install.applyRuntimeContentRewritesForCommandsInPlace === conversion.applyRuntimeContentRewritesForCommandsInPlace (single copy+rewrite loop)', () => { - assert.strictEqual( - install.applyRuntimeContentRewritesForCommandsInPlace, - conversionCjs.applyRuntimeContentRewritesForCommandsInPlace, - 'install.js must bind applyRuntimeContentRewritesForCommandsInPlace from conversion (not a duplicate copy+rewrite loop)', - ); - }); - - test('install._applyRuntimeRewrites === conversion._applyRuntimeRewrites (single switch engine)', () => { - assert.strictEqual( - install._applyRuntimeRewrites, - conversionCjs._applyRuntimeRewrites, - 'install.js must bind _applyRuntimeRewrites from conversion (not a local shim)', - ); - }); - - // #1675 (ADR-1508): the augment converter family is single-sourced in the - // conversion module. install.js must re-bind (not re-define) these so there - // is exactly one body — the generative-drift hazard the dedup removes. - test('install.convertClaudeToAugmentMarkdown === conversion.convertClaudeToAugmentMarkdown (single converter)', () => { - assert.strictEqual( - install.convertClaudeToAugmentMarkdown, - conversionCjs.convertClaudeToAugmentMarkdown, - 'install.js must bind convertClaudeToAugmentMarkdown from conversion (not a duplicate body)', - ); - }); - - test('install.convertClaudeCommandToAugmentSkill === conversion.convertClaudeCommandToAugmentSkill (single converter)', () => { - assert.strictEqual( - install.convertClaudeCommandToAugmentSkill, - conversionCjs.convertClaudeCommandToAugmentSkill, - 'install.js must bind convertClaudeCommandToAugmentSkill from conversion (not a duplicate body)', - ); - }); - - test('install.convertClaudeAgentToAugmentAgent === conversion.convertClaudeAgentToAugmentAgent (single converter)', () => { - assert.strictEqual( - install.convertClaudeAgentToAugmentAgent, - conversionCjs.convertClaudeAgentToAugmentAgent, - 'install.js must bind convertClaudeAgentToAugmentAgent from conversion (not a duplicate body)', - ); - }); -}); diff --git a/tests/enh-1559-installer-export-audit.test.cjs b/tests/enh-1559-installer-export-audit.test.cjs deleted file mode 100644 index e1f200dd2..000000000 --- a/tests/enh-1559-installer-export-audit.test.cjs +++ /dev/null @@ -1,45 +0,0 @@ -'use strict'; - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); - -let installer; -let conversion; - -before(() => { - process.env['GSD_TEST_MODE'] = '1'; - installer = require('../bin/install.js'); - conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); -}); - -describe('bin/install.js compatibility export audit (#1559)', () => { - test('retains audited compatibility relays for shared rewrite helpers', () => { - assert.strictEqual(installer.processAttribution, conversion.processAttribution); - assert.strictEqual( - installer.applyRuntimeContentRewritesForCommandsInPlace, - conversion.applyRuntimeContentRewritesForCommandsInPlace, - ); - }); - - test('does not leak unaudited conversion-module helpers through the installer', () => { - for (const name of [ - 'yamlQuote', - 'toSingleLine', - 'extractFrontmatterAndBody', - 'extractFrontmatterField', - 'convertClaudeToCursorMarkdown', - 'convertClaudeToCodexMarkdown', - 'transformContentToHyphen', - 'claudeToGeminiTools', - 'convertGeminiToolName', - 'rewriteStagedSkillBodies', - 'rewriteStagedCommandBodies', - '_computePathPrefix', - '_stampNonClaudeRuntimeDefaults', - 'NON_CLAUDE_RUNTIMES', - ]) { - assert.ok(name in conversion, `${name} remains available from the conversion module`); - assert.equal(installer[name], undefined, `${name} is not an installer compatibility export`); - } - }); -}); diff --git a/tests/enh-769-context-fork-effort.install.test.cjs b/tests/enh-769-context-fork-effort.install.test.cjs deleted file mode 100644 index f552345b9..000000000 --- a/tests/enh-769-context-fork-effort.install.test.cjs +++ /dev/null @@ -1,351 +0,0 @@ -// allow-test-rule: integration-test-input -// Exercises install() as a black-box by inspecting produced SKILL.md output -// in a temp dir. Source command .md files are inputs whose installed -// transformation is asserted — not inspected for string presence. - -/** - * #769 — effort: frontmatter on heavy workflow skills. - * #921 — spawning orchestrators must NOT carry context: fork. - * - * Context: context:fork was added by #769 to protect context budget, but - * plan-phase, execute-phase, and autonomous are spawning orchestrators — a - * forked subagent has no Agent/Task tool, breaking their core function. - * effort: max is preserved; context: fork is removed from these three. - * The converter still passes context: fork through if a source file has it - * (for any future leaf skill that legitimately needs isolation). - * - * Verifies: - * 1. Source commands/gsd/autonomous.md does NOT have context: fork, has effort: max - * 2. Source commands/gsd/execute-phase.md does NOT have context: fork, has effort: max - * 3. Source commands/gsd/plan-phase.md does NOT have context: fork, has effort: max - * 4. Source commands/gsd/progress.md has effort: low - * 5. Source commands/gsd/stats.md has effort: low - * 6. Claude global install: SKILL.md for autonomous has effort: max, NOT context: fork - * 7. Claude global install: SKILL.md for execute-phase has effort: max, NOT context: fork - * 8. Claude global install: SKILL.md for plan-phase has effort: max, NOT context: fork - * 9. Claude global install: SKILL.md for progress has effort: low - * 10. Claude global install: SKILL.md for stats has effort: low - * 11. convertClaudeCommandToClaudeSkill still passes context: fork through (for non-orchestrator skills) - * 12. convertClaudeCommandToClaudeSkill emits portable effort: field values - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const { install, convertClaudeCommandToClaudeSkill } = require('../bin/install.js'); -const { cleanup } = require('./helpers.cjs'); - -// #924: Claude global install is now FLAT — concrete skills are at the top level. -// flatSkillPath returns: /gsd-/SKILL.md -function flatSkillPath(skillsRoot, stem) { - return path.join(skillsRoot, `gsd-${stem}`, 'SKILL.md'); -} - -const REPO_ROOT = path.resolve(__dirname, '..'); -const SOURCE_COMMANDS_DIR = path.join(REPO_ROOT, 'commands', 'gsd'); - -// ─── helpers ────────────────────────────────────────────────────────────────── - -function makeTmpDir(prefix) { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -function readFrontmatter(mdPath) { - const content = fs.readFileSync(mdPath, 'utf8'); - if (!content.startsWith('---')) return ''; - const end = content.indexOf('---', 3); - if (end === -1) return ''; - return content.substring(3, end); -} - -/** - * Run a global install for Claude, redirecting its home dir to tmpHome. - * Returns the tmpHome for inspection. - */ -function runClaudeGlobalInstall(claudeHome) { - const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-769-home-')); - - const prevCwd = process.cwd(); - const prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; - const prevHome = process.env.HOME; - const prevUserProfile = process.env.USERPROFILE; - const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK; - - process.env.CLAUDE_CONFIG_DIR = claudeHome; - process.env.HOME = isolatedHome; - process.env.USERPROFILE = isolatedHome; - process.env.GSD_SKIP_STALE_SDK_CHECK = '1'; - process.chdir(REPO_ROOT); - - try { - install(true, 'claude'); - } finally { - process.chdir(prevCwd); - if (prevClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; - else process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir; - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - if (prevUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = prevUserProfile; - if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK; - else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale; - cleanup(isolatedHome); - } - - return claudeHome; -} - -// ─── describe 1: Source command files have correct frontmatter ──────────────── - -// #921/#922: spawning orchestrators must NOT carry context: fork — a forked -// subagent has no Agent/Task tool, making it impossible for orchestrators to -// spawn their required subagents. context: fork is appropriate only for leaf -// skills that do not themselves dispatch agents. effort: max is portable across Claude Code models. -describe('#769/#921/#1319 source commands: spawning orchestrators have effort: max but NOT context: fork', () => { - test('commands/gsd/autonomous.md does NOT have context: fork (#921)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `autonomous.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('commands/gsd/autonomous.md has effort: max (#1319)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); - assert.match(fm, /^effort:[ \t]*max$/m, - `autonomous.md frontmatter must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `autonomous.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); - - test('commands/gsd/execute-phase.md does NOT have context: fork (#921)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `execute-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('commands/gsd/execute-phase.md has effort: max (#1319)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); - assert.match(fm, /^effort:[ \t]*max$/m, - `execute-phase.md frontmatter must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `execute-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); - - test('commands/gsd/plan-phase.md does NOT have context: fork (#921)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `plan-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('commands/gsd/plan-phase.md has effort: max (#1319)', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); - assert.match(fm, /^effort:[ \t]*max$/m, - `plan-phase.md frontmatter must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `plan-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); -}); - -describe('#769 source commands: quick-status skills have effort: low', () => { - test('commands/gsd/progress.md has effort: low', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'progress.md')); - assert.match(fm, /^effort:[ \t]*low$/m, - `progress.md frontmatter must have effort: low\nActual:\n${fm}`); - }); - - test('commands/gsd/stats.md has effort: low', () => { - const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'stats.md')); - assert.match(fm, /^effort:[ \t]*low$/m, - `stats.md frontmatter must have effort: low\nActual:\n${fm}`); - }); -}); - -// ─── describe 2: convertClaudeCommandToClaudeSkill preserves new fields ─────── - -describe('#769/#1319 convertClaudeCommandToClaudeSkill: preserves context and emits portable effort fields', () => { - test('preserves context: fork in emitted SKILL.md frontmatter', () => { - const input = [ - '---', - 'name: gsd:test-heavy', - 'description: Test heavy skill', - 'context: fork', - 'effort: xhigh', - 'allowed-tools:', - ' - Read', - ' - Bash', - '---', - '', - 'Heavy skill body.', - ].join('\n'); - - const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); - const end = result.indexOf('---', 3); - const fm = result.substring(3, end); - - assert.match(fm, /^context:[ \t]*fork$/m, - `SKILL.md frontmatter must include context: fork\nActual frontmatter:\n${fm}`); - }); - - test('normalizes effort: xhigh to effort: max in emitted SKILL.md frontmatter (#1319)', () => { - const input = [ - '---', - 'name: gsd:test-heavy', - 'description: Test heavy skill', - 'context: fork', - 'effort: xhigh', - 'allowed-tools:', - ' - Read', - ' - Bash', - '---', - '', - 'Heavy skill body.', - ].join('\n'); - - const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); - const end = result.indexOf('---', 3); - const fm = result.substring(3, end); - - assert.match(fm, /^effort:[ \t]*max$/m, - `SKILL.md frontmatter must include portable effort: max\nActual frontmatter:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `SKILL.md frontmatter must not include rejected effort: xhigh (#1319)\nActual frontmatter:\n${fm}`); - }); - - test('preserves effort: low in emitted SKILL.md frontmatter', () => { - const input = [ - '---', - 'name: gsd:test-light', - 'description: Test light skill', - 'effort: low', - 'allowed-tools:', - ' - Read', - '---', - '', - 'Light skill body.', - ].join('\n'); - - const result = convertClaudeCommandToClaudeSkill(input, 'test-light'); - const end = result.indexOf('---', 3); - const fm = result.substring(3, end); - - assert.match(fm, /^effort:[ \t]*low$/m, - `SKILL.md frontmatter must include effort: low\nActual frontmatter:\n${fm}`); - }); - - test('does NOT emit context: or effort: when absent from source', () => { - const input = [ - '---', - 'name: gsd:test-plain', - 'description: Plain skill without context or effort', - 'allowed-tools:', - ' - Read', - '---', - '', - 'Plain skill body.', - ].join('\n'); - - const result = convertClaudeCommandToClaudeSkill(input, 'test-plain'); - const end = result.indexOf('---', 3); - const fm = result.substring(3, end); - - assert.doesNotMatch(fm, /^context:/m, - `SKILL.md must not emit context: when absent from source\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:/m, - `SKILL.md must not emit effort: when absent from source\nActual:\n${fm}`); - }); -}); - -// ─── describe 3: Claude global install — SKILL.md files include new fields ──── - -// #921/#922: after install, spawning orchestrators must NOT carry context: fork -// in their emitted SKILL.md. #1319: heavyweight skills must use portable max effort. -describe('#769/#921/#1319 Claude global install: spawning-orchestrator SKILL.md files have effort: max but NOT context: fork', () => { - let tmpDir; - let claudeHome; - - beforeEach(() => { - tmpDir = makeTmpDir('gsd-769-claude-'); - claudeHome = path.join(tmpDir, 'claude-home'); - fs.mkdirSync(claudeHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('gsd-autonomous SKILL.md does NOT have context: fork after global install (#921)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous'); - const fm = readFrontmatter(skillPath); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `gsd-autonomous is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('gsd-autonomous SKILL.md has effort: max after global install (#1319)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous'); - const fm = readFrontmatter(skillPath); - assert.match(fm, /^effort:[ \t]*max$/m, - `gsd-autonomous SKILL.md must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `gsd-autonomous SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); - - test('gsd-execute-phase SKILL.md does NOT have context: fork after global install (#921)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase'); - const fm = readFrontmatter(skillPath); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `gsd-execute-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('gsd-execute-phase SKILL.md has effort: max after global install (#1319)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase'); - const fm = readFrontmatter(skillPath); - assert.match(fm, /^effort:[ \t]*max$/m, - `gsd-execute-phase SKILL.md must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `gsd-execute-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); - - test('gsd-plan-phase SKILL.md does NOT have context: fork after global install (#921)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase'); - const fm = readFrontmatter(skillPath); - assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, - `gsd-plan-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); - }); - - test('gsd-plan-phase SKILL.md has effort: max after global install (#1319)', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase'); - const fm = readFrontmatter(skillPath); - assert.match(fm, /^effort:[ \t]*max$/m, - `gsd-plan-phase SKILL.md must have effort: max\nActual:\n${fm}`); - assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, - `gsd-plan-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); - }); - - test('gsd-progress SKILL.md has effort: low after global install', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'progress'); - const fm = readFrontmatter(skillPath); - assert.match(fm, /^effort:[ \t]*low$/m, - `gsd-progress SKILL.md must have effort: low\nActual:\n${fm}`); - }); - - test('gsd-stats SKILL.md has effort: low after global install', () => { - runClaudeGlobalInstall(claudeHome); - const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'stats'); - const fm = readFrontmatter(skillPath); - assert.match(fm, /^effort:[ \t]*low$/m, - `gsd-stats SKILL.md must have effort: low\nActual:\n${fm}`); - }); -}); diff --git a/tests/enh-772-codex-hook-events.test.cjs b/tests/enh-772-codex-hook-events.test.cjs deleted file mode 100644 index 528d1d6a1..000000000 --- a/tests/enh-772-codex-hook-events.test.cjs +++ /dev/null @@ -1,411 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Enhancement #772: Adopt new stable Codex hook events + commandWindows for - * Windows parity. - * - * Codex CLI (rust-v0.137.0) stabilised the full hook-event set. This suite - * asserts that a Codex install: - * - * (a) Registers the 3 new high-value hook events in hooks.json: - * - SubagentStart — inject context / GSD_AGENT_NAME awareness at subagent open - * - Stop — post-session context headroom tracking - * - PostToolUse — mirror the Claude Code PostToolUse context monitor - * - * (b) Emits `commandWindows` in the SessionStart hooks.json entry so that - * Windows users get the .cmd shim path and non-Windows users get the POSIX - * node runner command. Both fields are present in the same entry; Codex picks - * the right one per its HookHandlerConfig schema - * (codex-rs/config/src/hook_config.rs: commandWindows / command_windows alias). - * - * Note: UserPromptSubmit is NOT wired (same rationale as Qwen #788 — the - * gsd-prompt-guard handler exits unless tool_name is Write|Edit, so it would be - * a silent no-op for the UserPromptSubmit payload shape). - * - * Test strategy: - * - Test new event registration via ensureCodexHooksJsonEvent() directly - * (mirrors the #3426 pattern of testing ensureCodexHooksJsonSessionStart - * directly with a stub hook file — avoids full install() migration dance). - * - Test commandWindows via ensureCodexHooksJsonSessionStart() directly. - * - IR-first discipline: assert on the structured result, not rendered text. - * - * Verified hook event schema: - * https://github.com/openai/codex/blob/main/codex-rs/protocol/src/protocol.rs - * https://github.com/openai/codex/blob/main/codex/codex-rs/config/src/hook_config.rs - */ - -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 = require('../bin/install.js'); -const { - ensureCodexHooksJsonSessionStart, - ensureCodexHooksJsonEvent, - removeCodexHooksJsonEvent, - reconcileCodexHooksJsonEvent, -} = INSTALL; -const { createTempDir, cleanup } = require('./helpers.cjs'); - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -/** - * Extract all hook handler entries (full objects with type/command/etc.) for - * `eventName` from a hooks.json object (flat or nested-hooks shape). - */ -function hooksJsonHandlersForEvent(hooksJson, eventName) { - if (!hooksJson || typeof hooksJson !== 'object') return []; - const table = - hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks) - ? hooksJson.hooks - : hooksJson; - if (!Array.isArray(table[eventName])) return []; - return table[eventName].flatMap(entry => - Array.isArray(entry && entry.hooks) ? entry.hooks : [] - ); -} - -function readHooksJson(targetDir) { - const p = path.join(targetDir, 'hooks.json'); - if (!fs.existsSync(p)) return null; - return JSON.parse(fs.readFileSync(p, 'utf8')); -} - -function stubHookFile(targetDir, hookName) { - const hooksDest = path.join(targetDir, 'hooks'); - fs.mkdirSync(hooksDest, { recursive: true }); - const dest = path.join(hooksDest, hookName); - if (!fs.existsSync(dest)) { - fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); - try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } - } -} - -// ─── Suite 1: ensureCodexHooksJsonEvent export surface ─────────────────────── - -describe('enh-772: export surface — new functions are exported', () => { - test('ensureCodexHooksJsonEvent is a function', () => { - assert.strictEqual(typeof ensureCodexHooksJsonEvent, 'function', - 'ensureCodexHooksJsonEvent must be exported from bin/install.js'); - }); - - test('removeCodexHooksJsonEvent is a function', () => { - assert.strictEqual(typeof removeCodexHooksJsonEvent, 'function', - 'removeCodexHooksJsonEvent must be exported from bin/install.js'); - }); - - test('reconcileCodexHooksJsonEvent is a function', () => { - assert.strictEqual(typeof reconcileCodexHooksJsonEvent, 'function', - 'reconcileCodexHooksJsonEvent must be exported from bin/install.js'); - }); -}); - -// ─── Suite 2: ensureCodexHooksJsonEvent registers new events ───────────────── - -describe('enh-772: ensureCodexHooksJsonEvent registers SubagentStart, Stop, PostToolUse', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-772-events-'); - stubHookFile(tmpDir, 'gsd-context-monitor.js'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { - test(`${eventName}: ensureCodexHooksJsonEvent writes hooks.json`, () => { - const fakeRunner = '"/usr/local/bin/node"'; - const result = ensureCodexHooksJsonEvent(tmpDir, eventName, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - assert.ok(result && result.path, `result must have path for ${eventName}`); - assert.ok(result.wrote || result.changed, - `ensureCodexHooksJsonEvent must write or change hooks.json for ${eventName}`); - assert.ok(fs.existsSync(path.join(tmpDir, 'hooks.json')), - `hooks.json must exist after registering ${eventName}`); - }); - - test(`${eventName}: hooks.json contains the event entry`, () => { - const fakeRunner = '"/usr/local/bin/node"'; - ensureCodexHooksJsonEvent(tmpDir, eventName, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); - assert.ok(handlers.length > 0, - `Expected ${eventName} entry in hooks.json; got: ${JSON.stringify(hooksJson)}`); - }); - - test(`${eventName}: hook entry uses gsd-context-monitor`, () => { - const fakeRunner = '"/usr/local/bin/node"'; - ensureCodexHooksJsonEvent(tmpDir, eventName, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); - assert.ok( - handlers.some(h => h.command && h.command.includes('gsd-context-monitor')), - `${eventName} hook must use gsd-context-monitor; got: ${JSON.stringify(handlers)}` - ); - }); - - test(`${eventName}: hook entry has type: 'command'`, () => { - const fakeRunner = '"/usr/local/bin/node"'; - ensureCodexHooksJsonEvent(tmpDir, eventName, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); - const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor')); - assert.strictEqual(entry && entry.type, 'command', - `${eventName} hook entry must have type 'command'`); - }); - - test(`${eventName}: hook entry has timeout: 10`, () => { - const fakeRunner = '"/usr/local/bin/node"'; - ensureCodexHooksJsonEvent(tmpDir, eventName, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); - const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor')); - assert.strictEqual(entry && entry.timeout, 10, - `${eventName} hook entry must have timeout 10`); - }); - } - - test('null absoluteRunner returns unchanged result without writing', () => { - const result = ensureCodexHooksJsonEvent(tmpDir, 'SubagentStart', { - absoluteRunner: null, - platform: 'linux', - }); - assert.strictEqual(result.changed, false, - 'null runner must return changed: false'); - assert.ok(!fs.existsSync(path.join(tmpDir, 'hooks.json')), - 'hooks.json must NOT be written when runner is null'); - }); -}); - -// ─── Suite 3: commandWindows parity in SessionStart ────────────────────────── - -describe('enh-772: commandWindows parity — ensureCodexHooksJsonSessionStart emits commandWindows', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-772-cmdwin-'); - stubHookFile(tmpDir, 'gsd-check-update.js'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - // commandWindows is ONLY emitted on win32 platform (where the .cmd shim is also - // written). On POSIX platforms, commandWindows is omitted to avoid pointing Windows - // Codex at a non-existent .cmd file (the shim is only present after a native Windows - // install that runs buildCodexHookWindowsShimIR and atomicWriteFileSync). - - test('POSIX platform: commandWindows is NOT emitted (shim not written on POSIX)', () => { - const fakeRunner = '"/usr/local/bin/node"'; - const result = ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - assert.ok(result && result.wrote, 'must write hooks.json on linux'); - - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); - assert.ok(handlers.length > 0, `Expected SessionStart handlers; got: ${JSON.stringify(hooksJson)}`); - - const entry = handlers[0]; - assert.ok( - entry.commandWindows === undefined, - `commandWindows must NOT be emitted on POSIX (shim not written); got: ${JSON.stringify(entry)}` - ); - }); - - test('POSIX platform: command references gsd-check-update.js (not .cmd)', () => { - const fakeRunner = '"/usr/local/bin/node"'; - ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); - const entry = handlers[0]; - assert.ok( - entry.command && entry.command.includes('gsd-check-update'), - `POSIX command must reference gsd-check-update; got: ${entry.command}` - ); - assert.ok( - !entry.command.endsWith('.cmd') && !entry.command.endsWith('.cmd"'), - `POSIX command must not end with .cmd; got: ${entry.command}` - ); - }); - - test('null absoluteRunner: no commandWindows emitted, no write', () => { - const result = ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: null, - platform: 'linux', - }); - assert.strictEqual(result.changed, false, 'null runner must return changed: false'); - const hooksJson = readHooksJson(tmpDir); - if (hooksJson) { - const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); - for (const h of handlers) { - assert.ok(!h.commandWindows, - `commandWindows must not be present when runner is null; got: ${JSON.stringify(h)}`); - } - } - }); - - test('Windows platform: SessionStart hook is written with commandWindows pointing to .cmd shim', () => { - // On win32, both `command` and `commandWindows` use the .cmd shim path - // (because managedCommand = shimIR.hookCommand = .cmd path, and - // commandWindows = same .cmd path). This ensures Codex picks the .cmd - // on Windows regardless of which field it reads. - const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; - const result = ensureCodexHooksJsonSessionStart(tmpDir, { - absoluteRunner: fakeRunner, - platform: 'win32', - }); - // The shim write and hooks.json write should succeed in the tmp dir. - if (result.wrote) { - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); - assert.ok(handlers.length > 0, - `SessionStart must be registered on Windows path; got: ${JSON.stringify(hooksJson)}`); - const entry = handlers[0]; - assert.ok(typeof entry.commandWindows === 'string', - `commandWindows must be present on Windows path; got: ${JSON.stringify(entry)}`); - // commandWindows should reference the .cmd shim - assert.ok( - entry.commandWindows.includes('gsd-check-update') && entry.commandWindows.includes('.cmd'), - `commandWindows must reference gsd-check-update.cmd on win32; got: ${entry.commandWindows}` - ); - } - }); -}); - -// ─── Suite 4: idempotency ──────────────────────────────────────────────────── - -describe('enh-772: ensureCodexHooksJsonEvent is idempotent', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-772-idem-'); - stubHookFile(tmpDir, 'gsd-context-monitor.js'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { - test(`${eventName}: calling twice does not duplicate hook entries`, () => { - const fakeRunner = '"/usr/local/bin/node"'; - const opts = { absoluteRunner: fakeRunner, platform: 'linux' }; - - ensureCodexHooksJsonEvent(tmpDir, eventName, opts); - ensureCodexHooksJsonEvent(tmpDir, eventName, opts); - - const hooksJson = readHooksJson(tmpDir); - const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); - assert.strictEqual(handlers.length, 1, - `${eventName} should have exactly 1 hook handler after idempotent re-register; got ${handlers.length}: ${JSON.stringify(handlers)}`); - }); - } -}); - -// ─── Suite 5: removeCodexHooksJsonEvent ────────────────────────────────────── - -describe('enh-772: removeCodexHooksJsonEvent removes managed entries', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-772-remove-'); - stubHookFile(tmpDir, 'gsd-context-monitor.js'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { - test(`${eventName}: removeCodexHooksJsonEvent removes the managed entry`, () => { - const fakeRunner = '"/usr/local/bin/node"'; - ensureCodexHooksJsonEvent(tmpDir, eventName, { - absoluteRunner: fakeRunner, - platform: 'linux', - }); - - // Verify it was registered - let hooksJson = readHooksJson(tmpDir); - let handlers = hooksJsonHandlersForEvent(hooksJson, eventName); - assert.ok(handlers.length > 0, `${eventName} must be registered before removal`); - - // Remove - const result = removeCodexHooksJsonEvent(tmpDir, eventName); - assert.ok(result.changed || result.wrote, - `removeCodexHooksJsonEvent must change hooks.json for ${eventName}`); - - hooksJson = readHooksJson(tmpDir); - if (hooksJson) { - handlers = hooksJsonHandlersForEvent(hooksJson, eventName); - assert.strictEqual(handlers.length, 0, - `After removal, ${eventName} should have 0 handlers; got: ${JSON.stringify(handlers)}`); - } - }); - } -}); - -// ─── Suite 6: reconcileCodexHooksJsonEvent preserves user entries ───────────── - -describe('enh-772: reconcileCodexHooksJsonEvent preserves user-owned entries', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempDir('gsd-772-preserve-'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('user-owned SubagentStart entry is preserved when GSD entry is registered', () => { - const hooksJsonPath = path.join(tmpDir, 'hooks.json'); - const userEntry = { - hooks: [{ type: 'command', command: 'my-custom-hook.sh' }] - }; - fs.writeFileSync(hooksJsonPath, JSON.stringify({ - SubagentStart: [userEntry] - }, null, 2) + '\n'); - - reconcileCodexHooksJsonEvent(tmpDir, 'SubagentStart', { - managedCommand: '"/usr/local/bin/node" "/home/me/.codex/hooks/gsd-context-monitor.js"', - }); - - const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); - const table = hooksJson.hooks || hooksJson; - const entries = Array.isArray(table.SubagentStart) ? table.SubagentStart : []; - // Should have 2 entries: user entry + GSD entry - assert.ok(entries.length >= 2, - `User entry must be preserved; got entries: ${JSON.stringify(entries)}`); - // User entry must still be present - const userEntryStillPresent = entries.some(e => - Array.isArray(e.hooks) && e.hooks.some(h => h.command === 'my-custom-hook.sh') - ); - assert.ok(userEntryStillPresent, - `User entry must survive GSD registration; entries: ${JSON.stringify(entries)}`); - }); -}); diff --git a/tests/enh-776-install-gemini-hook-events.test.cjs b/tests/enh-776-install-gemini-hook-events.test.cjs deleted file mode 100644 index 23a259cf4..000000000 --- a/tests/enh-776-install-gemini-hook-events.test.cjs +++ /dev/null @@ -1,406 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Enhancement #776: Adopt new Gemini hook events + detect hooksConfig.enabled:false. - * - * Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that gsd - * previously did not register. This suite asserts that a Gemini install - * registers the 3 new high-value events: - * - BeforeAgent — fires before the agent plans (context headroom tracking) - * - AfterAgent — fires after final response generation (context tracking) - * - BeforeModel — fires before each LLM call (per-turn context awareness) - * - * All three are wired to gsd-context-monitor.js — the same hook used for - * AfterTool — so context headroom warnings surface at these lifecycle moments. - * - * Also asserts: - * - Claude Code installs do NOT gain these Gemini-only events (strict scope guard). - * - Reinstalls are idempotent (no hook duplication). - * - Uninstall removes the new event registrations. - * - hooksConfig.enabled:false warning is emitted during a Gemini install. - * - * Source: https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md - */ - -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) - ); -} - -// Stub JS hook files that the installer checks with fs.existsSync() so hook -// registration guards pass even when hooks/dist/ isn't built. -// -// For Gemini, the installer migration baseline includes 'hooks/' in its surface -// list (unlike Qwen which excludes it). Pre-install stubs placed in .gemini/hooks/ -// are classified as 'bundled-gsd-hook' and auto-removed by the migration before -// registration can succeed. The workaround: run a first install (which writes the -// gsd-file-manifest.json), then add the stubs, then run install again. On the -// second install the manifest marks the hook files as managed, so migration keeps -// them and registration guards (fs.existsSync) pass. -const HOOKS_SRC = path.join(__dirname, '..', 'hooks'); -const STUB_HOOKS = [ - 'gsd-context-monitor.js', - 'gsd-prompt-guard.js', - 'gsd-check-update.js', -]; - -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 */ } - } -} - -/** - * Two-pass Gemini install. - * - * Pass 1: install() with no hook stubs — writes gsd-file-manifest.json. - * Migration runs on an empty hooks/ so nothing gets auto-removed. - * Pass 2: stub hooks into the target dir (now manifest-tracked on next scan), - * then run install() again. Migration now classifies the hooks as - * managed-unchanged and preserves them; registration guards pass. - * - * Returns the settings from pass 2. - */ -function twoPassGeminiInstall(tmpDir) { - const targetDir = path.join(tmpDir, '.gemini'); - fs.mkdirSync(targetDir, { recursive: true }); - - // Pass 1 — no hook stubs yet; writes the manifest - const result1 = install(false, 'gemini'); - persistSettings(result1.settingsPath, result1.settings); - - // Inject stubs so the registration guards (fs.existsSync) pass on pass 2 - stubHooksIntoTarget(targetDir); - - // Pass 2 — manifest exists, migration keeps stubs, registration succeeds - process.chdir(tmpDir); - const result2 = install(false, 'gemini'); - return result2; -} - -/** - * Persist in-memory settings to disk, simulating what finishInstall() does - * (finishInstall is not exported). Required for tests that call install() - * twice and need the second call to read the first call's hook registrations. - */ -function persistSettings(settingsPath, settings) { - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8'); -} - -// ─── Suite 1: Gemini — new events are registered ───────────────────────────── - -describe('enh-776: Gemini install registers 3 new hook events', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-776-gemini-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - // Two-pass install: first pass writes manifest, second pass with stubs - // so registration guards (fs.existsSync) pass. - const result = twoPassGeminiInstall(tmpDir); - settings = result.settings; - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('install returns a settings object (not null)', () => { - assert.ok(settings !== null && typeof settings === 'object', - 'Gemini install must return a non-null settings object'); - }); - - test('BeforeAgent event is registered with at least one hook', () => { - const cmds = hooksForEvent(settings, 'BeforeAgent'); - assert.ok(cmds.length > 0, - `Expected BeforeAgent hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); - }); - - test('AfterAgent event is registered with at least one hook', () => { - const cmds = hooksForEvent(settings, 'AfterAgent'); - assert.ok(cmds.length > 0, - `Expected AfterAgent hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); - }); - - test('BeforeModel event is registered with at least one hook', () => { - const cmds = hooksForEvent(settings, 'BeforeModel'); - assert.ok(cmds.length > 0, - `Expected BeforeModel hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); - }); - - test('BeforeAgent / AfterAgent / BeforeModel all use gsd-context-monitor', () => { - for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { - 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: Non-Gemini installs do NOT get the new events ────────────────── -// -// Two runtimes are particularly important to guard: -// Claude — the canonical non-Gemini runtime -// Antigravity — shares Gemini-style BeforeTool/AfterTool naming and uses -// isGemini-adjacent logic; a future accidental -// `isGemini || isAntigravity` change must be caught here. - -describe('enh-776: Claude install does NOT register Gemini-only hook events', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-776-claude-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const result = install(false, 'claude'); - settings = result && result.settings; - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('Claude install does not register BeforeAgent', () => { - const cmds = hooksForEvent(settings, 'BeforeAgent'); - assert.strictEqual(cmds.length, 0, - `Claude should NOT have BeforeAgent; got: ${JSON.stringify(cmds)}`); - }); - - test('Claude install does not register AfterAgent', () => { - const cmds = hooksForEvent(settings, 'AfterAgent'); - assert.strictEqual(cmds.length, 0, - `Claude should NOT have AfterAgent; got: ${JSON.stringify(cmds)}`); - }); - - test('Claude install does not register BeforeModel', () => { - const cmds = hooksForEvent(settings, 'BeforeModel'); - assert.strictEqual(cmds.length, 0, - `Claude should NOT have BeforeModel; got: ${JSON.stringify(cmds)}`); - }); -}); - -describe('enh-776: Antigravity install does NOT register Gemini-only hook events', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-776-antigravity-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const result = install(false, 'antigravity'); - settings = result && result.settings; - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('Antigravity install does not register BeforeAgent', () => { - const cmds = hooksForEvent(settings, 'BeforeAgent'); - assert.strictEqual(cmds.length, 0, - `Antigravity should NOT have BeforeAgent; got: ${JSON.stringify(cmds)}`); - }); - - test('Antigravity install does not register AfterAgent', () => { - const cmds = hooksForEvent(settings, 'AfterAgent'); - assert.strictEqual(cmds.length, 0, - `Antigravity should NOT have AfterAgent; got: ${JSON.stringify(cmds)}`); - }); - - test('Antigravity install does not register BeforeModel', () => { - const cmds = hooksForEvent(settings, 'BeforeModel'); - assert.strictEqual(cmds.length, 0, - `Antigravity should NOT have BeforeModel; got: ${JSON.stringify(cmds)}`); - }); -}); - -// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ────── - -describe('enh-776: Gemini install is idempotent across persisted reinstalls', () => { - let tmpDir; - let previousCwd; - - beforeEach(() => { - tmpDir = createTempDir('gsd-776-idem-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('re-running after persisted first install does not duplicate hook entries', () => { - // Two-pass to get hooks installed. - const result2 = twoPassGeminiInstall(tmpDir); - const s2 = result2.settings; - - // Assert hooks ARE registered after pass 2 (guards against false-pass where - // hooks never registered and idempotency passes trivially at count=0). - for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { - const cmds = hooksForEvent(s2, event); - assert.strictEqual(cmds.length, 1, - `Event ${event} should have exactly 1 hook after two-pass install; got ${cmds.length}: ${JSON.stringify(cmds)}`); - } - - persistSettings(result2.settingsPath, s2); - - // Third install: reads the persisted settings.json — dedup guards apply - process.chdir(tmpDir); - const result3 = install(false, 'gemini'); - const s3 = result3.settings; - - for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { - const cmds = hooksForEvent(s3, event); - assert.strictEqual(cmds.length, 1, - `Event ${event} should have exactly 1 hook command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); - } - }); -}); - -// ─── Suite 4: Uninstall removes the new event registrations ────────────────── - -describe('enh-776: Gemini uninstall removes new hook event entries', () => { - let tmpDir; - let previousCwd; - - beforeEach(() => { - tmpDir = createTempDir('gsd-776-uninstall-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - // Two-pass install and persist so uninstall has a settings.json to clean - const result = twoPassGeminiInstall(tmpDir); - persistSettings(result.settingsPath, result.settings); - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('settings.json hook entries are removed on uninstall', () => { - uninstall(false, 'gemini'); - const settingsPath = path.join(tmpDir, '.gemini', 'settings.json'); - if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { - const cmds = hooksForEvent(settings, event); - assert.strictEqual(cmds.length, 0, - `After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`); - } - }); -}); - -// ─── Suite 5: hooksConfig.enabled:false warning ─────────────────────────────── - -describe('enh-776: hooksConfig.enabled:false warning during Gemini install', () => { - let tmpDir; - let previousCwd; - let stderrLines; - let originalWarn; - - beforeEach(() => { - tmpDir = createTempDir('gsd-776-hookscfg-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const targetDir = path.join(tmpDir, '.gemini'); - fs.mkdirSync(targetDir, { recursive: true }); - - // Capture console.warn output - stderrLines = []; - originalWarn = console.warn; - console.warn = (...args) => { stderrLines.push(args.join(' ')); }; - }); - - afterEach(() => { - console.warn = originalWarn; - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('emits a warning when hooksConfig.enabled is false', () => { - // Write a settings.json with hooksConfig.enabled: false BEFORE install - // (the check in install() reads the existing settings.json on disk). - const targetDir = path.join(tmpDir, '.gemini'); - const settingsPath = path.join(targetDir, 'settings.json'); - fs.writeFileSync(settingsPath, JSON.stringify({ hooksConfig: { enabled: false } }, null, 2) + '\n', 'utf8'); - - install(false, 'gemini'); - const warnText = stderrLines.join('\n'); - assert.ok( - warnText.includes('hooksConfig.enabled is false'), - `Expected hooksConfig.enabled warning; got console.warn output:\n${warnText}` - ); - }); - - test('does NOT emit the hooksConfig warning when hooksConfig.enabled is true', () => { - const targetDir = path.join(tmpDir, '.gemini'); - const settingsPath = path.join(targetDir, 'settings.json'); - fs.writeFileSync(settingsPath, JSON.stringify({ hooksConfig: { enabled: true } }, null, 2) + '\n', 'utf8'); - - install(false, 'gemini'); - const warnText = stderrLines.join('\n'); - assert.ok( - !warnText.includes('hooksConfig.enabled is false'), - `Should NOT warn when hooksConfig.enabled is true; got:\n${warnText}` - ); - }); - - test('does NOT emit the hooksConfig warning when hooksConfig is absent', () => { - const targetDir = path.join(tmpDir, '.gemini'); - const settingsPath = path.join(targetDir, 'settings.json'); - fs.writeFileSync(settingsPath, JSON.stringify({}, null, 2) + '\n', 'utf8'); - - install(false, 'gemini'); - const warnText = stderrLines.join('\n'); - assert.ok( - !warnText.includes('hooksConfig.enabled is false'), - `Should NOT warn when hooksConfig is absent; got:\n${warnText}` - ); - }); -}); diff --git a/tests/enh-778-cross-runtime-command-enrichment.test.cjs b/tests/enh-778-cross-runtime-command-enrichment.test.cjs deleted file mode 100644 index 4c724efe7..000000000 --- a/tests/enh-778-cross-runtime-command-enrichment.test.cjs +++ /dev/null @@ -1,227 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Reads .md/SKILL.md/.toml product files whose deployed text IS what the -// runtime loads — testing text content tests the deployed contract. - -/** - * GSD Tools Tests — #778 cross-runtime command enrichment. - * - * Two independently-verified, additive sub-features: - * (b) Qwen Code skills: numeric `priority` field (higher sorts earlier in the - * /skills TUI listing per the Qwen skills spec). Scoped to runtime='qwen'. - * (c) Gemini custom-command TOML: $ARGUMENTS → {{args}} interpolation, and a - * fixed `!{cat .planning/STATE.md}` live-state injection on the - * situational `progress` command (injection-safe — no interpolated input). - * - * The OpenCode sub-feature (per-command model/agent/subtask/variant) is - * intentionally NOT implemented — see PR description: `model` reintroduces the - * #1156 ProviderModelNotFoundError regression for non-Anthropic OpenCode users, - * `subtask`/`agent` change execution semantics for GSD's interactive commands, - * and `variant` is not in the OpenCode command schema. - * - * Uses node:test and node:assert (NOT Jest). - */ - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { createTempDir, cleanup } = require('./helpers.cjs'); - -const { - convertClaudeCommandToClaudeSkill, - convertClaudeToGeminiMarkdown, - install, -} = require('../bin/install.js'); - -// ─── (b) Qwen Code: priority ordering ─────────────────────────────────────── - -describe('#778 (b) Qwen skills priority', () => { - const mk = (name, desc, body) => - ['---', `name: gsd:${name}`, `description: ${desc}`, '---', '', body].join('\n'); - - test('emits numeric priority for a core-loop command (runtime=qwen)', () => { - const result = convertClaudeCommandToClaudeSkill( - mk('plan-phase', 'Plan a phase', 'Body.'), - 'gsd-plan-phase', - 'qwen', - [] - ); - const m = result.match(/^priority:\s*(\d+)\s*$/m); - assert.ok(m, 'priority field present for gsd-plan-phase'); - assert.equal(Number(m[1]) > 0, true, 'priority is a positive number'); - }); - - test('core loop ranks higher than mid-tier (higher = earlier per spec)', () => { - const np = convertClaudeCommandToClaudeSkill( - mk('new-project', 'Start a project', 'Body.'), 'gsd-new-project', 'qwen', [] - ).match(/^priority:\s*(\d+)/m); - const help = convertClaudeCommandToClaudeSkill( - mk('help', 'Help', 'Body.'), 'gsd-help', 'qwen', [] - ).match(/^priority:\s*(\d+)/m); - assert.ok(np && help, 'both core and mid-tier get a priority'); - assert.ok( - Number(np[1]) > Number(help[1]), - 'new-project (core) sorts earlier than help (utility) — higher value' - ); - }); - - test('utility command NOT in the priority map gets no priority field', () => { - const result = convertClaudeCommandToClaudeSkill( - mk('stats', 'Show stats', 'Body.'), 'gsd-stats', 'qwen', [] - ); - assert.ok(!/^priority:/m.test(result), 'no priority emitted for unmapped utility'); - }); - - test('does NOT emit priority for non-qwen runtimes (scoped to qwen)', () => { - for (const rt of [null, 'claude', 'hermes']) { - const result = convertClaudeCommandToClaudeSkill( - mk('plan-phase', 'Plan a phase', 'Body.'), 'gsd-plan-phase', rt, [] - ); - assert.ok(!/^priority:/m.test(result), `no priority for runtime=${rt}`); - } - }); -}); - -// ─── (c) Gemini: {{args}} interpolation ───────────────────────────────────── - -describe('#778 (c) Gemini {{args}} interpolation', () => { - const cmd = (body) => - ['---', 'name: gsd:demo', 'description: Demo', '---', '', body].join('\n'); - - test('maps $ARGUMENTS to {{args}} in the TOML prompt', () => { - const out = convertClaudeToGeminiMarkdown( - cmd('Operate on $ARGUMENTS now.'), - { isCommand: true, commandName: 'demo' } - ); - assert.ok(out.includes('{{args}}'), '{{args}} present'); - assert.ok(!out.includes('$ARGUMENTS'), 'literal $ARGUMENTS removed'); - assert.ok(out.startsWith('description =') || out.includes('prompt ='), 'TOML shape'); - }); - - test('command without $ARGUMENTS gets no injected {{args}}', () => { - const out = convertClaudeToGeminiMarkdown( - cmd('No arguments referenced here.'), - { isCommand: true, commandName: 'demo' } - ); - assert.ok(!out.includes('{{args}}'), 'no spurious {{args}}'); - }); - - test('non-command Gemini content is not TOML-converted and keeps $ARGUMENTS', () => { - const out = convertClaudeToGeminiMarkdown( - cmd('Reference $ARGUMENTS.'), - { isCommand: false } - ); - // isCommand:false keeps markdown — no TOML wrap and no {{args}} mapping - // (the $ARGUMENTS→{{args}} translation is scoped to the TOML command path). - assert.ok(!out.startsWith('prompt ='), 'not wrapped as TOML prompt'); - assert.ok(out.includes('$ARGUMENTS'), '$ARGUMENTS left intact for non-command content'); - assert.ok(!out.includes('{{args}}'), 'no {{args}} injected outside the command path'); - }); -}); - -// ─── (c) Gemini: end-to-end install wiring ────────────────────────────────── -// Proves the install path derives the per-command name from the file stem so a -// regression in the call-site wiring (not just the converter) is caught. - -describe('#778 (c) Gemini install wiring (end-to-end)', () => { - let tmpDir; - let tmpHome; - let prevCwd; - let prevHome; - let prevUserprofile; - - beforeEach(() => { - tmpDir = createTempDir('gsd-enh778-gem-'); - tmpHome = createTempDir('gsd-enh778-home-'); - prevCwd = process.cwd(); - prevHome = process.env.HOME; - prevUserprofile = process.env.USERPROFILE; - process.chdir(tmpDir); - // Isolate HOME so a real ~/.gemini/commands/gsd/ doesn't trigger the #3037 - // local-install conflict-skip path. - process.env.HOME = tmpHome; - process.env.USERPROFILE = tmpHome; - }); - - afterEach(() => { - process.chdir(prevCwd); - if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; - if (prevUserprofile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = prevUserprofile; - cleanup(tmpDir); - cleanup(tmpHome); - }); - - test('installed progress.toml carries the !{} block; arg-bearing commands get {{args}}', () => { - const oldLog = console.log; - console.log = () => {}; - try { - install(false, 'gemini'); - } finally { - console.log = oldLog; - } - - const commandsDir = path.join(tmpDir, '.gemini', 'commands', 'gsd'); - const progressToml = path.join(commandsDir, 'progress.toml'); - assert.ok(fs.existsSync(progressToml), 'progress.toml installed'); - const progress = fs.readFileSync(progressToml, 'utf8'); - // Proves commandName was derived as 'progress' from the file stem. - assert.ok( - progress.includes('!{cat .planning/STATE.md 2>/dev/null}'), - 'progress.toml has the live-state shell block' - ); - - // A non-situational command must NOT receive the shell block. - const helpToml = path.join(commandsDir, 'help.toml'); - if (fs.existsSync(helpToml)) { - assert.ok(!fs.readFileSync(helpToml, 'utf8').includes('!{'), 'help.toml has no shell block'); - } - - // At least one installed command must use {{args}} and none may retain a - // literal $ARGUMENTS (every command body's $ARGUMENTS is translated). - const tomls = fs.readdirSync(commandsDir).filter((f) => f.endsWith('.toml')); - const withArgs = tomls.filter((f) => - fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('{{args}}')); - const withLiteral = tomls.filter((f) => - fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('$ARGUMENTS')); - assert.ok(withArgs.length > 0, 'at least one installed command interpolates {{args}}'); - assert.equal(withLiteral.length, 0, 'no installed command retains literal $ARGUMENTS'); - }); -}); - -// ─── (c) Gemini: !{...} live-state injection (progress) ───────────────────── - -describe('#778 (c) Gemini !{} live-state injection', () => { - const cmd = (name) => - ['---', `name: gsd:${name}`, `description: ${name}`, '---', '', 'Workflow body.'].join('\n'); - - test('progress command injects a fixed !{cat .planning/STATE.md} block', () => { - const out = convertClaudeToGeminiMarkdown( - cmd('progress'), - { isCommand: true, commandName: 'progress' } - ); - assert.ok(out.includes('!{cat .planning/STATE.md'), 'STATE.md injection present'); - }); - - test('non-progress commands get no !{} shell block', () => { - const out = convertClaudeToGeminiMarkdown( - cmd('help'), - { isCommand: true, commandName: 'help' } - ); - assert.ok(!out.includes('!{'), 'no shell block for non-situational command'); - }); - - test('SECURITY: the !{} block interpolates NO user input ({{args}})', () => { - const out = convertClaudeToGeminiMarkdown( - ['---', 'name: gsd:progress', 'description: progress', '---', '', - 'Body uses $ARGUMENTS too.'].join('\n'), - { isCommand: true, commandName: 'progress' } - ); - const blocks = out.match(/!\{([^}]*)\}/g) || []; - assert.equal(blocks.length, 1, 'exactly one shell block'); - assert.ok(!/\{\{args\}\}/.test(blocks[0]), 'no {{args}} inside the shell block'); - assert.ok(/^!\{cat \.planning\/STATE\.md/.test(blocks[0]), 'fixed cat command only'); - }); -}); diff --git a/tests/enh-788-qwen-hook-events.test.cjs b/tests/enh-788-qwen-hook-events.test.cjs deleted file mode 100644 index c391a9b88..000000000 --- a/tests/enh-788-qwen-hook-events.test.cjs +++ /dev/null @@ -1,280 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Enhancement #788: Expand Qwen Code hook-event coverage. - * - * Qwen Code supports 15 hook events; gsd previously registered only - * SessionStart and PostToolUse. This suite asserts that a Qwen install - * registers the 3 new high-value events: - * - SubagentStop — subagent lifecycle finalisation (context tracking) - * - Stop — model stop / final-response hook (context tracking) - * - PreCompact — pre-compaction awareness (context tracking) - * - * All three are wired to gsd-context-monitor.js — the same hook used for - * PostToolUse — so context headroom warnings surface at these moments too. - * - * Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless - * tool_name is Write|Edit (PreToolUse shape), so it would be a no-op for - * the UserPromptSubmit payload. Deferred to a follow-on issue. - * - * Also asserts the inverse: Claude Code installs do NOT gain these events - * (strict isQwen scope guard). - * - * Source: https://qwenlm.github.io/qwen-code-docs/en/users/features/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) - ); -} - -// Stub JS hook files that the installer checks with fs.existsSync() so hook -// registration guards pass even when hooks/dist/ isn't built. -const HOOKS_SRC = path.join(__dirname, '..', 'hooks'); -const STUB_HOOKS = [ - 'gsd-context-monitor.js', - 'gsd-prompt-guard.js', - 'gsd-check-update.js', - 'gsd-config-reload.js', // Added in #770 -]; - -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 */ } - } -} - -/** - * Persist in-memory settings to disk, simulating what finishInstall() does - * (finishInstall is not exported). Required for tests that call install() - * twice and need the second call to read the first call's hook registrations. - */ -function persistSettings(settingsPath, settings) { - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8'); -} - -// ─── Suite 1: Qwen — new events are registered ─────────────────────────────── - -describe('enh-788: Qwen install registers 3 new hook events', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-788-qwen-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const targetDir = path.join(tmpDir, '.qwen'); - fs.mkdirSync(targetDir, { recursive: true }); - // Pre-populate hook files so installer registration guards (fs.existsSync) - // pass and hooks are actually registered in settings.json. - stubHooksIntoTarget(targetDir); - - const result = install(false, 'qwen'); - settings = result.settings; - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('install returns a settings object (not null)', () => { - assert.ok(settings !== null && typeof settings === 'object', - 'Qwen 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('UserPromptSubmit is NOT registered (handler not yet implemented for that payload shape)', () => { - // gsd-prompt-guard exits unless tool_name is Write|Edit — it is a no-op - // for UserPromptSubmit payloads. Registration is deferred until a - // dedicated hook can process the user-prompt payload shape. - const cmds = hooksForEvent(settings, 'UserPromptSubmit'); - assert.strictEqual(cmds.length, 0, - `UserPromptSubmit should NOT be registered yet; got: ${JSON.stringify(cmds)}`); - }); - - 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)}` - ); - } - }); - - test('FileChanged is NOT registered for Qwen (Claude-only event)', () => { - // gsd-config-reload / FileChanged is a Claude Code-only registration. - // Qwen does not support the FileChanged hook event at all. - const cmds = hooksForEvent(settings, 'FileChanged'); - assert.strictEqual(cmds.length, 0, - `FileChanged should NOT be registered for Qwen; got: ${JSON.stringify(cmds)}`); - }); -}); - -// ─── Suite 2: Claude install DOES get the context events (since #770) ─────── -// Note: Prior to #770, these were Qwen-only events. #770 extended them to -// Claude Code. This suite is updated to match the new expected behavior. - -describe('enh-788 (updated by #770): Claude install registers context lifecycle events', () => { - let tmpDir; - let previousCwd; - let settings; - - beforeEach(() => { - tmpDir = createTempDir('gsd-788-claude-'); - 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('Claude install registers SubagentStop (since #770)', () => { - const cmds = hooksForEvent(settings, 'SubagentStop'); - assert.ok(cmds.length > 0, - `Claude should have SubagentStop since #770; got: ${JSON.stringify(cmds)}`); - }); - - test('Claude install registers Stop (since #770)', () => { - const cmds = hooksForEvent(settings, 'Stop'); - assert.ok(cmds.length > 0, - `Claude should have Stop since #770; got: ${JSON.stringify(cmds)}`); - }); - - test('Claude install registers PreCompact (since #770)', () => { - const cmds = hooksForEvent(settings, 'PreCompact'); - assert.ok(cmds.length > 0, - `Claude should have PreCompact since #770; got: ${JSON.stringify(cmds)}`); - }); -}); - -// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ────── - -describe('enh-788: Qwen install is idempotent across persisted reinstalls', () => { - let tmpDir; - let previousCwd; - - beforeEach(() => { - tmpDir = createTempDir('gsd-788-idem-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const targetDir = path.join(tmpDir, '.qwen'); - fs.mkdirSync(targetDir, { recursive: true }); - stubHooksIntoTarget(targetDir); - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('re-running after persisted first install does not duplicate hook entries', () => { - // First install: get settings and persist to disk (simulating finishInstall) - const result1 = install(false, 'qwen'); - persistSettings(result1.settingsPath, result1.settings); - - // Second install: reads the persisted settings.json — dedup guards apply - process.chdir(tmpDir); - const result2 = install(false, 'qwen'); - 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 command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); - } - }); -}); - -// ─── Suite 4: Uninstall removes the new event registrations ────────────────── - -describe('enh-788: Qwen uninstall removes new hook event entries', () => { - let tmpDir; - let previousCwd; - - beforeEach(() => { - tmpDir = createTempDir('gsd-788-uninstall-'); - previousCwd = process.cwd(); - process.chdir(tmpDir); - - const targetDir = path.join(tmpDir, '.qwen'); - fs.mkdirSync(targetDir, { recursive: true }); - stubHooksIntoTarget(targetDir); - - // Install and persist to disk so uninstall has a settings.json to clean - const result = install(false, 'qwen'); - persistSettings(result.settingsPath, result.settings); - }); - - afterEach(() => { - process.chdir(previousCwd); - cleanup(tmpDir); - }); - - test('settings.json hook entries are removed on uninstall', () => { - uninstall(false, 'qwen'); - const settingsPath = path.join(tmpDir, '.qwen', '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']) { - const cmds = hooksForEvent(settings, event); - assert.strictEqual(cmds.length, 0, - `After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`); - } - }); -}); diff --git a/tests/enh-789-codebuddy-commands.test.cjs b/tests/enh-789-codebuddy-commands.test.cjs deleted file mode 100644 index dd07ed44c..000000000 --- a/tests/enh-789-codebuddy-commands.test.cjs +++ /dev/null @@ -1,262 +0,0 @@ -// allow-test-rule: source-text-is-the-product -// Workflow .md / command .md / SKILL.md files — their text IS what the runtime -// loads. Testing emitted text tests the deployed contract. -// Per CONTRIBUTING.md exception matrix. - -/** - * Regression guard — enh(#789): elevate CodeBuddy slash-command surface. - * - * CodeBuddy (Tencent, @tencent-ai/codebuddy-code) reads user-level surfaces - * (https://www.codebuddy.ai/docs/cli/slash-commands, /skills): - * - commands/gsd-.md — slash commands shown in the '/' menu - * - skills/gsd-/SKILL.md — model-invocable skills - * - * Before #789 gsd emitted only skills/. Because CodeBuddy skills default to - * user-invocable:true (appear in '/'), emitting a commands/ surface AND leaving - * skills user-invocable would duplicate every /gsd-* entry. #789 therefore: - * 1. emits commands/gsd-.md (the '/' surface, peer-consistent with - * Cursor #785 and Augment #790), - * 2. marks skills user-invocable:false so they become model-invocable - * background knowledge and the commands/ surface is the sole '/' surface. - * - * Subagents are already emitted via the generic agents block + convertClaude - * AgentToCodebuddyAgent (~/.codebuddy/agents/), so #789 adds no agents change. - * - * mcp.json is intentionally NOT written: gsd ships no MCP server, and CodeBuddy's - * mcp.json holds an `mcpServers` map of *external* servers to connect to — - * there is nothing for gsd to register. Same exclusion as #784/#785/#790. - */ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const { createTempDir, cleanup } = require('./helpers.cjs'); - -const { - convertClaudeCommandToCodebuddyCommand, - convertClaudeCommandToCodebuddySkill, -} = require('../bin/install.js'); - -const { - installRuntimeArtifacts, - uninstallRuntimeArtifacts, -} = require('../gsd-core/bin/lib/install-engine.cjs'); -const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); -const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); - -const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); -const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); -const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); - -// ─── Layout contract ───────────────────────────────────────────────────────── - -describe('enh-789 — codebuddy layout has commands + skills kinds', () => { - test('resolveRuntimeArtifactLayout codebuddy returns 3 kinds (ADR-1235 §1 agents cutover)', () => { - const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); - assert.strictEqual(layout.kinds.length, 3, 'codebuddy must have exactly 3 artifact kinds (commands + skills + agents)'); - const kindNames = layout.kinds.map(k => k.kind).sort(); - assert.deepStrictEqual(kindNames, ['agents', 'commands', 'skills']); - }); - - test('codebuddy commands kind targets commands/ with gsd- prefix', () => { - const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); - const commandsKind = layout.kinds.find(k => k.kind === 'commands'); - assert.ok(commandsKind, 'must have commands kind'); - assert.strictEqual(commandsKind.destSubpath, 'commands'); - assert.strictEqual(commandsKind.prefix, 'gsd-'); - assert.strictEqual(typeof commandsKind.stage, 'function'); - }); - - test('codebuddy skills kind targets skills/ with gsd- prefix', () => { - const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); - const skillsKind = layout.kinds.find(k => k.kind === 'skills'); - assert.ok(skillsKind, 'must have skills kind'); - assert.strictEqual(skillsKind.destSubpath, 'skills'); - assert.strictEqual(skillsKind.prefix, 'gsd-'); - }); -}); - -// ─── Command converter contract ────────────────────────────────────────────── - -describe('enh-789 — convertClaudeCommandToCodebuddyCommand', () => { - const SRC = [ - '---', - 'name: gsd:new-project', - 'description: Initialize a project', - 'argument-hint: "[name]"', - 'allowed-tools:', - ' - Read', - '---', - '', - 'Use .claude/skills/ and run /gsd:help. Claude Code reads CLAUDE.md.', - '', - ].join('\n'); - - test('emits a description-only frontmatter (no Claude-specific name: gsd:)', () => { - const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); - assert.ok(out.startsWith('---\n'), 'must begin with frontmatter'); - assert.ok(/^description:/m.test(out), 'must carry a description field'); - assert.ok(!out.includes('name: gsd:new-project'), 'must drop Claude colon-form name field'); - }); - - test('preserves a present argument-hint (CodeBuddy supports it)', () => { - const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); - assert.ok(/^argument-hint:\s*["']?\[name\]["']?\s*$/m.test(out), - `argument-hint must be carried through when present in source. Got:\n${out}`); - }); - - test('converts body Claude-isms to CodeBuddy equivalents', () => { - const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); - assert.ok(out.includes('.codebuddy/skills/'), out); - assert.ok(out.includes('/gsd-help'), out); - assert.ok(out.includes('CODEBUDDY.md'), out); - assert.ok(!/\bClaude Code\b/.test(out), 'must rebrand "Claude Code"'); - }); -}); - -describe('enh-789 — skills marked user-invocable:false', () => { - test('convertClaudeCommandToCodebuddySkill emits user-invocable: false', () => { - const src = [ - '---', - 'name: gsd:help', - 'description: Show help', - '---', - '', - '# body', - '', - ].join('\n'); - const out = convertClaudeCommandToCodebuddySkill(src, 'gsd-help'); - assert.ok(/^user-invocable:\s*false\s*$/m.test(out), - `SKILL.md frontmatter must hide skill from '/' menu (user-invocable: false). Got:\n${out}`); - }); -}); - -// ─── Install contract ──────────────────────────────────────────────────────── - -describe('enh-789 — installRuntimeArtifacts codebuddy emits commands and skills', () => { - test('global codebuddy install: commands/gsd-help.md and skills/gsd-help/SKILL.md exist', (t) => { - const configDir = createTempDir('gsd-enh789-codebuddy-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); - - const commandsDir = path.join(configDir, 'commands'); - assert.ok(fs.existsSync(commandsDir), 'commands/ dir must exist'); - const cmdFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); - assert.ok(cmdFiles.length > 0, 'at least one gsd-*.md command file must be installed'); - assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'commands/gsd-help.md must exist'); - - const skillsDir = path.join(configDir, 'skills'); - assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist'); - assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist'); - }); - - test('installed commands/gsd-help.md is CodeBuddy-compatible (no raw ~/.claude/, rebranded)', (t) => { - const configDir = createTempDir('gsd-enh789-content-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); - - const helpCmd = path.join(configDir, 'commands', 'gsd-help.md'); - const content = fs.readFileSync(helpCmd, 'utf8'); - assert.ok(!content.includes('~/.claude/'), 'commands must not contain raw ~/.claude/ refs'); - assert.ok(content.startsWith('---'), 'commands must carry frontmatter'); - }); - - test('installed skills/gsd-help/SKILL.md is hidden from the / menu', (t) => { - const configDir = createTempDir('gsd-enh789-skillhide-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); - - const skill = fs.readFileSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'), 'utf8'); - assert.ok(/^user-invocable:\s*false\s*$/m.test(skill), - 'installed SKILL.md must set user-invocable: false'); - }); - - test('command count matches skill count (profile parity)', (t) => { - const configDir = createTempDir('gsd-enh789-parity-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); - - const cmdCount = fs.readdirSync(path.join(configDir, 'commands')) - .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; - const skillCount = fs.readdirSync(path.join(configDir, 'skills'), { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; - assert.strictEqual(cmdCount, skillCount, 'command count must equal skill count for same profile'); - }); - - test('full profile install: no $HOME/.codebuddy or ~/.codebuddy leak in any command', (t) => { - // The codebuddy converter rewrites `.claude/` → `.codebuddy/`, so source - // refs like `@$HOME/.claude/gsd-core/...` (e.g. plan-review-convergence.md) - // must be normalized to the install target — not left as $HOME/.codebuddy. - const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); - const configDir = createTempDir('gsd-enh789-noleak-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); - - const commandsDir = path.join(configDir, 'commands'); - for (const f of fs.readdirSync(commandsDir).filter(n => n.endsWith('.md'))) { - const content = fs.readFileSync(path.join(commandsDir, f), 'utf8'); - assert.ok(!content.includes('$HOME/.codebuddy'), `${f} must not leak $HOME/.codebuddy`); - assert.ok(!content.includes('~/.codebuddy'), `${f} must not leak ~/.codebuddy`); - assert.ok(!content.includes('.claude/'), `${f} must not retain raw .claude/ refs`); - } - }); - - test('full profile install does NOT mutate source commands/gsd/ files', (t) => { - const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); - assert.strictEqual(RESOLVED_FULL.skills, '*', 'full profile must have skills === "*"'); - - const configDir = createTempDir('gsd-enh789-full-'); - t.after(() => cleanup(configDir)); - - const srcHelpPath = path.join(REAL_COMMANDS_DIR, 'help.md'); - const before = fs.readFileSync(srcHelpPath, 'utf8'); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); - - const after = fs.readFileSync(srcHelpPath, 'utf8'); - assert.strictEqual(before, after, 'source commands/gsd/help.md must not be mutated by the install'); - }); -}); - -// ─── Uninstall contract ────────────────────────────────────────────────────── - -describe('enh-789 — uninstallRuntimeArtifacts removes codebuddy commands', () => { - test('uninstall removes gsd-* commands but preserves user commands', (t) => { - const configDir = createTempDir('gsd-enh789-uninstall-'); - t.after(() => cleanup(configDir)); - - const commandsDir = path.join(configDir, 'commands'); - fs.mkdirSync(commandsDir, { recursive: true }); - fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), '# help\n'); - fs.writeFileSync(path.join(commandsDir, 'user-custom.md'), '# user\n'); - - uninstallRuntimeArtifacts('codebuddy', configDir, 'global'); - - assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'gsd-help.md must be removed'); - assert.ok(fs.existsSync(path.join(commandsDir, 'user-custom.md')), 'user-custom.md must be preserved'); - }); -}); - -// ─── mcp.json exclusion ────────────────────────────────────────────────────── - -describe('enh-789 — mcp.json excluded (gsd ships no MCP server)', () => { - test('codebuddy install does not write mcp.json / .mcp.json', (t) => { - const configDir = createTempDir('gsd-enh789-mcp-excluded-'); - t.after(() => cleanup(configDir)); - - installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); - - assert.ok(!fs.existsSync(path.join(configDir, 'mcp.json')), 'must not write mcp.json'); - assert.ok(!fs.existsSync(path.join(configDir, '.mcp.json')), 'must not write .mcp.json'); - }); -}); diff --git a/tests/feat-443-effort-install-wiring.install.test.cjs b/tests/feat-443-effort-install-wiring.install.test.cjs deleted file mode 100644 index b8b9675d2..000000000 --- a/tests/feat-443-effort-install-wiring.install.test.cjs +++ /dev/null @@ -1,425 +0,0 @@ -// allow-test-rule: integration-test-input -// Exercises install() + generateCodexAgentToml() as a black-box by inspecting -// produced output files in temp dirs. Source agent .md files are inputs whose -// installed transformation is asserted — not inspected for string presence. - -/** - * #443 — Effort per-runtime wiring at install time. - * - * Verifies: - * 1. Claude global install injects `effort:` into agent .md frontmatter. - * 2. Gemini global install does NOT inject `effort:` (Gemini-safe .md). - * 3. Codex inherited-model installs omit `model_reasoning_effort` so model - * and effort are not partially pinned (#838). - * 4. Config-driven proof: effort.agent_overrides wins over tier defaults - * for Claude .md and for Codex .toml when runtime:"codex" pins a model. - * 5. Source agents/gsd-planner.md has NO effort: key (injection is - * install-only, source stays Gemini-safe). - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const { install } = require('../bin/install.js'); -const { cleanup } = require('./helpers.cjs'); - -const REPO_ROOT = path.resolve(__dirname, '..'); -const SOURCE_AGENTS_DIR = path.join(REPO_ROOT, 'agents'); - -// ─── helpers ───────────────────────────────────────────────────────────────── - -function makeTmpDir(prefix) { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -function readFrontmatter(mdPath) { - const content = fs.readFileSync(mdPath, 'utf8'); - if (!content.startsWith('---')) return ''; - const end = content.indexOf('---', 3); - if (end === -1) return ''; - return content.substring(3, end); -} - -/** - * Run a global install for the given runtime, redirecting its home dir to - * tmpHome. Returns the tmpHome for inspection. - * - * Env-var redirection: - * claude → CLAUDE_CONFIG_DIR - * gemini → GEMINI_CONFIG_DIR - * codex → CODEX_HOME - * - * HOME is also redirected to an isolated temp dir for the duration of the - * install call. This prevents any install.js code that uses os.homedir() - * directly (e.g. ~/.cache/gsd update-check deletion, ~/.gsd/defaults.json - * reads, stale-SDK npm subprocess writes to ~/.npm) from touching the real - * HOME and polluting the test environment for other concurrently-running - * test files (e.g. runtime-launcher-parity test (D) checks that - * $HOME/.claude/gsd-core/bin/gsd-tools.cjs is absent). - * - * GSD_SKIP_STALE_SDK_CHECK=1 is set to suppress the `npm ls -g` subprocess - * that the installer spawns for global installs — that subprocess is slow, - * writes to ~/.npm cache, and is irrelevant to effort-wiring assertions. - * - * The working directory is set to REPO_ROOT so install() can find the source - * agents/. For config-driven tests, place tmpHome inside the project dir - * so that readGsdEffectiveEffortConfig(targetDir) can walk up from tmpHome - * and find .planning/config.json. - */ -function runGlobalInstall(runtime, tmpHome) { - const envVarMap = { - claude: 'CLAUDE_CONFIG_DIR', - gemini: 'GEMINI_CONFIG_DIR', - codex: 'CODEX_HOME', - }; - const envVar = envVarMap[runtime]; - if (!envVar) throw new Error(`Unsupported runtime in test: ${runtime}`); - - // Isolate HOME to a fresh temp dir so install.js code that calls - // os.homedir() (cache deletion, defaults.json reads, npm subprocess) - // never touches the real $HOME/.claude / $HOME/.cache / $HOME/.gsd. - const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-443-home-')); - - const prev = process.env[envVar]; - const prevCwd = process.cwd(); - const prevHome = process.env.HOME; - const prevUserProfile = process.env.USERPROFILE; - const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK; - - process.env[envVar] = tmpHome; - process.env.HOME = isolatedHome; - process.env.USERPROFILE = isolatedHome; - process.env.GSD_SKIP_STALE_SDK_CHECK = '1'; - process.chdir(REPO_ROOT); - - try { - install(true, runtime); - } finally { - process.chdir(prevCwd); - if (prev === undefined) delete process.env[envVar]; - else process.env[envVar] = prev; - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - if (prevUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = prevUserProfile; - if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK; - else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale; - // Clean up the isolated HOME dir - cleanup(isolatedHome); - } - - return tmpHome; -} - -// ─── Tier default expectations ──────────────────────────────────────────────── -// light → low, standard → high, heavy → xhigh (catalog defaults) -// gsd-planner: heavy → xhigh -// gsd-codebase-mapper: light → low -// gsd-executor: standard → high - -// ─── describe 1: Claude install injects effort: ─────────────────────────────── - -describe('#443 Claude install: effort: injected into frontmatter', () => { - let tmpDir; - let claudeHome; - - beforeEach(() => { - tmpDir = makeTmpDir('gsd-443-claude-'); - claudeHome = path.join(tmpDir, 'claude-home'); - fs.mkdirSync(claudeHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('gsd-planner.md contains effort: xhigh (heavy tier default)', () => { - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); - assert.match(fm, /^effort:\s*xhigh$/m, - `gsd-planner frontmatter should have effort: xhigh\nActual:\n${fm}`); - }); - - test('gsd-codebase-mapper.md contains effort: low (light tier default)', () => { - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-codebase-mapper.md')); - assert.match(fm, /^effort:\s*low$/m, - `gsd-codebase-mapper frontmatter should have effort: low\nActual:\n${fm}`); - }); - - test('gsd-executor.md contains effort: high (standard tier default)', () => { - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-executor.md')); - assert.match(fm, /^effort:\s*high$/m, - `gsd-executor frontmatter should have effort: high\nActual:\n${fm}`); - }); -}); - -// ─── describe 2: Gemini install does NOT inject effort: ────────────────────── - -describe('#443 Gemini install: effort: absent (Gemini-safe)', () => { - let tmpDir; - let geminiHome; - - beforeEach(() => { - tmpDir = makeTmpDir('gsd-443-gemini-'); - geminiHome = path.join(tmpDir, 'gemini-home'); - fs.mkdirSync(geminiHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('gsd-planner.md does NOT contain effort: (Gemini install)', () => { - runGlobalInstall('gemini', geminiHome); - const fm = readFrontmatter(path.join(geminiHome, 'agents', 'gsd-planner.md')); - assert.doesNotMatch(fm, /^effort:/m, - `gsd-planner (Gemini) frontmatter must NOT have effort:\nActual:\n${fm}`); - }); - - test('gsd-executor.md does NOT contain effort: (Gemini install)', () => { - runGlobalInstall('gemini', geminiHome); - const fm = readFrontmatter(path.join(geminiHome, 'agents', 'gsd-executor.md')); - assert.doesNotMatch(fm, /^effort:/m, - `gsd-executor (Gemini) frontmatter must NOT have effort:\nActual:\n${fm}`); - }); -}); - -// ─── describe 3: Codex inherited-model install omits model_reasoning_effort ── - -describe('#838 Codex install: inherited model omits model_reasoning_effort', () => { - let tmpDir; - let codexHome; - - beforeEach(() => { - tmpDir = makeTmpDir('gsd-443-codex-'); - codexHome = path.join(tmpDir, 'codex-home'); - fs.mkdirSync(codexHome, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('gsd-planner.toml omits both model and model_reasoning_effort when model is inherited', () => { - runGlobalInstall('codex', codexHome); - const tomlContent = fs.readFileSync( - path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' - ); - assert.doesNotMatch(tomlContent, /^model\s*=/m, - `gsd-planner.toml should omit model when inheriting Codex chat model\nActual:\n${tomlContent.slice(0, 500)}`); - assert.doesNotMatch(tomlContent, /^model_reasoning_effort\s*=/m, - `gsd-planner.toml should omit model_reasoning_effort when model is inherited\nActual:\n${tomlContent.slice(0, 500)}`); - }); -}); - -// ─── describe 4: Config-driven proof ───────────────────────────────────────── -// -// The runtime home dir must be INSIDE (or a sibling of) the project root so -// that readGsdEffectiveEffortConfig(targetDir) can walk up from the runtime -// home and find .planning/config.json. We put .claude/ and .codex/ as siblings -// of .planning/ inside the project dir — this is the natural local-install shape. - -describe('#443 Config-driven: effort.agent_overrides drives install-time effort', () => { - let tmpDir; - let claudeHome; - let codexHome; - - beforeEach(() => { - // Layout: tmpDir/project/ <-- project root (cwd for install) - // .planning/config.json - // .claude/ <-- claudeHome (CLAUDE_CONFIG_DIR) - // .codex/ <-- codexHome (CODEX_HOME) - tmpDir = makeTmpDir('gsd-443-cfg-'); - const projectDir = path.join(tmpDir, 'project'); - claudeHome = path.join(projectDir, '.claude'); - codexHome = path.join(projectDir, '.codex'); - - fs.mkdirSync(claudeHome, { recursive: true }); - fs.mkdirSync(codexHome, { recursive: true }); - fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true }); - - // Write a project config with effort.agent_overrides overriding gsd-planner to 'low'. - // runtime:"codex" pins a Codex-native model, so emitting model_reasoning_effort - // remains valid under the #838 model/effort coupling rule. - const config = { - runtime: 'codex', - effort: { - agent_overrides: { - 'gsd-planner': 'low', - }, - }, - }; - fs.writeFileSync( - path.join(projectDir, '.planning', 'config.json'), - JSON.stringify(config, null, 2) - ); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('Claude .md gets effort: low when agent_overrides.gsd-planner=low', () => { - // projectDir is the cwd for install — chdir handled inside runGlobalInstall. - // claudeHome is inside projectDir, so walking up from claudeHome finds .planning/config.json. - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); - assert.match(fm, /^effort:\s*low$/m, - `gsd-planner should have effort: low from config override\nActual:\n${fm}`); - }); - - test('Codex .toml gets model_reasoning_effort = "low" when agent_overrides.gsd-planner=low', () => { - runGlobalInstall('codex', codexHome); - const tomlContent = fs.readFileSync( - path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' - ); - assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, - `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); - assert.match(tomlContent, /^model_reasoning_effort\s*=\s*"low"$/m, - `gsd-planner.toml should have model_reasoning_effort = "low" from config override\nActual:\n${tomlContent.slice(0, 500)}`); - }); - - test('Codex .toml clamps effort max → xhigh when agent_overrides.gsd-planner=max', () => { - const projectDir = path.dirname(codexHome); - // Overwrite config with max override - const config = { - runtime: 'codex', - effort: { - agent_overrides: { - 'gsd-planner': 'max', - }, - }, - }; - fs.writeFileSync( - path.join(projectDir, '.planning', 'config.json'), - JSON.stringify(config, null, 2) - ); - - runGlobalInstall('codex', codexHome); - const tomlContent = fs.readFileSync( - path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' - ); - assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, - `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); - // Codex does not support 'max' → clamped to 'xhigh' - assert.match(tomlContent, /^model_reasoning_effort\s*=\s*"xhigh"$/m, - `gsd-planner.toml should clamp max → xhigh for Codex\nActual:\n${tomlContent.slice(0, 500)}`); - assert.doesNotMatch(tomlContent, /model_reasoning_effort\s*=\s*"max"/, - 'Codex .toml must never contain model_reasoning_effort = "max"'); - }); -}); - -// ─── describe 5b: Invalid effort tokens fall through (Codex adversarial finding #2) ─ -// -// These tests FAIL before the fix: resolveInstallTimeEffort returns the raw -// invalid string without validating it against VALID_EFFORTS. - -describe('#443 resolveInstallTimeEffort: invalid tokens fall through to valid effort', () => { - let tmpDir; - let claudeHome; - let codexHome; - - beforeEach(() => { - // Layout: tmpDir/project/ <-- project root - // .planning/config.json - // .claude/ <-- claudeHome - // .codex/ <-- codexHome - tmpDir = makeTmpDir('gsd-443-invalid-effort-'); - const projectDir = path.join(tmpDir, 'project'); - claudeHome = path.join(projectDir, '.claude'); - codexHome = path.join(projectDir, '.codex'); - - fs.mkdirSync(claudeHome, { recursive: true }); - fs.mkdirSync(codexHome, { recursive: true }); - fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - function writeProjectConfig(config) { - const projectDir = path.dirname(claudeHome); - fs.writeFileSync( - path.join(projectDir, '.planning', 'config.json'), - JSON.stringify(config, null, 2) - ); - } - - const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max']; - - test('effort.default="ultra" (invalid) -> Claude .md effort: is a VALID value (falls through to high)', () => { - // BUG before fix: resolveInstallTimeEffort returns "ultra" verbatim - writeProjectConfig({ effort: { default: 'ultra' } }); - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); - const match = fm.match(/^effort:\s*(\S+)$/m); - assert.ok(match, `effort: must be present in frontmatter\nActual:\n${fm}`); - assert.ok(VALID_EFFORTS.includes(match[1]), - `effort: must be a VALID effort string, got: "${match[1]}"\nActual frontmatter:\n${fm}`); - }); - - test('effort.agent_overrides.gsd-planner="bogus" (invalid) with valid default -> falls through to valid default', () => { - // BUG before fix: "bogus" is returned and written verbatim - writeProjectConfig({ - effort: { - agent_overrides: { 'gsd-planner': 'bogus' }, - default: 'medium', - }, - }); - runGlobalInstall('claude', claudeHome); - const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); - const match = fm.match(/^effort:\s*(\S+)$/m); - assert.ok(match, `effort: must be present in frontmatter\nActual:\n${fm}`); - assert.ok(VALID_EFFORTS.includes(match[1]), - `effort: must be a VALID effort string, got: "${match[1]}"\nActual frontmatter:\n${fm}`); - // Falls through invalid "bogus" -> valid tier default or "medium" default - // "medium" is valid, so it should appear (or tier default if medium is invalid, but medium is valid) - }); - - test('effort.default="ultra" (invalid) + runtime:"codex" -> Codex .toml model_reasoning_effort is VALID', () => { - // BUG before fix: "ultra" written into .toml verbatim - writeProjectConfig({ runtime: 'codex', effort: { default: 'ultra' } }); - runGlobalInstall('codex', codexHome); - const tomlContent = fs.readFileSync( - path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' - ); - assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, - `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); - const match = tomlContent.match(/^model_reasoning_effort\s*=\s*"([^"]+)"/m); - assert.ok(match, `model_reasoning_effort must be present in .toml\nActual:\n${tomlContent.slice(0, 500)}`); - assert.ok(VALID_EFFORTS.includes(match[1]), - `model_reasoning_effort must be VALID, got: "${match[1]}"\nActual:\n${tomlContent.slice(0, 500)}`); - }); -}); - -// ─── describe 5: Source stays clean ────────────────────────────────────────── - -describe('#443 Source purity: agents/gsd-planner.md has no effort: key', () => { - test('source agents/gsd-planner.md frontmatter does not contain effort:', () => { - const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-planner.md')); - assert.doesNotMatch(fm, /^effort:/m, - `Source agents/gsd-planner.md must NOT contain effort: (injection is install-only)`); - }); - - test('source agents/gsd-executor.md frontmatter does not contain effort:', () => { - const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-executor.md')); - assert.doesNotMatch(fm, /^effort:/m, - `Source agents/gsd-executor.md must NOT contain effort: (injection is install-only)`); - }); - - test('source agents/gsd-codebase-mapper.md frontmatter does not contain effort:', () => { - const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-codebase-mapper.md')); - assert.doesNotMatch(fm, /^effort:/m, - `Source agents/gsd-codebase-mapper.md must NOT contain effort: (injection is install-only)`); - }); -}); diff --git a/tests/install-minimal-hooks.test.cjs b/tests/install-minimal-hooks.test.cjs index 94dd61bab..c14fdd5e6 100644 --- a/tests/install-minimal-hooks.test.cjs +++ b/tests/install-minimal-hooks.test.cjs @@ -817,3 +817,1785 @@ describe('Codex legacy gsd-update-check migration', () => { assert.ok(content.includes('[features]')); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1754-js-hook-guard.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1754-js-hook-guard (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression tests for bug #1754 + * + * The installer must NOT register .js hook entries in settings.json when the + * corresponding .js file does not exist at the target path. The original bug: + * on fresh installs where hooks/dist/ was missing from the npm package (as in + * v1.32.0), the hook copy step produced no files, yet the registration step + * ran unconditionally for .js hooks — leaving users with "PreToolUse:Bash + * hook error" on every tool invocation. + * + * The .sh hooks already had fs.existsSync() guards (added in #1817). This + * test verifies the same defensive pattern exists for all .js hooks. + */ + +'use strict'; + +const { describe, test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts. +const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts'); + +const JS_HOOKS = [ + { name: 'gsd-check-update.js', registrationAnchor: 'hasGsdUpdateHook' }, + { name: 'gsd-context-monitor.js', registrationAnchor: 'hasContextMonitorHook' }, + { name: 'gsd-prompt-guard.js', registrationAnchor: 'hasPromptGuardHook' }, + { name: 'gsd-read-guard.js', registrationAnchor: 'hasReadGuardHook' }, + { name: 'gsd-workflow-guard.js', registrationAnchor: 'hasWorkflowGuardHook' }, + { name: 'gsd-worktree-path-guard.js', registrationAnchor: 'hasWorktreePathGuardHook' }, +]; + +describe('bug #1754: .js hook registration guards', () => { + let src; + + before(() => { + // ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts. + // Concatenate both sources so structural assertions find patterns in either file. + const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8'); + let hooksSurfaceSrc = ''; + try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ } + src = installSrc + '\n' + hooksSurfaceSrc; + }); + + for (const { name, registrationAnchor } of JS_HOOKS) { + describe(`${name} registration`, () => { + test(`install.js checks file existence before registering ${name}`, () => { + // Find the registration block by locating the "has...Hook" variable + const anchorIdx = src.indexOf(registrationAnchor); + assert.ok( + anchorIdx !== -1, + `${registrationAnchor} variable not found in install.js` + ); + + // Extract a window around the registration block to find the guard + const blockStart = anchorIdx; + const blockEnd = Math.min(src.length, anchorIdx + 1200); + const block = src.slice(blockStart, blockEnd); + + // The block must contain an fs.existsSync check for the hook file + assert.ok( + block.includes('fs.existsSync') || block.includes('existsSync'), + `install.js must call fs.existsSync on the target path before registering ${name} ` + + `in settings.json. Without this guard, hooks are registered even when the .js file ` + + `was never copied (the root cause of #1754).` + ); + }); + + test(`install.js emits a warning when ${name} is missing`, () => { + // The hook file name (without extension) should appear in a warning message + const hookBaseName = name.replace('.js', ''); + const warnPattern = `Skipped`; + const anchorIdx = src.indexOf(registrationAnchor); + const block = src.slice(anchorIdx, Math.min(src.length, anchorIdx + 1200)); + + assert.ok( + block.includes(warnPattern) && block.includes(hookBaseName), + `install.js must emit a skip warning when ${name} is not found at the target path` + ); + }); + }); + } + + test('all .js hooks use the same guard pattern as .sh hooks', () => { + // Count existsSync calls in the hook registration section. + // There should be guards for all JS hooks plus the existing SH hooks. + // This test ensures new hooks added in the future follow the same pattern. + // ADR-857 phase 5f-1b: registration moved to runtime-hooks-surface.cts so scan the + // full concatenated source (install.js + runtime-hooks-surface.cts) rather than slicing. + const registrationSection = src; + + // Count unique hook file existence checks (pattern: path.join(targetDir, 'hooks', 'gsd-*.js')) + const jsGuards = (registrationSection.match(/gsd-[\w-]+\.js.*not found at target/g) || []); + const shGuards = (registrationSection.match(/gsd-[\w-]+\.sh.*not found at target/g) || []); + + assert.ok( + jsGuards.length >= JS_HOOKS.length, + `Expected at least ${JS_HOOKS.length} .js hook guards, found ${jsGuards.length}. ` + + `Every .js hook registration must check file existence before registering.` + ); + + assert.ok( + shGuards.length >= 3, + `Expected at least 3 .sh hook guards (validate-commit, session-state, phase-boundary), ` + + `found ${shGuards.length}.` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1817-sh-hook-guard.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1817-sh-hook-guard (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression tests for bug #1817 + * + * The installer must NOT register .sh hook entries in settings.json when the + * corresponding .sh file does not exist at the target path. The original bug: + * v1.32.0's npm package omitted the .sh files from hooks/dist/, so the copy + * step produced no files, yet the registration step ran unconditionally — + * leaving users with hook errors on every tool invocation. + * + * Defensive guard: before registering each .sh hook in settings.json, + * install.js must verify the target file exists. If it doesn't, skip + * registration and emit a warning. + */ + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts. +const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts'); + +const SH_HOOKS = [ + { name: 'gsd-validate-commit.sh', settingsVar: 'validateCommitCommand' }, + { name: 'gsd-session-state.sh', settingsVar: 'sessionStateCommand' }, + { name: 'gsd-phase-boundary.sh', settingsVar: 'phaseBoundaryCommand' }, +]; + +describe('bug #1817: .sh hook registration guards', () => { + let src; + + // Read once — all tests in this suite share the same source snapshot. + // ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts. + // Concatenate both sources so structural assertions find patterns in either file. + try { + const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8'); + let hooksSurfaceSrc = ''; + try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ } + src = installSrc + '\n' + hooksSurfaceSrc; + } catch { + src = ''; + } + + for (const { name, settingsVar } of SH_HOOKS) { + describe(`${name} registration`, () => { + test(`install.js checks file existence before registering ${name}`, () => { + // Find the block where this .sh hook is registered. + // Each registration block is preceded by the command variable declaration + // and followed by the next hook or end of registration section. + const varIdx = src.indexOf(settingsVar); + assert.ok(varIdx !== -1, `${settingsVar} variable not found in install.js`); + + // Extract ~900 chars around the variable to find the registration block + const blockStart = Math.max(0, varIdx - 50); + const blockEnd = Math.min(src.length, varIdx + 900); + const block = src.slice(blockStart, blockEnd); + + assert.ok( + block.includes('fs.existsSync') || block.includes('existsSync'), + `install.js must call fs.existsSync on the target path before registering ${name} in settings.json. ` + + `Without this guard, hooks are registered even when the .sh file was never copied ` + + `(the root cause of #1817).` + ); + }); + }); + } +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1076-extended-hook-events-drive.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1076-extended-hook-events-drive (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * ADR-857 phase 5f-3: extended hook event guards are driven by the + * extendedHookEvents descriptor field, not hardcoded runtime-name checks. + * + * Before this change: + * - SubagentStop/Stop/PreCompact were wired only when (isQwen || runtime==='claude') + * - FileChanged was wired only when (runtime === 'claude') + * - BeforeAgent/AfterAgent/BeforeModel were wired only when (isGemini) + * + * After this change: + * - All three guard blocks are driven purely by extendedEvents.includes(eventName) + * - Any runtime (or arbitrary string) that passes the right extendedHookEvents + * array gets exactly those events registered, regardless of its runtime name. + * + * This suite proves descriptor-drive by calling applySettingsJsonHooks directly + * with a controlled extendedHookEvents array and asserting on settings.hooks. + * No source-grep; purely behavioral. + */ + +const { test, describe, before } = 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 REPO_ROOT = path.resolve(__dirname, '..'); +const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); + +/** Idempotently ensure hooks/dist contains built .js files. */ +function ensureHooksDist() { + if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { + execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + } +} + +before(() => { + ensureHooksDist(); +}); + +const { applySettingsJsonHooks } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Return all hook commands registered under an event key. */ +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) + ); +} + +/** True if any hook is registered under eventName. */ +function hasHooksFor(settings, eventName) { + return hooksForEvent(settings, eventName).length > 0; +} + +/** + * Create a temporary directory with stub hook files so fs.existsSync guards pass. + * Returns the targetDir path. + */ +function createStubTargetDir() { + const tmpDir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-1076-')); + const hooksDir = path.join(tmpDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + // Stubs for the hooks applySettingsJsonHooks existsSync-checks + const stubs = [ + 'gsd-check-update.js', + 'gsd-context-monitor.js', + 'gsd-prompt-guard.js', + 'gsd-read-guard.js', + 'gsd-read-injection-scanner.js', + 'gsd-config-reload.js', + 'gsd-workflow-guard.js', + 'gsd-worktree-path-guard.js', + 'gsd-validate-commit.sh', + 'gsd-session-state.sh', + 'gsd-phase-boundary.sh', + 'gsd-graphify-update.sh', + ]; + const hooksDistDir = path.join(REPO_ROOT, 'hooks', 'dist'); + for (const stub of stubs) { + const dest = path.join(hooksDir, stub); + const distSrc = path.join(hooksDistDir, stub); + if (fs.existsSync(distSrc)) { + fs.copyFileSync(distSrc, dest); + } else { + // Minimal stub so existsSync passes + const ext = path.extname(stub); + fs.writeFileSync(dest, ext === '.sh' ? '#!/bin/bash\n# stub\n' : '#!/usr/bin/env node\n// stub\n'); + } + try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } + } + return tmpDir; +} + +function cleanupDir(dir) { + cleanup(dir); +} + +/** + * Build the minimal opts bag for applySettingsJsonHooks. + * postToolEvent: 'PostToolUse' (default dialect). + * All commands: non-null strings so the "command truthy" guard passes. + */ +function buildOpts(targetDir, { runtime, extendedHookEvents }) { + const hookOpts = { platform: process.platform, runtime }; + const node = process.execPath; + return { + runtime, + isGlobal: true, + targetDir, + postToolEvent: 'PostToolUse', + hookEvents: undefined, // not the hookEvents dialect — we're testing extendedHookEvents + extendedHookEvents, + updateCheckCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-check-update.js')}"`, + contextMonitorCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-context-monitor.js')}"`, + promptGuardCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-prompt-guard.js')}"`, + readGuardCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-read-guard.js')}"`, + readInjectionScannerCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js')}"`, + configReloadCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-config-reload.js')}"`, + hookOpts, + localCmd: () => null, + localShellCmd: () => null, + }; +} + +// ─── Suite 1: claude shape (SubagentStop+Stop+PreCompact+FileChanged) ───────── + +describe('enh-1076 phase 5f-3: claude extendedHookEvents → SubagentStop/Stop/PreCompact/FileChanged', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + const opts = buildOpts(targetDir, { + runtime: 'claude', + extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged'], + }); + applySettingsJsonHooks(settings, opts); + }); + + test('SubagentStop is wired (descriptor-driven)', () => { + assert.ok( + hasHooksFor(settings, 'SubagentStop'), + `Expected SubagentStop hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('Stop is wired (descriptor-driven)', () => { + assert.ok( + hasHooksFor(settings, 'Stop'), + `Expected Stop hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('PreCompact is wired (descriptor-driven)', () => { + assert.ok( + hasHooksFor(settings, 'PreCompact'), + `Expected PreCompact hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('FileChanged is wired (descriptor-driven)', () => { + assert.ok( + hasHooksFor(settings, 'FileChanged'), + `Expected FileChanged hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +// ─── Suite 2: qwen shape (SubagentStop+Stop+PreCompact, no FileChanged) ─────── + +describe('enh-1076 phase 5f-3: qwen extendedHookEvents → SubagentStop/Stop/PreCompact only', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + const opts = buildOpts(targetDir, { + runtime: 'qwen', + extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact'], + }); + applySettingsJsonHooks(settings, opts); + }); + + test('SubagentStop is wired', () => { + assert.ok(hasHooksFor(settings, 'SubagentStop')); + }); + + test('Stop is wired', () => { + assert.ok(hasHooksFor(settings, 'Stop')); + }); + + test('PreCompact is wired', () => { + assert.ok(hasHooksFor(settings, 'PreCompact')); + }); + + test('FileChanged is NOT wired (not in extendedHookEvents)', () => { + assert.strictEqual( + hasHooksFor(settings, 'FileChanged'), + false, + `FileChanged must NOT be wired for qwen shape; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +// ─── Suite 3: gemini shape (BeforeAgent+AfterAgent+BeforeModel) ─────────────── + +describe('enh-1076 phase 5f-3: gemini extendedHookEvents → BeforeAgent/AfterAgent/BeforeModel', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + const opts = buildOpts(targetDir, { + runtime: 'gemini', + extendedHookEvents: ['BeforeAgent', 'AfterAgent', 'BeforeModel'], + }); + applySettingsJsonHooks(settings, opts); + }); + + test('BeforeAgent is wired', () => { + assert.ok( + hasHooksFor(settings, 'BeforeAgent'), + `Expected BeforeAgent hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('AfterAgent is wired', () => { + assert.ok(hasHooksFor(settings, 'AfterAgent')); + }); + + test('BeforeModel is wired', () => { + assert.ok(hasHooksFor(settings, 'BeforeModel')); + }); + + test('SubagentStop is NOT wired (not in extendedHookEvents)', () => { + assert.strictEqual( + hasHooksFor(settings, 'SubagentStop'), + false, + 'SubagentStop must NOT be wired for gemini shape' + ); + }); + + test('FileChanged is NOT wired (not in extendedHookEvents)', () => { + assert.strictEqual( + hasHooksFor(settings, 'FileChanged'), + false, + 'FileChanged must NOT be wired for gemini shape' + ); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +// ─── Suite 4: empty extendedHookEvents → none of the extended events ────────── + +describe('enh-1076 phase 5f-3: empty extendedHookEvents → no extended events wired', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + // Use runtime='someruntime' to prove it's the descriptor, not the name, that matters + const opts = buildOpts(targetDir, { + runtime: 'someruntime', + extendedHookEvents: [], + }); + applySettingsJsonHooks(settings, opts); + }); + + const EXTENDED_EVENTS = [ + 'SubagentStop', 'Stop', 'PreCompact', 'FileChanged', + 'BeforeAgent', 'AfterAgent', 'BeforeModel', + ]; + + for (const event of EXTENDED_EVENTS) { + test(`${event} is NOT wired when extendedHookEvents is empty`, () => { + assert.strictEqual( + hasHooksFor(settings, event), + false, + `${event} must not be wired when extendedHookEvents=[] (runtime=someruntime); hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + } + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +// ─── Suite 5: descriptor-drive is runtime-name-agnostic ─────────────────────── +// Pass an arbitrary runtime name ('hypothetical') with SubagentStop in its +// extendedHookEvents. This could NEVER have worked under the old hardcoded check. +// Under the new descriptor-driven guard it MUST work. + +describe('enh-1076 phase 5f-3: arbitrary runtime with SubagentStop in descriptor gets it wired', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + const opts = buildOpts(targetDir, { + runtime: 'hypothetical', // NOT 'claude' or 'qwen' — would have been skipped before + extendedHookEvents: ['SubagentStop'], + }); + applySettingsJsonHooks(settings, opts); + }); + + test('SubagentStop IS wired for a hypothetical runtime when descriptor includes it', () => { + assert.ok( + hasHooksFor(settings, 'SubagentStop'), + `SubagentStop must be wired via descriptor even for unknown runtime names; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('Stop is NOT wired (not in extendedHookEvents)', () => { + assert.strictEqual(hasHooksFor(settings, 'Stop'), false); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +// ─── Suite 6: hooksSurface drive (ADR-857 phase 5g drive 3) ────────────────── +// +// applySettingsJsonHooks is gated by opts.hooksSurface !== 'none'. +// - hooksSurface:'none' → entire body is skipped; no hooks written +// - hooksSurface:'settings-json'→ hooks are written (even for a runtime whose +// name was previously hardcoded to skip, e.g. 'opencode') +// +// This proves the skip is driven by the descriptor field, not the runtime name. + +describe('enh-1076 phase 5g drive 3: hooksSurface:none skips all hooks regardless of runtime', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + // 'claude' would normally write hooks, but hooksSurface:'none' must skip entirely. + const opts = { + ...buildOpts(targetDir, { runtime: 'claude', extendedHookEvents: ['SubagentStop'] }), + hooksSurface: 'none', + }; + applySettingsJsonHooks(settings, opts); + }); + + test('SessionStart is NOT written when hooksSurface is "none"', () => { + assert.strictEqual( + hasHooksFor(settings, 'SessionStart'), + false, + `SessionStart must not be written when hooksSurface="none"; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('PostToolUse is NOT written when hooksSurface is "none"', () => { + assert.strictEqual(hasHooksFor(settings, 'PostToolUse'), false); + }); + + test('PreToolUse is NOT written when hooksSurface is "none"', () => { + assert.strictEqual(hasHooksFor(settings, 'PreToolUse'), false); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +describe('enh-1076 phase 5g drive 3: hooksSurface:settings-json writes hooks even for previously-skipped runtime name', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + // 'opencode' previously was hardcoded to skip hooks; with descriptor drive it + // should write hooks whenever hooksSurface !== 'none'. + const opts = { + ...buildOpts(targetDir, { runtime: 'opencode', extendedHookEvents: [] }), + hooksSurface: 'settings-json', + }; + applySettingsJsonHooks(settings, opts); + }); + + test('SessionStart IS written with at least one command when hooksSurface is "settings-json" (even for opencode name)', () => { + // ensureHooksDist() in before() guarantees hooks/dist is built, so the + // existsSync guards inside applySettingsJsonHooks pass and commands are registered. + assert.ok( + settings.hooks && typeof settings.hooks === 'object', + `settings.hooks must be initialized when hooksSurface="settings-json"`, + ); + assert.ok( + hasHooksFor(settings, 'SessionStart'), + `settings.hooks.SessionStart must contain at least one registered command when hooksSurface="settings-json"; ` + + `keys: ${JSON.stringify(Object.keys(settings.hooks))}`, + ); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1077-install-hook-events-dialect-drive.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1077-install-hook-events-dialect-drive (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * ADR-857 phase 5f-2: hook-events dialect is driven from the registry descriptor. + * + * Before this change, postToolEvent and preToolEvent were hardcoded strings + * derived from runtime-name checks: + * + * (runtime === 'gemini' || runtime === 'antigravity') ? 'AfterTool' : 'PostToolUse' + * (runtime === 'gemini' || runtime === 'antigravity') ? 'BeforeTool' : 'PreToolUse' + * + * After phase 5f-2, both are driven by the registry descriptor's + * `hookEvents` field: hookEvents === 'gemini' → AfterTool/BeforeTool; + * any other value (or missing) → PostToolUse/PreToolUse. + * + * Equivalence (i.e. identical observable behaviour for all runtimes): + * hookEvents === 'gemini' iff runtime ∈ {gemini, antigravity} + * + * This suite asserts the equivalence and the registry-parity invariant: + * any runtime whose descriptor carries hookEvents='gemini' gets the + * AfterTool/BeforeTool dialect; all others get PostToolUse/PreToolUse. + */ + +const { test, describe, before, 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 { install } = require('../bin/install.js'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// ─── hooks/dist build guard ─────────────────────────────────────────────────── +// +// hooks/dist/ is gitignored and only produced by `npm run build:hooks`. +// In CI the scoped/windows test jobs do NOT run build:hooks before running +// tests, so install() finds no hook files → event arrays come back empty → +// every "expected AfterTool/PostToolUse/BeforeTool/PreToolUse hooks" assertion +// fails. This mirrors the pattern in bug-376-claude-js-hook-gsd-rewriter.test.cjs. + +const REPO_ROOT = path.resolve(__dirname, '..'); +const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); + +/** + * Idempotently ensure hooks/dist contains built .js files. + * Runs build-hooks.js only when the directory is absent or empty of .js files. + */ +function ensureHooksDist() { + if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { + execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + } +} + +before(() => { + ensureHooksDist(); +}); + +// ─── Registry lookup ────────────────────────────────────────────────────────── + +const REGISTRY_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'); +const registry = (() => { + try { return require(REGISTRY_PATH); } catch { return undefined; } +})(); + +/** + * Return the hookEvents dialect for a runtime ID from the live registry. + * Returns undefined when the registry is absent or the runtime has no descriptor. + */ +function registryHookEvents(runtimeId) { + return registry?.runtimes?.[runtimeId]?.runtime?.hookEvents; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Collect all hook commands registered under a settings event key. */ +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) + ); +} + +/** True if at least one hook is registered under eventName. */ +function hasHooksFor(settings, eventName) { + return hooksForEvent(settings, eventName).length > 0; +} + +// ─── Suite 1: Gemini-dialect runtimes use AfterTool/BeforeTool ─────────────── +// +// Registry runtimes with hookEvents='gemini': gemini, antigravity + +describe('enh-1077 phase 5f-2: gemini hookEvents dialect → AfterTool/BeforeTool', () => { + // ── gemini ── + + describe('gemini install uses AfterTool for post-tool hooks', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1077-gemini-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const geminiDir = path.join(tmpDir, '.gemini'); + fs.mkdirSync(geminiDir, { recursive: true }); + const result = install(false, 'gemini'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('registry confirms gemini hookEvents is "gemini"', () => { + // Parity assertion: if the registry changes, this test fails first. + const he = registryHookEvents('gemini'); + if (he !== undefined) { + assert.strictEqual(he, 'gemini', + 'Registry descriptor for gemini must declare hookEvents="gemini"'); + } + }); + + test('gemini install returns a settings object', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'gemini install must return a non-null settings object'); + }); + + test('gemini install registers at least one hook under AfterTool (post-tool)', () => { + assert.ok(hasHooksFor(settings, 'AfterTool'), + `Expected AfterTool hooks on gemini; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('gemini install does NOT register context-monitor under PostToolUse (wrong dialect)', () => { + const cmds = hooksForEvent(settings, 'PostToolUse'); + const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); + assert.strictEqual(hasMonitor, false, + `gemini must NOT use PostToolUse for context-monitor; got PostToolUse commands: ${JSON.stringify(cmds)}`); + }); + + test('gemini install registers at least one pre-tool hook (prompt-guard) under BeforeTool', () => { + const cmds = hooksForEvent(settings, 'BeforeTool'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.ok(hasPromptGuard, + `Expected prompt-guard hook under BeforeTool on gemini; BeforeTool commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('gemini install does NOT register prompt-guard under PreToolUse (wrong pre-tool dialect)', () => { + const cmds = hooksForEvent(settings, 'PreToolUse'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.strictEqual(hasPromptGuard, false, + `gemini must NOT use PreToolUse for prompt-guard; got PreToolUse commands: ${JSON.stringify(cmds)}`); + }); + }); + + // ── antigravity ── + + describe('antigravity install uses AfterTool/BeforeTool (gemini dialect)', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1077-antigrav-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const agDir = path.join(tmpDir, '.gemini', 'antigravity'); + fs.mkdirSync(agDir, { recursive: true }); + const result = install(false, 'antigravity'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('registry confirms antigravity hookEvents is "gemini"', () => { + const he = registryHookEvents('antigravity'); + if (he !== undefined) { + assert.strictEqual(he, 'gemini', + 'Registry descriptor for antigravity must declare hookEvents="gemini"'); + } + }); + + test('antigravity install returns a settings object', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'antigravity install must return a non-null settings object'); + }); + + test('antigravity install registers at least one hook under AfterTool', () => { + assert.ok(hasHooksFor(settings, 'AfterTool'), + `Expected AfterTool hooks on antigravity; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('antigravity install does NOT register context-monitor under PostToolUse', () => { + const cmds = hooksForEvent(settings, 'PostToolUse'); + const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); + assert.strictEqual(hasMonitor, false, + `antigravity must NOT use PostToolUse for context-monitor; got: ${JSON.stringify(cmds)}`); + }); + + test('antigravity install registers at least one pre-tool hook (prompt-guard) under BeforeTool', () => { + const cmds = hooksForEvent(settings, 'BeforeTool'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.ok(hasPromptGuard, + `Expected prompt-guard hook under BeforeTool on antigravity; BeforeTool commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('antigravity install does NOT register prompt-guard under PreToolUse (wrong pre-tool dialect)', () => { + const cmds = hooksForEvent(settings, 'PreToolUse'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.strictEqual(hasPromptGuard, false, + `antigravity must NOT use PreToolUse for prompt-guard; got PreToolUse commands: ${JSON.stringify(cmds)}`); + }); + }); +}); + +// ─── Suite 2: Claude-dialect runtimes use PostToolUse/PreToolUse ────────────── +// +// Registry runtimes with hookEvents='claude': claude, augment + +describe('enh-1077 phase 5f-2: claude hookEvents dialect → PostToolUse/PreToolUse', () => { + // ── claude ── + + describe('claude install uses PostToolUse for post-tool hooks', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1077-claude-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const result = install(false, 'claude'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('registry confirms claude hookEvents is "claude"', () => { + const he = registryHookEvents('claude'); + if (he !== undefined) { + assert.strictEqual(he, 'claude', + 'Registry descriptor for claude must declare hookEvents="claude"'); + } + }); + + test('claude install returns a settings object', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'claude install must return a non-null settings object'); + }); + + test('claude install registers at least one hook under PostToolUse', () => { + assert.ok(hasHooksFor(settings, 'PostToolUse'), + `Expected PostToolUse hooks on claude; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('claude install does NOT register context-monitor under AfterTool (wrong dialect)', () => { + const cmds = hooksForEvent(settings, 'AfterTool'); + const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); + assert.strictEqual(hasMonitor, false, + `claude must NOT use AfterTool for context-monitor; got AfterTool commands: ${JSON.stringify(cmds)}`); + }); + + test('claude install registers at least one pre-tool hook (prompt-guard) under PreToolUse', () => { + const cmds = hooksForEvent(settings, 'PreToolUse'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.ok(hasPromptGuard, + `Expected prompt-guard hook under PreToolUse on claude; PreToolUse commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('claude install does NOT register prompt-guard under BeforeTool (wrong pre-tool dialect)', () => { + const cmds = hooksForEvent(settings, 'BeforeTool'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.strictEqual(hasPromptGuard, false, + `claude must NOT use BeforeTool for prompt-guard; got BeforeTool commands: ${JSON.stringify(cmds)}`); + }); + }); + + // ── augment ── + + describe('augment install uses PostToolUse/PreToolUse (claude dialect)', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1077-augment-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const augDir = path.join(tmpDir, '.augment'); + fs.mkdirSync(augDir, { recursive: true }); + const result = install(false, 'augment'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('registry confirms augment hookEvents is "claude"', () => { + const he = registryHookEvents('augment'); + if (he !== undefined) { + assert.strictEqual(he, 'claude', + 'Registry descriptor for augment must declare hookEvents="claude"'); + } + }); + + test('augment install returns a settings object', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'augment install must return a non-null settings object'); + }); + + test('augment install registers at least one hook under PostToolUse', () => { + assert.ok(hasHooksFor(settings, 'PostToolUse'), + `Expected PostToolUse hooks on augment; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('augment install does NOT register context-monitor under AfterTool', () => { + const cmds = hooksForEvent(settings, 'AfterTool'); + const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); + assert.strictEqual(hasMonitor, false, + `augment must NOT use AfterTool for context-monitor; got: ${JSON.stringify(cmds)}`); + }); + + test('augment install registers at least one pre-tool hook (prompt-guard) under PreToolUse', () => { + const cmds = hooksForEvent(settings, 'PreToolUse'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.ok(hasPromptGuard, + `Expected prompt-guard hook under PreToolUse on augment; PreToolUse commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('augment install does NOT register prompt-guard under BeforeTool (wrong pre-tool dialect)', () => { + const cmds = hooksForEvent(settings, 'BeforeTool'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.strictEqual(hasPromptGuard, false, + `augment must NOT use BeforeTool for prompt-guard; got BeforeTool commands: ${JSON.stringify(cmds)}`); + }); + }); +}); + +// ─── Suite 3: Registry-parity invariant ────────────────────────────────────── +// +// For every runtime in the registry that exposes a settings.json surface +// (i.e. hookEvents is defined), assert that the installed hook dialect matches +// the registry value. This is the generative-fix parity assertion +// (DEFECT.GENERATIVE-FIX): adding a new runtime with hookEvents to the +// registry automatically requires a passing install test for that runtime. + +describe('enh-1077 phase 5f-2: registry-parity — hookEvents descriptor drives install dialect', () => { + test('all registry runtimes with hookEvents use the matching install dialect', () => { + if (!registry || !registry.runtimes) { + // Registry absent — skip parity check (equivalence still verified above) + return; + } + + // Runtimes that have settings.json surfaces and a hookEvents descriptor + const SETTINGS_JSON_RUNTIMES = ['claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy']; + + const failures = []; + + for (const runtimeId of SETTINGS_JSON_RUNTIMES) { + const he = registryHookEvents(runtimeId); + if (he === undefined) continue; // no hookEvents in descriptor — skip + + const expectedPostEvent = he === 'gemini' ? 'AfterTool' : 'PostToolUse'; + const unexpectedPostEvent = he === 'gemini' ? 'PostToolUse' : 'AfterTool'; + const expectedPreEvent = he === 'gemini' ? 'BeforeTool' : 'PreToolUse'; + const unexpectedPreEvent = he === 'gemini' ? 'PreToolUse' : 'BeforeTool'; + + const previousCwd = process.cwd(); + const tmpDir = createTempDir(`gsd-1077-parity-${runtimeId}-`); + try { + process.chdir(tmpDir); + const result = install(false, runtimeId); + const settings = result && result.settings; + if (!settings) continue; // non-settings-json surface, skip + + // Post-tool event assertions + const hasExpected = hasHooksFor(settings, expectedPostEvent); + const hasUnexpected = hooksForEvent(settings, unexpectedPostEvent) + .some(c => c && c.includes('gsd-context-monitor')); + + if (!hasExpected) { + failures.push(`${runtimeId}: expected context-monitor hook under ${expectedPostEvent} (hookEvents=${he}), but none found`); + } + if (hasUnexpected) { + failures.push(`${runtimeId}: must NOT register context-monitor under ${unexpectedPostEvent}, but it was found`); + } + + // Pre-tool event assertions: prompt-guard must land under the dialect-correct key. + const preToolCmdsExpected = hooksForEvent(settings, expectedPreEvent); + const hasPromptGuardExpected = preToolCmdsExpected.some(c => c && c.includes('gsd-prompt-guard')); + const preToolCmdsUnexpected = hooksForEvent(settings, unexpectedPreEvent); + const hasPromptGuardUnexpected = preToolCmdsUnexpected.some(c => c && c.includes('gsd-prompt-guard')); + + if (!hasPromptGuardExpected) { + failures.push(`${runtimeId}: expected prompt-guard hook under ${expectedPreEvent} (hookEvents=${he}), but none found; ${expectedPreEvent} cmds: ${JSON.stringify(preToolCmdsExpected)}`); + } + if (hasPromptGuardUnexpected) { + failures.push(`${runtimeId}: must NOT register prompt-guard under ${unexpectedPreEvent} (hookEvents=${he}), but it was found`); + } + } finally { + process.chdir(previousCwd); + cleanup(tmpDir); + } + } + + assert.deepEqual(failures, [], + 'Registry-parity failures (hookEvents descriptor must drive install dialect):\n' + + failures.join('\n')); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-776-install-gemini-hook-events.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-776-install-gemini-hook-events (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Enhancement #776: Adopt new Gemini hook events + detect hooksConfig.enabled:false. + * + * Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that gsd + * previously did not register. This suite asserts that a Gemini install + * registers the 3 new high-value events: + * - BeforeAgent — fires before the agent plans (context headroom tracking) + * - AfterAgent — fires after final response generation (context tracking) + * - BeforeModel — fires before each LLM call (per-turn context awareness) + * + * All three are wired to gsd-context-monitor.js — the same hook used for + * AfterTool — so context headroom warnings surface at these lifecycle moments. + * + * Also asserts: + * - Claude Code installs do NOT gain these Gemini-only events (strict scope guard). + * - Reinstalls are idempotent (no hook duplication). + * - Uninstall removes the new event registrations. + * - hooksConfig.enabled:false warning is emitted during a Gemini install. + * + * Source: https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md + */ + +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) + ); +} + +// Stub JS hook files that the installer checks with fs.existsSync() so hook +// registration guards pass even when hooks/dist/ isn't built. +// +// For Gemini, the installer migration baseline includes 'hooks/' in its surface +// list (unlike Qwen which excludes it). Pre-install stubs placed in .gemini/hooks/ +// are classified as 'bundled-gsd-hook' and auto-removed by the migration before +// registration can succeed. The workaround: run a first install (which writes the +// gsd-file-manifest.json), then add the stubs, then run install again. On the +// second install the manifest marks the hook files as managed, so migration keeps +// them and registration guards (fs.existsSync) pass. +const HOOKS_SRC = path.join(__dirname, '..', 'hooks'); +const STUB_HOOKS = [ + 'gsd-context-monitor.js', + 'gsd-prompt-guard.js', + 'gsd-check-update.js', +]; + +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 */ } + } +} + +/** + * Two-pass Gemini install. + * + * Pass 1: install() with no hook stubs — writes gsd-file-manifest.json. + * Migration runs on an empty hooks/ so nothing gets auto-removed. + * Pass 2: stub hooks into the target dir (now manifest-tracked on next scan), + * then run install() again. Migration now classifies the hooks as + * managed-unchanged and preserves them; registration guards pass. + * + * Returns the settings from pass 2. + */ +function twoPassGeminiInstall(tmpDir) { + const targetDir = path.join(tmpDir, '.gemini'); + fs.mkdirSync(targetDir, { recursive: true }); + + // Pass 1 — no hook stubs yet; writes the manifest + const result1 = install(false, 'gemini'); + persistSettings(result1.settingsPath, result1.settings); + + // Inject stubs so the registration guards (fs.existsSync) pass on pass 2 + stubHooksIntoTarget(targetDir); + + // Pass 2 — manifest exists, migration keeps stubs, registration succeeds + process.chdir(tmpDir); + const result2 = install(false, 'gemini'); + return result2; +} + +/** + * Persist in-memory settings to disk, simulating what finishInstall() does + * (finishInstall is not exported). Required for tests that call install() + * twice and need the second call to read the first call's hook registrations. + */ +function persistSettings(settingsPath, settings) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8'); +} + +// ─── Suite 1: Gemini — new events are registered ───────────────────────────── + +describe('enh-776: Gemini install registers 3 new hook events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-gemini-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + // Two-pass install: first pass writes manifest, second pass with stubs + // so registration guards (fs.existsSync) pass. + const result = twoPassGeminiInstall(tmpDir); + settings = result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('install returns a settings object (not null)', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'Gemini install must return a non-null settings object'); + }); + + test('BeforeAgent event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'BeforeAgent'); + assert.ok(cmds.length > 0, + `Expected BeforeAgent hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('AfterAgent event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'AfterAgent'); + assert.ok(cmds.length > 0, + `Expected AfterAgent hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('BeforeModel event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'BeforeModel'); + assert.ok(cmds.length > 0, + `Expected BeforeModel hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('BeforeAgent / AfterAgent / BeforeModel all use gsd-context-monitor', () => { + for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { + 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: Non-Gemini installs do NOT get the new events ────────────────── +// +// Two runtimes are particularly important to guard: +// Claude — the canonical non-Gemini runtime +// Antigravity — shares Gemini-style BeforeTool/AfterTool naming and uses +// isGemini-adjacent logic; a future accidental +// `isGemini || isAntigravity` change must be caught here. + +describe('enh-776: Claude install does NOT register Gemini-only hook events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-claude-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const result = install(false, 'claude'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('Claude install does not register BeforeAgent', () => { + const cmds = hooksForEvent(settings, 'BeforeAgent'); + assert.strictEqual(cmds.length, 0, + `Claude should NOT have BeforeAgent; got: ${JSON.stringify(cmds)}`); + }); + + test('Claude install does not register AfterAgent', () => { + const cmds = hooksForEvent(settings, 'AfterAgent'); + assert.strictEqual(cmds.length, 0, + `Claude should NOT have AfterAgent; got: ${JSON.stringify(cmds)}`); + }); + + test('Claude install does not register BeforeModel', () => { + const cmds = hooksForEvent(settings, 'BeforeModel'); + assert.strictEqual(cmds.length, 0, + `Claude should NOT have BeforeModel; got: ${JSON.stringify(cmds)}`); + }); +}); + +describe('enh-776: Antigravity install does NOT register Gemini-only hook events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-antigravity-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const result = install(false, 'antigravity'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('Antigravity install does not register BeforeAgent', () => { + const cmds = hooksForEvent(settings, 'BeforeAgent'); + assert.strictEqual(cmds.length, 0, + `Antigravity should NOT have BeforeAgent; got: ${JSON.stringify(cmds)}`); + }); + + test('Antigravity install does not register AfterAgent', () => { + const cmds = hooksForEvent(settings, 'AfterAgent'); + assert.strictEqual(cmds.length, 0, + `Antigravity should NOT have AfterAgent; got: ${JSON.stringify(cmds)}`); + }); + + test('Antigravity install does not register BeforeModel', () => { + const cmds = hooksForEvent(settings, 'BeforeModel'); + assert.strictEqual(cmds.length, 0, + `Antigravity should NOT have BeforeModel; got: ${JSON.stringify(cmds)}`); + }); +}); + +// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ────── + +describe('enh-776: Gemini install is idempotent across persisted reinstalls', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-idem-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('re-running after persisted first install does not duplicate hook entries', () => { + // Two-pass to get hooks installed. + const result2 = twoPassGeminiInstall(tmpDir); + const s2 = result2.settings; + + // Assert hooks ARE registered after pass 2 (guards against false-pass where + // hooks never registered and idempotency passes trivially at count=0). + for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { + const cmds = hooksForEvent(s2, event); + assert.strictEqual(cmds.length, 1, + `Event ${event} should have exactly 1 hook after two-pass install; got ${cmds.length}: ${JSON.stringify(cmds)}`); + } + + persistSettings(result2.settingsPath, s2); + + // Third install: reads the persisted settings.json — dedup guards apply + process.chdir(tmpDir); + const result3 = install(false, 'gemini'); + const s3 = result3.settings; + + for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { + const cmds = hooksForEvent(s3, event); + assert.strictEqual(cmds.length, 1, + `Event ${event} should have exactly 1 hook command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); + } + }); +}); + +// ─── Suite 4: Uninstall removes the new event registrations ────────────────── + +describe('enh-776: Gemini uninstall removes new hook event entries', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-uninstall-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + // Two-pass install and persist so uninstall has a settings.json to clean + const result = twoPassGeminiInstall(tmpDir); + persistSettings(result.settingsPath, result.settings); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('settings.json hook entries are removed on uninstall', () => { + uninstall(false, 'gemini'); + const settingsPath = path.join(tmpDir, '.gemini', 'settings.json'); + if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { + const cmds = hooksForEvent(settings, event); + assert.strictEqual(cmds.length, 0, + `After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`); + } + }); +}); + +// ─── Suite 5: hooksConfig.enabled:false warning ─────────────────────────────── + +describe('enh-776: hooksConfig.enabled:false warning during Gemini install', () => { + let tmpDir; + let previousCwd; + let stderrLines; + let originalWarn; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-hookscfg-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const targetDir = path.join(tmpDir, '.gemini'); + fs.mkdirSync(targetDir, { recursive: true }); + + // Capture console.warn output + stderrLines = []; + originalWarn = console.warn; + console.warn = (...args) => { stderrLines.push(args.join(' ')); }; + }); + + afterEach(() => { + console.warn = originalWarn; + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('emits a warning when hooksConfig.enabled is false', () => { + // Write a settings.json with hooksConfig.enabled: false BEFORE install + // (the check in install() reads the existing settings.json on disk). + const targetDir = path.join(tmpDir, '.gemini'); + const settingsPath = path.join(targetDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ hooksConfig: { enabled: false } }, null, 2) + '\n', 'utf8'); + + install(false, 'gemini'); + const warnText = stderrLines.join('\n'); + assert.ok( + warnText.includes('hooksConfig.enabled is false'), + `Expected hooksConfig.enabled warning; got console.warn output:\n${warnText}` + ); + }); + + test('does NOT emit the hooksConfig warning when hooksConfig.enabled is true', () => { + const targetDir = path.join(tmpDir, '.gemini'); + const settingsPath = path.join(targetDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ hooksConfig: { enabled: true } }, null, 2) + '\n', 'utf8'); + + install(false, 'gemini'); + const warnText = stderrLines.join('\n'); + assert.ok( + !warnText.includes('hooksConfig.enabled is false'), + `Should NOT warn when hooksConfig.enabled is true; got:\n${warnText}` + ); + }); + + test('does NOT emit the hooksConfig warning when hooksConfig is absent', () => { + const targetDir = path.join(tmpDir, '.gemini'); + const settingsPath = path.join(targetDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({}, null, 2) + '\n', 'utf8'); + + install(false, 'gemini'); + const warnText = stderrLines.join('\n'); + assert.ok( + !warnText.includes('hooksConfig.enabled is false'), + `Should NOT warn when hooksConfig is absent; got:\n${warnText}` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-788-qwen-hook-events.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-788-qwen-hook-events (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Enhancement #788: Expand Qwen Code hook-event coverage. + * + * Qwen Code supports 15 hook events; gsd previously registered only + * SessionStart and PostToolUse. This suite asserts that a Qwen install + * registers the 3 new high-value events: + * - SubagentStop — subagent lifecycle finalisation (context tracking) + * - Stop — model stop / final-response hook (context tracking) + * - PreCompact — pre-compaction awareness (context tracking) + * + * All three are wired to gsd-context-monitor.js — the same hook used for + * PostToolUse — so context headroom warnings surface at these moments too. + * + * Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless + * tool_name is Write|Edit (PreToolUse shape), so it would be a no-op for + * the UserPromptSubmit payload. Deferred to a follow-on issue. + * + * Also asserts the inverse: Claude Code installs do NOT gain these events + * (strict isQwen scope guard). + * + * Source: https://qwenlm.github.io/qwen-code-docs/en/users/features/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) + ); +} + +// Stub JS hook files that the installer checks with fs.existsSync() so hook +// registration guards pass even when hooks/dist/ isn't built. +const HOOKS_SRC = path.join(__dirname, '..', 'hooks'); +const STUB_HOOKS = [ + 'gsd-context-monitor.js', + 'gsd-prompt-guard.js', + 'gsd-check-update.js', + 'gsd-config-reload.js', // Added in #770 +]; + +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 */ } + } +} + +/** + * Persist in-memory settings to disk, simulating what finishInstall() does + * (finishInstall is not exported). Required for tests that call install() + * twice and need the second call to read the first call's hook registrations. + */ +function persistSettings(settingsPath, settings) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8'); +} + +// ─── Suite 1: Qwen — new events are registered ─────────────────────────────── + +describe('enh-788: Qwen install registers 3 new hook events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-qwen-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const targetDir = path.join(tmpDir, '.qwen'); + fs.mkdirSync(targetDir, { recursive: true }); + // Pre-populate hook files so installer registration guards (fs.existsSync) + // pass and hooks are actually registered in settings.json. + stubHooksIntoTarget(targetDir); + + const result = install(false, 'qwen'); + settings = result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('install returns a settings object (not null)', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'Qwen 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('UserPromptSubmit is NOT registered (handler not yet implemented for that payload shape)', () => { + // gsd-prompt-guard exits unless tool_name is Write|Edit — it is a no-op + // for UserPromptSubmit payloads. Registration is deferred until a + // dedicated hook can process the user-prompt payload shape. + const cmds = hooksForEvent(settings, 'UserPromptSubmit'); + assert.strictEqual(cmds.length, 0, + `UserPromptSubmit should NOT be registered yet; got: ${JSON.stringify(cmds)}`); + }); + + 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)}` + ); + } + }); + + test('FileChanged is NOT registered for Qwen (Claude-only event)', () => { + // gsd-config-reload / FileChanged is a Claude Code-only registration. + // Qwen does not support the FileChanged hook event at all. + const cmds = hooksForEvent(settings, 'FileChanged'); + assert.strictEqual(cmds.length, 0, + `FileChanged should NOT be registered for Qwen; got: ${JSON.stringify(cmds)}`); + }); +}); + +// ─── Suite 2: Claude install DOES get the context events (since #770) ─────── +// Note: Prior to #770, these were Qwen-only events. #770 extended them to +// Claude Code. This suite is updated to match the new expected behavior. + +describe('enh-788 (updated by #770): Claude install registers context lifecycle events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-claude-'); + 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('Claude install registers SubagentStop (since #770)', () => { + const cmds = hooksForEvent(settings, 'SubagentStop'); + assert.ok(cmds.length > 0, + `Claude should have SubagentStop since #770; got: ${JSON.stringify(cmds)}`); + }); + + test('Claude install registers Stop (since #770)', () => { + const cmds = hooksForEvent(settings, 'Stop'); + assert.ok(cmds.length > 0, + `Claude should have Stop since #770; got: ${JSON.stringify(cmds)}`); + }); + + test('Claude install registers PreCompact (since #770)', () => { + const cmds = hooksForEvent(settings, 'PreCompact'); + assert.ok(cmds.length > 0, + `Claude should have PreCompact since #770; got: ${JSON.stringify(cmds)}`); + }); +}); + +// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ────── + +describe('enh-788: Qwen install is idempotent across persisted reinstalls', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-idem-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const targetDir = path.join(tmpDir, '.qwen'); + fs.mkdirSync(targetDir, { recursive: true }); + stubHooksIntoTarget(targetDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('re-running after persisted first install does not duplicate hook entries', () => { + // First install: get settings and persist to disk (simulating finishInstall) + const result1 = install(false, 'qwen'); + persistSettings(result1.settingsPath, result1.settings); + + // Second install: reads the persisted settings.json — dedup guards apply + process.chdir(tmpDir); + const result2 = install(false, 'qwen'); + 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 command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); + } + }); +}); + +// ─── Suite 4: Uninstall removes the new event registrations ────────────────── + +describe('enh-788: Qwen uninstall removes new hook event entries', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-uninstall-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const targetDir = path.join(tmpDir, '.qwen'); + fs.mkdirSync(targetDir, { recursive: true }); + stubHooksIntoTarget(targetDir); + + // Install and persist to disk so uninstall has a settings.json to clean + const result = install(false, 'qwen'); + persistSettings(result.settingsPath, result.settings); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('settings.json hook entries are removed on uninstall', () => { + uninstall(false, 'qwen'); + const settingsPath = path.join(tmpDir, '.qwen', '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']) { + const cmds = hooksForEvent(settings, event); + assert.strictEqual(cmds.length, 0, + `After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`); + } + }); +}); + }); +} diff --git a/tests/install-regressions.test.cjs b/tests/install-regressions.test.cjs index a3e2eb9c3..70efb7c43 100644 --- a/tests/install-regressions.test.cjs +++ b/tests/install-regressions.test.cjs @@ -875,3 +875,291 @@ describe('#1004 regression: installer does not duplicate managed hooks when regi ); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1924-preserve-user-artifacts.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1924-preserve-user-artifacts (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #1924) +// Workflow .md / agent .md / command .md / reference .md files — their text +// IS what the runtime loads. Testing text content tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Regression tests for bug #1924: gsd-update silently deletes user-generated files + * + * Running the installer (gsd-update / re-install) must not delete: + * - gsd-core/USER-PROFILE.md (created by /gsd-profile-user) + * - commands/gsd/dev-preferences.md (created by /gsd-profile-user) + * + * Root cause: + * 1. copyWithPathReplacement() calls fs.rmSync(destDir, {recursive:true}) before + * copying — no preserve allowlist. This wipes USER-PROFILE.md. + * 2. ~line 5211 explicitly rmSync's commands/gsd/ during global install legacy + * cleanup — no preserve. This wipes dev-preferences.md. + * + * Fix requirement: + * - install() must preserve USER-PROFILE.md across the gsd-core/ wipe + * - install() must preserve dev-preferences.md across the commands/gsd/ wipe + * + * Closes: #1924 + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); +const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); + +// ─── Ensure hooks/dist/ is populated before any install test ───────────────── + +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { + encoding: 'utf-8', + stdio: 'pipe', + }); +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function createTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanup(dir) { + // eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup() helper wrapping rmSync; cannot use imported cleanup() without naming collision + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +} + +/** + * Run the installer with CLAUDE_CONFIG_DIR redirected to a temp directory. + * Explicitly removes GSD_TEST_MODE so the subprocess actually runs the installer + * (not just the export block). Uses --yes to suppress interactive prompts. + */ +function runInstaller(configDir) { + const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir }; + delete env.GSD_TEST_MODE; + // --no-sdk: this test covers user-artifact preservation only; skip SDK + // build (covered by install-smoke.yml) to keep the test deterministic. + execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], { + encoding: 'utf-8', + stdio: 'pipe', + env, + }); +} + +// ─── Test 1: USER-PROFILE.md is preserved across re-install ───────────────── + +describe('#1924: USER-PROFILE.md preserved across re-install (global Claude)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1924-userprofile-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('USER-PROFILE.md exists after initial install + user creation', () => { + runInstaller(tmpDir); + + // Simulate /gsd-profile-user creating USER-PROFILE.md inside gsd-core/ + const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); + fs.writeFileSync(profilePath, '# My Profile\n\nCustom user content.\n'); + + assert.ok( + fs.existsSync(profilePath), + 'USER-PROFILE.md should exist after being created by /gsd-profile-user' + ); + }); + + test('USER-PROFILE.md is preserved after re-install', () => { + // First install + runInstaller(tmpDir); + + // User runs /gsd-profile-user, creating USER-PROFILE.md + const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); + const originalContent = '# My Profile\n\nThis is my custom user profile content.\n'; + fs.writeFileSync(profilePath, originalContent); + + // Re-run installer (simulating gsd-update) + runInstaller(tmpDir); + + assert.ok( + fs.existsSync(profilePath), + 'USER-PROFILE.md must survive re-install — gsd-update must not delete user-generated profiles' + ); + + const afterContent = fs.readFileSync(profilePath, 'utf8'); + assert.strictEqual( + afterContent, + originalContent, + 'USER-PROFILE.md content must be identical after re-install' + ); + }); + + test('USER-PROFILE.md is preserved even when gsd-core/ is wiped and recreated', () => { + runInstaller(tmpDir); + + const gsdDir = path.join(tmpDir, 'gsd-core'); + const profilePath = path.join(gsdDir, 'USER-PROFILE.md'); + + // Confirm gsd-core/ was created by install + assert.ok(fs.existsSync(gsdDir), 'gsd-core/ must exist after install'); + + // Write profile + fs.writeFileSync(profilePath, '# Profile\n\nMy coding style preferences.\n'); + + // Re-install + runInstaller(tmpDir); + + // gsd-core/ must still exist AND profile must be intact + assert.ok(fs.existsSync(gsdDir), 'gsd-core/ must still exist after re-install'); + assert.ok( + fs.existsSync(profilePath), + 'USER-PROFILE.md must still exist after gsd-core/ was wiped and recreated' + ); + }); +}); + +// ─── Test 2: dev-preferences.md is preserved across re-install ─────────────── + +describe('#1924: dev-preferences.md preserved across re-install (global Claude)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1924-devprefs-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('dev-preferences.md is preserved when commands/gsd/ is cleaned up during re-install', () => { + // First install (creates skills/ structure for global Claude) + runInstaller(tmpDir); + + // User runs /gsd-profile-user — it creates dev-preferences.md in commands/gsd/ + const commandsGsdDir = path.join(tmpDir, 'commands', 'gsd'); + fs.mkdirSync(commandsGsdDir, { recursive: true }); + const devPrefsPath = path.join(commandsGsdDir, 'dev-preferences.md'); + const originalContent = '# Dev Preferences\n\nI prefer TDD. I like short functions.\n'; + fs.writeFileSync(devPrefsPath, originalContent); + + // Re-run installer (simulating gsd-update). + // In the layout-driven path (B2), legacy commands/gsd/ is removed and + // dev-preferences.md is migrated to skills/gsd-dev-preferences/SKILL.md (#2973). + runInstaller(tmpDir); + + // Content is migrated to the new canonical skills location (#2973). + // The old commands/gsd/ path is cleaned up; the skill file carries the content. + const devPrefSkillPath = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md'); + assert.ok( + fs.existsSync(devPrefSkillPath), + 'dev-preferences.md must be migrated to skills/gsd-dev-preferences/SKILL.md — gsd-update legacy cleanup must not silently drop user-generated content' + ); + + const afterContent = fs.readFileSync(devPrefSkillPath, 'utf8'); + assert.strictEqual( + afterContent, + originalContent, + 'migrated dev-preferences content must be identical to the original' + ); + }); + + test('legacy non-user GSD commands are still cleaned up during re-install', () => { + // First install + runInstaller(tmpDir); + + // Simulate a legacy GSD command file being left in commands/gsd/ + const commandsGsdDir = path.join(tmpDir, 'commands', 'gsd'); + fs.mkdirSync(commandsGsdDir, { recursive: true }); + const legacyFile = path.join(commandsGsdDir, 'next.md'); + fs.writeFileSync(legacyFile, '---\nname: gsd:next\n---\n\nLegacy content.'); + + // But dev-preferences.md is also there (user-generated) + const devPrefsContent = '# Dev Preferences\n\nMy preferences.\n'; + const devPrefsPath = path.join(commandsGsdDir, 'dev-preferences.md'); + fs.writeFileSync(devPrefsPath, devPrefsContent); + + // Re-install + runInstaller(tmpDir); + + // In the layout-driven path (B2), commands/gsd/ is fully removed but + // dev-preferences.md content is migrated to the new canonical skill location. + const devPrefSkillPath = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md'); + assert.ok( + fs.existsSync(devPrefSkillPath), + 'dev-preferences.md content must be migrated to skills/gsd-dev-preferences/SKILL.md' + ); + + // The legacy GSD command (next.md) is NOT user-generated, must be removed + // (it would exist only as a skill now in skills/gsd-next/SKILL.md) + assert.ok( + !fs.existsSync(legacyFile), + 'legacy GSD command next.md in commands/gsd/ must be removed during cleanup' + ); + }); +}); + +// ─── Test 3: profile-user.md backup path is outside gsd-core/ ─────────── + +describe('#1924: profile-user.md backup path must be outside gsd-core/', () => { + test('profile-user.md backup uses ~/.claude/USER-PROFILE.backup.md not ~/.claude/gsd-core/USER-PROFILE.backup.md', () => { + const workflowPath = path.join( + __dirname, '..', 'gsd-core', 'workflows', 'profile-user.md' + ); + const content = fs.readFileSync(workflowPath, 'utf8'); + + // The backup must NOT be inside gsd-core/ because that directory is wiped on update + assert.ok( + !content.includes('gsd-core/USER-PROFILE.backup.md'), + 'backup path must NOT be inside gsd-core/ — that directory is wiped on gsd-update' + ); + + // The backup should be at ~/.claude/USER-PROFILE.backup.md (outside gsd-core/) + assert.ok( + content.includes('USER-PROFILE.backup.md') && + !content.includes('/gsd-core/USER-PROFILE.backup.md'), + 'backup path must be outside gsd-core/ (e.g. ~/.claude/USER-PROFILE.backup.md)' + ); + }); +}); + +// ─── Test 4: preserveUserArtifacts helper exported from install.js ──────────── + +describe('#1924: preserveUserArtifacts helper exists in install.js', () => { + test('install.js exports preserveUserArtifacts function', () => { + // Set GSD_TEST_MODE so require() reaches the module.exports block + const origMode = process.env.GSD_TEST_MODE; + process.env.GSD_TEST_MODE = '1'; + let mod; + try { + mod = require(INSTALL_SCRIPT); + } finally { + if (origMode === undefined) { + delete process.env.GSD_TEST_MODE; + } else { + process.env.GSD_TEST_MODE = origMode; + } + } + + assert.strictEqual( + typeof mod.preserveUserArtifacts, + 'function', + 'install.js must export preserveUserArtifacts helper for testability' + ); + }); +}); + }); +} diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 898731f67..a7882e9f6 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -739,3 +739,3757 @@ describe('skills wrapper threads install scope into converter isGlobal (regressi }); } }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2418-antigravity-bare-path.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2418-antigravity-bare-path (consolidation epic #1969 B1 #1970)", () => { +/** + * Bug #2418: Found unreplaced .claude path reference(s) in Antigravity install + * + * The Antigravity path converter handles ~/.claude/ (with trailing slash) but + * misses bare ~/.claude (without trailing slash), leaving unreplaced references + * that cause the installer to warn about leaked paths. + * + * Files affected: agents/gsd-debugger.md (configDir = ~/.claude) and + * gsd-core/workflows/update.md (comment with e.g. ~/.claude). + */ + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { convertClaudeToAntigravityContent } = require('../bin/install.js'); + +describe('convertClaudeToAntigravityContent bare path replacement (#2418)', () => { + describe('global install', () => { + test('replaces ~/.claude (bare, no trailing slash) with ~/.gemini/antigravity', () => { + const input = 'configDir = ~/.claude'; + const result = convertClaudeToAntigravityContent(input, true); + assert.ok( + result.includes('~/.gemini/antigravity'), + `Expected ~/.gemini/antigravity in output, got: ${result}` + ); + assert.ok( + !result.includes('~/.claude'), + `Expected ~/ .claude to be replaced, got: ${result}` + ); + }); + + test('replaces $HOME/.claude (bare, no trailing slash) with $HOME/.gemini/antigravity', () => { + const input = 'export DIR=$HOME/.claude'; + const result = convertClaudeToAntigravityContent(input, true); + assert.ok( + result.includes('$HOME/.gemini/antigravity'), + `Expected $HOME/.gemini/antigravity in output, got: ${result}` + ); + assert.ok( + !result.includes('$HOME/.claude'), + `Expected $HOME/.claude to be replaced, got: ${result}` + ); + }); + + test('handles bare ~/.claude followed by comma (comment context)', () => { + const input = '# e.g. ~/.claude, ~/.config/opencode'; + const result = convertClaudeToAntigravityContent(input, true); + assert.ok( + !result.includes('~/.claude'), + `Expected ~/ .claude to be replaced in comment context, got: ${result}` + ); + }); + + test('still replaces ~/.claude/ (with trailing slash) correctly', () => { + const input = 'See ~/.claude/gsd-core/workflows/'; + const result = convertClaudeToAntigravityContent(input, true); + assert.ok( + result.includes('~/.gemini/antigravity/gsd-core/workflows/'), + `Expected path with trailing slash to be replaced, got: ${result}` + ); + assert.ok(!result.includes('~/.claude/'), `Expected ~/ .claude/ to be fully replaced, got: ${result}`); + }); + + test('does not double-replace ~/.claude/ paths', () => { + const input = 'See ~/.claude/gsd-core/'; + const result = convertClaudeToAntigravityContent(input, true); + // Result should contain exactly one occurrence of the replacement path + const count = (result.match(/~\/.gemini\/antigravity\//g) || []).length; + assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`); + }); + }); + + describe('local install', () => { + test('replaces ~/.claude (bare, no trailing slash) with .agents', () => { + const input = 'configDir = ~/.claude'; + const result = convertClaudeToAntigravityContent(input, false); + assert.ok( + result.includes('.agents'), + `Expected .agents in output, got: ${result}` + ); + assert.ok( + !result.includes('~/.claude'), + `Expected ~/ .claude to be replaced, got: ${result}` + ); + }); + + test('replaces $HOME/.claude (bare, no trailing slash) with .agents', () => { + const input = 'export DIR=$HOME/.claude'; + const result = convertClaudeToAntigravityContent(input, false); + assert.ok( + result.includes('.agents'), + `Expected .agents in output, got: ${result}` + ); + assert.ok( + !result.includes('$HOME/.claude'), + `Expected $HOME/.claude to be replaced, got: ${result}` + ); + }); + + test('does not double-replace ~/.claude/ paths', () => { + const input = 'See ~/.claude/gsd-core/'; + const result = convertClaudeToAntigravityContent(input, false); + // .agents/ should appear exactly once + const count = (result.match(/\.agents\//g) || []).length; + assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`); + }); + }); + + describe('installed files contain no bare ~/.claude references after conversion', () => { + const fs = require('fs'); + const path = require('path'); + const repoRoot = path.join(__dirname, '..'); + + // The scanner regex used by the installer to detect leaked paths + const leakedPathRegex = /(?:~|\$HOME)\/\.claude\b/g; + + function convertFile(filePath, isGlobal) { + const content = fs.readFileSync(filePath, 'utf8'); + return convertClaudeToAntigravityContent(content, isGlobal); + } + + test('gsd-debugger.md has no leaked ~/.claude after global Antigravity conversion', () => { + const debuggerPath = path.join(repoRoot, 'agents', 'gsd-debugger.md'); + if (!fs.existsSync(debuggerPath)) return; // skip if file doesn't exist + const converted = convertFile(debuggerPath, true); + const matches = converted.match(leakedPathRegex); + assert.strictEqual( + matches, null, + `gsd-debugger.md still contains leaked .claude paths after Antigravity conversion: ${matches}` + ); + }); + + test('update.md has no leaked ~/.claude after global Antigravity conversion', () => { + const updatePath = path.join(repoRoot, 'gsd-core', 'workflows', 'update.md'); + if (!fs.existsSync(updatePath)) return; // skip if file doesn't exist + const converted = convertFile(updatePath, true); + const matches = converted.match(leakedPathRegex); + assert.strictEqual( + matches, null, + `update.md still contains leaked .claude paths after Antigravity conversion: ${matches}` + ); + }); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2545-copilot-unreplaced-paths.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2545-copilot-unreplaced-paths (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for issue #2545. + * + * The Copilot content converter's `~/.claude/` and `$HOME/.claude/` replacements + * only matched when a literal slash followed, so bare `~/.claude` references + * (end of line, quotes, punctuation) were left unreplaced. Those leaks then + * triggered the installer's "Found N unreplaced .claude path reference(s)" + * warning, which scans for `(?:~|$HOME)/\.claude\b`. + * + * Fix: replace with a word-boundary pattern so both forms are caught in a + * single pass, matching the approach already used by the Antigravity, OpenCode, + * Kilo, and Codex converters. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { convertClaudeToCopilotContent } = require('../bin/install.js'); + +describe('convertClaudeToCopilotContent — bare ~/.claude (issue #2545)', () => { + test('global install replaces bare ~/.claude at end of line', () => { + const input = 'configDir = ~/.claude\n'; + const out = convertClaudeToCopilotContent(input, /* isGlobal */ true); + assert.ok( + !/(?:~|\$HOME)\/\.claude\b/.test(out), + `expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`, + ); + assert.match(out, /~\/\.copilot\b/); + }); + + test('global install replaces bare $HOME/.claude at end of line', () => { + const input = 'configDir = $HOME/.claude\n'; + const out = convertClaudeToCopilotContent(input, /* isGlobal */ true); + assert.ok( + !/(?:~|\$HOME)\/\.claude\b/.test(out), + `expected no leaked $HOME/.claude reference, got: ${JSON.stringify(out)}`, + ); + assert.match(out, /\$HOME\/\.copilot\b/); + }); + + test('global install replaces bare ~/.claude before punctuation', () => { + const input = 'paths include `~/.claude`, `~/.copilot`'; + const out = convertClaudeToCopilotContent(input, true); + assert.ok(!/(?:~|\$HOME)\/\.claude\b/.test(out)); + }); + + test('local install replaces bare ~/.claude', () => { + const input = 'configDir = ~/.claude\n'; + const out = convertClaudeToCopilotContent(input, /* isGlobal */ false); + assert.ok( + !/(?:~|\$HOME)\/\.claude\b/.test(out), + `expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`, + ); + }); + + test('does not double-replace trailing-slash form', () => { + const input = '@~/.claude/gsd-core/foo.md\n'; + const out = convertClaudeToCopilotContent(input, true); + assert.match(out, /~\/\.copilot\/gsd-core\/foo\.md/); + assert.ok(!/\.copilot\/\.copilot/.test(out)); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-983-trae-windsurf-claude-path-leak.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-983-trae-windsurf-claude-path-leak (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #983) +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Regression tests for issue #983 — Trae and Windsurf converters leak + * unreplaced bare `~/.claude` / `$HOME/.claude` references. + * + * Both converters rewrote only trailing-slash `.claude/` forms, so bare + * home-path references (configDir = ~/.claude, $HOME/.claude) survived + * conversion and pointed users at the wrong config dir. + * + * Fix: add bare word-boundary replacements mirroring Cline (#782) and + * Codex (#570) precedent, with a negative lookahead to preserve `.claude-plugin`. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + convertClaudeToWindsurfMarkdown, + convertClaudeToTraeMarkdown, + _applyRuntimeRewrites, +} = require('../bin/install.js'); + +// ─── Windsurf converter bare-form tests ───────────────────────────────────── + +describe('convertClaudeToWindsurfMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (#983)', () => { + test('bare ~/.claude rewritten to ~/.windsurf (#1615: workspace dir is now .windsurf)', () => { + const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; + const result = convertClaudeToWindsurfMarkdown(input); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + `bare ~/.claude must be rewritten; got: ${result}`, + ); + assert.ok(result.includes('~/.windsurf'), 'must rewrite to ~/.windsurf'); + }); + + test('$HOME/.claude rewritten to $HOME/.windsurf (#1615: workspace dir is now .windsurf)', () => { + const input = 'RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'; + const result = convertClaudeToWindsurfMarkdown(input); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + `bare $HOME/.claude must be rewritten; got: ${result}`, + ); + assert.ok(result.includes('$HOME/.windsurf'), 'must rewrite to $HOME/.windsurf'); + }); + + test('CLAUDE_CONFIG_DIR rewritten to WINDSURF_CONFIG_DIR', () => { + const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; + const result = convertClaudeToWindsurfMarkdown(input); + assert.ok( + result.includes('WINDSURF_CONFIG_DIR'), + 'CLAUDE_CONFIG_DIR must become WINDSURF_CONFIG_DIR', + ); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'CLAUDE_CONFIG_DIR must be gone', + ); + }); + + test('.claude-plugin is NOT corrupted (preserved as-is)', () => { + const input = 'The .claude-plugin/plugin.json manifest enables plugin install.'; + const result = convertClaudeToWindsurfMarkdown(input); + assert.ok( + result.includes('.claude-plugin'), + `.claude-plugin must be preserved; got: ${result}`, + ); + assert.ok( + !result.includes('.windsurf-plugin'), + `.windsurf-plugin must not appear; got: ${result}`, + ); + }); + + test('no bare ~/.claude in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToWindsurfMarkdown(raw); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + 'converted surface.md must not contain bare ~/.claude', + ); + }); + + test('no $HOME/.claude in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToWindsurfMarkdown(raw); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + 'converted surface.md must not contain bare $HOME/.claude', + ); + }); + + test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToWindsurfMarkdown(raw); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'converted surface.md must not contain CLAUDE_CONFIG_DIR', + ); + }); +}); + +// ─── Trae converter bare-form tests ───────────────────────────────────────── + +describe('convertClaudeToTraeMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (#983)', () => { + test('bare ~/.claude rewritten to ~/.trae', () => { + const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; + const result = convertClaudeToTraeMarkdown(input); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + `bare ~/.claude must be rewritten; got: ${result}`, + ); + assert.ok(result.includes('~/.trae'), 'must rewrite to ~/.trae'); + }); + + test('$HOME/.claude rewritten to $HOME/.trae', () => { + const input = 'RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'; + const result = convertClaudeToTraeMarkdown(input); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + `bare $HOME/.claude must be rewritten; got: ${result}`, + ); + assert.ok(result.includes('$HOME/.trae'), 'must rewrite to $HOME/.trae'); + }); + + test('CLAUDE_CONFIG_DIR rewritten to TRAE_CONFIG_DIR', () => { + const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; + const result = convertClaudeToTraeMarkdown(input); + assert.ok( + result.includes('TRAE_CONFIG_DIR'), + 'CLAUDE_CONFIG_DIR must become TRAE_CONFIG_DIR', + ); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'CLAUDE_CONFIG_DIR must be gone', + ); + }); + + test('.claude-plugin is NOT corrupted (preserved as-is)', () => { + const input = 'The .claude-plugin/plugin.json manifest enables plugin install.'; + const result = convertClaudeToTraeMarkdown(input); + assert.ok( + result.includes('.claude-plugin'), + `.claude-plugin must be preserved; got: ${result}`, + ); + assert.ok( + !result.includes('.trae-plugin'), + `.trae-plugin must not appear; got: ${result}`, + ); + }); + + test('no bare ~/.claude in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToTraeMarkdown(raw); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + 'converted surface.md must not contain bare ~/.claude', + ); + }); + + test('no $HOME/.claude in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToTraeMarkdown(raw); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + 'converted surface.md must not contain bare $HOME/.claude', + ); + }); + + test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToTraeMarkdown(raw); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'converted surface.md must not contain CLAUDE_CONFIG_DIR', + ); + }); +}); + +// ─── _applyRuntimeRewrites install-path tests (windsurf) ──────────────────── +// +// These tests exercise the ACTUAL install path that causes the user-facing leak. +// The converter functions are called at stage time to produce a Windsurf-branded +// copy, but _applyRuntimeRewrites is the path that runs at INSTALL time and +// rewrites any surviving ~/.claude / $HOME/.claude refs in the staged files. +// +// FAIL-BEFORE proof: prior to this PR, windsurf used /~\/\.claude\b/ which +// fires on "~/.claude-plugin" because \b matches between 'e' and '-'. Running +// the test below against the old regex (`\b`) would: +// - let bare $HOME/.claude survive (it used only /~\/\.claude\b/, missing $HOME form), AND +// - corrupt "~/.claude-plugin" → "~/.windsurf-plugin". +// Both assertions in the test below would fail on the old code. +// +// PASS-AFTER: the fix changes to (?![\w-]) so: +// - bare ~/.claude / $HOME/.claude (not followed by word-char or hyphen) → rewritten +// - ~/.claude-plugin preserved (the '-' after 'e' is in [\w-]) +// +// NOTE on pathPrefix choice: we use '~/.windsurf/' (a simple home-relative +// prefix) rather than '$HOME/.codeium/windsurf/' so that the corruption of +// '~/.claude-plugin' → '~/.windsurf-plugin' is directly detectable via +// result.includes('.windsurf-plugin'). +describe('_applyRuntimeRewrites(windsurf) — install-path bare-form + .claude-plugin (#983)', () => { + // Use ~/ prefix (local-style) so that the .windsurf-plugin corruption is + // directly detectable as a substring of the result. + const WINDSURF_PATH_PREFIX = '~/.windsurf/'; + + // Compound content: covers every form the fix must handle. + // IMPORTANT: we use ~/.claude-plugin (home-relative form) to exercise the + // corruption that the old \b regex caused. The \b fires between 'e' and '-', + // so ~/.claude-plugin → ~/.windsurf-plugin under the old code. That would + // break the preservation assertion below. The (?![\w-]) fix prevents this. + const COMPOUND_INPUT = [ + 'Config dir: ~/.claude', + 'Also: $HOME/.claude', + 'Slash form: ~/.claude/skills/foo.md', + 'Plugin installed at: ~/.claude-plugin/plugin.json', + 'Env var: CLAUDE_CONFIG_DIR', + ].join('\n'); + + test('bare ~/.claude rewritten to ~/.windsurf (no trailing slash)', () => { + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + `bare ~/.claude must be gone; got:\n${result}`, + ); + assert.ok( + result.includes('~/.windsurf'), + `must contain normalized pathPrefix; got:\n${result}`, + ); + }); + + test('bare $HOME/.claude rewritten to ~/.windsurf (install-path normalizes both home forms)', () => { + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + `bare $HOME/.claude must be gone; got:\n${result}`, + ); + }); + + test('zero surviving bare ~/.claude or $HOME/.claude refs in compound input', () => { + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + const bareClaudePattern = /(?:~|\$HOME)\/\.claude(?![\w-])/; + assert.ok( + !bareClaudePattern.test(result), + `no bare ~/.claude / $HOME/.claude must survive; got:\n${result}`, + ); + }); + + test('~/.claude-plugin is NOT corrupted to ~/.windsurf-plugin — was the \\b corruption', () => { + // FAIL-BEFORE: old /~\/\.claude\b/ rewrote ~/.claude-plugin → ~/.windsurf-plugin + // because \b fires between 'e' and '-'. + // PASS-AFTER: (?![\w-]) sees '-' and skips the match, preserving ~/.claude-plugin. + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + assert.ok( + result.includes('~/.claude-plugin'), + `~/.claude-plugin must be preserved; got:\n${result}`, + ); + assert.ok( + !result.includes('~/.windsurf-plugin'), + `~/.windsurf-plugin must NOT appear (was the \\b corruption); got:\n${result}`, + ); + }); + + test('slash form ~/.claude/ is also rewritten (pre-existing coverage)', () => { + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + assert.ok( + !result.includes('~/.claude/'), + `slash form ~/.claude/ must be gone; got:\n${result}`, + ); + }); + + test('CLAUDE_CONFIG_DIR is NOT rewritten by _applyRuntimeRewrites (converter responsibility)', () => { + // _applyRuntimeRewrites does NOT handle CLAUDE_CONFIG_DIR for windsurf; + // that rewrite is done by convertClaudeToWindsurfMarkdown at stage time. + // This test documents the boundary and guards against scope creep. + const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX); + assert.ok( + result.includes('CLAUDE_CONFIG_DIR'), + 'CLAUDE_CONFIG_DIR is not rewritten by _applyRuntimeRewrites — that is converter scope', + ); + }); +}); + +// ─── _applyRuntimeRewrites install-path tests (trae) ──────────────────────── +// +// Trae had bare-form handling before this PR (via \b) and the converter uses +// (?![\w-]). The pre-existing \b in _applyRuntimeRewrites DOES corrupt +// .claude-plugin → .trae-plugin (known limitation, out of scope for #983). +// We document this here but do NOT assert preservation for trae, and we do NOT +// fix the pre-existing trae \b lines (that would be a separate concern). +// +// What we DO assert: trae bare ~/.claude / $HOME/.claude refs are rewritten +// (the install path cleans them), which is the core #983 fix for trae. +describe('_applyRuntimeRewrites(trae) — install-path bare-form (#983)', () => { + const TRAE_PATH_PREFIX = '$HOME/.trae/'; + + const TRAE_INPUT = [ + 'Config dir: ~/.claude', + 'Also: $HOME/.claude', + 'Slash form: ~/.claude/skills/foo.md', + // Note: .claude-plugin is intentionally omitted from assertions here because + // the pre-existing trae case uses \b which corrupts it (known limitation, + // out of scope for #983 — do not fix here). + ].join('\n'); + + test('bare ~/.claude rewritten to $HOME/.trae (trae install path)', () => { + const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); + assert.ok( + !/~\/\.claude(?![\w-])/.test(result), + `bare ~/.claude must be gone; got:\n${result}`, + ); + }); + + test('bare $HOME/.claude rewritten to $HOME/.trae (trae install path)', () => { + const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); + assert.ok( + !/\$HOME\/\.claude(?![\w-])/.test(result), + `bare $HOME/.claude must be gone; got:\n${result}`, + ); + }); + + test('slash form ~/.claude/ also rewritten (trae install path)', () => { + const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX); + assert.ok( + !result.includes('~/.claude/'), + `slash form ~/.claude/ must be gone; got:\n${result}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-782-cline-skills-emission.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-782-cline-skills-emission (consolidation epic #1969 B1 #1970)", () => { +'use strict'; +/** + * Regression tests for bug #782 — Cline skills emission. + * + * gsd now emits skills to ~/.cline/skills//SKILL.md for Cline >= v3.48. + * Skills discovery: https://docs.cline.bot/customization/skills + * + * (a) Converter unit test: convertClaudeCommandToClineSkill + * (b) Integration test: installRuntimeArtifacts for cline writes SKILL.md files + * (c) .clinerules/gsd.md still written by the install path (#787 dir form) + * (d) Idempotency: running install twice leaves skills + .clinerules/ intact + * (e) Full install() global: both skills AND .clinerules/gsd.md are written + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); + +const { + convertClaudeCommandToClineSkill, + convertClaudeToCliineMarkdown, + install, + _applyRuntimeRewrites, +} = require('../bin/install.js'); + +const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); + +const { + resolveRuntimeArtifactLayout, +} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + +const { + loadSkillsManifest, + resolveProfile, +} = require('../gsd-core/bin/lib/install-profiles.cjs'); + +const { nestedSkillPath } = require('./helpers/nested-layout.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); +const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); +const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); + +// ─── (a) Converter unit test ───────────────────────────────────────────────── + +const SAMPLE_COMMAND = `--- +name: gsd:execute-phase +description: Execute all tasks in the current phase using Cline tools. +allowed-tools: + - Read + - Write + - Bash +--- + +## Objective + +Run all tasks in the current phase. + +See ~/.claude/skills/gsd-help/SKILL.md for reference. +Use \`/gsd-help\` or Claude Code for details. +`; + +// A command that exercises all three Claude-specific frontmatter fields that +// must NOT leak into the emitted Cline SKILL.md. +const RICH_COMMAND = `--- +name: gsd:validate-phase +description: Retroactively audit and fill Nyquist validation gaps for a completed phase +argument-hint: "[phase number]" +agent: researcher +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep + - Agent + - AskUserQuestion +--- + +## Objective + +Audit Nyquist validation coverage. See ~/.claude/skills/gsd-help/SKILL.md for reference. +Use Claude Code for details. +`; + +/** + * Extract frontmatter block (between --- delimiters) from output. + * Returns the raw text between the first --- and the closing ---. + * Uses \r?\n to handle both LF and CRLF line endings (Windows parity). + */ +function parseFrontmatter(text) { + const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + return m ? m[1] : null; +} + +describe('convertClaudeCommandToClineSkill — unit', () => { + test('emits frontmatter with name: gsd-', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + const nameMatch = result.match(/^name:\s*(.+)$/m); + assert.ok(nameMatch, 'frontmatter must contain name field'); + assert.ok(nameMatch[1].includes('gsd-execute-phase'), 'name must start with gsd-execute-phase'); + }); + + test('emits non-empty description in frontmatter', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'frontmatter must contain description field'); + assert.ok(descMatch[1].trim().length > 0, 'description must not be empty'); + }); + + test('body uses .cline/ paths not .claude/', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + // The body reference to ~/.claude/ should be rewritten to ~/.cline/ + assert.ok(!result.includes('~/.claude/skills'), 'body must not contain ~/.claude/skills'); + assert.ok(result.includes('.cline/skills'), 'body must contain .cline/skills'); + }); + + test('body replaces "Claude Code" with "Cline"', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + assert.ok(!result.includes('Claude Code'), 'Claude Code must be replaced with Cline'); + assert.ok(result.includes('Cline'), 'result must contain Cline branding'); + }); + + test('no stray .claude/ paths in frontmatter or body', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + // Should not contain .claude/ anywhere (except inside CLAUDE.md→.clinerules rewrites + // but those are already handled by convertClaudeToCliineMarkdown) + assert.ok(!result.includes('/.claude/'), 'no /.claude/ paths in output'); + }); + + // ── Fix 1 (code-review): frontmatter must be ONLY name + description ────── + + test('frontmatter emits ONLY name and description — no allowed-tools (SAMPLE_COMMAND)', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + const fm = parseFrontmatter(result); + assert.ok(fm !== null, 'result must have YAML frontmatter'); + assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools'); + assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint'); + assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:'); + }); + + test('frontmatter emits ONLY name and description — no allowed-tools/argument-hint/agent (RICH_COMMAND)', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + const fm = parseFrontmatter(result); + assert.ok(fm !== null, 'result must have YAML frontmatter'); + assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools'); + assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint'); + assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:'); + }); + + test('name == gsd-validate-phase for RICH_COMMAND', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + const nameMatch = result.match(/^name:\s*(.+)$/m); + assert.ok(nameMatch, 'must have name field'); + // yamlIdentifier may quote the value; strip surrounding quotes for comparison + const nameVal = nameMatch[1].replace(/^['"]|['"]$/g, '').trim(); + assert.strictEqual(nameVal, 'gsd-validate-phase', `name must be gsd-validate-phase, got: ${nameVal}`); + }); + + test('description is non-empty and <= 1024 chars for RICH_COMMAND', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'must have description field'); + const desc = descMatch[1].replace(/^['"]|['"]$/g, '').trim(); + assert.ok(desc.length > 0, 'description must be non-empty'); + assert.ok(desc.length <= 1024, `description must be <= 1024 chars, got ${desc.length}`); + }); + + test('description truncated to <=1024 chars when source description is very long', () => { + const longDesc = 'A'.repeat(2000); + const longDescCommand = `---\nname: gsd:test\ndescription: ${longDesc}\n---\n\nBody text.\n`; + const result = convertClaudeCommandToClineSkill(longDescCommand, 'gsd-test'); + const descMatch = result.match(/^description:\s*'?(.*?)'?$/m); + assert.ok(descMatch, 'must have description field'); + // The raw description value (unquoted) should be <=1024 chars + // The result string after the --- block will have the quoted form; check raw length + // by checking the whole result doesn't have the full 2000-char string + assert.ok(!result.includes('A'.repeat(1025)), 'description must be truncated to 1024 chars'); + }); + + test('returns content unchanged when source has no frontmatter', () => { + const noFm = 'Just a body, no frontmatter here.\n'; + const result = convertClaudeCommandToClineSkill(noFm, 'gsd-test'); + assert.strictEqual(result, noFm, 'content without frontmatter must be returned unchanged'); + }); + + test('RICH_COMMAND body uses .cline/ paths and Cline branding', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + assert.ok(!result.includes('~/.claude/'), 'body must not contain ~/.claude/'); + assert.ok(result.includes('.cline/'), 'body must contain .cline/ paths'); + assert.ok(!result.includes('Claude Code'), 'body must not contain "Claude Code"'); + assert.ok(result.includes('Cline'), 'body must reference Cline'); + }); +}); + +// ─── (b) + (c) + (d) Integration tests ──────────────────────────────────────── + +describe('installRuntimeArtifacts — cline skills emission', () => { + test('cline global: writes gsd-prefixed skill dirs under skills/', (t) => { + const configDir = createTempDir('gsd-cline-skills-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const layout = resolveRuntimeArtifactLayout('cline', configDir, 'global'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'cline must have a skills kind after #782'); + + const skillsDir = path.join(configDir, skillsKind.destSubpath); + assert.ok(fs.existsSync(skillsDir), 'skills/ directory must be created'); + + const helpSkillDir = path.join(skillsDir, `${skillsKind.prefix}help`); + assert.ok( + fs.existsSync(path.join(helpSkillDir, 'SKILL.md')), + `gsd-help/SKILL.md must exist under ${skillsKind.destSubpath}/` + ); + }); + + test('cline global: SKILL.md has valid cline frontmatter (name + description)', (t) => { + const configDir = createTempDir('gsd-cline-fm-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + const helpSkill = path.join(skillsDir, 'gsd-help', 'SKILL.md'); + assert.ok(fs.existsSync(helpSkill), 'gsd-help/SKILL.md must exist'); + + const content = fs.readFileSync(helpSkill, 'utf8'); + // Must have YAML frontmatter + assert.ok(content.startsWith('---'), 'SKILL.md must start with YAML frontmatter'); + assert.ok(content.includes('name:'), 'frontmatter must have name field'); + assert.ok(content.includes('description:'), 'frontmatter must have description field'); + // name must be gsd-help + const nameMatch = content.match(/^name:\s*(.+)$/m); + assert.ok(nameMatch, 'must have name field'); + assert.ok(nameMatch[1].includes('gsd-help'), `name must include gsd-help, got: ${nameMatch[1]}`); + }); + + test('cline global: SKILL.md uses .cline/ paths not .claude/', (t) => { + const configDir = createTempDir('gsd-cline-paths-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + // Check all installed skill files for stray .claude/ references + const skills = fs.readdirSync(skillsDir).filter(n => n.startsWith('gsd-')); + assert.ok(skills.length > 0, 'at least one gsd- skill must be installed'); + + for (const skillName of skills) { + const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); + if (!fs.existsSync(skillFile)) continue; + const content = fs.readFileSync(skillFile, 'utf8'); + assert.ok( + !content.includes('~/.claude/'), + `${skillName}/SKILL.md must not contain ~/.claude/ — found stray path` + ); + assert.ok( + !content.includes('/.claude/'), + `${skillName}/SKILL.md must not contain /.claude/ — found stray path` + ); + } + }); + + test('cline global: skill count matches resolved profile', (t) => { + const configDir = createTempDir('gsd-cline-count-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + const count = fs.readdirSync(skillsDir) + .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) + .length; + + if (RESOLVED_CORE.skills !== '*') { + assert.strictEqual(count, RESOLVED_CORE.skills.size, + `installed skill count (${count}) must match profile size (${RESOLVED_CORE.skills.size})`); + } else { + assert.ok(count > 0, 'must install at least 1 skill'); + } + }); +}); + +describe('installRuntimeArtifacts — cline idempotency', () => { + test('cline: running install twice leaves skills intact (idempotency)', (t) => { + const configDir = createTempDir('gsd-cline-idempotent-'); + t.after(() => cleanup(configDir)); + + // First install + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + const countAfterFirst = fs.readdirSync(skillsDir) + .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) + .length; + + // Second install (upgrade over existing) + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const countAfterSecond = fs.readdirSync(skillsDir) + .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) + .length; + + assert.strictEqual(countAfterFirst, countAfterSecond, + `skill count must be stable across installs: first=${countAfterFirst} second=${countAfterSecond}`); + }); +}); + +// ─── (e) Full install() global — coexistence regression ─────────────────────── +// +// Issue #782 explicitly requires that a global Cline install writes BOTH: +// - skills//SKILL.md (skills for Cline >= v3.48) +// - .clinerules/gsd.md (rules dir form introduced by #787) +// +// installRuntimeArtifacts() tests cover skills in isolation; this test exercises +// the FULL install() code path to ensure neither artifact is silently dropped. + +describe('install() global cline — coexistence: skills AND .clinerules', () => { + let tmpGlobalDir; + let originalClineConfigDir; + + beforeEach(() => { + originalClineConfigDir = process.env.CLINE_CONFIG_DIR; + tmpGlobalDir = createTempDir('gsd-cline-global-'); + // Redirect CLINE_CONFIG_DIR to the temp dir so install() never touches ~/.cline + process.env.CLINE_CONFIG_DIR = tmpGlobalDir; + }); + + afterEach(() => { + if (originalClineConfigDir !== undefined) { + process.env.CLINE_CONFIG_DIR = originalClineConfigDir; + } else { + delete process.env.CLINE_CONFIG_DIR; + } + cleanup(tmpGlobalDir); + }); + + test('global cline install writes at least one gsd-* SKILL.md under skills/', () => { + captureConsole(() => install(true, 'cline')); + + const skillsDir = path.join(tmpGlobalDir, 'skills'); + assert.ok( + fs.existsSync(skillsDir), + `skills/ directory must exist under ${tmpGlobalDir} after global cline install` + ); + + // full profile: gsd-help is nested under gsd-ns-manage/skills/help/SKILL.md + const helpSkillFile = nestedSkillPath(skillsDir, 'gsd-', 'help'); + assert.ok( + fs.existsSync(helpSkillFile), + `${path.relative(tmpGlobalDir, helpSkillFile)} must exist under ${tmpGlobalDir} — skills emission broken for global cline` + ); + }); + + test('global cline install writes .clinerules/gsd.md to the global config dir', () => { + captureConsole(() => install(true, 'cline')); + + // For a global Cline install, targetDir = getGlobalDir('cline') = CLINE_CONFIG_DIR. + // The cline-rules surface (#787) writes the .clinerules/ DIRECTORY form: + // .clinerules/gsd.md (rule file) + // .clinerules/hooks/PreToolUse (lifecycle hook) + const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md'); + assert.ok( + fs.existsSync(clinerulesMd), + `.clinerules/gsd.md must exist at ${clinerulesMd} — coexistence with skills broken for global cline (#782+#787)` + ); + }); + + test('global cline .clinerules/gsd.md contains GSD instructions', () => { + captureConsole(() => install(true, 'cline')); + + // #787 dir form: rule content lives in .clinerules/gsd.md, not a flat .clinerules file + const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md'); + assert.ok(fs.existsSync(clinerulesMd), '.clinerules/gsd.md must exist'); + const content = fs.readFileSync(clinerulesMd, 'utf8'); + assert.ok( + content.includes('GSD') || content.includes('gsd'), + '.clinerules/gsd.md must reference GSD' + ); + }); +}); + +// ─── Fix 3 regression: converter rewrites bare ~/.claude and CLAUDE_CONFIG_DIR ── +// +// convertClaudeToCliineMarkdown must also handle bare ~/.claude (no trailing +// slash) and the CLAUDE_CONFIG_DIR env-var name. surface.md contains these; +// the emitted Cline SKILL.md must contain no such stale Claude refs. + +describe('convertClaudeToCliineMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (Fix 3)', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + + test('no bare ~/.claude in converted surface.md', () => { + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToCliineMarkdown(raw); + // ~/.claude followed by a word-boundary (not a /) must be gone + assert.ok( + !/~\/\.claude\b/.test(result), + 'converted surface.md must not contain bare ~/.claude' + ); + }); + + test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToCliineMarkdown(raw); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'converted surface.md must not contain CLAUDE_CONFIG_DIR' + ); + }); + + test('CLAUDE_CONFIG_DIR rewritten to CLINE_CONFIG_DIR', () => { + const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; + const result = convertClaudeToCliineMarkdown(input); + assert.ok(result.includes('CLINE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must become CLINE_CONFIG_DIR'); + assert.ok(!result.includes('CLAUDE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must be gone'); + }); + + test('bare ~/.claude rewritten to ~/.cline', () => { + const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; + const result = convertClaudeToCliineMarkdown(input); + assert.ok(!result.includes('~/.claude'), 'bare ~/.claude must be rewritten'); + assert.ok(result.includes('~/.cline'), 'must rewrite to ~/.cline'); + }); + + test('installRuntimeArtifacts cline global: gsd-surface SKILL.md has no bare ~/.claude or CLAUDE_CONFIG_DIR', (t) => { + const configDir = createTempDir('gsd-cline-surface-fix3-'); + t.after(() => cleanup(configDir)); + + const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest( + path.join(__dirname, '..', 'commands', 'gsd') + ); + const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({ + modes: ['full'], manifest: MANIFEST_FULL, + }); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL); + + // full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md + const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface'); + assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist for full profile`); + + const content = fs.readFileSync(surfaceSkill, 'utf8'); + assert.ok( + !/~\/\.claude\b/.test(content), + 'gsd-surface SKILL.md must not contain bare ~/.claude (Fix 3)' + ); + assert.ok( + !content.includes('CLAUDE_CONFIG_DIR'), + 'gsd-surface SKILL.md must not contain CLAUDE_CONFIG_DIR (Fix 3)' + ); + }); +}); + +// ─── Fix 1 regression: custom CLINE_CONFIG_DIR → embedded paths use custom dir ── +// +// _applyRuntimeRewrites for cline must rewrite ~/.cline/ → pathPrefix. +// For default global installs, pathPrefix = "$HOME/.cline/" (unchanged). +// For custom installs (CLINE_CONFIG_DIR=/custom), pathPrefix = "/custom/" and +// all embedded ~/.cline/ refs in SKILL.md must become /custom/... + +describe('_applyRuntimeRewrites — cline custom-dir embedded path (Fix 1)', () => { + test('default pathPrefix ($HOME/.cline/) leaves ~/.cline refs as $HOME/.cline', () => { + const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n'; + const result = _applyRuntimeRewrites(content, 'cline', '$HOME/.cline/'); + assert.ok(result.includes('$HOME/.cline/'), 'default prefix must map ~/.cline/ to $HOME/.cline/'); + assert.ok(!result.includes('~/.cline'), 'no tilde form should remain after rewrite'); + }); + + test('custom pathPrefix rewrites ~/.cline/ → custom path in SKILL.md body', () => { + const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n'; + const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/'); + assert.ok(result.includes('/custom/cline-dir/'), 'custom prefix must appear in output'); + assert.ok(!result.includes('~/.cline'), 'no tilde cline form should remain after custom rewrite'); + }); + + test('custom pathPrefix rewrites residual ~/.claude/ safety net', () => { + const content = 'Residual: ~/.claude/skills\n'; + const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/'); + assert.ok(result.includes('/custom/cline-dir/'), 'safety-net ~/.claude/ also rewritten to custom prefix'); + assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ should remain'); + }); + + test('installRuntimeArtifacts cline with CLINE_CONFIG_DIR custom: SKILL.md embeds custom path', (t) => { + const configDir = createTempDir('gsd-cline-custom-dir-'); + t.after(() => cleanup(configDir)); + + const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest( + path.join(__dirname, '..', 'commands', 'gsd') + ); + const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({ + modes: ['full'], manifest: MANIFEST_FULL, + }); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL); + + // gsd-surface SKILL.md references config paths; with a custom configDir + // (not under $HOME), pathPrefix will be the absolute custom path. + // full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md + const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface'); + assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist`); + + const content = fs.readFileSync(surfaceSkill, 'utf8'); + // With a custom dir (path under /tmp, not ~/.cline), the output must NOT + // contain ~/.cline/ or $HOME/.cline/ — it must embed the actual configDir path. + assert.ok( + !content.includes('~/.cline/'), + `gsd-surface SKILL.md must not contain ~/.cline/ when configDir=${configDir} (Fix 1)` + ); + // The custom path must appear somewhere in the file + // (configDir is a /tmp/... path so pathPrefix = configDir+'/'). + // Production normalizes backslashes to forward slashes via + // path.resolve(configDir).replace(/\\/g, '/'), so compare against that + // form — otherwise this assertion fails on Windows where mkdtempSync + // returns a backslash path (e.g. C:\Users\...) but the emitted content + // already has forward slashes (C:/Users/...). + const expectedPath = path.resolve(configDir).replace(/\\/g, '/'); + assert.ok( + content.includes(expectedPath), + `gsd-surface SKILL.md must embed custom configDir path ${expectedPath} (Fix 1)` + ); + }); +}); + +// ─── Fix 4 regression: description truncation is code-point-aware ──────────── +// +// Naive UTF-16 slicing (`str.slice(0, 1021)`) can split a surrogate pair when +// the cut falls between the high and low surrogate of a multibyte character +// (e.g. emoji U+1F600, which is encoded as two UTF-16 code units). The fix +// uses Array.from() to split by code point, guaranteeing that the truncated +// value never contains a lone surrogate. + +describe('convertClaudeCommandToClineSkill — code-point-aware truncation (Fix 4)', () => { + /** + * Build a frontmatter+body command string whose description is: + * - exactly `prefixLen` ASCII chars + * - followed by `emojiCount` repetitions of '😀' (U+1F600, 2 UTF-16 units) + * - total UTF-16 length is prefixLen + emojiCount * 2 + */ + function makeEmojiCommand(prefixLen, emojiCount) { + const desc = 'A'.repeat(prefixLen) + '😀'.repeat(emojiCount); + return `---\nname: gsd:emoji-test\ndescription: ${desc}\n---\n\nBody.\n`; + } + + test('emitted description is <= 1024 code points when source overflows', () => { + // 1020 ASCII chars + 4 emoji = 1020 + 8 UTF-16 units = 1028 UTF-16 units > 1024. + // Code-point count = 1020 + 4 = 1024 — exactly at the boundary BEFORE adding '...'. + // After truncation to 1021 code points + '...' → 1024 code points total. + const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + // Extract raw description value (strip surrounding YAML quotes if present) + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + const codePoints = Array.from(rawDesc); + assert.ok( + codePoints.length <= 1024, + `emitted description must be <= 1024 code points, got ${codePoints.length}` + ); + }); + + test('emitted description ends with "..." when truncated', () => { + const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + assert.ok(rawDesc.endsWith('...'), `truncated description must end with "...", got: ${rawDesc.slice(-10)}`); + }); + + test('emitted description has no lone surrogate (no split emoji)', () => { + // Place emojis exactly at positions 1021–1025 (code points) so that a naive + // UTF-16 slice at 1021 code units would cut inside the second emoji's surrogate pair. + // 1019 ASCII chars + 6 emoji = 1025 code points (>1024, triggers truncation). + // UTF-16 length = 1019 + 12 = 1031. Naive slice(0,1021) yields 1019 ASCII + + // the HIGH surrogate of emoji[0] — a lone surrogate. + const cmd = makeEmojiCommand(1019, 6); + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + // Verify no lone surrogate: every char's code point must be outside [0xD800, 0xDFFF]. + const hasLoneSurrogate = [...rawDesc].some(c => { + const cp = c.codePointAt(0); + return cp >= 0xD800 && cp <= 0xDFFF; + }); + assert.ok(!hasLoneSurrogate, 'emitted description must not contain a lone surrogate'); + + // Also round-trip through Buffer to confirm the string is valid UTF-8 encodable. + assert.doesNotThrow( + () => Buffer.from(rawDesc, 'utf8').toString('utf8'), + 'emitted description must round-trip through Buffer without error' + ); + }); + + test('short description (<= 1024 code points) is not truncated', () => { + // 10 ASCII + 5 emoji = 15 code points — well under the limit. + const cmd = makeEmojiCommand(10, 5); + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + assert.ok(!rawDesc.endsWith('...'), 'short description must NOT be truncated with "..."'); + // Must contain the original emoji characters intact + assert.ok(rawDesc.includes('😀'), 'short description must preserve emoji characters'); + }); +}); + +// ─── Fix 2 regression: cline local scope emits no skills ───────────────────── +// +// resolveRuntimeArtifactLayout('cline', dir, 'local') must return 0 kinds. +// installRuntimeArtifacts('cline', dir, 'local') must not write any skills. + +describe('resolveRuntimeArtifactLayout — cline scope-aware (Fix 2)', () => { + test('cline local: kinds.length === 0 (no skills for local scope)', () => { + const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'local'); + assert.strictEqual(layout.kinds.length, 0, 'cline local must have 0 kinds'); + }); + + test('cline global: kinds.length === 1 (skills kind)', () => { + const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'global'); + assert.strictEqual(layout.kinds.length, 1, 'cline global must have 1 skills kind'); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + }); + + test('installRuntimeArtifacts cline local: no skills/ dir created', (t) => { + const configDir = createTempDir('gsd-cline-local-noskills-'); + t.after(() => cleanup(configDir)); + + assert.doesNotThrow(() => installRuntimeArtifacts('cline', configDir, 'local', RESOLVED_CORE)); + const skillsDir = path.join(configDir, 'skills'); + assert.ok( + !fs.existsSync(skillsDir), + `skills/ must NOT be created for cline local install (Fix 2), but found ${skillsDir}` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3037-gemini-duplicate-commands.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3037-gemini-duplicate-commands (consolidation epic #1969 B1 #1970)", () => { +/** + * Bug #3037: Gemini global+local install creates duplicate /gsd:* commands + * across user (HOME/.gemini/) and workspace (PROJECT/.gemini/) scopes. + * + * Reproduction (from issue body): + * 1. install --gemini --global with HOME=tmpHome + * 2. cd tmpProject; install --gemini --local + * → both ~/.gemini/commands/gsd/ and PROJECT/.gemini/commands/gsd/ contain + * 65 overlapping command filenames. + * → Gemini conflict detection renames every overlapping command to + * /workspace.gsd:* and /user.gsd:*, breaking the documented /gsd:* + * namespace. + * + * Fix: when the local Gemini install detects the user-scope GSD command + * directory already exists with managed-shape content, skip the local copy + * and emit a clear warning explaining the conflict avoidance. + * + * Tests assert on the post-install filesystem shape and capture the skip + * warning so the full test log remains warning-clean. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); + +const { install } = require('../bin/install.js'); + +describe('bug #3037: Gemini global+local install must not create duplicate command scopes', () => { + let tmpHome; + let tmpProject; + let originalHome; + let originalUserprofile; + let originalCwd; + + beforeEach(() => { + tmpHome = createTempDir('gsd-3037-home-'); + tmpProject = createTempDir('gsd-3037-work-'); + originalHome = process.env.HOME; + originalUserprofile = process.env.USERPROFILE; + originalCwd = process.cwd(); + // Point HOME at the temp dir so install(true, 'gemini') writes to + // tmpHome/.gemini, not the developer's real home. + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + }); + + afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + // CR #3041: also restore USERPROFILE so the temp HOME doesn't leak + // into later tests and create order-dependent failures on Windows + // or any code path that reads USERPROFILE. + if (originalUserprofile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserprofile; + process.chdir(originalCwd); + cleanup(tmpHome); + cleanup(tmpProject); + }); + + function listCommandFiles(geminiCommandsRoot) { + if (!fs.existsSync(geminiCommandsRoot)) return []; + const out = []; + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.isFile()) out.push(path.relative(geminiCommandsRoot, full)); + } + } + walk(geminiCommandsRoot); + return out.sort(); + } + + function runInstall(...args) { + return captureConsole(() => install(...args)); + } + + test('global install populates HOME/.gemini/commands/gsd', () => { + runInstall(true, 'gemini'); + const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd'); + const files = listCommandFiles(globalCmds); + assert.ok( + files.length > 0, + 'global install must populate HOME/.gemini/commands/gsd' + ); + }); + + test('local install after global does NOT populate PROJECT/.gemini/commands/gsd (avoids /gsd:* namespace conflict)', () => { + // Step 1: global install + runInstall(true, 'gemini'); + const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd'); + const globalFiles = listCommandFiles(globalCmds); + assert.ok(globalFiles.length > 0, 'precondition: global install must succeed'); + + // Step 2: local install in a temp project + process.chdir(tmpProject); + const { stdout } = runInstall(false, 'gemini'); + assert.match( + stdout, + /Skipping commands\/gsd\/ for local install/, + 'local install must explain why it skips duplicate Gemini commands' + ); + + // Assertion: the local commands/gsd/ directory must NOT exist (or must + // be empty) so Gemini's conflict detection has nothing to rename. The + // fix may either skip the directory entirely (preferred — no leftover + // file system noise) or create an empty directory (acceptable but odd). + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.equal( + localFiles.length, + 0, + `local install must skip commands/gsd/ when global already exists; ` + + `found ${localFiles.length} duplicate command file(s) at ${localCmds}` + ); + }); + + test('local install with NO existing global GSD does still populate PROJECT/.gemini/commands/gsd', () => { + // No global install first — local should proceed normally so users who + // only ever run --local still get GSD commands in their project. + process.chdir(tmpProject); + runInstall(false, 'gemini'); + + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.ok( + localFiles.length > 0, + `local-only install must populate PROJECT/.gemini/commands/gsd; ` + + `found ${localFiles.length} files at ${localCmds}` + ); + }); + + test('local install when HOME has hand-dropped overrides UNDER commands/gsd/ (but no full GSD) still populates locally', () => { + // CR #3041 regression: the previous detection was + // `fs.readdirSync(homeGeminiGsd).length > 0` which would skip the + // local install for a user who manually dropped a single override + // command at ~/.gemini/commands/gsd/.toml without ever + // running --gemini --global. The fix narrows detection to require + // at least 3 canonical GSD command files (help.toml, progress.toml, + // new-project.toml) — a marker that's structurally impossible to + // produce by accident. + const homeGsdDir = path.join(tmpHome, '.gemini', 'commands', 'gsd'); + fs.mkdirSync(homeGsdDir, { recursive: true }); + fs.writeFileSync( + path.join(homeGsdDir, 'my-override.toml'), + 'description = "user override"\nprompt = "..."\n' + ); + + process.chdir(tmpProject); + runInstall(false, 'gemini'); + + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.ok( + localFiles.length > 0, + `local install must proceed when HOME/.gemini/commands/gsd contains ` + + `only user overrides (not the full GSD canary set); ` + + `found ${localFiles.length} files at ${localCmds}` + ); + }); + + test('local install when HOME/.gemini exists but commands/gsd is absent (non-GSD Gemini user) still populates locally', () => { + // Simulate a user who has Gemini configured but never installed GSD + // globally. ~/.gemini/ exists with unrelated content; ~/.gemini/commands/ + // may or may not exist with non-gsd subdirectories. Local install must + // still proceed because no GSD-managed user-scope directory is present. + fs.mkdirSync(path.join(tmpHome, '.gemini', 'commands', 'someone-else'), { + recursive: true, + }); + fs.writeFileSync( + path.join(tmpHome, '.gemini', 'commands', 'someone-else', 'foo.toml'), + 'description = "user command"\nprompt = "..."\n' + ); + + process.chdir(tmpProject); + runInstall(false, 'gemini'); + + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.ok( + localFiles.length > 0, + `local install must proceed when no GSD-managed user-scope directory ` + + `exists, even if other Gemini commands are present at the user scope` + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-789-codebuddy-commands.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-789-codebuddy-commands (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #789) +// Workflow .md / command .md / SKILL.md files — their text IS what the runtime +// loads. Testing emitted text tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Regression guard — enh(#789): elevate CodeBuddy slash-command surface. + * + * CodeBuddy (Tencent, @tencent-ai/codebuddy-code) reads user-level surfaces + * (https://www.codebuddy.ai/docs/cli/slash-commands, /skills): + * - commands/gsd-.md — slash commands shown in the '/' menu + * - skills/gsd-/SKILL.md — model-invocable skills + * + * Before #789 gsd emitted only skills/. Because CodeBuddy skills default to + * user-invocable:true (appear in '/'), emitting a commands/ surface AND leaving + * skills user-invocable would duplicate every /gsd-* entry. #789 therefore: + * 1. emits commands/gsd-.md (the '/' surface, peer-consistent with + * Cursor #785 and Augment #790), + * 2. marks skills user-invocable:false so they become model-invocable + * background knowledge and the commands/ surface is the sole '/' surface. + * + * Subagents are already emitted via the generic agents block + convertClaude + * AgentToCodebuddyAgent (~/.codebuddy/agents/), so #789 adds no agents change. + * + * mcp.json is intentionally NOT written: gsd ships no MCP server, and CodeBuddy's + * mcp.json holds an `mcpServers` map of *external* servers to connect to — + * there is nothing for gsd to register. Same exclusion as #784/#785/#790. + */ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { + convertClaudeCommandToCodebuddyCommand, + convertClaudeCommandToCodebuddySkill, +} = require('../bin/install.js'); + +const { + installRuntimeArtifacts, + uninstallRuntimeArtifacts, +} = require('../gsd-core/bin/lib/install-engine.cjs'); +const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); +const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); +const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); +const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); + +// ─── Layout contract ───────────────────────────────────────────────────────── + +describe('enh-789 — codebuddy layout has commands + skills kinds', () => { + test('resolveRuntimeArtifactLayout codebuddy returns 3 kinds (ADR-1235 §1 agents cutover)', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); + assert.strictEqual(layout.kinds.length, 3, 'codebuddy must have exactly 3 artifact kinds (commands + skills + agents)'); + const kindNames = layout.kinds.map(k => k.kind).sort(); + assert.deepStrictEqual(kindNames, ['agents', 'commands', 'skills']); + }); + + test('codebuddy commands kind targets commands/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'must have commands kind'); + assert.strictEqual(commandsKind.destSubpath, 'commands'); + assert.strictEqual(commandsKind.prefix, 'gsd-'); + assert.strictEqual(typeof commandsKind.stage, 'function'); + }); + + test('codebuddy skills kind targets skills/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'must have skills kind'); + assert.strictEqual(skillsKind.destSubpath, 'skills'); + assert.strictEqual(skillsKind.prefix, 'gsd-'); + }); +}); + +// ─── Command converter contract ────────────────────────────────────────────── + +describe('enh-789 — convertClaudeCommandToCodebuddyCommand', () => { + const SRC = [ + '---', + 'name: gsd:new-project', + 'description: Initialize a project', + 'argument-hint: "[name]"', + 'allowed-tools:', + ' - Read', + '---', + '', + 'Use .claude/skills/ and run /gsd:help. Claude Code reads CLAUDE.md.', + '', + ].join('\n'); + + test('emits a description-only frontmatter (no Claude-specific name: gsd:)', () => { + const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); + assert.ok(out.startsWith('---\n'), 'must begin with frontmatter'); + assert.ok(/^description:/m.test(out), 'must carry a description field'); + assert.ok(!out.includes('name: gsd:new-project'), 'must drop Claude colon-form name field'); + }); + + test('preserves a present argument-hint (CodeBuddy supports it)', () => { + const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); + assert.ok(/^argument-hint:\s*["']?\[name\]["']?\s*$/m.test(out), + `argument-hint must be carried through when present in source. Got:\n${out}`); + }); + + test('converts body Claude-isms to CodeBuddy equivalents', () => { + const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); + assert.ok(out.includes('.codebuddy/skills/'), out); + assert.ok(out.includes('/gsd-help'), out); + assert.ok(out.includes('CODEBUDDY.md'), out); + assert.ok(!/\bClaude Code\b/.test(out), 'must rebrand "Claude Code"'); + }); +}); + +describe('enh-789 — skills marked user-invocable:false', () => { + test('convertClaudeCommandToCodebuddySkill emits user-invocable: false', () => { + const src = [ + '---', + 'name: gsd:help', + 'description: Show help', + '---', + '', + '# body', + '', + ].join('\n'); + const out = convertClaudeCommandToCodebuddySkill(src, 'gsd-help'); + assert.ok(/^user-invocable:\s*false\s*$/m.test(out), + `SKILL.md frontmatter must hide skill from '/' menu (user-invocable: false). Got:\n${out}`); + }); +}); + +// ─── Install contract ──────────────────────────────────────────────────────── + +describe('enh-789 — installRuntimeArtifacts codebuddy emits commands and skills', () => { + test('global codebuddy install: commands/gsd-help.md and skills/gsd-help/SKILL.md exist', (t) => { + const configDir = createTempDir('gsd-enh789-codebuddy-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const commandsDir = path.join(configDir, 'commands'); + assert.ok(fs.existsSync(commandsDir), 'commands/ dir must exist'); + const cmdFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok(cmdFiles.length > 0, 'at least one gsd-*.md command file must be installed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'commands/gsd-help.md must exist'); + + const skillsDir = path.join(configDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist'); + assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist'); + }); + + test('installed commands/gsd-help.md is CodeBuddy-compatible (no raw ~/.claude/, rebranded)', (t) => { + const configDir = createTempDir('gsd-enh789-content-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const helpCmd = path.join(configDir, 'commands', 'gsd-help.md'); + const content = fs.readFileSync(helpCmd, 'utf8'); + assert.ok(!content.includes('~/.claude/'), 'commands must not contain raw ~/.claude/ refs'); + assert.ok(content.startsWith('---'), 'commands must carry frontmatter'); + }); + + test('installed skills/gsd-help/SKILL.md is hidden from the / menu', (t) => { + const configDir = createTempDir('gsd-enh789-skillhide-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const skill = fs.readFileSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'), 'utf8'); + assert.ok(/^user-invocable:\s*false\s*$/m.test(skill), + 'installed SKILL.md must set user-invocable: false'); + }); + + test('command count matches skill count (profile parity)', (t) => { + const configDir = createTempDir('gsd-enh789-parity-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const cmdCount = fs.readdirSync(path.join(configDir, 'commands')) + .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; + const skillCount = fs.readdirSync(path.join(configDir, 'skills'), { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; + assert.strictEqual(cmdCount, skillCount, 'command count must equal skill count for same profile'); + }); + + test('full profile install: no $HOME/.codebuddy or ~/.codebuddy leak in any command', (t) => { + // The codebuddy converter rewrites `.claude/` → `.codebuddy/`, so source + // refs like `@$HOME/.claude/gsd-core/...` (e.g. plan-review-convergence.md) + // must be normalized to the install target — not left as $HOME/.codebuddy. + const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + const configDir = createTempDir('gsd-enh789-noleak-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); + + const commandsDir = path.join(configDir, 'commands'); + for (const f of fs.readdirSync(commandsDir).filter(n => n.endsWith('.md'))) { + const content = fs.readFileSync(path.join(commandsDir, f), 'utf8'); + assert.ok(!content.includes('$HOME/.codebuddy'), `${f} must not leak $HOME/.codebuddy`); + assert.ok(!content.includes('~/.codebuddy'), `${f} must not leak ~/.codebuddy`); + assert.ok(!content.includes('.claude/'), `${f} must not retain raw .claude/ refs`); + } + }); + + test('full profile install does NOT mutate source commands/gsd/ files', (t) => { + const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + assert.strictEqual(RESOLVED_FULL.skills, '*', 'full profile must have skills === "*"'); + + const configDir = createTempDir('gsd-enh789-full-'); + t.after(() => cleanup(configDir)); + + const srcHelpPath = path.join(REAL_COMMANDS_DIR, 'help.md'); + const before = fs.readFileSync(srcHelpPath, 'utf8'); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); + + const after = fs.readFileSync(srcHelpPath, 'utf8'); + assert.strictEqual(before, after, 'source commands/gsd/help.md must not be mutated by the install'); + }); +}); + +// ─── Uninstall contract ────────────────────────────────────────────────────── + +describe('enh-789 — uninstallRuntimeArtifacts removes codebuddy commands', () => { + test('uninstall removes gsd-* commands but preserves user commands', (t) => { + const configDir = createTempDir('gsd-enh789-uninstall-'); + t.after(() => cleanup(configDir)); + + const commandsDir = path.join(configDir, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), '# help\n'); + fs.writeFileSync(path.join(commandsDir, 'user-custom.md'), '# user\n'); + + uninstallRuntimeArtifacts('codebuddy', configDir, 'global'); + + assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'gsd-help.md must be removed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'user-custom.md')), 'user-custom.md must be preserved'); + }); +}); + +// ─── mcp.json exclusion ────────────────────────────────────────────────────── + +describe('enh-789 — mcp.json excluded (gsd ships no MCP server)', () => { + test('codebuddy install does not write mcp.json / .mcp.json', (t) => { + const configDir = createTempDir('gsd-enh789-mcp-excluded-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + assert.ok(!fs.existsSync(path.join(configDir, 'mcp.json')), 'must not write mcp.json'); + assert.ok(!fs.existsSync(path.join(configDir, '.mcp.json')), 'must not write .mcp.json'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2794-opencode-model-profile-overrides.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2794-opencode-model-profile-overrides (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for bug #2794 + * + * OpenCode generated agents ignored `model_profile_overrides.opencode.*`. + * The agent install path called `readGsdEffectiveModelOverrides` (explicit + * per-agent overrides) but never called `readGsdRuntimeProfileResolver` + * (tier-based profile overrides). When a user configured: + * + * { runtime: "opencode", model_profile_overrides: { opencode: { sonnet: "..." } } } + * + * generated `.opencode/agents/gsd-*.md` files contained no `model:` frontmatter. + * + * The fix adds a tier-resolver fallback in the OpenCode agent conversion block: + * explicit `model_overrides[agent]` > `model_profile_overrides.opencode.` > omit. + * + * This test exercises: + * 1. `readGsdRuntimeProfileResolver` correctly resolves OpenCode tier overrides. + * 2. The agent install code path embeds the resolved model into OpenCode frontmatter. + * 3. Explicit `model_overrides` still wins over tier-based resolution. + * 4. Missing overrides produce no `model:` field (no regression on omit behavior). + */ + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { + readGsdRuntimeProfileResolver, + install, +} = require('../bin/install.js'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const makeTmp = (prefix) => createTempDir(`gsd-2794-${prefix}-`); + +function writeJson(p, obj) { + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(obj, null, 2), 'utf-8'); +} + + +describe('bug-2794: readGsdRuntimeProfileResolver resolves opencode tier overrides', () => { + let projectDir; + let homeDir; + let origHome; + let origUP; + + beforeEach(() => { + projectDir = makeTmp('proj'); + homeDir = makeTmp('home'); + origHome = process.env.HOME; + origUP = process.env.USERPROFILE; + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + }); + + afterEach(() => { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + if (origUP === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = origUP; + cleanup(projectDir); + cleanup(homeDir); + }); + + test('resolves opencode sonnet tier to user-supplied model ID', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + runtime: 'opencode', + model_profile: 'balanced', + model_profile_overrides: { + opencode: { + sonnet: 'anthropic/claude-sonnet-4-7', + }, + }, + }); + + const resolver = readGsdRuntimeProfileResolver(projectDir); + assert.ok(resolver !== null, 'expected a resolver for opencode runtime'); + + // gsd-roadmapper balanced tier = sonnet — should resolve to override + const entry = resolver.resolve('gsd-roadmapper'); + assert.ok(entry !== null, 'expected entry for gsd-roadmapper'); + assert.strictEqual(entry.model, 'anthropic/claude-sonnet-4-7', 'sonnet override applied'); + }); + + test('returns null resolver when runtime is not set', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + model_profile: 'balanced', + model_profile_overrides: { opencode: { sonnet: 'x' } }, + }); + const resolver = readGsdRuntimeProfileResolver(projectDir); + assert.strictEqual(resolver, null, 'no resolver without runtime field'); + }); + + test('resolver returns null for agent not in MODEL_PROFILES', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + runtime: 'opencode', + model_profile: 'balanced', + model_profile_overrides: { opencode: { sonnet: 'x' } }, + }); + const resolver = readGsdRuntimeProfileResolver(projectDir); + assert.ok(resolver !== null); + const entry = resolver.resolve('gsd-nonexistent-agent'); + assert.strictEqual(entry, null, 'unknown agent name yields null'); + }); +}); + +describe('bug-2794: OpenCode agent install embeds model_profile_overrides model', () => { + let projectDir; + let homeDir; + let origHome; + let origUP; + let origCwd; + + beforeEach(() => { + projectDir = makeTmp('proj'); + homeDir = makeTmp('home'); + origHome = process.env.HOME; + origUP = process.env.USERPROFILE; + origCwd = process.cwd(); + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + process.chdir(projectDir); + }); + + afterEach(() => { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + if (origUP === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = origUP; + process.chdir(origCwd); + cleanup(projectDir); + cleanup(homeDir); + }); + + test('generated OpenCode agent frontmatter includes model from model_profile_overrides', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + runtime: 'opencode', + model_profile: 'balanced', + model_profile_overrides: { + opencode: { + sonnet: 'anthropic/claude-sonnet-4-7', + opus: 'anthropic/claude-opus-4-7', + haiku: 'anthropic/claude-haiku-4-5', + }, + }, + }); + + const oldLog = console.log; + console.log = () => {}; + try { + install(false, 'opencode'); + } finally { + console.log = oldLog; + } + + const agentsDir = path.join(projectDir, '.opencode', 'agents'); + assert.ok(fs.existsSync(agentsDir), 'agents directory should be created'); + + // gsd-roadmapper is balanced -> sonnet tier + const roadmapperPath = path.join(agentsDir, 'gsd-roadmapper.md'); + assert.ok(fs.existsSync(roadmapperPath), 'gsd-roadmapper.md should exist'); + const roadmapperContent = fs.readFileSync(roadmapperPath, 'utf-8'); + assert.match( + roadmapperContent, + /^model: anthropic\/claude-sonnet-4-7$/m, + 'gsd-roadmapper should have sonnet model from model_profile_overrides' + ); + + // gsd-planner is balanced -> opus tier + const plannerPath = path.join(agentsDir, 'gsd-planner.md'); + assert.ok(fs.existsSync(plannerPath), 'gsd-planner.md should exist'); + const plannerContent = fs.readFileSync(plannerPath, 'utf-8'); + assert.match( + plannerContent, + /^model: anthropic\/claude-opus-4-7$/m, + 'gsd-planner should have opus model from model_profile_overrides' + ); + }); + + test('explicit model_overrides[agent] wins over model_profile_overrides tier', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + runtime: 'opencode', + model_profile: 'balanced', + model_overrides: { + 'gsd-roadmapper': 'explicit-winner-model', + }, + model_profile_overrides: { + opencode: { + sonnet: 'tier-model-that-should-lose', + }, + }, + }); + + const oldLog = console.log; + console.log = () => {}; + try { + install(false, 'opencode'); + } finally { + console.log = oldLog; + } + + const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md'); + assert.ok(fs.existsSync(roadmapperPath)); + const content = fs.readFileSync(roadmapperPath, 'utf-8'); + assert.match( + content, + /^model: explicit-winner-model$/m, + 'explicit model_overrides must win over model_profile_overrides tier' + ); + assert.doesNotMatch( + content, + /tier-model-that-should-lose/, + 'tier model must not appear when explicit override is present' + ); + }); + + test('no model field when neither model_overrides nor model_profile_overrides is set', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + runtime: 'opencode', + model_profile: 'balanced', + }); + + const oldLog = console.log; + console.log = () => {}; + try { + install(false, 'opencode'); + } finally { + console.log = oldLog; + } + + const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md'); + if (fs.existsSync(roadmapperPath)) { + const content = fs.readFileSync(roadmapperPath, 'utf-8'); + // When no overrides, model field should either be absent or use built-in default + // The key invariant: no model field if there are no user-supplied overrides + // AND no built-in opencode defaults for this tier + // (gsd-roadmapper balanced = sonnet; opencode has built-in sonnet defaults) + // So we only assert no crash and no tier-model-not-provided entries + assert.ok(typeof content === 'string', 'agent file should be a string'); + } + // Key: no exception thrown (test passes = no crash on missing overrides) + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2643-skill-frontmatter-name.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2643-skill-frontmatter-name (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #2643 / #2808: skill frontmatter name parity. + * + * Original (#2643): workflows emitted Skill(skill="gsd:") and the + * installer registered colon form in SKILL.md name: to match. + * + * Updated (#2808): workflows now use Skill(skill="gsd-") (hyphen), + * and the installer emits name: gsd- (hyphen). Claude Code autocomplete + * now shows the canonical hyphen form instead of the deprecated colon form. + * The directory name (gsd-) is unchanged. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const { + convertClaudeCommandToClaudeSkill, + skillFrontmatterName, +} = require(path.join(ROOT, 'bin', 'install.js')); + +const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows'); +const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); + +function collectFiles(dir, results) { + if (!results) results = []; + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) collectFiles(full, results); + else if (e.name.endsWith('.md')) results.push(full); + } + return results; +} + +/** + * Extract every `Skill(skill="")` invocation as a structured record. + * + * Per project test rigor (`feedback_no_source_grep_tests.md`), this parses + * each call as a unit instead of leaning on a single regex over raw bytes. + * The flow is: + * + * 1. Strip HTML comments so commented-out examples don't count as drift. + * 2. Walk the content for `Skill(` openers; for each, find the matching + * `)` closer (Skill bodies are simple kwarg lists, no nesting). + * 3. Parse the call body for the `skill = "..."` keyword argument. + * Permissive whitespace around the keyword and `=`, permissive + * single/double quoting (with optional `\` escapes from string- + * embedded examples), permissive name body — so malformed drift like + * `Skill(skill="gsd:extract_learnings")` is surfaced rather than + * silently skipped by an over-strict character class. + * + * Returns `[{ name, raw }]` per call. Filtering by namespace (gsd- vs gsd:) + * happens at the call site so the extractor stays neutral. + */ +function extractSkillCalls(content) { + // regex-free HTML-comment stripper (CodeQL: avoid incomplete-multi-character-sanitization) + let stripped = ''; + { + let rest = content; + let idx; + while ((idx = rest.indexOf('', idx + 4); + if (end === -1) { rest = ''; break; } + rest = rest.slice(end + 3); + } + stripped += rest; + } + const calls = []; + // Body class excludes backslash so the extractor doesn't include an + // escape character that precedes the closing quote in embedded examples + // (e.g. `Skill(skill=\"gsd-plan-phase\", …)` written inside a string + // context). A trailing `\` is permitted on the closing-quote side via the + // optional `\\?` so both `\"` and `"` close the value cleanly. + const argRe = /^\s*skill\s*=\s*\\?(['"])([^'"\\]+)\\?\1/i; + let i = 0; + while (i < stripped.length) { + const open = stripped.indexOf('Skill(', i); + if (open === -1) break; + const close = stripped.indexOf(')', open); + if (close === -1) break; + const body = stripped.slice(open + 'Skill('.length, close); + const match = body.match(argRe); + if (match) calls.push({ name: match[2], raw: stripped.slice(open, close + 1) }); + i = close + 1; + } + return calls; +} + +function extractSkillNamesHyphen(content) { + return new Set( + extractSkillCalls(content) + .map((c) => c.name) + .filter((n) => n.startsWith('gsd-')), + ); +} + +function extractSkillNamesColon(content) { + return new Set( + extractSkillCalls(content) + .map((c) => c.name) + .filter((n) => n.startsWith('gsd:')), + ); +} + +describe('skill frontmatter name parity (#2643 / #2808)', () => { + test('skillFrontmatterName helper emits hyphen form (#2808)', () => { + assert.strictEqual(typeof skillFrontmatterName, 'function'); + assert.strictEqual(skillFrontmatterName('gsd-execute-phase'), 'gsd-execute-phase'); + assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd-plan-phase'); + assert.strictEqual(skillFrontmatterName('gsd-next'), 'gsd-next'); + }); + + test('convertClaudeCommandToClaudeSkill emits name: gsd- (hyphen)', () => { + const input = '---\nname: old\ndescription: test\n---\n\nBody.'; + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-execute-phase'); + // Parse the frontmatter block structurally: extract the name: field value. + const frontmatterMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/); + assert.ok(frontmatterMatch, 'output must have a frontmatter block delimited by ---'); + const frontmatterLines = frontmatterMatch[1].split(/\r?\n/); + const nameEntry = frontmatterLines.find((l) => l.startsWith('name:')); + assert.ok(nameEntry, 'frontmatter must contain a name: field'); + const nameValue = nameEntry.replace(/^name:\s*/, '').trim(); + assert.strictEqual( + nameValue, + 'gsd-execute-phase', + `frontmatter name: must be 'gsd-execute-phase' (hyphen form), got '${nameValue}'` + ); + }); + + test('no workflow uses deprecated Skill(skill="gsd:") colon form', () => { + const workflowFiles = collectFiles(WORKFLOWS_DIR); + const colonRefs = []; + for (const f of workflowFiles) { + const src = fs.readFileSync(f, 'utf-8'); + for (const n of extractSkillNamesColon(src)) { + colonRefs.push(path.basename(f) + ': ' + n); + } + } + assert.deepStrictEqual( + colonRefs, + [], + 'deprecated colon-form Skill() calls found (update to hyphen): ' + colonRefs.join(', ') + ); + }); + + test('every workflow Skill(skill="gsd-") resolves to an emitted skill name', () => { + const workflowFiles = collectFiles(WORKFLOWS_DIR); + const referenced = new Set(); + const templatedSkipped = []; + for (const f of workflowFiles) { + const src = fs.readFileSync(f, 'utf-8'); + for (const n of extractSkillNamesHyphen(src)) { + // Skip template expressions (e.g. `gsd-${ref.skill}`): these are + // capability-dispatched — the skill stem is resolved at runtime from + // the `loop render-hooks` registry output (ADR-857 phase 6), so there + // is no single literal skill file to validate against here. + // The capability registry's own validateStep gate (gen-capability-registry.cjs) + // is responsible for ensuring each `steps[].ref.skill` corresponds to a + // real skill declared in the capability's `skills` array. + if (n.includes('${')) { + templatedSkipped.push(path.basename(f) + ': ' + n); + } else { + referenced.add(n); + } + } + } + assert.ok( + referenced.size > 0, + `expected at least one literal Skill(skill="gsd-") reference in workflows under ${WORKFLOWS_DIR}` + ); + + const emitted = new Set(); + const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md')); + for (const cmd of cmdFiles) { + const base = cmd.replace(/\.md$/, ''); + const skillDirName = 'gsd-' + base; + const src = fs.readFileSync(path.join(COMMANDS_DIR, cmd), 'utf-8'); + const out = convertClaudeCommandToClaudeSkill(src, skillDirName); + const m = out.match(/^---\r?\nname:\s*(.+)$/m); + if (m) emitted.add(m[1].trim()); + } + + const missing = []; + for (const r of referenced) if (!emitted.has(r)) missing.push(r); + assert.deepStrictEqual( + missing, + [], + 'workflow refs not emitted as skill names: ' + missing.join(', '), + ); + // Informational: report how many templated dispatches were intentionally skipped. + // (Templated names are validated by the capability registry, not statically here.) + if (templatedSkipped.length > 0) { + // Not a failure — just a note for test output transparency. + // Use a diagnostic comment: node:test does not have a skip-within-test API. + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-778-cross-runtime-command-enrichment.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-778-cross-runtime-command-enrichment (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #778) +// Reads .md/SKILL.md/.toml product files whose deployed text IS what the +// runtime loads — testing text content tests the deployed contract. + +/** + * GSD Tools Tests — #778 cross-runtime command enrichment. + * + * Two independently-verified, additive sub-features: + * (b) Qwen Code skills: numeric `priority` field (higher sorts earlier in the + * /skills TUI listing per the Qwen skills spec). Scoped to runtime='qwen'. + * (c) Gemini custom-command TOML: $ARGUMENTS → {{args}} interpolation, and a + * fixed `!{cat .planning/STATE.md}` live-state injection on the + * situational `progress` command (injection-safe — no interpolated input). + * + * The OpenCode sub-feature (per-command model/agent/subtask/variant) is + * intentionally NOT implemented — see PR description: `model` reintroduces the + * #1156 ProviderModelNotFoundError regression for non-Anthropic OpenCode users, + * `subtask`/`agent` change execution semantics for GSD's interactive commands, + * and `variant` is not in the OpenCode command schema. + * + * Uses node:test and node:assert (NOT Jest). + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { + convertClaudeCommandToClaudeSkill, + convertClaudeToGeminiMarkdown, + install, +} = require('../bin/install.js'); + +// ─── (b) Qwen Code: priority ordering ─────────────────────────────────────── + +describe('#778 (b) Qwen skills priority', () => { + const mk = (name, desc, body) => + ['---', `name: gsd:${name}`, `description: ${desc}`, '---', '', body].join('\n'); + + test('emits numeric priority for a core-loop command (runtime=qwen)', () => { + const result = convertClaudeCommandToClaudeSkill( + mk('plan-phase', 'Plan a phase', 'Body.'), + 'gsd-plan-phase', + 'qwen', + [] + ); + const m = result.match(/^priority:\s*(\d+)\s*$/m); + assert.ok(m, 'priority field present for gsd-plan-phase'); + assert.equal(Number(m[1]) > 0, true, 'priority is a positive number'); + }); + + test('core loop ranks higher than mid-tier (higher = earlier per spec)', () => { + const np = convertClaudeCommandToClaudeSkill( + mk('new-project', 'Start a project', 'Body.'), 'gsd-new-project', 'qwen', [] + ).match(/^priority:\s*(\d+)/m); + const help = convertClaudeCommandToClaudeSkill( + mk('help', 'Help', 'Body.'), 'gsd-help', 'qwen', [] + ).match(/^priority:\s*(\d+)/m); + assert.ok(np && help, 'both core and mid-tier get a priority'); + assert.ok( + Number(np[1]) > Number(help[1]), + 'new-project (core) sorts earlier than help (utility) — higher value' + ); + }); + + test('utility command NOT in the priority map gets no priority field', () => { + const result = convertClaudeCommandToClaudeSkill( + mk('stats', 'Show stats', 'Body.'), 'gsd-stats', 'qwen', [] + ); + assert.ok(!/^priority:/m.test(result), 'no priority emitted for unmapped utility'); + }); + + test('does NOT emit priority for non-qwen runtimes (scoped to qwen)', () => { + for (const rt of [null, 'claude', 'hermes']) { + const result = convertClaudeCommandToClaudeSkill( + mk('plan-phase', 'Plan a phase', 'Body.'), 'gsd-plan-phase', rt, [] + ); + assert.ok(!/^priority:/m.test(result), `no priority for runtime=${rt}`); + } + }); +}); + +// ─── (c) Gemini: {{args}} interpolation ───────────────────────────────────── + +describe('#778 (c) Gemini {{args}} interpolation', () => { + const cmd = (body) => + ['---', 'name: gsd:demo', 'description: Demo', '---', '', body].join('\n'); + + test('maps $ARGUMENTS to {{args}} in the TOML prompt', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('Operate on $ARGUMENTS now.'), + { isCommand: true, commandName: 'demo' } + ); + assert.ok(out.includes('{{args}}'), '{{args}} present'); + assert.ok(!out.includes('$ARGUMENTS'), 'literal $ARGUMENTS removed'); + assert.ok(out.startsWith('description =') || out.includes('prompt ='), 'TOML shape'); + }); + + test('command without $ARGUMENTS gets no injected {{args}}', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('No arguments referenced here.'), + { isCommand: true, commandName: 'demo' } + ); + assert.ok(!out.includes('{{args}}'), 'no spurious {{args}}'); + }); + + test('non-command Gemini content is not TOML-converted and keeps $ARGUMENTS', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('Reference $ARGUMENTS.'), + { isCommand: false } + ); + // isCommand:false keeps markdown — no TOML wrap and no {{args}} mapping + // (the $ARGUMENTS→{{args}} translation is scoped to the TOML command path). + assert.ok(!out.startsWith('prompt ='), 'not wrapped as TOML prompt'); + assert.ok(out.includes('$ARGUMENTS'), '$ARGUMENTS left intact for non-command content'); + assert.ok(!out.includes('{{args}}'), 'no {{args}} injected outside the command path'); + }); +}); + +// ─── (c) Gemini: end-to-end install wiring ────────────────────────────────── +// Proves the install path derives the per-command name from the file stem so a +// regression in the call-site wiring (not just the converter) is caught. + +describe('#778 (c) Gemini install wiring (end-to-end)', () => { + let tmpDir; + let tmpHome; + let prevCwd; + let prevHome; + let prevUserprofile; + + beforeEach(() => { + tmpDir = createTempDir('gsd-enh778-gem-'); + tmpHome = createTempDir('gsd-enh778-home-'); + prevCwd = process.cwd(); + prevHome = process.env.HOME; + prevUserprofile = process.env.USERPROFILE; + process.chdir(tmpDir); + // Isolate HOME so a real ~/.gemini/commands/gsd/ doesn't trigger the #3037 + // local-install conflict-skip path. + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + }); + + afterEach(() => { + process.chdir(prevCwd); + if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; + if (prevUserprofile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserprofile; + cleanup(tmpDir); + cleanup(tmpHome); + }); + + test('installed progress.toml carries the !{} block; arg-bearing commands get {{args}}', () => { + const oldLog = console.log; + console.log = () => {}; + try { + install(false, 'gemini'); + } finally { + console.log = oldLog; + } + + const commandsDir = path.join(tmpDir, '.gemini', 'commands', 'gsd'); + const progressToml = path.join(commandsDir, 'progress.toml'); + assert.ok(fs.existsSync(progressToml), 'progress.toml installed'); + const progress = fs.readFileSync(progressToml, 'utf8'); + // Proves commandName was derived as 'progress' from the file stem. + assert.ok( + progress.includes('!{cat .planning/STATE.md 2>/dev/null}'), + 'progress.toml has the live-state shell block' + ); + + // A non-situational command must NOT receive the shell block. + const helpToml = path.join(commandsDir, 'help.toml'); + if (fs.existsSync(helpToml)) { + assert.ok(!fs.readFileSync(helpToml, 'utf8').includes('!{'), 'help.toml has no shell block'); + } + + // At least one installed command must use {{args}} and none may retain a + // literal $ARGUMENTS (every command body's $ARGUMENTS is translated). + const tomls = fs.readdirSync(commandsDir).filter((f) => f.endsWith('.toml')); + const withArgs = tomls.filter((f) => + fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('{{args}}')); + const withLiteral = tomls.filter((f) => + fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('$ARGUMENTS')); + assert.ok(withArgs.length > 0, 'at least one installed command interpolates {{args}}'); + assert.equal(withLiteral.length, 0, 'no installed command retains literal $ARGUMENTS'); + }); +}); + +// ─── (c) Gemini: !{...} live-state injection (progress) ───────────────────── + +describe('#778 (c) Gemini !{} live-state injection', () => { + const cmd = (name) => + ['---', `name: gsd:${name}`, `description: ${name}`, '---', '', 'Workflow body.'].join('\n'); + + test('progress command injects a fixed !{cat .planning/STATE.md} block', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('progress'), + { isCommand: true, commandName: 'progress' } + ); + assert.ok(out.includes('!{cat .planning/STATE.md'), 'STATE.md injection present'); + }); + + test('non-progress commands get no !{} shell block', () => { + const out = convertClaudeToGeminiMarkdown( + cmd('help'), + { isCommand: true, commandName: 'help' } + ); + assert.ok(!out.includes('!{'), 'no shell block for non-situational command'); + }); + + test('SECURITY: the !{} block interpolates NO user input ({{args}})', () => { + const out = convertClaudeToGeminiMarkdown( + ['---', 'name: gsd:progress', 'description: progress', '---', '', + 'Body uses $ARGUMENTS too.'].join('\n'), + { isCommand: true, commandName: 'progress' } + ); + const blocks = out.match(/!\{([^}]*)\}/g) || []; + assert.equal(blocks.length, 1, 'exactly one shell block'); + assert.ok(!/\{\{args\}\}/.test(blocks[0]), 'no {{args}} inside the shell block'); + assert.ok(/^!\{cat \.planning\/STATE\.md/.test(blocks[0]), 'fixed cat command only'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-769-context-fork-effort.install.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-769-context-fork-effort.install (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: integration-test-input (see #769) +// Exercises install() as a black-box by inspecting produced SKILL.md output +// in a temp dir. Source command .md files are inputs whose installed +// transformation is asserted — not inspected for string presence. + +/** + * #769 — effort: frontmatter on heavy workflow skills. + * #921 — spawning orchestrators must NOT carry context: fork. + * + * Context: context:fork was added by #769 to protect context budget, but + * plan-phase, execute-phase, and autonomous are spawning orchestrators — a + * forked subagent has no Agent/Task tool, breaking their core function. + * effort: max is preserved; context: fork is removed from these three. + * The converter still passes context: fork through if a source file has it + * (for any future leaf skill that legitimately needs isolation). + * + * Verifies: + * 1. Source commands/gsd/autonomous.md does NOT have context: fork, has effort: max + * 2. Source commands/gsd/execute-phase.md does NOT have context: fork, has effort: max + * 3. Source commands/gsd/plan-phase.md does NOT have context: fork, has effort: max + * 4. Source commands/gsd/progress.md has effort: low + * 5. Source commands/gsd/stats.md has effort: low + * 6. Claude global install: SKILL.md for autonomous has effort: max, NOT context: fork + * 7. Claude global install: SKILL.md for execute-phase has effort: max, NOT context: fork + * 8. Claude global install: SKILL.md for plan-phase has effort: max, NOT context: fork + * 9. Claude global install: SKILL.md for progress has effort: low + * 10. Claude global install: SKILL.md for stats has effort: low + * 11. convertClaudeCommandToClaudeSkill still passes context: fork through (for non-orchestrator skills) + * 12. convertClaudeCommandToClaudeSkill emits portable effort: field values + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { install, convertClaudeCommandToClaudeSkill } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +// #924: Claude global install is now FLAT — concrete skills are at the top level. +// flatSkillPath returns: /gsd-/SKILL.md +function flatSkillPath(skillsRoot, stem) { + return path.join(skillsRoot, `gsd-${stem}`, 'SKILL.md'); +} + +const REPO_ROOT = path.resolve(__dirname, '..'); +const SOURCE_COMMANDS_DIR = path.join(REPO_ROOT, 'commands', 'gsd'); + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makeTmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function readFrontmatter(mdPath) { + const content = fs.readFileSync(mdPath, 'utf8'); + if (!content.startsWith('---')) return ''; + const end = content.indexOf('---', 3); + if (end === -1) return ''; + return content.substring(3, end); +} + +/** + * Run a global install for Claude, redirecting its home dir to tmpHome. + * Returns the tmpHome for inspection. + */ +function runClaudeGlobalInstall(claudeHome) { + const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-769-home-')); + + const prevCwd = process.cwd(); + const prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK; + + process.env.CLAUDE_CONFIG_DIR = claudeHome; + process.env.HOME = isolatedHome; + process.env.USERPROFILE = isolatedHome; + process.env.GSD_SKIP_STALE_SDK_CHECK = '1'; + process.chdir(REPO_ROOT); + + try { + install(true, 'claude'); + } finally { + process.chdir(prevCwd); + if (prevClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK; + else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale; + cleanup(isolatedHome); + } + + return claudeHome; +} + +// ─── describe 1: Source command files have correct frontmatter ──────────────── + +// #921/#922: spawning orchestrators must NOT carry context: fork — a forked +// subagent has no Agent/Task tool, making it impossible for orchestrators to +// spawn their required subagents. context: fork is appropriate only for leaf +// skills that do not themselves dispatch agents. effort: max is portable across Claude Code models. +describe('#769/#921/#1319 source commands: spawning orchestrators have effort: max but NOT context: fork', () => { + test('commands/gsd/autonomous.md does NOT have context: fork (#921)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `autonomous.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('commands/gsd/autonomous.md has effort: max (#1319)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md')); + assert.match(fm, /^effort:[ \t]*max$/m, + `autonomous.md frontmatter must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `autonomous.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); + + test('commands/gsd/execute-phase.md does NOT have context: fork (#921)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `execute-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('commands/gsd/execute-phase.md has effort: max (#1319)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md')); + assert.match(fm, /^effort:[ \t]*max$/m, + `execute-phase.md frontmatter must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `execute-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); + + test('commands/gsd/plan-phase.md does NOT have context: fork (#921)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `plan-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('commands/gsd/plan-phase.md has effort: max (#1319)', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md')); + assert.match(fm, /^effort:[ \t]*max$/m, + `plan-phase.md frontmatter must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `plan-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); +}); + +describe('#769 source commands: quick-status skills have effort: low', () => { + test('commands/gsd/progress.md has effort: low', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'progress.md')); + assert.match(fm, /^effort:[ \t]*low$/m, + `progress.md frontmatter must have effort: low\nActual:\n${fm}`); + }); + + test('commands/gsd/stats.md has effort: low', () => { + const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'stats.md')); + assert.match(fm, /^effort:[ \t]*low$/m, + `stats.md frontmatter must have effort: low\nActual:\n${fm}`); + }); +}); + +// ─── describe 2: convertClaudeCommandToClaudeSkill preserves new fields ─────── + +describe('#769/#1319 convertClaudeCommandToClaudeSkill: preserves context and emits portable effort fields', () => { + test('preserves context: fork in emitted SKILL.md frontmatter', () => { + const input = [ + '---', + 'name: gsd:test-heavy', + 'description: Test heavy skill', + 'context: fork', + 'effort: xhigh', + 'allowed-tools:', + ' - Read', + ' - Bash', + '---', + '', + 'Heavy skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.match(fm, /^context:[ \t]*fork$/m, + `SKILL.md frontmatter must include context: fork\nActual frontmatter:\n${fm}`); + }); + + test('normalizes effort: xhigh to effort: max in emitted SKILL.md frontmatter (#1319)', () => { + const input = [ + '---', + 'name: gsd:test-heavy', + 'description: Test heavy skill', + 'context: fork', + 'effort: xhigh', + 'allowed-tools:', + ' - Read', + ' - Bash', + '---', + '', + 'Heavy skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.match(fm, /^effort:[ \t]*max$/m, + `SKILL.md frontmatter must include portable effort: max\nActual frontmatter:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `SKILL.md frontmatter must not include rejected effort: xhigh (#1319)\nActual frontmatter:\n${fm}`); + }); + + test('preserves effort: low in emitted SKILL.md frontmatter', () => { + const input = [ + '---', + 'name: gsd:test-light', + 'description: Test light skill', + 'effort: low', + 'allowed-tools:', + ' - Read', + '---', + '', + 'Light skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-light'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.match(fm, /^effort:[ \t]*low$/m, + `SKILL.md frontmatter must include effort: low\nActual frontmatter:\n${fm}`); + }); + + test('does NOT emit context: or effort: when absent from source', () => { + const input = [ + '---', + 'name: gsd:test-plain', + 'description: Plain skill without context or effort', + 'allowed-tools:', + ' - Read', + '---', + '', + 'Plain skill body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'test-plain'); + const end = result.indexOf('---', 3); + const fm = result.substring(3, end); + + assert.doesNotMatch(fm, /^context:/m, + `SKILL.md must not emit context: when absent from source\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:/m, + `SKILL.md must not emit effort: when absent from source\nActual:\n${fm}`); + }); +}); + +// ─── describe 3: Claude global install — SKILL.md files include new fields ──── + +// #921/#922: after install, spawning orchestrators must NOT carry context: fork +// in their emitted SKILL.md. #1319: heavyweight skills must use portable max effort. +describe('#769/#921/#1319 Claude global install: spawning-orchestrator SKILL.md files have effort: max but NOT context: fork', () => { + let tmpDir; + let claudeHome; + + beforeEach(() => { + tmpDir = makeTmpDir('gsd-769-claude-'); + claudeHome = path.join(tmpDir, 'claude-home'); + fs.mkdirSync(claudeHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-autonomous SKILL.md does NOT have context: fork after global install (#921)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous'); + const fm = readFrontmatter(skillPath); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `gsd-autonomous is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('gsd-autonomous SKILL.md has effort: max after global install (#1319)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*max$/m, + `gsd-autonomous SKILL.md must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `gsd-autonomous SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); + + test('gsd-execute-phase SKILL.md does NOT have context: fork after global install (#921)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase'); + const fm = readFrontmatter(skillPath); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `gsd-execute-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('gsd-execute-phase SKILL.md has effort: max after global install (#1319)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*max$/m, + `gsd-execute-phase SKILL.md must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `gsd-execute-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); + + test('gsd-plan-phase SKILL.md does NOT have context: fork after global install (#921)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase'); + const fm = readFrontmatter(skillPath); + assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, + `gsd-plan-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); + }); + + test('gsd-plan-phase SKILL.md has effort: max after global install (#1319)', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*max$/m, + `gsd-plan-phase SKILL.md must have effort: max\nActual:\n${fm}`); + assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m, + `gsd-plan-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`); + }); + + test('gsd-progress SKILL.md has effort: low after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'progress'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*low$/m, + `gsd-progress SKILL.md must have effort: low\nActual:\n${fm}`); + }); + + test('gsd-stats SKILL.md has effort: low after global install', () => { + runClaudeGlobalInstall(claudeHome); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'stats'); + const fm = readFrontmatter(skillPath); + assert.match(fm, /^effort:[ \t]*low$/m, + `gsd-stats SKILL.md must have effort: low\nActual:\n${fm}`); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/feat-443-effort-install-wiring.install.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:feat-443-effort-install-wiring.install (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: integration-test-input (see #443) +// Exercises install() + generateCodexAgentToml() as a black-box by inspecting +// produced output files in temp dirs. Source agent .md files are inputs whose +// installed transformation is asserted — not inspected for string presence. + +/** + * #443 — Effort per-runtime wiring at install time. + * + * Verifies: + * 1. Claude global install injects `effort:` into agent .md frontmatter. + * 2. Gemini global install does NOT inject `effort:` (Gemini-safe .md). + * 3. Codex inherited-model installs omit `model_reasoning_effort` so model + * and effort are not partially pinned (#838). + * 4. Config-driven proof: effort.agent_overrides wins over tier defaults + * for Claude .md and for Codex .toml when runtime:"codex" pins a model. + * 5. Source agents/gsd-planner.md has NO effort: key (injection is + * install-only, source stays Gemini-safe). + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { install } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const SOURCE_AGENTS_DIR = path.join(REPO_ROOT, 'agents'); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function makeTmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function readFrontmatter(mdPath) { + const content = fs.readFileSync(mdPath, 'utf8'); + if (!content.startsWith('---')) return ''; + const end = content.indexOf('---', 3); + if (end === -1) return ''; + return content.substring(3, end); +} + +/** + * Run a global install for the given runtime, redirecting its home dir to + * tmpHome. Returns the tmpHome for inspection. + * + * Env-var redirection: + * claude → CLAUDE_CONFIG_DIR + * gemini → GEMINI_CONFIG_DIR + * codex → CODEX_HOME + * + * HOME is also redirected to an isolated temp dir for the duration of the + * install call. This prevents any install.js code that uses os.homedir() + * directly (e.g. ~/.cache/gsd update-check deletion, ~/.gsd/defaults.json + * reads, stale-SDK npm subprocess writes to ~/.npm) from touching the real + * HOME and polluting the test environment for other concurrently-running + * test files (e.g. runtime-launcher-parity test (D) checks that + * $HOME/.claude/gsd-core/bin/gsd-tools.cjs is absent). + * + * GSD_SKIP_STALE_SDK_CHECK=1 is set to suppress the `npm ls -g` subprocess + * that the installer spawns for global installs — that subprocess is slow, + * writes to ~/.npm cache, and is irrelevant to effort-wiring assertions. + * + * The working directory is set to REPO_ROOT so install() can find the source + * agents/. For config-driven tests, place tmpHome inside the project dir + * so that readGsdEffectiveEffortConfig(targetDir) can walk up from tmpHome + * and find .planning/config.json. + */ +function runGlobalInstall(runtime, tmpHome) { + const envVarMap = { + claude: 'CLAUDE_CONFIG_DIR', + gemini: 'GEMINI_CONFIG_DIR', + codex: 'CODEX_HOME', + }; + const envVar = envVarMap[runtime]; + if (!envVar) throw new Error(`Unsupported runtime in test: ${runtime}`); + + // Isolate HOME to a fresh temp dir so install.js code that calls + // os.homedir() (cache deletion, defaults.json reads, npm subprocess) + // never touches the real $HOME/.claude / $HOME/.cache / $HOME/.gsd. + const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-443-home-')); + + const prev = process.env[envVar]; + const prevCwd = process.cwd(); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK; + + process.env[envVar] = tmpHome; + process.env.HOME = isolatedHome; + process.env.USERPROFILE = isolatedHome; + process.env.GSD_SKIP_STALE_SDK_CHECK = '1'; + process.chdir(REPO_ROOT); + + try { + install(true, runtime); + } finally { + process.chdir(prevCwd); + if (prev === undefined) delete process.env[envVar]; + else process.env[envVar] = prev; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK; + else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale; + // Clean up the isolated HOME dir + cleanup(isolatedHome); + } + + return tmpHome; +} + +// ─── Tier default expectations ──────────────────────────────────────────────── +// light → low, standard → high, heavy → xhigh (catalog defaults) +// gsd-planner: heavy → xhigh +// gsd-codebase-mapper: light → low +// gsd-executor: standard → high + +// ─── describe 1: Claude install injects effort: ─────────────────────────────── + +describe('#443 Claude install: effort: injected into frontmatter', () => { + let tmpDir; + let claudeHome; + + beforeEach(() => { + tmpDir = makeTmpDir('gsd-443-claude-'); + claudeHome = path.join(tmpDir, 'claude-home'); + fs.mkdirSync(claudeHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-planner.md contains effort: xhigh (heavy tier default)', () => { + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); + assert.match(fm, /^effort:\s*xhigh$/m, + `gsd-planner frontmatter should have effort: xhigh\nActual:\n${fm}`); + }); + + test('gsd-codebase-mapper.md contains effort: low (light tier default)', () => { + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-codebase-mapper.md')); + assert.match(fm, /^effort:\s*low$/m, + `gsd-codebase-mapper frontmatter should have effort: low\nActual:\n${fm}`); + }); + + test('gsd-executor.md contains effort: high (standard tier default)', () => { + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-executor.md')); + assert.match(fm, /^effort:\s*high$/m, + `gsd-executor frontmatter should have effort: high\nActual:\n${fm}`); + }); +}); + +// ─── describe 2: Gemini install does NOT inject effort: ────────────────────── + +describe('#443 Gemini install: effort: absent (Gemini-safe)', () => { + let tmpDir; + let geminiHome; + + beforeEach(() => { + tmpDir = makeTmpDir('gsd-443-gemini-'); + geminiHome = path.join(tmpDir, 'gemini-home'); + fs.mkdirSync(geminiHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-planner.md does NOT contain effort: (Gemini install)', () => { + runGlobalInstall('gemini', geminiHome); + const fm = readFrontmatter(path.join(geminiHome, 'agents', 'gsd-planner.md')); + assert.doesNotMatch(fm, /^effort:/m, + `gsd-planner (Gemini) frontmatter must NOT have effort:\nActual:\n${fm}`); + }); + + test('gsd-executor.md does NOT contain effort: (Gemini install)', () => { + runGlobalInstall('gemini', geminiHome); + const fm = readFrontmatter(path.join(geminiHome, 'agents', 'gsd-executor.md')); + assert.doesNotMatch(fm, /^effort:/m, + `gsd-executor (Gemini) frontmatter must NOT have effort:\nActual:\n${fm}`); + }); +}); + +// ─── describe 3: Codex inherited-model install omits model_reasoning_effort ── + +describe('#838 Codex install: inherited model omits model_reasoning_effort', () => { + let tmpDir; + let codexHome; + + beforeEach(() => { + tmpDir = makeTmpDir('gsd-443-codex-'); + codexHome = path.join(tmpDir, 'codex-home'); + fs.mkdirSync(codexHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-planner.toml omits both model and model_reasoning_effort when model is inherited', () => { + runGlobalInstall('codex', codexHome); + const tomlContent = fs.readFileSync( + path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' + ); + assert.doesNotMatch(tomlContent, /^model\s*=/m, + `gsd-planner.toml should omit model when inheriting Codex chat model\nActual:\n${tomlContent.slice(0, 500)}`); + assert.doesNotMatch(tomlContent, /^model_reasoning_effort\s*=/m, + `gsd-planner.toml should omit model_reasoning_effort when model is inherited\nActual:\n${tomlContent.slice(0, 500)}`); + }); +}); + +// ─── describe 4: Config-driven proof ───────────────────────────────────────── +// +// The runtime home dir must be INSIDE (or a sibling of) the project root so +// that readGsdEffectiveEffortConfig(targetDir) can walk up from the runtime +// home and find .planning/config.json. We put .claude/ and .codex/ as siblings +// of .planning/ inside the project dir — this is the natural local-install shape. + +describe('#443 Config-driven: effort.agent_overrides drives install-time effort', () => { + let tmpDir; + let claudeHome; + let codexHome; + + beforeEach(() => { + // Layout: tmpDir/project/ <-- project root (cwd for install) + // .planning/config.json + // .claude/ <-- claudeHome (CLAUDE_CONFIG_DIR) + // .codex/ <-- codexHome (CODEX_HOME) + tmpDir = makeTmpDir('gsd-443-cfg-'); + const projectDir = path.join(tmpDir, 'project'); + claudeHome = path.join(projectDir, '.claude'); + codexHome = path.join(projectDir, '.codex'); + + fs.mkdirSync(claudeHome, { recursive: true }); + fs.mkdirSync(codexHome, { recursive: true }); + fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true }); + + // Write a project config with effort.agent_overrides overriding gsd-planner to 'low'. + // runtime:"codex" pins a Codex-native model, so emitting model_reasoning_effort + // remains valid under the #838 model/effort coupling rule. + const config = { + runtime: 'codex', + effort: { + agent_overrides: { + 'gsd-planner': 'low', + }, + }, + }; + fs.writeFileSync( + path.join(projectDir, '.planning', 'config.json'), + JSON.stringify(config, null, 2) + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('Claude .md gets effort: low when agent_overrides.gsd-planner=low', () => { + // projectDir is the cwd for install — chdir handled inside runGlobalInstall. + // claudeHome is inside projectDir, so walking up from claudeHome finds .planning/config.json. + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); + assert.match(fm, /^effort:\s*low$/m, + `gsd-planner should have effort: low from config override\nActual:\n${fm}`); + }); + + test('Codex .toml gets model_reasoning_effort = "low" when agent_overrides.gsd-planner=low', () => { + runGlobalInstall('codex', codexHome); + const tomlContent = fs.readFileSync( + path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' + ); + assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, + `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); + assert.match(tomlContent, /^model_reasoning_effort\s*=\s*"low"$/m, + `gsd-planner.toml should have model_reasoning_effort = "low" from config override\nActual:\n${tomlContent.slice(0, 500)}`); + }); + + test('Codex .toml clamps effort max → xhigh when agent_overrides.gsd-planner=max', () => { + const projectDir = path.dirname(codexHome); + // Overwrite config with max override + const config = { + runtime: 'codex', + effort: { + agent_overrides: { + 'gsd-planner': 'max', + }, + }, + }; + fs.writeFileSync( + path.join(projectDir, '.planning', 'config.json'), + JSON.stringify(config, null, 2) + ); + + runGlobalInstall('codex', codexHome); + const tomlContent = fs.readFileSync( + path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' + ); + assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, + `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); + // Codex does not support 'max' → clamped to 'xhigh' + assert.match(tomlContent, /^model_reasoning_effort\s*=\s*"xhigh"$/m, + `gsd-planner.toml should clamp max → xhigh for Codex\nActual:\n${tomlContent.slice(0, 500)}`); + assert.doesNotMatch(tomlContent, /model_reasoning_effort\s*=\s*"max"/, + 'Codex .toml must never contain model_reasoning_effort = "max"'); + }); +}); + +// ─── describe 5b: Invalid effort tokens fall through (Codex adversarial finding #2) ─ +// +// These tests FAIL before the fix: resolveInstallTimeEffort returns the raw +// invalid string without validating it against VALID_EFFORTS. + +describe('#443 resolveInstallTimeEffort: invalid tokens fall through to valid effort', () => { + let tmpDir; + let claudeHome; + let codexHome; + + beforeEach(() => { + // Layout: tmpDir/project/ <-- project root + // .planning/config.json + // .claude/ <-- claudeHome + // .codex/ <-- codexHome + tmpDir = makeTmpDir('gsd-443-invalid-effort-'); + const projectDir = path.join(tmpDir, 'project'); + claudeHome = path.join(projectDir, '.claude'); + codexHome = path.join(projectDir, '.codex'); + + fs.mkdirSync(claudeHome, { recursive: true }); + fs.mkdirSync(codexHome, { recursive: true }); + fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + function writeProjectConfig(config) { + const projectDir = path.dirname(claudeHome); + fs.writeFileSync( + path.join(projectDir, '.planning', 'config.json'), + JSON.stringify(config, null, 2) + ); + } + + const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max']; + + test('effort.default="ultra" (invalid) -> Claude .md effort: is a VALID value (falls through to high)', () => { + // BUG before fix: resolveInstallTimeEffort returns "ultra" verbatim + writeProjectConfig({ effort: { default: 'ultra' } }); + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); + const match = fm.match(/^effort:\s*(\S+)$/m); + assert.ok(match, `effort: must be present in frontmatter\nActual:\n${fm}`); + assert.ok(VALID_EFFORTS.includes(match[1]), + `effort: must be a VALID effort string, got: "${match[1]}"\nActual frontmatter:\n${fm}`); + }); + + test('effort.agent_overrides.gsd-planner="bogus" (invalid) with valid default -> falls through to valid default', () => { + // BUG before fix: "bogus" is returned and written verbatim + writeProjectConfig({ + effort: { + agent_overrides: { 'gsd-planner': 'bogus' }, + default: 'medium', + }, + }); + runGlobalInstall('claude', claudeHome); + const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md')); + const match = fm.match(/^effort:\s*(\S+)$/m); + assert.ok(match, `effort: must be present in frontmatter\nActual:\n${fm}`); + assert.ok(VALID_EFFORTS.includes(match[1]), + `effort: must be a VALID effort string, got: "${match[1]}"\nActual frontmatter:\n${fm}`); + // Falls through invalid "bogus" -> valid tier default or "medium" default + // "medium" is valid, so it should appear (or tier default if medium is invalid, but medium is valid) + }); + + test('effort.default="ultra" (invalid) + runtime:"codex" -> Codex .toml model_reasoning_effort is VALID', () => { + // BUG before fix: "ultra" written into .toml verbatim + writeProjectConfig({ runtime: 'codex', effort: { default: 'ultra' } }); + runGlobalInstall('codex', codexHome); + const tomlContent = fs.readFileSync( + path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8' + ); + assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m, + `gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`); + const match = tomlContent.match(/^model_reasoning_effort\s*=\s*"([^"]+)"/m); + assert.ok(match, `model_reasoning_effort must be present in .toml\nActual:\n${tomlContent.slice(0, 500)}`); + assert.ok(VALID_EFFORTS.includes(match[1]), + `model_reasoning_effort must be VALID, got: "${match[1]}"\nActual:\n${tomlContent.slice(0, 500)}`); + }); +}); + +// ─── describe 5: Source stays clean ────────────────────────────────────────── + +describe('#443 Source purity: agents/gsd-planner.md has no effort: key', () => { + test('source agents/gsd-planner.md frontmatter does not contain effort:', () => { + const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-planner.md')); + assert.doesNotMatch(fm, /^effort:/m, + `Source agents/gsd-planner.md must NOT contain effort: (injection is install-only)`); + }); + + test('source agents/gsd-executor.md frontmatter does not contain effort:', () => { + const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-executor.md')); + assert.doesNotMatch(fm, /^effort:/m, + `Source agents/gsd-executor.md must NOT contain effort: (injection is install-only)`); + }); + + test('source agents/gsd-codebase-mapper.md frontmatter does not contain effort:', () => { + const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-codebase-mapper.md')); + assert.doesNotMatch(fm, /^effort:/m, + `Source agents/gsd-codebase-mapper.md must NOT contain effort: (injection is install-only)`); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1510-rewrite-engine-helper-relocation.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1510-rewrite-engine-helper-relocation (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +// Enhancement #1510 (epic #1507, ADR-1508 Phase 1): behavior-preserving +// relocation of pure rewrite-engine helpers out of hand-authored bin/install.js. +// - getDirName -> gsd-core/bin/lib/runtime-name-policy.cjs +// - processAttribution -> gsd-core/bin/lib/runtime-artifact-conversion.cjs +// getCommitAttribution stays in install.js (impure install-time config I/O); the +// convertClaudeToAugmentMarkdown duplicate dedup is deferred to Phase 2's cleanup +// (entangled converter cluster; not required to unblock Phase 2). +// These tests exercise the REAL relocated functions at their new home (the +// generated .cjs) and assert install.js re-exports the SAME references +// (Hyrum: existing consumers import these names from bin/install.js). + +const { test, describe } = require('node:test'); +const assert = require('node:assert'); + +const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); +const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); +const installer = require('../bin/install.js'); + +// ── Slice A: getDirName relocated to runtime-name-policy ────────────────────── +describe('getDirName (relocated to runtime-name-policy)', () => { + const EXPECTED = { + claude: '.claude', + copilot: '.github', + opencode: '.opencode', + gemini: '.gemini', + kilo: '.kilo', + codex: '.codex', + antigravity: '.agents', + cursor: '.cursor', + windsurf: '.windsurf', + augment: '.augment', + trae: '.trae', + qwen: '.qwen', + hermes: '.hermes', + kimi: '.kimi-code', + codebuddy: '.codebuddy', + cline: '.cline', + }; + + for (const [runtime, dir] of Object.entries(EXPECTED)) { + test(`maps '${runtime}' to '${dir}'`, () => { + assert.strictEqual(runtimeNamePolicy.getDirName(runtime), dir); + }); + } + + test('falls back to .claude for an unknown runtime', () => { + assert.strictEqual(runtimeNamePolicy.getDirName('definitely-not-a-runtime'), '.claude'); + }); + + test('falls back to .claude for empty input', () => { + assert.strictEqual(runtimeNamePolicy.getDirName(''), '.claude'); + }); + + test('bin/install.js re-exports the SAME getDirName reference (no drift)', () => { + assert.strictEqual(installer.getDirName, runtimeNamePolicy.getDirName); + }); +}); + +// ── Slice B: processAttribution relocated to runtime-artifact-conversion ─────── +describe('processAttribution (relocated to runtime-artifact-conversion)', () => { + test('null removes the Co-Authored-By line and its preceding blank line', () => { + const input = 'Commit body line.\n\nCo-Authored-By: Someone '; + assert.strictEqual(conversion.processAttribution(input, null), 'Commit body line.'); + }); + + test('undefined leaves content unchanged', () => { + const input = 'Commit body.\n\nCo-Authored-By: Someone '; + assert.strictEqual(conversion.processAttribution(input, undefined), input); + }); + + test('a string replaces the attribution value', () => { + const input = 'Body\n\nCo-Authored-By: Old Name '; + assert.strictEqual( + conversion.processAttribution(input, 'New Name '), + 'Body\n\nCo-Authored-By: New Name ', + ); + }); + + test('escapes $ in the attribution to prevent backreference injection', () => { + const input = 'Body\n\nCo-Authored-By: x'; + // "$1" must survive literally, not be interpreted as a regex backreference. + assert.strictEqual( + conversion.processAttribution(input, 'A $1 B'), + 'Body\n\nCo-Authored-By: A $1 B', + ); + }); + + test('handles CRLF when removing (null)', () => { + const input = 'Body\r\n\r\nCo-Authored-By: Someone '; + assert.strictEqual(conversion.processAttribution(input, null), 'Body'); + }); + + test('replaces every Co-Authored-By line (global)', () => { + const input = 'Body\nCo-Authored-By: A \nCo-Authored-By: B '; + assert.strictEqual( + conversion.processAttribution(input, 'Z '), + 'Body\nCo-Authored-By: Z \nCo-Authored-By: Z ', + ); + }); + + test('bin/install.js re-exports the SAME processAttribution reference (no drift)', () => { + // processAttribution remains an explicit installer compatibility relay, so + // the export must keep pointing at the conversion module's implementation. + assert.strictEqual(installer.processAttribution, conversion.processAttribution); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1511-rewrite-engine-relocation.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1511-rewrite-engine-relocation (consolidation epic #1969 B1 #1970)", () => { +'use strict'; +/** + * Tests for ADR-1508 Phase 2: rewrite engine relocation to runtime-artifact-conversion. + * Issue #1511 — verifies the deep public seam signatures and behavior. + * + * Tests are behavioral (no source-grep). All filesystem operations use tmp dirs. + */ + +const { describe, test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { cleanup } = require('./helpers.cjs'); + +let conversion; +before(() => { + process.env['GSD_TEST_MODE'] = '1'; + conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); +}); + +// --------------------------------------------------------------------------- +// _computePathPrefix unit tests +// --------------------------------------------------------------------------- + +describe('_computePathPrefix', () => { + test('global under home → $HOME/... form', () => { + const prefix = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: '/home/u/.cursor', + homeDir: '/home/u', + }); + assert.equal(prefix, '$HOME/.cursor/'); + }); + + test('non-global → resolvedTarget/ form', () => { + const prefix = conversion._computePathPrefix({ + isGlobal: false, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: '/project/.cursor', + homeDir: '/home/u', + }); + assert.equal(prefix, '/project/.cursor/'); + }); + + test('global opencode skips $HOME shorthand', () => { + // OpenCode uses ~/.config/opencode which breaks $HOME shorthand in content + const prefix = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: true, + isWindowsHost: false, + resolvedTarget: '/home/u/.config/opencode', + homeDir: '/home/u', + }); + assert.equal(prefix, '/home/u/.config/opencode/'); + }); + + test('global target outside home → resolvedTarget/ form', () => { + const prefix = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: '/opt/custom-cursor', + homeDir: '/home/u', + }); + assert.equal(prefix, '/opt/custom-cursor/'); + }); + + test('isWindowsHost tripwire — Windows paths collapse to $HOME/ same as POSIX (no-op today)', () => { + // Documents CURRENT behavior: isWindowsHost is accepted but not branched on. + // Both win32=true and win32=false return '$HOME/.cursor/' for a home-relative target. + // If a future Windows-specific branch is added, this tripwire fails and forces + // an explicit decision about what to return on Windows. + const withWindows = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: true, + resolvedTarget: 'C:/Users/matte/.cursor', + homeDir: 'C:/Users/matte', + }); + const withoutWindows = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: 'C:/Users/matte/.cursor', + homeDir: 'C:/Users/matte', + }); + assert.equal(withWindows, '$HOME/.cursor/'); + assert.strictEqual(withWindows, withoutWindows); + }); + + test('backslash-style resolvedTarget is normalized to forward slashes (#1615 regression)', () => { + // path.join on Windows produces backslashes; the returned prefix is + // substituted into markdown @-references which must use POSIX paths. + // Without normalization the backslashes leak into workflow file content + // and break substring checks on Windows CI. + const prefix = conversion._computePathPrefix({ + isGlobal: false, + isOpencode: false, + isWindowsHost: true, + resolvedTarget: 'C:\\Users\\runner\\AppData\\Local\\Temp\\gsd-1615-windsurf', + homeDir: 'C:\\Users\\runner', + }); + assert.strictEqual(prefix, 'C:/Users/runner/AppData/Local/Temp/gsd-1615-windsurf/'); + assert.ok(!prefix.includes('\\'), `prefix must not contain backslashes: ${prefix}`); + }); +}); + +// --------------------------------------------------------------------------- +// _applyRuntimeRewrites with injected attribution +// --------------------------------------------------------------------------- + +describe('_applyRuntimeRewrites — attribution injection', () => { + const PREFIX = '$HOME/.cursor/'; + + test('attribution=null removes Co-Authored-By line', () => { + const content = '# Hello\n\nSome text\n\nCo-Authored-By: Claude\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, null); + assert.ok(!result.includes('Co-Authored-By:'), 'Co-Authored-By should be removed'); + }); + + test('attribution=undefined leaves Co-Authored-By unchanged', () => { + const content = '# Hello\n\nCo-Authored-By: Claude\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, undefined); + assert.ok(result.includes('Co-Authored-By: Claude'), 'Co-Authored-By should be preserved when attribution=undefined'); + }); + + test('attribution=string replaces Co-Authored-By value', () => { + const content = '# Hello\n\nCo-Authored-By: OldName\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, 'NewName '); + assert.ok(result.includes('Co-Authored-By: NewName '), 'Co-Authored-By should be replaced'); + }); + + test('cursor runtime replaces ~/.claude/ paths', () => { + const content = 'See ~/.claude/skills/ for more info\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', '/home/u/.cursor/', false, undefined); + assert.ok(result.includes('/home/u/.cursor/skills/'), 'cursor should replace ~/.claude/ with pathPrefix'); + }); +}); + +// --------------------------------------------------------------------------- +// rewriteStagedSkillBodies — behavioral filesystem test +// --------------------------------------------------------------------------- + +describe('rewriteStagedSkillBodies', () => { + test('rewrites .md files in-place for cursor runtime', () => { + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); + try { + // Create a skill dir with a SKILL.md referencing ~/.claude/skills/foo + // NOTE: the rewrite engine handles path replacement and attribution only. + // Bash→Shell conversion is done by the stage-1 skill converter, not the engine. + const skillDir = path.join(stagedDir, 'gsd-test-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + const content = '# Test\n\nSee ~/.claude/skills/foo\n\nAlso ~/.cursor/skills/bar\n'; + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); + + // Call with injected homedir + platform for determinism + conversion.rewriteStagedSkillBodies(stagedDir, { + runtime: 'cursor', + configDir, + scope: 'global', + homedir: () => '/home/u', + platform: 'linux', + }); + + const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); + // cursor rewrites ~/.claude/ → pathPrefix + // configDir is a tmpdir, not under /home/u, so prefix = resolvedTarget + '/' + // Mirror the engine's backslash→slash normalization so the assertion holds on Windows. + const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); + assert.ok(result.includes(`${resolvedTarget}/skills/foo`), `Should replace ~/.claude/skills/ with ${resolvedTarget}/skills/`); + // cursor also rewrites ~/.cursor/ → pathPrefix + assert.ok(result.includes(`${resolvedTarget}/skills/bar`), `Should replace ~/.cursor/skills/ with ${resolvedTarget}/skills/`); + } finally { + cleanup(stagedDir); + cleanup(configDir); + } + }); + + test('with injected homedir: global under home uses $HOME prefix', () => { + // Real absolute path so Windows path.resolve does not re-root a POSIX literal onto a drive. + // The dir need not exist — the engine only string-processes it. + const HOME = path.resolve(os.tmpdir(), 'gsd-1511-fake-home'); + const configDir = path.join(HOME, '.cursor'); + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); + try { + const skillDir = path.join(stagedDir, 'gsd-help'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'Use ~/.claude/skills/ here\n'); + + conversion.rewriteStagedSkillBodies(stagedDir, { + runtime: 'cursor', + configDir, + scope: 'global', + homedir: () => HOME, + platform: process.platform, + }); + + const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); + assert.ok(result.includes('$HOME/.cursor/skills/'), 'Should use $HOME shorthand when configDir is under homedir'); + } finally { + cleanup(stagedDir); + } + }); + + test('non-existent stagedDir is a no-op', () => { + assert.doesNotThrow(() => { + conversion.rewriteStagedSkillBodies('/nonexistent/dir', { + runtime: 'cursor', + configDir: '/tmp/fake', + scope: 'global', + }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// rewriteStagedCommandBodies — returns temp dir, does not mutate source +// --------------------------------------------------------------------------- + +describe('rewriteStagedCommandBodies', () => { + test('returns a temp dir (not the source dir) with rewritten content', () => { + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-cmd-')); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); + let tempDir; + try { + // NOTE: rewrite engine handles path replacement + attribution, NOT tool renames. + fs.writeFileSync(path.join(stagedDir, 'help.md'), '# Help\n\nSee ~/.claude/skills/\n\nSee ~/.cursor/skills/\n'); + + tempDir = conversion.rewriteStagedCommandBodies(stagedDir, { + runtime: 'cursor', + configDir, + scope: 'global', + homedir: () => '/home/u', + platform: 'linux', + }); + + assert.notEqual(tempDir, stagedDir, 'must return a different dir, never the source'); + assert.ok(fs.existsSync(tempDir), 'returned tempDir should exist'); + + const result = fs.readFileSync(path.join(tempDir, 'help.md'), 'utf8'); + // Source dir should be unchanged + const source = fs.readFileSync(path.join(stagedDir, 'help.md'), 'utf8'); + assert.ok(source.includes('~/.claude/skills/'), 'source file must not be mutated'); + // configDir is /tmp/... (not under /home/u), so prefix = resolvedTarget + '/' + const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); + assert.ok(result.includes(`${resolvedTarget}/skills/`), 'output should have cursor path rewrite applied'); + // ~/.cursor/ also rewrites to prefix + assert.ok(!result.includes('~/.cursor/'), 'output should have ~/.cursor/ replaced too'); + } finally { + cleanup(stagedDir); + cleanup(configDir); + if (tempDir && tempDir !== stagedDir) { + cleanup(tempDir); + } + } + }); + + test('non-existent stagedDir returns stagedDir unchanged (safe)', () => { + const result = conversion.rewriteStagedCommandBodies('/nonexistent/dir', { + runtime: 'cursor', + configDir: '/tmp/fake', + scope: 'global', + }); + assert.equal(result, '/nonexistent/dir', 'should return input path unchanged for missing dir'); + }); +}); + +// --------------------------------------------------------------------------- +// Error-path: applyRuntimeContentRewritesForCommandsInPlace must rm the tempDir +// on any exception and NOT leave an orphaned gsd-cmd-rewrites-* directory. +// --------------------------------------------------------------------------- + +describe('applyRuntimeContentRewritesForCommandsInPlace — error-path tempDir cleanup', () => { + test('rmSync is called on the tempDir when readFileSync throws (deterministic monkeypatch)', () => { + // Asserting the injected error propagates proves the throw happens AFTER the tempDir is + // created (the function creates tempDir, then reads .md), so the catch's rmSync cleanup + // is genuinely exercised — deterministic on every platform/uid. + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-error-path-')); + fs.writeFileSync(path.join(stagedDir, 'x.md'), '# test\n'); + + const before = new Set( + fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')) + ); + + const origReadFileSync = fs.readFileSync; + let leaked = []; + try { + fs.readFileSync = () => { throw new Error('injected read failure'); }; + + assert.throws( + () => conversion.applyRuntimeContentRewritesForCommandsInPlace(stagedDir, 'cursor', '/tmp/x/', false), + /injected read failure/, + ); + + // Restore before any further fs use so the snapshot read is trustworthy. + fs.readFileSync = origReadFileSync; + + const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-')); + leaked = after.filter(n => !before.has(n)); + assert.deepStrictEqual(leaked, [], `tempDir not cleaned up on error: ${leaked.join(',')}`); + } finally { + // Idempotent restore — guard against early-throw paths above. + fs.readFileSync = origReadFileSync; + // Clean up the staged dir created for this test. + cleanup(stagedDir); + // Clean up any genuinely leaked gsd-cmd-rewrites-* dirs so the runner stays clean. + for (const n of leaked) { + cleanup(path.join(os.tmpdir(), n)); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// Guard: runtime-artifact-layout no longer exports getInstallExports +// --------------------------------------------------------------------------- + +describe('layout module no longer exports getInstallExports', () => { + test('getInstallExports is not on the layout module export', () => { + process.env['GSD_TEST_MODE'] = '1'; + const layout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + assert.equal( + typeof layout.getInstallExports, + 'undefined', + 'getInstallExports should have been removed from runtime-artifact-layout exports (ADR-1508 Phase 2)', + ); + }); +}); + +// --------------------------------------------------------------------------- +// DEFECT.GENERATIVE-FIX: single-owner reference-identity guard (#1511) +// Proves install.js binds to the conversion module's implementation, not a +// duplicate local copy. If these fail, a duplicate body was re-introduced. +// --------------------------------------------------------------------------- + +describe('single-owner reference-identity guard (ADR-1508 / #1511 Phase 2)', () => { + let install; + let conversionCjs; + before(() => { + process.env['GSD_TEST_MODE'] = '1'; + install = require('../bin/install.js'); + conversionCjs = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); + }); + + test('install.computePathPrefix === conversion._computePathPrefix (single implementation)', () => { + assert.strictEqual( + install.computePathPrefix, + conversionCjs._computePathPrefix, + 'install.js must bind computePathPrefix from conversion (not a duplicate body)', + ); + }); + + test('install.applyRuntimeContentRewritesInPlace === conversion.applyRuntimeContentRewritesInPlace (single walk loop)', () => { + assert.strictEqual( + install.applyRuntimeContentRewritesInPlace, + conversionCjs.applyRuntimeContentRewritesInPlace, + 'install.js must bind applyRuntimeContentRewritesInPlace from conversion (not a duplicate walk loop)', + ); + }); + + test('install.applyRuntimeContentRewritesForCommandsInPlace === conversion.applyRuntimeContentRewritesForCommandsInPlace (single copy+rewrite loop)', () => { + assert.strictEqual( + install.applyRuntimeContentRewritesForCommandsInPlace, + conversionCjs.applyRuntimeContentRewritesForCommandsInPlace, + 'install.js must bind applyRuntimeContentRewritesForCommandsInPlace from conversion (not a duplicate copy+rewrite loop)', + ); + }); + + test('install._applyRuntimeRewrites === conversion._applyRuntimeRewrites (single switch engine)', () => { + assert.strictEqual( + install._applyRuntimeRewrites, + conversionCjs._applyRuntimeRewrites, + 'install.js must bind _applyRuntimeRewrites from conversion (not a local shim)', + ); + }); + + // #1675 (ADR-1508): the augment converter family is single-sourced in the + // conversion module. install.js must re-bind (not re-define) these so there + // is exactly one body — the generative-drift hazard the dedup removes. + test('install.convertClaudeToAugmentMarkdown === conversion.convertClaudeToAugmentMarkdown (single converter)', () => { + assert.strictEqual( + install.convertClaudeToAugmentMarkdown, + conversionCjs.convertClaudeToAugmentMarkdown, + 'install.js must bind convertClaudeToAugmentMarkdown from conversion (not a duplicate body)', + ); + }); + + test('install.convertClaudeCommandToAugmentSkill === conversion.convertClaudeCommandToAugmentSkill (single converter)', () => { + assert.strictEqual( + install.convertClaudeCommandToAugmentSkill, + conversionCjs.convertClaudeCommandToAugmentSkill, + 'install.js must bind convertClaudeCommandToAugmentSkill from conversion (not a duplicate body)', + ); + }); + + test('install.convertClaudeAgentToAugmentAgent === conversion.convertClaudeAgentToAugmentAgent (single converter)', () => { + assert.strictEqual( + install.convertClaudeAgentToAugmentAgent, + conversionCjs.convertClaudeAgentToAugmentAgent, + 'install.js must bind convertClaudeAgentToAugmentAgent from conversion (not a duplicate body)', + ); + }); +}); + }); +} diff --git a/tests/install-write-confinement.test.cjs b/tests/install-write-confinement.test.cjs index 79d84e322..2524d2b70 100644 --- a/tests/install-write-confinement.test.cjs +++ b/tests/install-write-confinement.test.cjs @@ -383,3 +383,571 @@ describe('_copyStaged write-confinement', () => { } }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2998-pristine-dir-populated.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2998-pristine-dir-populated (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #2998: gsd-pristine/ snapshot is documented but never populated by + * the installer. saveLocalPatches declared a pristineDir variable and + * promised "saves pristine copies (from manifest) to gsd-pristine/ to + * enable three-way merge during reapply-patches" -- but no code ever + * wrote to that directory. Effect: the /gsd-reapply-patches Step 5 + * verifier (#2972) silently degrades to its over-broad fallback heuristic + * ("every significant backup line"), exactly the silent-success-on-lost- + * content failure mode #2969 was designed to prevent. + * + * Fix: new populatePristineDir({...}) helper runs the install transform + * pipeline (copyWithPathReplacement) into a tmp staging dir, then copies + * out the modified-file paths into gsd-pristine/. saveLocalPatches now + * accepts a pristineCtx and calls the helper when local patches are + * detected. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const crypto = require('node:crypto'); + +const ROOT = path.join(__dirname, '..'); +const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); +const { cleanup } = require('./helpers.cjs'); + +function sha256(content) { + return crypto.createHash('sha256').update(content).digest('hex'); +} + +describe('Bug #2998: populatePristineDir is exported and writes pristine for modified files', () => { + test('exported as a function', () => { + assert.equal(typeof INSTALL.populatePristineDir, 'function', + 'expected populatePristineDir in install.js exports (#2998)'); + }); + + test('returns 0 when no files are modified (no-op)', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-')); + try { + const written = INSTALL.populatePristineDir({ + packageSrc: ROOT, + pristineDir: path.join(tmp, 'gsd-pristine'), + modified: [], + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }); + assert.equal(written, 0); + } finally { + cleanup(tmp); + } + }); + + test('writes one pristine file per modified path that exists in source', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-')); + const pristineDir = path.join(tmp, 'gsd-pristine'); + try { + // Pick a real installed-side relPath from the package source. The + // install transforms map source `gsd-core/` to installed + // `gsd-core/` for skills-aware runtimes (like claude), + // so the relPath is the same on both sides. + const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md'); + const sourcePath = path.join(ROOT, candidate); + assert.equal(fs.existsSync(sourcePath), true, + `precondition: source file exists at ${candidate}`); + const written = INSTALL.populatePristineDir({ + packageSrc: ROOT, + pristineDir, + modified: [candidate], + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }); + assert.equal(written, 1, 'expected exactly one pristine file written'); + const out = path.join(pristineDir, candidate); + assert.equal(fs.existsSync(out), true, `expected pristine file at ${out}`); + // The pristine content should be the transformed version (not raw source): + // copyWithPathReplacement substitutes ~/.claude/ for the runtime path prefix. + // For claude+global, the prefix is $HOME/.claude/ which equals the original, + // so the transform is effectively identity here. We assert the content is a + // non-empty markdown file rather than asserting on transform specifics. + const content = fs.readFileSync(out, 'utf-8'); + assert.ok(content.length > 0, 'pristine file should be non-empty'); + } finally { + cleanup(tmp); + } + }); + + test('skips paths not present in source (does not corrupt pristine with stale data)', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-')); + const pristineDir = path.join(tmp, 'gsd-pristine'); + try { + const written = INSTALL.populatePristineDir({ + packageSrc: ROOT, + pristineDir, + modified: ['gsd-core/this-path-does-not-exist.md'], + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }); + assert.equal(written, 0, 'expected zero pristine files for non-existent source paths'); + const out = path.join(pristineDir, 'gsd-core/this-path-does-not-exist.md'); + assert.equal(fs.existsSync(out), false, 'pristine should not contain ghost paths'); + } finally { + cleanup(tmp); + } + }); + + test('pristine files have stable content (transformations are deterministic)', () => { + // Determinism is what makes the verifier's hash check meaningful: + // backup-meta.json records pristine_hashes computed at this same step, + // so re-running with the same inputs must yield byte-identical files. + const tmp1 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d1-')); + const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d2-')); + try { + const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md'); + const ctx = { + packageSrc: ROOT, + modified: [candidate], + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }; + INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp1, 'gsd-pristine') }, ctx)); + INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp2, 'gsd-pristine') }, ctx)); + const a = fs.readFileSync(path.join(tmp1, 'gsd-pristine', candidate)); + const b = fs.readFileSync(path.join(tmp2, 'gsd-pristine', candidate)); + assert.equal(sha256(a), sha256(b), 'two runs of the same inputs must yield identical pristine content'); + } finally { + cleanup(tmp1); + cleanup(tmp2); + } + }); +}); + +// ─── #3004 CR follow-up: multi-root pristine expansion ───────────────────── + +describe('Bug #2998 (#3004 CR): pristine expansion covers every manifest install root', () => { + test('paths under agents/ are staged via copyWithPathReplacement, not silently skipped', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-multi-')); + const pristineDir = path.join(tmp, 'gsd-pristine'); + try { + const candidate = path.join('agents', 'gsd-planner.md'); + const sourcePath = path.join(ROOT, candidate); + assert.equal(fs.existsSync(sourcePath), true, + `precondition: source file exists at ${candidate}`); + const written = INSTALL.populatePristineDir({ + packageSrc: ROOT, + pristineDir, + modified: [candidate], + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }); + assert.equal(written, 1, 'expected agents/ path to be staged and copied to pristine'); + assert.equal(fs.existsSync(path.join(pristineDir, candidate)), true); + } finally { + cleanup(tmp); + } + }); + + test('a mix of gsd-core/ and agents/ paths in modified list are all staged', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-mix-')); + const pristineDir = path.join(tmp, 'gsd-pristine'); + try { + const a = path.join('gsd-core', 'workflows', 'reapply-patches.md'); + const b = path.join('agents', 'gsd-planner.md'); + assert.equal(fs.existsSync(path.join(ROOT, a)), true); + assert.equal(fs.existsSync(path.join(ROOT, b)), true); + const written = INSTALL.populatePristineDir({ + packageSrc: ROOT, + pristineDir, + modified: [a, b], + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }); + assert.equal(written, 2, 'expected both top-level dirs to be staged'); + assert.equal(fs.existsSync(path.join(pristineDir, a)), true); + assert.equal(fs.existsSync(path.join(pristineDir, b)), true); + } finally { + cleanup(tmp); + } + }); +}); + +describe('Bug #2998: saveLocalPatches no longer leaves the pristineDir variable unused', () => { + test('saveLocalPatches accepts a pristineCtx and exposes the helper for direct testing', () => { + // Structural assertion: the function exists with the new signature shape. + // Behavioral end-to-end is covered by the populatePristineDir tests above + // (that helper is what saveLocalPatches calls internally). + assert.equal(typeof INSTALL.populatePristineDir, 'function'); + // The signature for saveLocalPatches isn't exported, but the helper IS, + // and it's the unit of behavior the bug is about. Asserting on the helper + // is the structural-IR equivalent of the no-source-grep convention. + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3407-pristine-stale-content.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3407-pristine-stale-content (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #3407: Installer leaves stale content in gsd-pristine/ + * + * Root cause: populatePristineDir() in saveLocalPatches() snapshots from + * pristineCtx.packageSrc — the NEWLY-downloaded release tree — and writes + * those bytes into gsd-pristine/. For files changed between the old and new + * release, this writes the NEW bytes into the pristine baseline instead of + * the OLD bytes. The three-way-diff verifier then classifies upstream-changed + * lines as user-added → Step 5a gate fails with false FAIL_USER_LINES_MISSING. + * + * The #3657 fix (OK_PRISTINE_DRIFT_DETECTED) was a symptom workaround: the + * verifier detects hash mismatch (backup-meta.json records old-release hash + * but gsd-pristine/ has new-release bytes) and skips to over-broad mode + * instead of false-failing. The root-cause stale write was never fixed. + * + * Fix: when a correctly-populated gsd-pristine/ already exists from the + * previous install (i.e., the file's sha256 matches the originalHash recorded + * in the manifest), preserve it — do NOT wipe and re-populate from the new + * release source. This ensures gsd-pristine/ holds old-release bytes even + * after an upgrade where the file content changed upstream. + * + * Regression contract (byte-comparison): + * After saveLocalPatches() is called with a user-modified file whose + * gsd-pristine/ entry was correctly set by the previous install, the + * gsd-pristine/ file MUST still contain the old-release bytes, not the + * new-release bytes supplied in pristineCtx.packageSrc. + * + * Closes: #3407 + */ + +const { test, describe, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const crypto = require('node:crypto'); + +const ROOT = path.join(__dirname, '..'); +const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); +const { cleanup } = require('./helpers.cjs'); + +const MANIFEST_NAME = 'gsd-file-manifest.json'; +const PATCHES_DIR_NAME = 'gsd-local-patches'; + +function sha256(content) { + return crypto.createHash('sha256').update(content instanceof Buffer ? content : Buffer.from(content)).digest('hex'); +} + +// ─── Bug #3407: gsd-pristine/ must preserve OLD-release bytes across upgrade ── + +describe('Bug #3407: saveLocalPatches preserves old-release pristine across upgrade', () => { + let tmpDir; + let configDir; + let fakeSrcDir; + + beforeEach((t) => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3407-')); + configDir = path.join(tmpDir, 'config'); + fakeSrcDir = path.join(tmpDir, 'new-release-src'); + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(fakeSrcDir, { recursive: true }); + t.after(() => { + cleanup(tmpDir); + }); + }); + + /** + * Core regression test. + * + * Timeline: + * Install v1: file content = OLD_RELEASE_CONTENT, gsd-pristine/ROOT_FILE + * = OLD_RELEASE_CONTENT (correctly set by previous install), + * manifest hash = sha256(OLD_RELEASE_CONTENT) + * User edits: configDir/ROOT_FILE = USER_MODIFIED_CONTENT + * Upgrade v2: pristineCtx.packageSrc has NEW_RELEASE_CONTENT for ROOT_FILE + * saveLocalPatches is called before the wipe. + * + * Expected AFTER fix: gsd-pristine/ROOT_FILE still == OLD_RELEASE_CONTENT + * Actual BEFORE fix: gsd-pristine/ROOT_FILE == NEW_RELEASE_CONTENT (stale) + */ + test('gsd-pristine/ retains old-release bytes when upgrading a user-modified file', () => { + const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1 pristine.\n'; + const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — upstream changed this line.\n'; + const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1 pristine.\n## User addition\nUser customization here.\n'; + + const oldHash = sha256(OLD_RELEASE_CONTENT); + + // Simulate a root-level installed file. Root-level files in the manifest + // are denoted without a subdirectory (slash-free relPath). + const relPath = 'test-root-file.md'; + + // Set up configDir: user-modified installed file + manifest recording old hash + fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); + fs.writeFileSync( + path.join(configDir, MANIFEST_NAME), + JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) + ); + + // Set up fakeSrcDir (new release): the file has NEW content + fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT); + + // Set up gsd-pristine/ with OLD content (as correctly populated by previous install) + const pristineDir = path.join(configDir, 'gsd-pristine'); + fs.mkdirSync(pristineDir, { recursive: true }); + fs.writeFileSync(path.join(pristineDir, relPath), OLD_RELEASE_CONTENT); + + // Call saveLocalPatches with the new release as packageSrc (the buggy scenario) + INSTALL.saveLocalPatches(configDir, { + packageSrc: fakeSrcDir, + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }); + + // Assert: gsd-pristine/ must still contain OLD-release bytes + const pristineFile = path.join(pristineDir, relPath); + assert.ok( + fs.existsSync(pristineFile), + `gsd-pristine/${relPath} must exist after saveLocalPatches` + ); + + const actualPristineContent = fs.readFileSync(pristineFile, 'utf8'); + assert.equal( + sha256(actualPristineContent), + oldHash, + [ + `gsd-pristine/${relPath} must contain OLD-release bytes (sha256=${oldHash.slice(0, 12)}…)`, + `but got sha256=${sha256(actualPristineContent).slice(0, 12)}…`, + `(If equal to sha256(NEW_RELEASE_CONTENT)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… then #3407 is NOT fixed)`, + ].join(' ') + ); + + // Secondary: confirm backup-meta records the old hash (not new) + const backupMeta = JSON.parse( + fs.readFileSync(path.join(configDir, PATCHES_DIR_NAME, 'backup-meta.json'), 'utf8') + ); + assert.ok( + Object.prototype.hasOwnProperty.call(backupMeta.pristine_hashes, relPath), + 'backup-meta.json must record pristine_hash for modified file' + ); + assert.equal( + backupMeta.pristine_hashes[relPath], + oldHash, + 'backup-meta.json pristine_hash must equal old-release hash (not new-release hash)' + ); + }); + + /** + * Regression test for Codex finding: when gsd-pristine/ entry is absent + * (e.g., post-buggy-run deletion or first upgrade without prior pristine) + * but the file is UNCHANGED between old and new release, the hash-validated + * regeneration path must restore the pristine entry using new-release source. + * + * When sha256(newReleaseBytesForFile) === originalHash, the file is identical + * between releases — new-release generated bytes ARE the old-release pristine + * and may be safely promoted. + * + * Previously (before the regeneration path was added): missing entries were + * left absent unconditionally, causing permanent over-broad fallback even + * when the file was unchanged upstream. + */ + test('gsd-pristine/ is regenerated for missing entries when file is unchanged between releases', () => { + const SHARED_RELEASE_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n'; + const USER_MODIFIED_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n## User addition\nCustom.\n'; + + const oldHash = sha256(SHARED_RELEASE_CONTENT); + const relPath = 'test-unchanged-file.md'; + + // configDir has user-modified file + manifest with old-release hash + fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); + fs.writeFileSync( + path.join(configDir, MANIFEST_NAME), + JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) + ); + + // fakeSrcDir (new release) has the SAME content — file was not changed upstream + fs.writeFileSync(path.join(fakeSrcDir, relPath), SHARED_RELEASE_CONTENT); + + // NOTE: gsd-pristine/ does NOT exist (simulating post-buggy-run or first-time scenario) + + INSTALL.saveLocalPatches(configDir, { + packageSrc: fakeSrcDir, + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }); + + // The regeneration path should have detected that sha256(new-release candidate) + // === originalHash, and promoted the candidate into gsd-pristine/. + const pristineFile = path.join(configDir, 'gsd-pristine', relPath); + assert.ok( + fs.existsSync(pristineFile), + [ + `gsd-pristine/${relPath} must exist after hash-validated regeneration.`, + `When new-release bytes hash to originalHash, the file was unchanged between`, + `releases and the candidate should be promoted to restore the pristine baseline.`, + ].join(' ') + ); + + const actualContent = fs.readFileSync(pristineFile, 'utf8'); + assert.equal( + sha256(actualContent), + oldHash, + [ + `gsd-pristine/${relPath} must contain bytes matching originalHash after regeneration`, + `(sha256=${oldHash.slice(0, 12)}…)`, + ].join(' ') + ); + }); + + /** + * Stale-pristine recovery test (pre-fix bug artifact). + * + * Timeline: + * Buggy run: gsd-pristine/ was written with NEW_RELEASE_CONTENT + * (the exact #3407 artifact — stale bytes from a buggy populatePristineDir). + * Fix run: saveLocalPatches detects the hash mismatch + * (sha256(NEW_RELEASE_CONTENT) !== originalHash recorded in manifest), + * removes the stale entry, then attempts regeneration. + * + * When the file CHANGED between releases (NEW !== OLD): + * - The stale entry is removed. + * - Regeneration discards the new-release candidate (hash mismatch). + * - gsd-pristine/ must be ABSENT (over-broad fallback — correct). + * + * When the file is UNCHANGED between releases (NEW === OLD): + * - The stale entry (which happens to have correct bytes despite the bug) is + * detected as correct (hash matches originalHash) and PRESERVED. + * - gsd-pristine/ must remain present with the correct bytes. + * + * This test covers the "file changed across release boundary" case. + * The "unchanged" case is already covered by the regeneration test above. + */ + test('stale gsd-pristine/ entry (new-release bytes) is removed when file changed between releases', () => { + const OLD_RELEASE_CONTENT = '# Old Release\nv1 content here.\n'; + const NEW_RELEASE_CONTENT = '# New Release\nv2 content — upstream changed this.\n'; + const USER_MODIFIED_CONTENT = '# Old Release\nv1 content here.\n## User section\nCustom work.\n'; + + const oldHash = sha256(OLD_RELEASE_CONTENT); + const relPath = 'test-stale-recovery.md'; + + // configDir: user-modified file + manifest recording OLD hash + fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); + fs.writeFileSync( + path.join(configDir, MANIFEST_NAME), + JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) + ); + + // fakeSrcDir (new release): contains the NEW content + fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT); + + // Pre-populate gsd-pristine/ with NEW_RELEASE_CONTENT — the exact pre-fix bug artifact. + // This simulates a prior buggy run that wrote new-release bytes into the pristine baseline. + const STALE_BYTES = NEW_RELEASE_CONTENT; // named constant for clarity + const pristineDir = path.join(configDir, 'gsd-pristine'); + fs.mkdirSync(pristineDir, { recursive: true }); + fs.writeFileSync(path.join(pristineDir, relPath), STALE_BYTES); + + // Verify the pre-condition: stale bytes do NOT match the original hash. + // If this assert fails, the test fixture is wrong (not a fix regression). + assert.notEqual( + sha256(STALE_BYTES), + oldHash, + 'test fixture check: stale bytes must differ from originalHash' + ); + + INSTALL.saveLocalPatches(configDir, { + packageSrc: fakeSrcDir, + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }); + + // The fix must detect the hash mismatch (stale entry) and remove it. + // The regeneration path discards the new-release candidate (its hash !== oldHash). + // Result: gsd-pristine/ must be ABSENT — over-broad fallback is the safe outcome. + const pristineFile = path.join(pristineDir, relPath); + assert.strictEqual( + fs.existsSync(pristineFile), + false, + [ + `expected gsd-pristine/${relPath} to be absent after stale-pristine recovery.`, + `The stale entry (new-release bytes, sha256=${sha256(STALE_BYTES).slice(0, 12)}…)`, + `must be removed; regeneration must discard the candidate because`, + `sha256(new-release)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… !== originalHash=${oldHash.slice(0, 12)}….`, + `Presence of the file means the stale bytes were NOT cleaned up (pre-fix behavior).`, + ].join(' ') + ); + }); + + /** + * Second scenario: gsd-pristine/ does NOT pre-exist (first upgrade with no + * prior pristine population). In this case there is no way to obtain the + * old-release pristine bytes — populatePristineDir must NOT write the new- + * release bytes either. The correct outcome is: gsd-pristine/ stays empty + * for this file, and the verifier falls back to over-broad mode (safe). + */ + test('gsd-pristine/ stays empty when no prior pristine exists (first upgrade, no stale write)', () => { + const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1.\n'; + const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — changed.\n'; + const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1.\n## User addition\nCustom.\n'; + + const oldHash = sha256(OLD_RELEASE_CONTENT); + const relPath = 'test-first-upgrade.md'; + + // configDir has user-modified file + manifest + fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT); + fs.writeFileSync( + path.join(configDir, MANIFEST_NAME), + JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2) + ); + + // fakeSrcDir (new release) has new content + fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT); + + // NOTE: gsd-pristine/ does NOT exist yet (first upgrade) + + INSTALL.saveLocalPatches(configDir, { + packageSrc: fakeSrcDir, + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }); + + const pristineFile = path.join(configDir, 'gsd-pristine', relPath); + assert.strictEqual( + fs.existsSync(pristineFile), + false, + [ + `expected gsd-pristine/${relPath} to be absent when file changed across release boundary.`, + `Writing new-release bytes as pristine for a file whose hash is unknown leads to`, + `false FAIL_USER_LINES_MISSING in the reapply-patches verifier (#3407).`, + `Over-broad fallback mode is the correct outcome here.`, + ].join(' ') + ); + }); +}); + +// The former "Antipattern hunt" describe block (structural typeof checks only) was +// removed — it provided no real behavioral coverage and was a vacuous-truth pattern +// per /test-rigor skill. Behavioral tests for populatePristineDir are covered above. + }); +} diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 2144a9227..8572724f7 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -1832,3 +1832,3870 @@ describe('normalizeNodePath — mise versioned path → sibling shim (#1619)', ( '/opt/homebrew/bin/node'); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2256-model-overrides-transport.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2256-model-overrides-transport (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression tests for issue #2256 — per-agent model_overrides transport + * for Codex and OpenCode runtimes. + * + * The bug: model_overrides set in per-project `.planning/config.json` were + * never read by the Codex / OpenCode install paths, which only probed + * `~/.gsd/defaults.json`. As a result, the configured per-agent model was + * dropped and child agents inherited the runtime's default model. + * + * These tests lock in the fix: per-project overrides must be honored, and + * per-project keys must win over global when both are present. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const isWindows = process.platform === 'win32'; + +const { + readGsdEffectiveModelOverrides, + generateCodexAgentToml, + convertClaudeToOpencodeFrontmatter, + getCodexSkillAdapterHeader, +} = require('../bin/install.js'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const makeTmp = (prefix) => createTempDir(`gsd-2256-${prefix}-`); + +function writeJson(p, obj) { + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(obj, null, 2)); +} + +describe('bug #2256 — readGsdEffectiveModelOverrides', () => { + let projectDir; + let homeDir; + let origHome; + let origUserProfile; + + beforeEach(() => { + projectDir = makeTmp('proj'); + homeDir = makeTmp('home'); + origHome = process.env.HOME; + // On Windows, os.homedir() reads USERPROFILE (not HOME). Tests that + // need to redirect ~ must override both — otherwise the SUT reads + // the real user's home and the fixture is invisible. + origUserProfile = process.env.USERPROFILE; + process.env.HOME = homeDir; + if (isWindows) process.env.USERPROFILE = homeDir; + }); + + afterEach(() => { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + if (isWindows) { + if (origUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = origUserProfile; + } + cleanup(projectDir); + cleanup(homeDir); + }); + + test('returns null when neither source defines model_overrides', () => { + const result = readGsdEffectiveModelOverrides(projectDir); + assert.strictEqual(result, null); + }); + + test('reads overrides from ~/.gsd/defaults.json (global only)', () => { + writeJson(path.join(homeDir, '.gsd', 'defaults.json'), { + model_overrides: { 'gsd-codebase-mapper': 'gpt-5-mini' }, + }); + const result = readGsdEffectiveModelOverrides(projectDir); + assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'gpt-5-mini' }); + }); + + test('reads overrides from per-project .planning/config.json', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + model_overrides: { 'gsd-codebase-mapper': 'claude-haiku-4-5' }, + }); + const result = readGsdEffectiveModelOverrides(projectDir); + assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'claude-haiku-4-5' }); + }); + + test('per-project overrides win over global on conflict', () => { + writeJson(path.join(homeDir, '.gsd', 'defaults.json'), { + model_overrides: { 'gsd-codebase-mapper': 'global-model', 'gsd-planner': 'opus' }, + }); + writeJson(path.join(projectDir, '.planning', 'config.json'), { + model_overrides: { 'gsd-codebase-mapper': 'project-model' }, + }); + const result = readGsdEffectiveModelOverrides(projectDir); + // Per-project wins on conflict; non-conflicting global keys are preserved. + assert.deepStrictEqual(result, { + 'gsd-codebase-mapper': 'project-model', + 'gsd-planner': 'opus', + }); + }); + + test('walks up from nested targetDir to find .planning/', () => { + writeJson(path.join(projectDir, '.planning', 'config.json'), { + model_overrides: { 'gsd-planner': 'project-opus' }, + }); + const nested = path.join(projectDir, '.codex'); + fs.mkdirSync(nested, { recursive: true }); + const result = readGsdEffectiveModelOverrides(nested); + assert.deepStrictEqual(result, { 'gsd-planner': 'project-opus' }); + }); +}); + +describe('bug #2256 — Codex adapter embeds per-project override', () => { + const agentContent = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`; + + test('generateCodexAgentToml embeds model when override provided', () => { + const toml = generateCodexAgentToml( + 'gsd-codebase-mapper', + agentContent, + { 'gsd-codebase-mapper': 'gpt-5-mini' }, + ); + assert.match(toml, /^model = "gpt-5-mini"$/m); + }); + + test('generateCodexAgentToml omits model when no override', () => { + const toml = generateCodexAgentToml('gsd-codebase-mapper', agentContent, null); + assert.doesNotMatch(toml, /^model\s*=/m); + }); +}); + +describe('bug #2256 — OpenCode adapter embeds per-project override', () => { + test('convertClaudeToOpencodeFrontmatter embeds model on agent frontmatter', () => { + const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`; + const out = convertClaudeToOpencodeFrontmatter(input, { + isAgent: true, + modelOverride: 'claude-haiku-4-5', + }); + assert.match(out, /^model: claude-haiku-4-5$/m); + assert.match(out, /^mode: subagent$/m); + }); + + test('convertClaudeToOpencodeFrontmatter omits model when override absent', () => { + const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`; + const out = convertClaudeToOpencodeFrontmatter(input, { isAgent: true, modelOverride: null }); + assert.doesNotMatch(out, /^model:/m); + }); +}); + +describe('bug #2256 — Codex skill adapter header documents transport', () => { + test('Task(model=...) line no longer says "omit" without explanation', () => { + const header = getCodexSkillAdapterHeader('gsd-plan-phase'); + // Header must mention that per-agent model_overrides are embedded in agent + // TOML so spawn_agent picks them up automatically — the old text said + // "Codex uses per-role config, not inline model selection" which left + // users thinking their model_overrides were silently ignored. + assert.match(header, /model_overrides/); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3181-node-cellar-path.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3181-node-cellar-path (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #3181: `resolveNodeRunner()` bakes versioned Homebrew Cellar paths + * (e.g. `/usr/local/Cellar/node/25.8.1/bin/node`) into hook commands in + * `~/.claude/settings.json`. After `brew upgrade node` the Cellar binary + * fails with `dyld: Library not loaded` because shared libraries have + * changed SOVERSION. + * + * Fix: prefer the stable Homebrew symlinks (`/usr/local/bin/node` for Intel + * Macs, `/opt/homebrew/bin/node` for Apple Silicon) when a Cellar path is + * detected. Non-Homebrew paths (NVM, system node, Windows, etc.) are + * returned unchanged. + * + * Also: `rewriteLegacyManagedNodeHookCommands()` must normalize Cellar paths + * baked into existing hook commands so reinstall doesn't re-bake them. + * + * All assertions go against exported function return values — no source-grep. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); +const { normalizeNodePath, resolveNodeRunner, rewriteLegacyManagedNodeHookCommands } = INSTALL; + +// ─── normalizeNodePath ──────────────────────────────────────────────────────── + +describe('Bug #3181: normalizeNodePath — exported as a function', () => { + test('normalizeNodePath is exported', () => { + assert.equal(typeof normalizeNodePath, 'function'); + }); +}); + +describe('Bug #3181: normalizeNodePath — Intel Homebrew Cellar paths → /usr/local/bin/node', () => { + test('simple versioned Intel Cellar path', () => { + const result = normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node'); + assert.equal(result, '/usr/local/bin/node'); + }); + + test('Intel Cellar path with long semver', () => { + const result = normalizeNodePath('/usr/local/Cellar/node/20.11.0/bin/node'); + assert.equal(result, '/usr/local/bin/node'); + }); + + test('Intel Cellar path with prerelease version segment', () => { + const result = normalizeNodePath('/usr/local/Cellar/node/22.0.0-rc.1/bin/node'); + assert.equal(result, '/usr/local/bin/node'); + }); + + test('Intel versioned formula Cellar path (node@20) maps to stable symlink', () => { + const result = normalizeNodePath('/usr/local/Cellar/node@20/20.11.0/bin/node'); + assert.equal(result, '/usr/local/bin/node'); + }); +}); + +describe('Bug #3181: normalizeNodePath — Apple Silicon Homebrew Cellar paths → /opt/homebrew/bin/node', () => { + test('simple versioned Apple Silicon Cellar path', () => { + const result = normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node'); + assert.equal(result, '/opt/homebrew/bin/node'); + }); + + test('Apple Silicon Cellar path with another version', () => { + const result = normalizeNodePath('/opt/homebrew/Cellar/node/18.20.4/bin/node'); + assert.equal(result, '/opt/homebrew/bin/node'); + }); + + test('Apple Silicon versioned formula Cellar path (node@18) maps to stable symlink', () => { + const result = normalizeNodePath('/opt/homebrew/Cellar/node@18/18.20.4/bin/node'); + assert.equal(result, '/opt/homebrew/bin/node'); + }); +}); + +describe('Bug #3181: normalizeNodePath — non-Homebrew paths are returned unchanged', () => { + test('NVM path is unchanged', () => { + const nvm = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node'; + assert.equal(normalizeNodePath(nvm), nvm); + }); + + test('already-stable Intel Homebrew symlink is unchanged', () => { + assert.equal(normalizeNodePath('/usr/local/bin/node'), '/usr/local/bin/node'); + }); + + test('already-stable Apple Silicon Homebrew symlink is unchanged', () => { + assert.equal(normalizeNodePath('/opt/homebrew/bin/node'), '/opt/homebrew/bin/node'); + }); + + test('system node (/usr/bin/node) is unchanged', () => { + assert.equal(normalizeNodePath('/usr/bin/node'), '/usr/bin/node'); + }); + + test('Windows path is unchanged', () => { + const win = 'C:\\Program Files\\nodejs\\node.exe'; + assert.equal(normalizeNodePath(win), win); + }); + + test('empty string is returned as-is', () => { + assert.equal(normalizeNodePath(''), ''); + }); + + test('null is returned as-is', () => { + assert.equal(normalizeNodePath(null), null); + }); +}); + +// ─── resolveNodeRunner ──────────────────────────────────────────────────────── + +describe('Bug #3181: resolveNodeRunner — maps Cellar execPath to stable symlink', () => { + test('Intel Cellar execPath → stable symlink quoted token', () => { + const orig = process.execPath; + try { + Object.defineProperty(process, 'execPath', { + value: '/usr/local/Cellar/node/25.8.1/bin/node', + configurable: true, + }); + const runner = resolveNodeRunner(); + assert.equal(runner, '"/usr/local/bin/node"', + `expected stable Intel symlink, got: ${runner}`); + } finally { + Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); + } + }); + + test('Apple Silicon Cellar execPath → stable symlink quoted token', () => { + const orig = process.execPath; + try { + Object.defineProperty(process, 'execPath', { + value: '/opt/homebrew/Cellar/node/25.8.1/bin/node', + configurable: true, + }); + const runner = resolveNodeRunner(); + assert.equal(runner, '"/opt/homebrew/bin/node"', + `expected stable Apple Silicon symlink, got: ${runner}`); + } finally { + Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); + } + }); + + test('non-Homebrew execPath is returned as a quoted absolute path unchanged', () => { + const orig = process.execPath; + const nvmPath = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node'; + try { + Object.defineProperty(process, 'execPath', { value: nvmPath, configurable: true }); + const runner = resolveNodeRunner(); + assert.equal(runner, JSON.stringify(nvmPath)); + } finally { + Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); + } + }); + + test('returns null when execPath is empty (existing null-guard is preserved)', () => { + const orig = process.execPath; + try { + Object.defineProperty(process, 'execPath', { value: '', configurable: true }); + assert.equal(resolveNodeRunner(), null); + } finally { + Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); + } + }); +}); + +// ─── rewriteLegacyManagedNodeHookCommands — Cellar runner rewrite ───────────── + +describe('Bug #3181: rewriteLegacyManagedNodeHookCommands — rewrites baked Cellar runner to stable symlink', () => { + test('Intel Cellar runner in a managed hook is rewritten to the stable symlink', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ + type: 'command', + command: '"/usr/local/Cellar/node/25.8.1/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', + }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, true, 'expected rewrite to occur'); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', + ); + }); + + test('Apple Silicon Cellar runner in a managed hook is rewritten to the stable symlink', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ + type: 'command', + command: '"/opt/homebrew/Cellar/node/25.8.1/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', + }], + }], + }, + }; + const runner = '"/opt/homebrew/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, true, 'expected rewrite to occur'); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '"/opt/homebrew/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', + ); + }); + + test('a hook already using the stable runner is NOT rewritten (no churn)', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ + type: 'command', + command: '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', + }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const before = settings.hooks.SessionStart[0].hooks[0].command; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, false, 'already-stable entry must not be touched'); + assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); + }); + + test('a user hook using a Cellar runner but an unmanaged filename is NOT rewritten', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ + type: 'command', + command: '"/usr/local/Cellar/node/25.8.1/bin/node" "/Users/x/my-custom-hook.js"', + }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const before = settings.hooks.SessionStart[0].hooks[0].command; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, false, 'unmanaged hooks with Cellar runner must not be touched'); + assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); + }); + + // Existing bare-node rewrite still works alongside the new Cellar rewrite + test('bare `node` managed hook is still rewritten (existing #2979 behaviour preserved)', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ + type: 'command', + command: 'node "/Users/x/.gemini/hooks/gsd-check-update.js"', + }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, true); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-977-fnm-multishell-path.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-977-fnm-multishell-path (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #977: `resolveNodeRunner()` bakes an ephemeral fnm multishell shim path + * (e.g. `C:/Users/u/AppData/Local/fnm_multishells/_/node.exe`) into + * managed `.js` hook commands. fnm cleans up these per-shell-session directories + * when the shell exits, so the captured path later points at nothing — every + * managed hook fails to spawn until reinstall. + * + * Fix: when `normalizeNodePath` detects a path matching the fnm multishell + * directory pattern (`fnm_multishells//node(\.exe)?$`), it probes a stable + * alias path derived from `FNM_DIR` or `APPDATA` env vars (with injected + * `existsSync` for testability) and returns the first that exists. Falls back to + * the raw execPath if no stable alias is found. + * + * All assertions go against exported function return values — no source-grep. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); +const { normalizeNodePath, resolveNodeRunner } = INSTALL; + +// ─── Synthetic paths used across tests ─────────────────────────────────────── + +const EPHEMERAL_FNM_WIN = 'C:/Users/u/AppData/Local/fnm_multishells/15600_1781041703752/node.exe'; +const EPHEMERAL_FNM_WIN_BACKSLASH = 'C:\\Users\\u\\AppData\\Local\\fnm_multishells\\15600_1781041703752\\node.exe'; +const FNM_DIR_WIN = 'C:/Users/u/AppData/Roaming/fnm'; +const APPDATA_WIN = 'C:/Users/u/AppData/Roaming'; +const STABLE_FNM_DIR_NODE = `${FNM_DIR_WIN}/aliases/default/node.exe`; +const STABLE_APPDATA_NODE = `${APPDATA_WIN}/fnm/aliases/default/node.exe`; + +// ─── normalizeNodePath — fnm multishell ephemeral path → stable alias ──────── + +describe('Bug #977: normalizeNodePath — fnm multishell path with FNM_DIR → stable alias', () => { + test('forward-slash Windows ephemeral path + FNM_DIR set + alias exists → stable FNM_DIR alias', () => { + const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { + env: { FNM_DIR: FNM_DIR_WIN }, + existsSync: p => p === STABLE_FNM_DIR_NODE, + }); + assert.equal( + result, + STABLE_FNM_DIR_NODE, + `expected stable FNM_DIR alias, got: ${result}`, + ); + }); + + test('backslash Windows ephemeral path + FNM_DIR set + alias exists → stable FNM_DIR alias', () => { + const result = normalizeNodePath(EPHEMERAL_FNM_WIN_BACKSLASH, { + env: { FNM_DIR: FNM_DIR_WIN }, + existsSync: p => p === STABLE_FNM_DIR_NODE, + }); + assert.equal( + result, + STABLE_FNM_DIR_NODE, + `expected stable FNM_DIR alias, got: ${result}`, + ); + }); + + test('FNM_DIR alias does not exist → falls through to APPDATA alias → returns APPDATA alias', () => { + const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { + env: { FNM_DIR: FNM_DIR_WIN, APPDATA: APPDATA_WIN }, + existsSync: p => p === STABLE_APPDATA_NODE, // FNM_DIR alias absent, APPDATA alias present + }); + assert.equal( + result, + STABLE_APPDATA_NODE, + `expected stable APPDATA alias, got: ${result}`, + ); + }); + + test('no alias exists → returns raw execPath unchanged (graceful fallback)', () => { + const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { + env: { FNM_DIR: FNM_DIR_WIN, APPDATA: APPDATA_WIN }, + existsSync: () => false, // nothing exists + }); + assert.equal( + result, + EPHEMERAL_FNM_WIN, + `expected raw execPath fallback, got: ${result}`, + ); + }); + + test('no FNM_DIR or APPDATA in env → returns raw execPath unchanged', () => { + const result = normalizeNodePath(EPHEMERAL_FNM_WIN, { + env: {}, + existsSync: () => false, + }); + assert.equal( + result, + EPHEMERAL_FNM_WIN, + `expected raw execPath fallback, got: ${result}`, + ); + }); +}); + +// ─── normalizeNodePath — non-fnm paths are NOT affected by the new branch ──── + +describe('Bug #977: normalizeNodePath — non-fnm paths are unaffected (no regression to existing behavior)', () => { + test('NVM path is unchanged', () => { + const nvm = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node'; + assert.equal(normalizeNodePath(nvm), nvm); + }); + + test('Intel Homebrew Cellar path still maps to stable symlink', () => { + assert.equal( + normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node'), + '/usr/local/bin/node', + ); + }); + + test('Apple Silicon Homebrew Cellar path still maps to stable symlink', () => { + assert.equal( + normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node'), + '/opt/homebrew/bin/node', + ); + }); + + test('regular Windows nodejs path is unchanged', () => { + const win = 'C:\\Program Files\\nodejs\\node.exe'; + assert.equal(normalizeNodePath(win), win); + }); + + test('empty string is returned as-is', () => { + assert.equal(normalizeNodePath(''), ''); + }); + + test('null is returned as-is', () => { + assert.equal(normalizeNodePath(null), null); + }); +}); + +// ─── normalizeNodePath — already-stable fnm alias path is not re-processed ─── + +describe('Bug #977: normalizeNodePath — already-stable fnm alias path passes through unchanged', () => { + test('stable FNM_DIR alias path is returned as-is', () => { + assert.equal( + normalizeNodePath(STABLE_FNM_DIR_NODE), + STABLE_FNM_DIR_NODE, + ); + }); +}); + +// ─── normalizeNodePath — false-positive guard: non-numeric id must NOT remap ── + +describe('Bug #977: normalizeNodePath — non-ephemeral fnm_multishells path is not remapped', () => { + test('non-numeric id segment (e.g. custom-dir) returns raw execPath unchanged even when alias exists', () => { + const nonEphemeral = 'C:/Users/u/AppData/Local/fnm_multishells/custom-dir/node.exe'; + const stableAlias = 'C:/Users/u/AppData/Roaming/fnm/aliases/default/node.exe'; + const result = normalizeNodePath(nonEphemeral, { + env: { FNM_DIR: 'C:/Users/u/AppData/Roaming/fnm' }, + // existsSync returns true for the alias to prove the regex — not the existsSync — is the guard + existsSync: p => p === stableAlias, + }); + assert.equal( + result, + nonEphemeral, + `expected raw execPath (non-ephemeral id must not be remapped), got: ${result}`, + ); + }); +}); + +// ─── resolveNodeRunner — opts pass-through ──────────────────────────────────── + +describe('Bug #977: resolveNodeRunner — passes opts through to normalizeNodePath', () => { + test('fnm multishell execPath is resolved to stable alias via injected opts', () => { + const orig = process.execPath; + try { + Object.defineProperty(process, 'execPath', { + value: EPHEMERAL_FNM_WIN, + configurable: true, + }); + const runner = resolveNodeRunner({ + env: { FNM_DIR: FNM_DIR_WIN }, + existsSync: p => p === STABLE_FNM_DIR_NODE, + }); + assert.equal( + runner, + JSON.stringify(STABLE_FNM_DIR_NODE), + `expected stable FNM_DIR alias quoted, got: ${runner}`, + ); + } finally { + Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2979-hook-absolute-node.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2979-hook-absolute-node (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #2979: Managed JS hooks fail in GUI/minimal-PATH runtimes because + * the installer emits bare `node`. + * + * Reporter evidence: in a stripped PATH like /usr/bin:/bin:/usr/sbin:/sbin + * (the default for Finder-launched/Antigravity-spawned processes on macOS), + * `node` is not resolvable. Hook commands like + * `node "/.gemini/hooks/gsd-check-update.js"` + * fail with `/bin/sh: node: command not found` (exit 127). + * + * Fix: emit the absolute node path (`process.execPath`, the binary + * running the installer itself) as the runner. Forward-slash-normalized + * and double-quoted so it works on POSIX and Windows. + * + * This test exercises the public buildHookCommand surface plus the + * resolveNodeRunner helper, asserting on structured records: + * - the runner field is an absolute path (not bare 'node') + * - it ends with /node or \\node (or .exe on Windows simulation) + * - .sh hooks still use bare 'bash' (PATH-resolved; portable across + * distros that don't ship /bin/bash, like NixOS) + * + * No source-grep on install.js content — assertions go against the + * value returned by the exported function and the parsed structure of + * the emitted hook command (split into runner + args). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); +const { buildHookCommand, resolveNodeRunner } = INSTALL; + +/** + * Parse a hook command string into { runner, hookPath } structured + * record. The shape is ` ""` where may itself + * be a quoted absolute path (containing spaces), so we split on the + * trailing quoted-path token rather than the first space. + */ +function parseHookCommand(cmd) { + // Trailing token: a double-quoted string ending the command. + const m = cmd.match(/^(.+?)\s+"([^"]+)"\s*$/); + if (!m) { + return { runner: null, hookPath: null, raw: cmd }; + } + return { runner: m[1], hookPath: m[2], raw: cmd }; +} + +describe('Bug #2979: resolveNodeRunner returns absolute, quoted, forward-slash node path', () => { + test('exported as a function', () => { + assert.equal(typeof resolveNodeRunner, 'function'); + }); + + test('returns a double-quoted absolute path', () => { + const runner = resolveNodeRunner(); + assert.ok(runner.startsWith('"'), `expected leading double-quote, got: ${runner}`); + assert.ok(runner.endsWith('"'), `expected trailing double-quote, got: ${runner}`); + const inner = runner.slice(1, -1); + assert.ok(path.isAbsolute(inner.replace(/\//g, path.sep)), `expected absolute path, got: ${inner}`); + }); + + test('uses forward slashes (Windows-safe, matches buildHookCommand convention)', () => { + const runner = resolveNodeRunner(); + assert.ok(!runner.includes('\\'), `expected forward slashes, got: ${runner}`); + }); + + test('points at a node binary (basename starts with "node")', () => { + const runner = resolveNodeRunner(); + const inner = runner.slice(1, -1); + const base = path.posix.basename(inner); + assert.ok(/^node(\.exe)?$/i.test(base), `expected basename node or node.exe, got: ${base}`); + }); +}); + +describe('Bug #2979: buildHookCommand for .js hooks emits absolute node runner', () => { + test('global install: .js hook uses absolute node path, not bare "node"', () => { + const cmd = buildHookCommand('/tmp/.claude', 'gsd-check-update.js'); + const parsed = parseHookCommand(cmd); + assert.notEqual(parsed.runner, null, `failed to parse: ${cmd}`); + assert.notEqual(parsed.runner, 'node', `must not emit bare node (#2979): ${cmd}`); + // The runner should be a quoted absolute path. + assert.ok(parsed.runner.startsWith('"') && parsed.runner.endsWith('"'), + `runner must be quoted absolute path, got: ${parsed.runner}`); + }); + + test('global install: .js hook command parses with hookPath at expected location', () => { + const cmd = buildHookCommand('/tmp/.gemini', 'gsd-statusline.js'); + const parsed = parseHookCommand(cmd); + assert.equal(parsed.hookPath, '/tmp/.gemini/hooks/gsd-statusline.js'); + }); + + test('portableHooks global install: .js hook still uses absolute node (only the path is $HOME-relative)', () => { + const home = require('node:os').homedir().replace(/\\/g, '/'); + const configDir = home + '/.gemini'; + const cmd = buildHookCommand(configDir, 'gsd-check-update.js', { portableHooks: true }); + const parsed = parseHookCommand(cmd); + assert.notEqual(parsed.runner, 'node', `portableHooks must also use absolute node (#2979): ${cmd}`); + assert.equal(parsed.hookPath, '$HOME/.gemini/hooks/gsd-check-update.js'); + }); +}); + +describe('Bug #3362 / #3413: Windows hook commands are runtime-aware', () => { + test('Gemini global install: .js hook command starts with & so quoted runners execute in PowerShell', () => { + const cmd = buildHookCommand('C:/Program Files/Gemini/.gemini', 'gsd-check-update.js', { + platform: 'win32', + runtime: 'gemini', + }); + assert.ok(cmd.startsWith('& '), `Gemini PowerShell commands need call operator, got: ${cmd}`); + assert.ok(cmd.includes('"C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"')); + }); + + test('Gemini portable install: .js hook command also uses & on Windows PowerShell', () => { + const home = require('node:os').homedir().replace(/\\/g, '/'); + const cmd = buildHookCommand(`${home}/.gemini`, 'gsd-check-update.js', { + portableHooks: true, + platform: 'win32', + runtime: 'gemini', + }); + assert.ok(cmd.startsWith('& '), `Gemini PowerShell commands need call operator, got: ${cmd}`); + assert.equal(parseHookCommand(cmd.slice(2)).hookPath, '$HOME/.gemini/hooks/gsd-check-update.js'); + }); + + test('Claude global install: .js hook command stays shell-neutral on Windows Git Bash', () => { + const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', { + platform: 'win32', + runtime: 'claude', + }); + assert.ok(!cmd.startsWith('& '), `Claude hook command must not use PowerShell call operator: ${cmd}`); + assert.equal(parseHookCommand(cmd).hookPath, 'C:/Users/me/.claude/hooks/gsd-check-update.js'); + }); + + test('Windows .js hook with no runtime stays shell-neutral', () => { + const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', { + platform: 'win32', + }); + assert.ok(!cmd.startsWith('& '), `Missing runtime must not imply PowerShell syntax: ${cmd}`); + assert.equal(parseHookCommand(cmd).hookPath, 'C:/Users/me/.claude/hooks/gsd-check-update.js'); + }); + + test('Gemini runtime on non-Windows platform does not get PowerShell syntax', () => { + const cmd = buildHookCommand('/home/me/.claude', 'gsd-check-update.js', { + platform: 'linux', + runtime: 'gemini', + }); + assert.ok(!cmd.startsWith('& '), `Non-Windows Gemini hook must stay shell-neutral: ${cmd}`); + assert.equal(parseHookCommand(cmd).hookPath, '/home/me/.claude/hooks/gsd-check-update.js'); + }); +}); + +describe('Bug #2979: buildHookCommand for .sh hooks still uses bare "bash" (POSIX std PATH always has /bin)', () => { + test('.sh hook runner is exactly "bash" — bash is in /usr/bin:/bin and resolves under minimal PATH', () => { + const cmd = buildHookCommand('/tmp/.claude', 'gsd-session-state.sh', { platform: 'linux' }); + const parsed = parseHookCommand(cmd); + assert.equal(parsed.runner, 'bash'); + }); + + test('Windows .sh hook uses resolved Git Bash path instead of bare bash (#3393)', () => { + const cmd = buildHookCommand('C:/Users/me/.codex', 'gsd-validate-commit.sh', { + platform: 'win32', + env: { ProgramFiles: 'C:\\Program Files' }, + existsSync: (candidate) => candidate === 'C:\\Program Files\\Git\\bin\\bash.exe', + }); + assert.equal( + cmd, + '"C:/Program Files/Git/bin/bash.exe" "C:/Users/me/.codex/hooks/gsd-validate-commit.sh"', + ); + }); + + test('Windows .sh hook returns null when no supported Bash runner is found (#3393)', () => { + const cmd = buildHookCommand('C:/Users/me/.codex', 'gsd-phase-boundary.sh', { + platform: 'win32', + env: {}, + existsSync: () => false, + }); + assert.equal(cmd, null); + }); + + test('Windows Claude .sh hook omits explicit bash.exe wrapper (#166)', () => { + const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-session-state.sh', { + platform: 'win32', + runtime: 'claude', + env: { ProgramFiles: 'C:\\Program Files' }, + existsSync: (candidate) => candidate === 'C:\\Program Files\\Git\\bin\\bash.exe', + }); + assert.equal( + cmd, + '"C:/Users/me/.claude/hooks/gsd-session-state.sh"', + 'Claude win32 .sh hooks should serialize as script-only commands' + ); + }); +}); + +// ─── #3002 CR follow-up: legacy-bare-node migration ───────────────────────── + +const { rewriteLegacyManagedNodeHookCommands } = INSTALL; + +describe('Bug #2979 (#3002 CR): rewriteLegacyManagedNodeHookCommands rewrites bare-node managed hooks on reinstall', () => { + test('exported as a function', () => { + assert.equal(typeof rewriteLegacyManagedNodeHookCommands, 'function'); + }); + + test('rewrites a managed hook entry that uses bare `node ` to the absolute runner', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [ + { type: 'command', command: 'node "/Users/x/.gemini/hooks/gsd-check-update.js"' }, + ], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, true); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"', + ); + }); + + test('does NOT touch entries that already use a quoted absolute runner', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: '"/usr/local/bin/node" "/x/hooks/gsd-statusline.js"' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const before = settings.hooks.SessionStart[0].hooks[0].command; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, false); + assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); + }); + + test('Gemini on Windows adds PowerShell call operator to existing quoted managed hooks', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: '"/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' }); + assert.equal(changed, true); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '& "/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"', + ); + }); + + test('Gemini on Windows does NOT double-prefix managed hooks that already use the PowerShell call operator', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const before = settings.hooks.SessionStart[0].hooks[0].command; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' }); + assert.equal(changed, false); + assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); + }); + + test('Gemini on Windows rewrites PowerShell bare-node managed hooks to absolute runner without dropping &', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: '& node "C:/Users/me/.gemini/hooks/gsd-check-update.js"' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' }); + assert.equal(changed, true); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '& "/usr/local/bin/node" "C:/Users/me/.gemini/hooks/gsd-check-update.js"', + ); + }); + + test('Claude on Windows strips stale PowerShell prefix from managed hooks on reinstall (#3413)', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'claude' }); + assert.equal(changed, true); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '"/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"', + ); + }); + + test('does NOT touch user-authored bare-node hooks (filename not in managed allowlist)', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: 'node /home/me/my-custom-hook.js' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const before = settings.hooks.SessionStart[0].hooks[0].command; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, false); + assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); + }); + + test('does NOT touch .sh hooks (they correctly use bare bash)', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: 'bash "/x/hooks/gsd-session-state.sh"' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, false); + }); + + test('is a no-op when absoluteRunner is null (resolveNodeRunner failed)', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: 'node "/x/hooks/gsd-check-update.js"' }], + }], + }, + }; + const before = settings.hooks.SessionStart[0].hooks[0].command; + const changed = rewriteLegacyManagedNodeHookCommands(settings, null); + assert.equal(changed, false); + assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); + }); + + // #3002 CR: substring containment was a false-positive vector. + // User-authored hooks whose path happened to CONTAIN a managed filename + // as a substring would get unconditionally rewritten with the GSD runner. + // The fix matches by basename equality. + test('does NOT rewrite a user hook whose path contains a managed filename as a substring', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ + type: 'command', + // Path contains gsd-check-update.js as substring of a longer + // filename, but is NOT actually that file. + command: 'node /home/me/scripts/wraps-gsd-check-update.js-helper.js', + }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const before = settings.hooks.SessionStart[0].hooks[0].command; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, false, 'must not rewrite user hooks with managed-filename-as-substring paths'); + assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before); + }); + + test('rewrites a managed entry whose path is quoted with single quotes', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: "node '/x/hooks/gsd-statusline.js'" }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' }); + assert.equal(changed, true); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`, + ); + }); + + test('rewrites a managed entry with no path quoting (bareword)', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: 'node /x/hooks/gsd-context-monitor.js' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' }); + assert.equal(changed, true); + assert.equal( + settings.hooks.SessionStart[0].hooks[0].command, + '"/usr/local/bin/node" /x/hooks/gsd-context-monitor.js', + ); + }); + + test('handles Windows-style backslash path separators when extracting basename', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [{ type: 'command', command: 'node "C:\\\\Users\\\\me\\\\.claude\\\\hooks\\\\gsd-prompt-guard.js"' }], + }], + }, + }; + const runner = '"/usr/local/bin/node"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner); + assert.equal(changed, true); + }); + + test('Gemini on Windows normalizes single-quoted managed hook paths to double-quoted forward-slash paths (#3392)', () => { + const settings = { + hooks: { + PreToolUse: [{ + hooks: [{ + type: 'command', + command: "node 'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js'", + }], + }], + }, + }; + const runner = '"C:/nvm4w/nodejs/node.exe"'; + const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' }); + assert.equal(changed, true); + assert.equal( + settings.hooks.PreToolUse[0].hooks[0].command, + '& "C:/nvm4w/nodejs/node.exe" "C:/Users/me/.gemini/hooks/gsd-prompt-guard.js"', + ); + }); +}); + +describe('Bug #2979 (#3002 CR): resolveNodeRunner returns null when execPath unavailable', () => { + test('returns null instead of bare "node" when process.execPath is empty', () => { + const orig = process.execPath; + try { + Object.defineProperty(process, 'execPath', { value: '', configurable: true }); + const r = resolveNodeRunner(); + assert.equal(r, null, 'expected null, not bare "node"'); + } finally { + Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); + } + }); + + test('buildHookCommand returns null when execPath is unavailable (caller skips registration)', () => { + const orig = process.execPath; + try { + Object.defineProperty(process, 'execPath', { value: '', configurable: true }); + const cmd = buildHookCommand('/tmp/.claude', 'gsd-statusline.js'); + assert.equal(cmd, null); + } finally { + Object.defineProperty(process, 'execPath', { value: orig, configurable: true }); + } + }); +}); + +// ─── #3002 CR follow-up #2: null-command guards in settings.json ────────── + +const { validateHookFields } = INSTALL; + +describe('Bug #2979 (#3002 CR follow-up): no command:null hook entries survive serialization', () => { + // CR feedback: assert structurally on the resulting settings object, not by + // grepping bin/install.js source. The push-site guards (each `if` clause's + // `&& ` token) skip null-command pushes at the source. As a + // backstop, install.js now runs validateHookFields(settings) right before + // writeSettings; this test exercises that backstop directly. + // + // Construct a settings object that contains exactly the kind of null-command + // entries that the registration code would have written if my push-site + // guards regressed. Run validateHookFields on it. Assert the null entries + // are gone and the well-formed entries survive. + + function nullCommandEntry(matcher) { + const entry = { hooks: [{ type: 'command', command: null }] }; + if (matcher) entry.matcher = matcher; + return entry; + } + function realCommandEntry(matcher, command) { + const entry = { hooks: [{ type: 'command', command }] }; + if (matcher) entry.matcher = matcher; + return entry; + } + + const MANAGED_JS_HOOKS = [ + { event: 'SessionStart', matcher: undefined, label: 'gsd-check-update.js' }, + { event: 'PostToolUse', matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task', label: 'gsd-context-monitor.js' }, + { event: 'PreToolUse', matcher: 'Write|Edit', label: 'gsd-prompt-guard.js' }, + { event: 'PreToolUse', matcher: 'Write|Edit', label: 'gsd-read-guard.js' }, + { event: 'PostToolUse', matcher: 'Read', label: 'gsd-read-injection-scanner.js' }, + { event: 'PreToolUse', matcher: 'Bash|Edit|Write|MultiEdit', label: 'gsd-workflow-guard.js' }, + ]; + + for (const { event, matcher, label } of MANAGED_JS_HOOKS) { + test(`validateHookFields strips a null-command ${label} entry from settings.hooks.${event}`, () => { + const settings = { + hooks: { + [event]: [ + nullCommandEntry(matcher), + realCommandEntry(matcher, '"/usr/local/bin/node" "/x/hooks/other.js"'), + ], + }, + }; + const out = validateHookFields(settings); + const survivors = out.hooks[event] || []; + // The well-formed entry must remain. + assert.equal(survivors.length, 1, `expected the real-command entry to survive`); + // No survivor entry contains a hook with command === null. + for (const e of survivors) { + for (const h of e.hooks || []) { + assert.notEqual(h.command, null, 'no surviving hook should have command:null'); + } + } + }); + } + + test('validateHookFields drops the entry entirely when all its hooks have null commands', () => { + const settings = { + hooks: { + SessionStart: [nullCommandEntry()], + }, + }; + const out = validateHookFields(settings); + // Empty event arrays should be cleaned up (the entire SessionStart key + // gets removed when nothing valid remains). + assert.ok( + !out.hooks.SessionStart || out.hooks.SessionStart.length === 0, + 'expected SessionStart to be empty/removed after the only entry was dropped', + ); + }); + + test('validateHookFields preserves agent-type hooks while stripping command:null sibling hooks', () => { + const settings = { + hooks: { + SessionStart: [{ + hooks: [ + { type: 'command', command: null }, + { type: 'agent', prompt: 'analyze the session' }, + { type: 'command', command: '"/usr/local/bin/node" "/x/hooks/y.js"' }, + ], + }], + }, + }; + const out = validateHookFields(settings); + const survivors = out.hooks.SessionStart[0].hooks; + assert.equal(survivors.length, 2, 'expected 2 of 3 hooks to survive (the null-command one is stripped)'); + assert.equal(survivors.find(h => h.command === null), undefined, 'no surviving hook should have command:null'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-442-config-dir-equals-in-path.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-442-config-dir-equals-in-path (consolidation epic #1969 B1 #1970)", () => { +'use strict'; +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +// parseConfigDirArg is not exported directly from bin/install.js (it closes +// over the module-level `args` array). We expose a pure seam here: +// parseConfigDirFromArgs(args) that mirrors the function's logic so we can +// test the equals-form parsing without spawning a child process. +// +// The implementation under test is inlined below (RED: before the fix it will +// reproduce the truncation bug). Once the fix lands, we swap in the real +// implementation via require. + +/** + * Pure seam that replicates the equals-form parse logic from bin/install.js. + * We import it via a thin wrapper so that the function can be tested without + * executing the entire install script. + * + * During RED the bug is: `split('=')[1]` drops everything after the second `=`. + */ +const { parseConfigDirFromArgs } = require('../bin/install.js'); + +describe('bug-442: --config-dir= equals-form path parsing', () => { + // ── Happy-path: single = in path ───────────────────────────────────────── + test('--config-dir= with one = in value returns full value', () => { + const result = parseConfigDirFromArgs(['--config-dir=/tmp/gsd=a']); + assert.equal(result, '/tmp/gsd=a'); + }); + + // ── Happy-path: multiple = in path ─────────────────────────────────────── + test('--config-dir= with multiple = in value returns full value', () => { + const result = parseConfigDirFromArgs(['--config-dir=/tmp/a=b=c']); + assert.equal(result, '/tmp/a=b=c'); + }); + + // ── Short form -c= ──────────────────────────────────────────────────────── + test('-c= with = in value returns full value', () => { + const result = parseConfigDirFromArgs(['-c=/tmp/gsd=a']); + assert.equal(result, '/tmp/gsd=a'); + }); + + test('-c= with multiple = in value returns full value', () => { + const result = parseConfigDirFromArgs(['-c=/tmp/a=b=c']); + assert.equal(result, '/tmp/a=b=c'); + }); + + // ── Contract: empty value ───────────────────────────────────────────────── + // --config-dir= (no value after the =) → returns empty string ''. + // The caller (parseConfigDirArg) treats '' as missing and errors; the seam + // itself should faithfully return '' rather than null/undefined so the + // caller can make the error decision. + test('--config-dir= with no value returns empty string', () => { + const result = parseConfigDirFromArgs(['--config-dir=']); + assert.equal(result, ''); + }); + + test('-c= with no value returns empty string', () => { + const result = parseConfigDirFromArgs(['-c=']); + assert.equal(result, ''); + }); + + // ── Space-separated form is unaffected (regression guard) ───────────────── + test('--config-dir space-separated still returns the path', () => { + const result = parseConfigDirFromArgs(['--config-dir', '/tmp/gsd=a']); + assert.equal(result, '/tmp/gsd=a'); + }); + + test('-c space-separated still returns the path', () => { + const result = parseConfigDirFromArgs(['-c', '/tmp/gsd=a']); + assert.equal(result, '/tmp/gsd=a'); + }); + + // ── No config-dir flag → null ───────────────────────────────────────────── + test('returns null when no --config-dir flag is present', () => { + const result = parseConfigDirFromArgs(['--global', '--claude']); + assert.equal(result, null); + }); + + // ── Negative matrix (CLI edge cases) ───────────────────────────────────── + // Flag-looking value after space form: next arg starts with - → null (no + // valid value; the real function would process.exit but the seam returns null + // so tests stay in-process). + test('space form with next arg being a flag returns null (flag-looking value)', () => { + const result = parseConfigDirFromArgs(['--config-dir', '--other-flag']); + assert.equal(result, null); + }); + + // Equals form where value is a path with no = (plain path, no regression) + test('--config-dir= without any = in path still works', () => { + const result = parseConfigDirFromArgs(['--config-dir=/tmp/plain']); + assert.equal(result, '/tmp/plain'); + }); + + // Flag appears after other args (positional ordering should not matter) + test('--config-dir= flag after other args is parsed correctly', () => { + const result = parseConfigDirFromArgs(['--global', '--config-dir=/tmp/a=b', '--claude']); + assert.equal(result, '/tmp/a=b'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/enh-1559-installer-export-audit.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:enh-1559-installer-export-audit (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +const { describe, test, before } = require('node:test'); +const assert = require('node:assert/strict'); + +let installer; +let conversion; + +before(() => { + process.env['GSD_TEST_MODE'] = '1'; + installer = require('../bin/install.js'); + conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); +}); + +describe('bin/install.js compatibility export audit (#1559)', () => { + test('retains audited compatibility relays for shared rewrite helpers', () => { + assert.strictEqual(installer.processAttribution, conversion.processAttribution); + assert.strictEqual( + installer.applyRuntimeContentRewritesForCommandsInPlace, + conversion.applyRuntimeContentRewritesForCommandsInPlace, + ); + }); + + test('does not leak unaudited conversion-module helpers through the installer', () => { + for (const name of [ + 'yamlQuote', + 'toSingleLine', + 'extractFrontmatterAndBody', + 'extractFrontmatterField', + 'convertClaudeToCursorMarkdown', + 'convertClaudeToCodexMarkdown', + 'transformContentToHyphen', + 'claudeToGeminiTools', + 'convertGeminiToolName', + 'rewriteStagedSkillBodies', + 'rewriteStagedCommandBodies', + '_computePathPrefix', + '_stampNonClaudeRuntimeDefaults', + 'NON_CLAUDE_RUNTIMES', + ]) { + assert.ok(name in conversion, `${name} remains available from the conversion module`); + assert.equal(installer[name], undefined, `${name} is not an installer compatibility export`); + } + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1908-uninstall-manifest.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1908-uninstall-manifest (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for bug #1908 + * + * `--uninstall` did not remove `gsd-file-manifest.json` from the target + * directory, leaving a stale metadata file after uninstall. + * + * Fix: `uninstall()` must call + * fs.rmSync(path.join(targetDir, MANIFEST_NAME), { force: true }) + * after cleaning up the rest of the GSD artefacts. + */ + +'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('fs'); +const path = require('path'); +const os = require('os'); + +const { uninstall } = require('../bin/install.js'); + +const MANIFEST_NAME = 'gsd-file-manifest.json'; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function createFakeInstall(prefix = 'gsd-uninstall-test-') { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + + // Simulate the minimum directory/file layout produced by the installer: + // gsd-core/ directory, agents/ directory, and the manifest file. + fs.mkdirSync(path.join(dir, 'gsd-core', 'workflows'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'gsd-core', 'workflows', 'execute-phase.md'), '# stub'); + + fs.mkdirSync(path.join(dir, 'agents'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'agents', 'gsd-executor.md'), '# stub'); + + const manifest = { + version: '1.34.0', + timestamp: new Date().toISOString(), + files: { + 'gsd-core/workflows/execute-phase.md': 'abc123', + 'agents/gsd-executor.md': 'def456', + }, + }; + fs.writeFileSync(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2)); + + return dir; +} + +function cleanup(dir) { + // eslint-disable-next-line local/no-raw-rmsync-in-tests -- local teardown helper predates helpers.cjs; renaming would collide with the imported cleanup + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} +} + +// ─── tests ──────────────────────────────────────────────────────────────────── + +describe('uninstall — manifest cleanup (#1908)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createFakeInstall(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-file-manifest.json is removed after global uninstall', () => { + const manifestPath = path.join(tmpDir, MANIFEST_NAME); + + // Pre-condition: manifest exists before uninstall + assert.ok( + fs.existsSync(manifestPath), + 'Test setup failure: manifest file should exist before uninstall' + ); + + // Run uninstall against tmpDir (pass it via CLAUDE_CONFIG_DIR so getGlobalDir() + // resolves to our temp directory; pass isGlobal=true) + const savedEnv = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = tmpDir; + try { + uninstall(true, 'claude'); + } finally { + if (savedEnv === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = savedEnv; + } + } + + assert.ok( + !fs.existsSync(manifestPath), + [ + `${MANIFEST_NAME} must be removed by uninstall() but still exists at`, + manifestPath, + ].join(' ') + ); + }); + + test('gsd-file-manifest.json is removed after local uninstall', () => { + const manifestPath = path.join(tmpDir, MANIFEST_NAME); + + assert.ok( + fs.existsSync(manifestPath), + 'Test setup failure: manifest file should exist before uninstall' + ); + + // For a local install, getGlobalDir is not called — targetDir = cwd + dirName. + // Simulate by creating .claude/ inside tmpDir and placing artefacts there. + const localDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(path.join(localDir, 'gsd-core', 'workflows'), { recursive: true }); + fs.writeFileSync(path.join(localDir, 'gsd-core', 'workflows', 'execute-phase.md'), '# stub'); + const localManifestPath = path.join(localDir, MANIFEST_NAME); + fs.writeFileSync(localManifestPath, JSON.stringify({ version: '1.34.0', files: {} }, null, 2)); + + const savedCwd = process.cwd(); + process.chdir(tmpDir); + try { + uninstall(false, 'claude'); + } finally { + process.chdir(savedCwd); + } + + assert.ok( + !fs.existsSync(localManifestPath), + [ + `${MANIFEST_NAME} must be removed by uninstall() (local) but still exists at`, + localManifestPath, + ].join(' ') + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2771-user-profile-manifest.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2771-user-profile-manifest (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression tests for bug #2771: USER-PROFILE.md tracked in install manifest + * + * USER-PROFILE.md is a user-owned artifact created/refreshed by /gsd-profile-user. + * preserveUserArtifacts() correctly preserves it across reinstalls. But writeManifest() + * also records it under "gsd-core/USER-PROFILE.md" with a SHA-256 of whatever was + * on disk at install time. On the next install, saveLocalPatches() compares the on-disk + * (refreshed) hash to the manifest hash, finds them different, and emits the spurious + * "Found N locally modified GSD file(s) — backed up to gsd-local-patches/" warning. + * + * Invariant: a file is either distribution (manifest-tracked, diff'd against manifest) + * or user artifact (preserved across installs, never diff'd). It cannot be both. The + * shared truth source must be a single USER_OWNED_ARTIFACTS list referenced by both + * preserveUserArtifacts callers and writeManifest. + * + * Closes: #2771 + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { execFileSync } = require('child_process'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); +const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +const MANIFEST_NAME = 'gsd-file-manifest.json'; +const PATCHES_DIR_NAME = 'gsd-local-patches'; + +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); +}); + +function runInstaller(configDir) { + const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir }; + delete env.GSD_TEST_MODE; + return execFileSync( + process.execPath, + [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], + { encoding: 'utf-8', stdio: 'pipe', env } + ); +} + +// ─── Test 1: writeManifest must NOT record USER-PROFILE.md ──────────────────── + +describe('#2771: USER-PROFILE.md is excluded from gsd-file-manifest.json', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempDir('gsd-2771-manifest-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('writeManifest excludes gsd-core/USER-PROFILE.md even when present on disk', () => { + runInstaller(tmpDir); + + // Simulate /gsd-profile-user creating USER-PROFILE.md + const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); + fs.writeFileSync(profilePath, '# My Profile\n\nFirst version.\n'); + + // Re-install: writeManifest runs again with USER-PROFILE.md present on disk + runInstaller(tmpDir); + + const manifestPath = path.join(tmpDir, MANIFEST_NAME); + assert.ok(fs.existsSync(manifestPath), 'manifest must be written'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + + assert.ok( + !Object.prototype.hasOwnProperty.call(manifest.files, 'gsd-core/USER-PROFILE.md'), + 'manifest.files must NOT contain gsd-core/USER-PROFILE.md — it is a user artifact, not distribution' + ); + }); +}); + +// ─── Test 2: preserveUserArtifacts still preserves USER-PROFILE.md ──────────── + +describe('#2771: USER-PROFILE.md is still preserved across reinstall', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempDir('gsd-2771-preserve-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('USER-PROFILE.md content survives reinstall (preservation regression guard)', () => { + runInstaller(tmpDir); + + const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); + const content = '# Profile\n\nUser content from /gsd-profile-user.\n'; + fs.writeFileSync(profilePath, content); + + runInstaller(tmpDir); + + assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md must survive reinstall'); + assert.strictEqual(fs.readFileSync(profilePath, 'utf8'), content); + }); +}); + +// ─── Test 3: no spurious "local patches" hit for USER-PROFILE.md refresh ────── + +describe('#2771: refreshed USER-PROFILE.md does not trigger local-patches warning', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempDir('gsd-2771-patches-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('saveLocalPatches does not classify a refreshed USER-PROFILE.md as a local patch', () => { + // Initial install + runInstaller(tmpDir); + + // /gsd-profile-user creates USER-PROFILE.md (v1) + const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); + fs.writeFileSync(profilePath, '# Profile v1\n'); + + // Reinstall — manifest written with v1 contents (under buggy code) or excluded (under fix) + runInstaller(tmpDir); + + // /gsd-profile-user --refresh rewrites USER-PROFILE.md (v2 != v1) + fs.writeFileSync(profilePath, '# Profile v2 — refreshed\n'); + + // Reinstall — saveLocalPatches scans manifest. Under bug, v2 hash != v1 manifest + // hash → patch detected. Under fix, file is not in manifest → no patch. + const output = runInstaller(tmpDir); + + const patchesDir = path.join(tmpDir, PATCHES_DIR_NAME); + const patchFile = path.join(patchesDir, 'gsd-core', 'USER-PROFILE.md'); + assert.ok( + !fs.existsSync(patchFile), + 'USER-PROFILE.md must NOT appear in gsd-local-patches/ — it is a user artifact, not a modified distribution file' + ); + + const offendingLine = output + .split('\n') + .find((line) => /locally modified GSD file/.test(line) && /USER-PROFILE/.test(line)); + assert.strictEqual( + offendingLine, + undefined, + 'installer output must not report USER-PROFILE.md as a locally modified GSD file on any single line. Output was:\n' + output + ); + }); +}); + +// ─── Test 5: legacy manifest with USER-PROFILE.md entry is normalized ───────── + +describe('#2771: legacy manifest entries for USER_OWNED_ARTIFACTS are normalized', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempDir('gsd-2771-legacy-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('pre-existing manifest entry for USER-PROFILE.md does not trigger patches warning', () => { + // Initial install + runInstaller(tmpDir); + + const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md'); + fs.writeFileSync(profilePath, '# Profile v1\n'); + + // Reinstall to populate manifest under the (now-fixed) writer + runInstaller(tmpDir); + + // Inject a stale manifest entry simulating a pre-#2771 install: a hash for + // USER-PROFILE.md that does NOT match current content. + const manifestPath = path.join(tmpDir, MANIFEST_NAME); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + manifest.files = manifest.files || {}; + manifest.files['gsd-core/USER-PROFILE.md'] = 'deadbeef'.repeat(8); // stale hash + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + + // /gsd-profile-user --refresh rewrites USER-PROFILE.md + fs.writeFileSync(profilePath, '# Profile v2 — refreshed\n'); + + // Reinstall — saveLocalPatches must strip the legacy entry before scanning + const output = runInstaller(tmpDir); + + const patchesDir = path.join(tmpDir, PATCHES_DIR_NAME); + const patchFile = path.join(patchesDir, 'gsd-core', 'USER-PROFILE.md'); + assert.ok( + !fs.existsSync(patchFile), + 'legacy USER-PROFILE.md manifest entry must be normalized away — not backed up as a patch' + ); + + const offendingLine = output + .split('\n') + .find((line) => /locally modified GSD file/.test(line) && /USER-PROFILE/.test(line)); + assert.strictEqual( + offendingLine, + undefined, + 'legacy manifest entry must not surface a USER-PROFILE.md patches warning. Output was:\n' + output + ); + }); +}); + +// ─── Test 4: shared constant exists and is used by both call sites ──────────── + +describe('#2771: USER_OWNED_ARTIFACTS is a single source of truth', () => { + test('install.js exports USER_OWNED_ARTIFACTS containing USER-PROFILE.md', () => { + const origMode = process.env.GSD_TEST_MODE; + process.env.GSD_TEST_MODE = '1'; + let mod; + try { + delete require.cache[require.resolve(INSTALL_SCRIPT)]; + mod = require(INSTALL_SCRIPT); + } finally { + if (origMode === undefined) delete process.env.GSD_TEST_MODE; + else process.env.GSD_TEST_MODE = origMode; + } + + assert.ok( + Array.isArray(mod.USER_OWNED_ARTIFACTS) || mod.USER_OWNED_ARTIFACTS instanceof Set, + 'install.js must export USER_OWNED_ARTIFACTS as a single source of truth' + ); + const list = Array.isArray(mod.USER_OWNED_ARTIFACTS) + ? mod.USER_OWNED_ARTIFACTS + : Array.from(mod.USER_OWNED_ARTIFACTS); + assert.ok( + list.includes('USER-PROFILE.md'), + 'USER_OWNED_ARTIFACTS must include USER-PROFILE.md' + ); + }); +}); + +describe('manifest path safety', () => { + let tmpDir; + let outside; + + beforeEach(() => { + tmpDir = createTempDir('gsd-manifest-path-safety-'); + outside = path.join(tmpDir, '..', `outside-managed-file-${path.basename(tmpDir)}.txt`); + }); + afterEach(() => { + cleanup(outside); + cleanup(tmpDir); + }); + + test('saveLocalPatches ignores manifest entries that escape the install root', () => { + const origMode = process.env.GSD_TEST_MODE; + process.env.GSD_TEST_MODE = '1'; + let mod; + try { + delete require.cache[require.resolve(INSTALL_SCRIPT)]; + mod = require(INSTALL_SCRIPT); + } finally { + if (origMode === undefined) delete process.env.GSD_TEST_MODE; + else process.env.GSD_TEST_MODE = origMode; + } + + fs.writeFileSync(outside, 'outside user data\n', 'utf8'); + fs.writeFileSync( + path.join(tmpDir, MANIFEST_NAME), + JSON.stringify({ + version: 'legacy', + timestamp: '2026-05-11T00:00:00.000Z', + files: { + '../outside-managed-file.txt': 'deadbeef', + }, + }, null, 2), + 'utf8' + ); + + const modified = mod.saveLocalPatches(tmpDir); + + assert.deepEqual(modified, []); + assert.equal(fs.readFileSync(outside, 'utf8'), 'outside user data\n'); + assert.equal(fs.existsSync(path.join(tmpDir, PATCHES_DIR_NAME, '..', path.basename(outside))), false); + }); + + test('saveLocalPatches does not follow symlinked patch directories outside the install root', () => { + const origMode = process.env.GSD_TEST_MODE; + process.env.GSD_TEST_MODE = '1'; + let mod; + try { + delete require.cache[require.resolve(INSTALL_SCRIPT)]; + mod = require(INSTALL_SCRIPT); + } finally { + if (origMode === undefined) delete process.env.GSD_TEST_MODE; + else process.env.GSD_TEST_MODE = origMode; + } + + const hookPath = path.join(tmpDir, 'hooks', 'managed.js'); + fs.mkdirSync(path.dirname(hookPath), { recursive: true }); + fs.writeFileSync(hookPath, 'user edited hook\n', 'utf8'); + fs.writeFileSync( + path.join(tmpDir, MANIFEST_NAME), + JSON.stringify({ + version: 'legacy', + timestamp: '2026-05-11T00:00:00.000Z', + files: { + 'hooks/managed.js': crypto.createHash('sha256').update('managed hook\n').digest('hex'), + }, + }, null, 2), + 'utf8' + ); + + fs.mkdirSync(outside, { recursive: true }); + try { + fs.symlinkSync(outside, path.join(tmpDir, PATCHES_DIR_NAME), 'dir'); + } catch { + return; + } + + const modified = mod.saveLocalPatches(tmpDir); + + assert.deepEqual(modified, []); + assert.equal(fs.existsSync(path.join(outside, 'hooks', 'managed.js')), false); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3571-configuration-manifest-install-path.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3571-configuration-manifest-install-path (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for #3571: configuration.cjs used the source + * checkout sdk/shared path only, which breaks installed gsd-tools.cjs because + * runtime installs copy gsd-core/ but not sdk/. + */ + +'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 REPO_ROOT = path.join(__dirname, '..'); +const CONFIGURATION_CJS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'configuration.cjs'); +const SHARED_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared'); + +const { install } = require('../bin/install.js'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const makeTmpDir = () => createTempDir('gsd-3571-'); + +function silenceConsole(fn) { + const original = { + log: console.log, + warn: console.warn, + error: console.error, + }; + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + try { + return fn(); + } finally { + console.log = original.log; + console.warn = original.warn; + console.error = original.error; + } +} + +describe('bug #3571: configuration generated manifests resolve in install layout', () => { + let tmpRoot; + let savedHome; + let savedUserProfile; + let savedExplicitConfigDir; + + beforeEach(() => { + tmpRoot = makeTmpDir(); + savedHome = process.env.HOME; + // On Windows, os.homedir() reads USERPROFILE; install() resolves via it. + savedUserProfile = process.env.USERPROFILE; + savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; + delete process.env.GSD_EXPLICIT_CONFIG_DIR; + }); + + afterEach(() => { + process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; + if (savedExplicitConfigDir === undefined) { + delete process.env.GSD_EXPLICIT_CONFIG_DIR; + } else { + process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; + } + cleanup(tmpRoot); + }); + + test('co-located bin/shared manifests let configuration.cjs load without sdk/shared', () => { + const gsdBinDir = path.join(tmpRoot, '.codex', 'gsd-core', 'bin'); + const gsdLibDir = path.join(gsdBinDir, 'lib'); + const gsdSharedDir = path.join(gsdBinDir, 'shared'); + fs.mkdirSync(gsdLibDir, { recursive: true }); + fs.mkdirSync(gsdSharedDir, { recursive: true }); + + const installedCjs = path.join(gsdLibDir, 'configuration.cjs'); + fs.copyFileSync(CONFIGURATION_CJS, installedCjs); + fs.copyFileSync( + path.join(SHARED_DIR, 'config-defaults.manifest.json'), + path.join(gsdSharedDir, 'config-defaults.manifest.json') + ); + fs.copyFileSync( + path.join(SHARED_DIR, 'config-schema.manifest.json'), + path.join(gsdSharedDir, 'config-schema.manifest.json') + ); + + delete require.cache[installedCjs]; + let mod; + assert.doesNotThrow(() => { + mod = require(installedCjs); + }, 'installed configuration.cjs must not require ~/.codex/sdk/shared'); + + assert.ok(mod.VALID_CONFIG_KEYS.has('workflow.plan_review_convergence')); + }); + + test('post-install: install() copies configuration manifests to co-located bin/shared', () => { + process.env.HOME = tmpRoot; + process.env.USERPROFILE = tmpRoot; + + silenceConsole(() => { + install(true, 'codex'); + }); + + const sharedDir = path.join(tmpRoot, '.codex', 'gsd-core', 'bin', 'shared'); + for (const fileName of ['config-defaults.manifest.json', 'config-schema.manifest.json']) { + const installedManifest = path.join(sharedDir, fileName); + assert.ok(fs.existsSync(installedManifest), `${fileName} must be copied to ${sharedDir}`); + assert.doesNotThrow(() => { + JSON.parse(fs.readFileSync(installedManifest, 'utf8')); + }, `${fileName} must be valid JSON`); + } + + const installedCjs = path.join( + tmpRoot, + '.codex', + 'gsd-core', + 'bin', + 'lib', + 'configuration.cjs' + ); + + delete require.cache[installedCjs]; + assert.doesNotThrow(() => { + require(installedCjs); + }, 'post-install configuration.cjs must load from co-located manifests'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-3288-model-catalog-install-path.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-3288-model-catalog-install-path (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for #3288: model-catalog.cjs uses brittle relative path + * that breaks after install. + * + * Repro: + * After `node bin/install.js --global --claude`, the installed + * `~/.claude/gsd-core/bin/lib/model-catalog.cjs` tries: + * require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json')) + * which resolves to `~/.claude/sdk/shared/model-catalog.json`. + * The installer copies `gsd-core/` but never copies `sdk/shared/`, + * so the require throws MODULE_NOT_FOUND. + * + * Fix contract: + * 1. model-catalog.cjs must use a resolve-chain that checks a co-located + * path first (bin/shared/model-catalog.json) before the legacy + * source-repo path. + * 2. bin/install.js must copy shared model-catalog.json into + * gsd-core/bin/shared/model-catalog.json (co-located inside the + * gsd-core/ payload). + * + * Both halves must be true for the install layout to work. + */ + +'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 REPO_ROOT = path.join(__dirname, '..'); +const MODEL_CATALOG_CJS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'model-catalog.cjs'); +const MODEL_CATALOG_JSON = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'model-catalog.json'); + +const { install } = require('../bin/install.js'); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const makeTmpDir = createTempDir; + +const rmTmpDir = cleanup; + +/** + * Silence console output during install to avoid noise in test output. + */ +function silenceConsole(fn) { + const orig = { + log: console.log, + warn: console.warn, + error: console.error, + }; + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + try { + return fn(); + } finally { + console.log = orig.log; + console.warn = orig.warn; + console.error = orig.error; + } +} + +// ─── test 1: fake-install layout reproduces MODULE_NOT_FOUND ──────────────── +// +// Build a fake post-install layout that mirrors what the OLD install did: +// /.claude/gsd-core/bin/lib/model-catalog.cjs (copy of real file) +// /.claude/sdk/shared/model-catalog.json ABSENT +// +// Then attempt to require model-catalog.cjs from that layout. +// Under the old path scheme (3 levels up → sdk/shared/) this should throw. +// After the fix, if we DON'T also copy the json, it should still throw — this +// confirms the co-located path IS required. + +describe('bug #3288: model-catalog.cjs install-layout resolution', () => { + let tmpRoot; + let savedHome; + let savedUserProfile; + let savedExplicitConfigDir; + + beforeEach(() => { + tmpRoot = makeTmpDir('gsd-3288-'); + savedHome = process.env.HOME; + // On Windows, os.homedir() reads USERPROFILE (and HOMEDRIVE+HOMEPATH), NOT + // HOME. install() resolves the install destination via os.homedir(), so the + // tests must also redirect USERPROFILE → tmpRoot on win32 to keep the + // installer writing inside the fixture. + savedUserProfile = process.env.USERPROFILE; + // Stash and clear explicitConfigDir via env so install() picks up our tmp dir. + // Must delete (not just save) so any CI-set value doesn't leak into install() + // and target a different directory than tmpRoot (CR finding, PR #3293). + savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; + delete process.env.GSD_EXPLICIT_CONFIG_DIR; + }); + + afterEach(() => { + process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; + if (savedExplicitConfigDir === undefined) { + delete process.env.GSD_EXPLICIT_CONFIG_DIR; + } else { + process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; + } + rmTmpDir(tmpRoot); + }); + + // ── test A ────────────────────────────────────────────────────────────────── + test('OLD layout (3-level __dirname, no co-located json) fails to require', () => { + // Build the old install layout manually: + // /.claude/gsd-core/bin/lib/model-catalog.cjs (copy of the real CJS) + // sdk/shared/model-catalog.json ABSENT + const gsdLibDir = path.join(tmpRoot, '.claude', 'gsd-core', 'bin', 'lib'); + fs.mkdirSync(gsdLibDir, { recursive: true }); + + // Write a minimal model-catalog.cjs that uses ONLY the 3-level path (the old/broken path). + const oldCjsContent = `'use strict'; +const path = require('node:path'); +// This is the BRITTLE path: 3 levels up from bin/lib → sdk/shared/ +const catalog = require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json')); +module.exports = { catalog }; +`; + const catalogCjsPath = path.join(gsdLibDir, 'model-catalog.cjs'); + fs.writeFileSync(catalogCjsPath, oldCjsContent); + + // Deliberately do NOT create sdk/shared/model-catalog.json (simulates missing file post-install). + + // Require must fail with MODULE_NOT_FOUND. + assert.throws( + () => { + // Delete from require cache to force a fresh load. + delete require.cache[catalogCjsPath]; + require(catalogCjsPath); + }, + (err) => { + assert.ok( + err.code === 'MODULE_NOT_FOUND' || err.message.includes('model-catalog.json'), + `Expected MODULE_NOT_FOUND or model-catalog.json error, got: ${err.message}`, + ); + return true; + }, + 'OLD 3-level path must fail when sdk/shared/model-catalog.json is not present (install layout)', + ); + }); + + // ── test B ────────────────────────────────────────────────────────────────── + test('NEW layout (co-located bin/shared/model-catalog.json) resolves correctly', () => { + // Build the new install layout: + // /.claude/gsd-core/bin/lib/model-catalog.cjs (copy of real CJS) + // /.claude/gsd-core/bin/shared/model-catalog.json (co-located copy) + const gsdBinDir = path.join(tmpRoot, '.claude', 'gsd-core', 'bin'); + const gsdLibDir = path.join(gsdBinDir, 'lib'); + const gsdSharedDir = path.join(gsdBinDir, 'shared'); + fs.mkdirSync(gsdLibDir, { recursive: true }); + fs.mkdirSync(gsdSharedDir, { recursive: true }); + + // Copy the real model-catalog.cjs into the fake install. + const catalogCjsPath = path.join(gsdLibDir, 'model-catalog.cjs'); + fs.copyFileSync(MODEL_CATALOG_CJS, catalogCjsPath); + + // Copy the real model-catalog.json to the co-located path. + fs.copyFileSync(MODEL_CATALOG_JSON, path.join(gsdSharedDir, 'model-catalog.json')); + + // Require must succeed and expose catalog with expected shape. + delete require.cache[catalogCjsPath]; + let mod; + assert.doesNotThrow(() => { + mod = require(catalogCjsPath); + }, 'NEW co-located layout must not throw MODULE_NOT_FOUND'); + + assert.ok(mod.catalog, 'module must export catalog'); + assert.ok(Array.isArray(mod.VALID_PROFILES), 'module must export VALID_PROFILES'); + assert.ok(mod.VALID_PROFILES.length > 0, 'VALID_PROFILES must not be empty'); + }); + + // ── test C ────────────────────────────────────────────────────────────────── + test('post-install: install() copies model-catalog.json to co-located path', () => { + // Run the real installer against a tmp target dir, then assert the co-located + // json is present and parseable. + const claudeDir = path.join(tmpRoot, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + process.env.HOME = tmpRoot; + process.env.USERPROFILE = tmpRoot; + + // Capture process.exit to prevent the test from being killed. + const origExit = process.exit; + let exitCalled = false; + process.exit = (code) => { + exitCalled = true; + throw new Error(`process.exit(${code}) during install — should not happen`); + }; + + try { + silenceConsole(() => { + install(true /* isGlobal */, 'claude'); + }); + } catch (e) { + if (exitCalled) { + assert.fail(`install() called process.exit — unexpected: ${e.message}`); + } + throw e; + } finally { + process.exit = origExit; + } + + // The co-located json must be present after install. + const colocatedJson = path.join( + claudeDir, + 'gsd-core', + 'bin', + 'shared', + 'model-catalog.json', + ); + assert.ok( + fs.existsSync(colocatedJson), + `model-catalog.json must be present at co-located path post-install: ${colocatedJson}`, + ); + + // The json must be valid and have expected shape. + let parsed; + assert.doesNotThrow(() => { + parsed = JSON.parse(fs.readFileSync(colocatedJson, 'utf8')); + }, 'co-located model-catalog.json must be valid JSON'); + + assert.ok(Array.isArray(parsed.profiles), 'catalog.profiles must be an array'); + assert.ok(parsed.profiles.length > 0, 'catalog.profiles must not be empty'); + + // And the installed model-catalog.cjs must be requireable from its install location. + const installedCjs = path.join( + claudeDir, + 'gsd-core', + 'bin', + 'lib', + 'model-catalog.cjs', + ); + assert.ok(fs.existsSync(installedCjs), `model-catalog.cjs must be installed at: ${installedCjs}`); + + delete require.cache[installedCjs]; + let installedMod; + assert.doesNotThrow(() => { + installedMod = require(installedCjs); + }, 'installed model-catalog.cjs must not throw MODULE_NOT_FOUND after install'); + + assert.ok(installedMod.catalog, 'installed module must export catalog'); + assert.ok(installedMod.VALID_PROFILES.length > 0, 'installed module must have valid profiles'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-130-finishinstall-opencode-testmode.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-130-finishinstall-opencode-testmode (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #130: finishInstall calls configureOpencodePermissions unconditionally, + * violating the GSD_TEST_MODE side-effect-free contract. + * + * configureOpencodePermissions does fs.mkdirSync + fs.writeFileSync, which + * must NOT run under GSD_TEST_MODE='1'. This test asserts that the opencode + * config file (opencode.json) is NOT created when GSD_TEST_MODE is set. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const os = require('node:os'); +const fs = require('node:fs'); + +const ROOT = path.join(__dirname, '..'); + +// Point HOME at a temp dir so configureOpencodePermissions can't write to +// the real ~/.config/opencode/ even if the guard is missing. +const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-130-test-')); +process.env.HOME = FAKE_HOME; +process.env.USERPROFILE = FAKE_HOME; + +// The opencode config dir that configureOpencodePermissions would use for a +// global install when configDir=null: /.config/opencode/ +// The file it writes is opencode.json (or opencode.jsonc if pre-existing). +const OPENCODE_CONFIG_DIR = path.join(FAKE_HOME, '.config', 'opencode'); +const OPENCODE_CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json'); + +// configDir is passed explicitly so the function targets our FAKE_HOME dir +// regardless of how getGlobalDir resolves. +const installModule = require(path.join(ROOT, 'bin', 'install.js')); + +const SETTINGS_PATH = path.join(FAKE_HOME, `gsd-test-settings-${process.pid}.json`); + +function callFinishInstall() { + const original = console.log; + console.log = () => {}; + try { + installModule.finishInstall( + SETTINGS_PATH, + {}, + null, + false, + 'opencode', + true, + OPENCODE_CONFIG_DIR, // pass explicit configDir pointing at our temp dir + ); + } finally { + console.log = original; + } +} + +describe('Bug #130: finishInstall opencode + GSD_TEST_MODE side-effect guard', () => { + test('configureOpencodePermissions does NOT write opencode.json under GSD_TEST_MODE', () => { + // Confirm the file does not exist before the call + assert.equal( + fs.existsSync(OPENCODE_CONFIG_FILE), + false, + 'opencode.json should not exist before finishInstall call', + ); + + callFinishInstall(); + + // Assert the file was NOT created — the side-effect must be suppressed + assert.equal( + fs.existsSync(OPENCODE_CONFIG_FILE), + false, + `opencode.json must NOT be created under GSD_TEST_MODE; found at ${OPENCODE_CONFIG_FILE}`, + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-410-install-defaults-test-mode-guard.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-410-install-defaults-test-mode-guard (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +/** + * Bug #410: finishInstall writes ~/.gsd/defaults.json for non-Claude runtimes + * without a GSD_TEST_MODE guard, polluting the real developer home directory + * during test runs. + * + * The opencode permission-config write a few lines above already carries the + * GSD_TEST_MODE guard (added for #130) — this test covers the un-fixed sibling + * (the resolve_model_ids: "omit" write). + */ + +const { test, describe } = require('node:test'); +const { cleanup } = require('./helpers.cjs'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const os = require('node:os'); +const fs = require('node:fs'); + +const ROOT = path.join(__dirname, '..'); + +// Point HOME at a temp dir so the defaults.json write can't reach the real +// ~/.gsd/ even if the guard is missing. +// On Windows, os.homedir() reads USERPROFILE (not HOME). Set both so +// finishInstall's path.join(os.homedir(), '.gsd') resolves into FAKE_HOME +// on every platform. Node docs: https://nodejs.org/docs/latest-v22.x/api/os.html#oshomedir +const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-410-test-')); +process.env.HOME = FAKE_HOME; +process.env.USERPROFILE = FAKE_HOME; + +// The path that finishInstall would write to for a non-Claude runtime. +const GSD_DIR = path.join(FAKE_HOME, '.gsd'); +const DEFAULTS_PATH = path.join(GSD_DIR, 'defaults.json'); + +// Set GSD_TEST_MODE before requiring install.js so any module-level guards +// also see the flag. +process.env.GSD_TEST_MODE = '1'; + +const installModule = require(path.join(ROOT, 'bin', 'install.js')); + +// A synthetic settingsPath that won't exist — finishInstall should cope. +const SETTINGS_PATH = path.join(FAKE_HOME, `gsd-test-settings-${process.pid}.json`); + +function callFinishInstallForRuntime(runtime) { + const original = console.log; + console.log = () => {}; + try { + installModule.finishInstall( + SETTINGS_PATH, + {}, // empty settings + null, // statuslineCommand + false, // shouldInstallStatusline + runtime, + true, // isGlobal + null, // configDir + ); + } finally { + console.log = original; + } +} + +describe('Bug #410: finishInstall non-Claude runtime + GSD_TEST_MODE side-effect guard', () => { + test('defaults.json is NOT written for opencode runtime under GSD_TEST_MODE', () => { + assert.equal( + fs.existsSync(DEFAULTS_PATH), + false, + 'defaults.json should not exist before finishInstall call', + ); + + callFinishInstallForRuntime('opencode'); + + assert.equal( + fs.existsSync(DEFAULTS_PATH), + false, + `defaults.json must NOT be created under GSD_TEST_MODE; found at ${DEFAULTS_PATH}`, + ); + }); + + test('defaults.json is NOT written for gemini runtime under GSD_TEST_MODE', () => { + // Reset in case previous test left artifacts (it shouldn't). + assert.equal( + fs.existsSync(DEFAULTS_PATH), + false, + 'defaults.json should not exist before gemini test', + ); + + callFinishInstallForRuntime('gemini'); + + assert.equal( + fs.existsSync(DEFAULTS_PATH), + false, + `defaults.json must NOT be created under GSD_TEST_MODE for gemini; found at ${DEFAULTS_PATH}`, + ); + }); + + test('defaults.json IS written for opencode runtime when GSD_TEST_MODE is unset', () => { + // Temporarily unset GSD_TEST_MODE to verify the user-facing path still works. + const saved = process.env.GSD_TEST_MODE; + delete process.env.GSD_TEST_MODE; + try { + callFinishInstallForRuntime('opencode'); + assert.equal( + fs.existsSync(DEFAULTS_PATH), + true, + `defaults.json must be written for non-Claude runtime when GSD_TEST_MODE is unset`, + ); + // Verify the written content is correct. + const contents = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal(contents.resolve_model_ids, 'omit', 'resolve_model_ids must be "omit"'); + } finally { + // Restore GSD_TEST_MODE and clean up the written file. + process.env.GSD_TEST_MODE = saved; + cleanup(DEFAULTS_PATH); + try { fs.rmdirSync(GSD_DIR); } catch { /* not empty or already gone */ } + } + }); +}); + +// Bug #1569 folded here (sibling on the SAME finishInstall resolve_model_ids block): +// the #1156 default-to-"omit" step keyed its write on `!== "omit"`, so an explicit +// `resolve_model_ids: true` opt-in (resolveModelInternal returns full materialized +// model IDs) was silently clobbered across all 14 non-Claude runtimes. The fix +// preserves `true` and only defaults absent/falsy → "omit". Reuses the #410 harness. + +describe('Bug #1569: non-Claude finishInstall preserves explicit resolve_model_ids:true', () => { + function seedDefaults(obj) { + fs.mkdirSync(GSD_DIR, { recursive: true }); + fs.writeFileSync(DEFAULTS_PATH, JSON.stringify(obj, null, 2) + '\n', 'utf8'); + } + + function withUserPath(fn) { + const saved = process.env.GSD_TEST_MODE; + delete process.env.GSD_TEST_MODE; + try { + return fn(); + } finally { + process.env.GSD_TEST_MODE = saved; + } + } + + test('explicit resolve_model_ids:true survives a codex global install (the reported case)', () => { + withUserPath(() => { + seedDefaults({ runtime: 'codex', model_profile: 'balanced', resolve_model_ids: true }); + callFinishInstallForRuntime('codex'); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal( + after.resolve_model_ids, + true, + 'explicit resolve_model_ids:true must be preserved across a codex install, not clobbered to "omit"', + ); + }); + }); + + // The clobber guard is runtime-agnostic (`runtime !== 'claude'`); parameterize + // across a representative slice of non-Claude runtimes. + for (const runtime of ['codex', 'opencode', 'gemini']) { + test(`explicit resolve_model_ids:true survives a ${runtime} global install`, () => { + withUserPath(() => { + seedDefaults({ runtime, resolve_model_ids: true }); + callFinishInstallForRuntime(runtime); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal( + after.resolve_model_ids, + true, + `explicit resolve_model_ids:true must be preserved for ${runtime}`, + ); + }); + }); + } + + test('absent resolve_model_ids still defaults to "omit" (preserves #1156 intent)', () => { + withUserPath(() => { + seedDefaults({ runtime: 'codex' }); + callFinishInstallForRuntime('codex'); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal( + after.resolve_model_ids, + 'omit', + 'absent resolve_model_ids must still default to "omit" for non-Claude runtimes', + ); + }); + }); + + test('explicit resolve_model_ids:false still defaults to "omit"', () => { + withUserPath(() => { + seedDefaults({ runtime: 'codex', resolve_model_ids: false }); + callFinishInstallForRuntime('codex'); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal(after.resolve_model_ids, 'omit', 'false must still be normalized to "omit"'); + }); + }); + + test('non-canonical resolve_model_ids values (0, "", "yes", {}) default to "omit" — no Claude alias leak (#1569 codex review)', () => { + // The domain is true/false/"omit"/absent. Any OTHER value is malformed; the safe + // non-Claude default is "omit" (don't leak Claude aliases the runtime can't resolve). + withUserPath(() => { + for (const bad of [0, '', 'yes', {}]) { + seedDefaults({ runtime: 'codex', resolve_model_ids: bad }); + callFinishInstallForRuntime('codex'); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal( + after.resolve_model_ids, + 'omit', + `non-canonical resolve_model_ids:${JSON.stringify(bad)} must default to "omit", not pass through`, + ); + } + }); + }); + + test('already-"omit" is left unchanged (idempotent, no rewrite churn)', () => { + withUserPath(() => { + seedDefaults({ runtime: 'codex', resolve_model_ids: 'omit' }); + const beforeMtime = fs.statSync(DEFAULTS_PATH).mtimeMs; + // fs mtime resolution can be coarse; wait briefly so an accidental rewrite is detectable. + const start = Date.now(); + while (Date.now() - start < 20) { /* spin briefly */ } + callFinishInstallForRuntime('codex'); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + const afterMtime = fs.statSync(DEFAULTS_PATH).mtimeMs; + assert.equal(after.resolve_model_ids, 'omit'); + assert.equal( + afterMtime, + beforeMtime, + 'defaults.json must not be rewritten when resolve_model_ids is already "omit" (idempotent)', + ); + }); + }); + + test('claude runtime never touches resolve_model_ids (cross-runtime parity)', () => { + withUserPath(() => { + seedDefaults({ runtime: 'claude', resolve_model_ids: true }); + callFinishInstallForRuntime('claude'); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal( + after.resolve_model_ids, + true, + 'claude install must never rewrite resolve_model_ids', + ); + }); + }); + + test('malformed defaults.json does not crash — still defaults to "omit"', () => { + withUserPath(() => { + fs.mkdirSync(GSD_DIR, { recursive: true }); + fs.writeFileSync(DEFAULTS_PATH, '{ not valid json }', 'utf8'); + // Must not throw. + callFinishInstallForRuntime('codex'); + const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + assert.equal( + after.resolve_model_ids, + 'omit', + 'malformed defaults.json must be recovered to a valid state with resolve_model_ids:omit', + ); + }); + }); +}); + +// Bug #1657 — finishInstall reads ~/.gsd/defaults.json with JSON.parse but did not +// validate the result is a plain object. A valid-JSON-but-non-object value (null, [], +// 42, "str") bypassed the catch and flowed through, leaving the malformed file on disk +// unrecovered (and, for null, throwing a TypeError swallowed by the outer try/catch). +// Folded into the owning install-defaults test (no new top-level bug-NNNN file). +describe('Bug #1657: finishInstall recovers a malformed (non-object) defaults.json', () => { + function seedDefaultsRaw(raw) { + fs.mkdirSync(GSD_DIR, { recursive: true }); + fs.writeFileSync(DEFAULTS_PATH, raw, 'utf8'); + } + function runAndRead(runtime) { + const saved = process.env.GSD_TEST_MODE; + delete process.env.GSD_TEST_MODE; + const log = console.log; console.log = () => {}; + let threw = null; + try { + installModule.finishInstall(SETTINGS_PATH, {}, null, false, runtime, true, null); + } catch (e) { threw = e.message; } finally { console.log = log; process.env.GSD_TEST_MODE = saved; } + let after = null; + try { after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); } catch (e) { after = 'UNPARSEABLE: ' + e.message; } + return { threw, after }; + } + + for (const [label, raw] of [['null', 'null'], ['array', '[]'], ['number', '42'], ['string', '"oops"']]) { + test(`seed ${label} (${raw}) recovers to a valid object with resolve_model_ids:omit`, () => { + seedDefaultsRaw(raw); + const { threw, after } = runAndRead('codex'); + assert.equal(threw, null, `must not throw for seed ${label} (got: ${threw})`); + assert.equal( + after !== null && typeof after === 'object' && !Array.isArray(after) && after.resolve_model_ids === 'omit', + true, + `seed ${label} must recover to { resolve_model_ids: 'omit' }, got: ${JSON.stringify(after)}`, + ); + }); + } +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-1736-local-install-commands.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-1736-local-install-commands (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for #1736: local Claude install missing commands/gsd/ + * + * After a fresh local install (`--claude --local`), all /gsd-* commands + * except /gsd-help return "Unknown skill: gsd-quick" because + * .claude/commands/gsd/ was not populated. Claude Code reads local project + * commands from .claude/commands/ (one level up) using the file stem as the + * command name. + * + * #1367 follow-up: the fix changed the layout from the old commands/gsd/.md + * (which caused /gsd: colon namespace) to flat commands/gsd-.md + * (which produces /gsd- hyphen form). This test has been updated to assert + * the new flat layout while preserving the core invariant from #1736: commands + * must be present and usable after a local install. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, before, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +const { install } = require(INSTALL_SRC); +const { cleanup } = require('./helpers.cjs'); + +// ─── Ensure hooks/dist/ is populated before install tests ──────────────────── +// With --test-concurrency=4, other install tests (bug-1834, bug-1924) run +// build-hooks.js concurrently. That script creates hooks/dist/ empty first, +// then copies files — creating a window where this test sees an empty dir and +// install() fails with "directory is empty" → process.exit(1). + +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { + encoding: 'utf-8', + stdio: 'pipe', + }); +}); + +// ─── #1736 + #1367: local install deploys commands in flat gsd-.md layout ─── + +describe('#1736: local Claude install deploys slash commands (flat gsd-.md layout, #1367)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-local-install-1736-')); + }); + + afterEach(() => { + // Use the shared helper which has a 5s Windows-EBUSY retry budget + // (20×250ms). The inline 1s budget here was insufficient on cold runners. + cleanup(tmpDir); + }); + + test('local install creates .claude/commands/ directory with flat gsd-*.md files (#1367)', (t) => { + // #1736 invariant: commands must be deployed. + // #1367 fix: commands land as flat gsd-.md at commands/ (not commands/gsd/.md). + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + install(false, 'claude'); + + const commandsDir = path.join(tmpDir, '.claude', 'commands'); + assert.ok( + fs.existsSync(commandsDir), + '.claude/commands/ directory must exist after local install' + ); + const flatFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok( + flatFiles.length > 0, + `.claude/commands/ must have flat gsd-*.md files (e.g. gsd-help.md). Found: ${JSON.stringify(flatFiles)}` + ); + // The old commands/gsd/ subdirectory must NOT exist (#1367) + const oldSubdir = path.join(commandsDir, 'gsd'); + assert.ok( + !fs.existsSync(oldSubdir), + '.claude/commands/gsd/ subdir must NOT exist — flat gsd-.md layout required (#1367)' + ); + }); + + test('local install deploys at least one .md command file to .claude/commands/ (#1736 invariant)', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + install(false, 'claude'); + + const commandsDir = path.join(tmpDir, '.claude', 'commands'); + assert.ok( + fs.existsSync(commandsDir), + '.claude/commands/ must exist' + ); + + const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok( + files.length > 0, + `.claude/commands/ must contain at least one gsd-*.md file, found: ${JSON.stringify(files)}` + ); + }); + + test('local install deploys gsd-quick.md to .claude/commands/ (#1367: flat hyphen form)', (t) => { + // Was: .claude/commands/gsd/quick.md (caused /gsd:quick colon form). + // Now: .claude/commands/gsd-quick.md (produces /gsd-quick hyphen form). + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + install(false, 'claude'); + + const quickCmd = path.join(tmpDir, '.claude', 'commands', 'gsd-quick.md'); + assert.ok( + fs.existsSync(quickCmd), + '.claude/commands/gsd-quick.md must exist after local install (#1367 flat layout)' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2248-local-install-statusline.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2248-local-install-statusline (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for #2248: local Claude install clobbers profile-level statusLine + * + * When installing with `--claude --local`, the repo-level `.claude/settings.json` + * takes precedence over the user's profile-level `~/.claude/settings.json` in + * Claude Code. Writing `statusLine` to repo settings during a local install + * silently overrides any profile-level statusLine the user configured. + * + * Fix: local installs skip writing `statusLine` to settings.json unless + * `--force-statusline` is passed. + * + * Note: `install()` only copies files. `finishInstall()` writes settings.json. + * The production code calls both from `installAllRuntimes()`. Tests must mirror + * that two-phase pattern. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, before, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +const { install, finishInstall } = require(INSTALL_SRC); +const { cleanup, captureConsole } = require('./helpers.cjs'); + +// ─── Ensure hooks/dist/ is populated before install tests ──────────────────── +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { + encoding: 'utf-8', + stdio: 'pipe', + }); +}); + +// ─── #2248: local install must NOT write statusLine to repo settings.json ──── + +describe('#2248: local Claude install does not clobber profile-level statusLine', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-local-install-2248-')); + }); + + afterEach(() => { + // Use the shared 5s Windows-EBUSY retry budget instead of inline 1s. + cleanup(tmpDir); + }); + + test('local install writes hooks to .claude/settings.local.json and does not write statusLine', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // Phase 1: copy files (mirrors installAllRuntimes) + const result = install(false, 'claude'); + + // Phase 2: configure settings.local.json (mirrors installAllRuntimes → finalize) + // #338: local Claude installs now write to settings.local.json, not settings.json. + // shouldInstallStatusline=true mirrors what handleStatusline picks for a fresh install + const { stdout } = captureConsole(() => { + finishInstall( + result.settingsPath, + result.settings, + result.statuslineCommand, + true, // shouldInstallStatusline + 'claude', + false // isGlobal=false -> local install + ); + }); + assert.match( + stdout, + /Skipping statusLine for local install/, + 'Local install must explain that it skipped statusLine unless --force-statusline is passed' + ); + + // #338: local installs write to settings.local.json, not settings.json + const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); + assert.ok( + fs.existsSync(localSettingsPath), + '.claude/settings.local.json must exist after local Claude install (#338)' + ); + + const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.strictEqual( + settings.statusLine, + undefined, + 'Local install must not write statusLine to settings.local.json — it would clobber profile-level settings (#2248)' + ); + + // settings.json must not be touched by a fresh local install + const sharedSettingsPath = path.join(tmpDir, '.claude', 'settings.json'); + assert.strictEqual( + fs.existsSync(sharedSettingsPath), + false, + '.claude/settings.json must NOT be created by a fresh local Claude install (#338)' + ); + }); + + test('global install still writes statusLine to settings.json', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + + // Global install writes to CLAUDE_CONFIG_DIR; point it at our tmpDir + const configDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(configDir, { recursive: true }); + const origEnv = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + t.after(() => { + if (origEnv === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = origEnv; + } + }); + + // Phase 1: copy files + const result = install(true, 'claude'); + + // Phase 2: configure settings.json + finishInstall( + result.settingsPath, + result.settings, + result.statuslineCommand, + true, // shouldInstallStatusline + 'claude', + true // isGlobal=true + ); + + const settingsPath = path.join(configDir, 'settings.json'); + assert.ok( + fs.existsSync(settingsPath), + '~/.claude/settings.json must exist after global install' + ); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + assert.ok( + settings.statusLine !== undefined, + 'Global install should write statusLine to settings.json' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-338-local-install-settings-local-json.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-338-local-install-settings-local-json (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression tests for #338: Claude --local installs must write hook wiring to + * `.claude/settings.local.json` (Claude Code's per-user gitignored slot) instead + * of the repo-shared `.claude/settings.json`. + * + * Three cases: + * 1. Fresh local install: settings.local.json is created with hook block; + * settings.json is not touched. + * 2. Global install (regression guard): continues to write to settings.json. + * 3. Migration: if a prior local install wrote GSD entries to settings.json, + * re-running local install moves them to settings.local.json and removes + * them from settings.json in the same run. + * + * Note: `install()` only copies files. `finishInstall()` writes settings. + * The production code calls both from `installAllRuntimes()`. Tests mirror + * that two-phase pattern. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, before, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +const { install, finishInstall } = require(INSTALL_SRC); +const { cleanup } = require('./helpers.cjs'); + +// ─── Ensure hooks/dist/ is populated before install tests ──────────────────── +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { + encoding: 'utf-8', + stdio: 'pipe', + }); +}); + +// ─── Helper: run both install phases ───────────────────────────────────────── + +/** + * Run install + finishInstall (mirrors installAllRuntimes two-phase pattern). + * @param {boolean} isGlobal + * @param {object} [opts] + * @param {boolean} [opts.shouldInstallStatusline] + * @returns {{ result: object }} + */ +function runInstall(isGlobal, opts = {}) { + const { shouldInstallStatusline = false } = opts; + const result = install(isGlobal, 'claude'); + finishInstall( + result.settingsPath, + result.settings, + result.statuslineCommand, + shouldInstallStatusline, + 'claude', + isGlobal + ); + return { result }; +} + +// ─── Case 1: fresh local install → settings.local.json, not settings.json ─── + +describe('#338 case 1: fresh local Claude install writes to settings.local.json', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-local-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('settings.local.json is created with hook block', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + runInstall(false); + + const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); + assert.ok( + fs.existsSync(localSettingsPath), + '.claude/settings.local.json must exist after local Claude install (#338)' + ); + + const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.ok( + settings && typeof settings === 'object', + 'settings.local.json must be a valid JSON object' + ); + // Hook block must be present (hooks key or at minimum the file was written) + assert.ok( + settings.hooks !== undefined || Object.keys(settings).length >= 0, + 'settings.local.json must contain the hook block' + ); + }); + + test('settings.json is NOT created by a fresh local install', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + runInstall(false); + + const sharedSettingsPath = path.join(tmpDir, '.claude', 'settings.json'); + assert.strictEqual( + fs.existsSync(sharedSettingsPath), + false, + '.claude/settings.json must NOT be created by a fresh local Claude install (#338) — ' + + 'engineer-specific absolute paths must not leak into the repo-shared file' + ); + }); + + test('install() returns settingsPath pointing to settings.local.json', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + const result = install(false, 'claude'); + assert.ok( + result.settingsPath.endsWith('settings.local.json'), + `install() must return settingsPath ending in settings.local.json for local Claude installs; got: ${result.settingsPath}` + ); + }); +}); + +// ─── Case 2: global Claude install (regression guard) ──────────────────────── + +describe('#338 case 2: global Claude install continues to write to settings.json', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-global-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('global install writes hook block to settings.json', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + + // Point CLAUDE_CONFIG_DIR at a subdir of tmpDir to avoid polluting ~/.claude + const configDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(configDir, { recursive: true }); + const origEnv = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + t.after(() => { + if (origEnv === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = origEnv; + } + }); + + runInstall(true); + + const settingsPath = path.join(configDir, 'settings.json'); + assert.ok( + fs.existsSync(settingsPath), + '~/.claude/settings.json must exist after global Claude install (regression guard for #338)' + ); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + assert.ok( + settings && typeof settings === 'object', + 'settings.json must be a valid JSON object after global install' + ); + }); + + test('global install does NOT create settings.local.json', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + + const configDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(configDir, { recursive: true }); + const origEnv = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + t.after(() => { + if (origEnv === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = origEnv; + } + }); + + runInstall(true); + + const localSettingsPath = path.join(configDir, 'settings.local.json'); + assert.strictEqual( + fs.existsSync(localSettingsPath), + false, + '~/.claude/settings.local.json must NOT be created by a global Claude install' + ); + }); +}); + +// ─── Case 3: migration — prior local install wrote GSD entries to settings.json ─ + +describe('#338 case 3: migration of prior local install GSD entries from settings.json to settings.local.json', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-migrate-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('GSD hook entries are moved from settings.json to settings.local.json', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // Pre-populate .claude/settings.json with a GSD-shaped hook block (simulating + // a prior local install that wrote to the wrong file). + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const sharedSettingsPath = path.join(claudeDir, 'settings.json'); + const priorSettings = { + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-check-update.js')}`, + } + ] + } + ], + PostToolUse: [ + { + matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task', + hooks: [ + { + type: 'command', + command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-context-monitor.js')}`, + timeout: 10, + } + ] + } + ] + } + }; + fs.writeFileSync(sharedSettingsPath, JSON.stringify(priorSettings, null, 2) + '\n'); + + // Run a fresh local install — this should trigger migration + runInstall(false); + + // Verify GSD entries are now in settings.local.json + const localSettingsPath = path.join(claudeDir, 'settings.local.json'); + assert.ok( + fs.existsSync(localSettingsPath), + '.claude/settings.local.json must exist after migration run' + ); + const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + const sessionStartHooks = (localSettings.hooks && localSettings.hooks.SessionStart) || []; + const hasGsdUpdateHook = sessionStartHooks.some( + entry => entry && entry.hooks && Array.isArray(entry.hooks) && + entry.hooks.some(h => h && h.command && h.command.includes('gsd-check-update')) + ); + assert.ok( + hasGsdUpdateHook, + 'settings.local.json must contain the migrated gsd-check-update hook after migration' + ); + }); + + test('GSD hook entries are removed from settings.json after migration', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const sharedSettingsPath = path.join(claudeDir, 'settings.json'); + const priorSettings = { + // Include a non-GSD key to verify user content is preserved + myCustomKey: 'keep-me', + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-check-update.js')}`, + } + ] + } + ] + } + }; + fs.writeFileSync(sharedSettingsPath, JSON.stringify(priorSettings, null, 2) + '\n'); + + runInstall(false); + + // settings.json must exist (we don't delete it — user may have other content) + assert.ok( + fs.existsSync(sharedSettingsPath), + '.claude/settings.json must still exist after migration (may have non-GSD user content)' + ); + const sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf-8')); + + // GSD hooks must be gone from settings.json + const sessionStartHooks = (sharedSettings.hooks && sharedSettings.hooks.SessionStart) || []; + const hasGsdHook = sessionStartHooks.some( + entry => entry && entry.hooks && Array.isArray(entry.hooks) && + entry.hooks.some(h => h && h.command && h.command.includes('gsd-check-update')) + ); + assert.strictEqual( + hasGsdHook, + false, + 'GSD hook entries must be removed from settings.json after migration to settings.local.json' + ); + + // Non-GSD user content must be preserved + assert.strictEqual( + sharedSettings.myCustomKey, + 'keep-me', + 'Non-GSD user content in settings.json must be preserved during migration' + ); + }); + + test('settings.json with no GSD entries is left unchanged', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const sharedSettingsPath = path.join(claudeDir, 'settings.json'); + const userOnlySettings = { + userKey: 'user-value', + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: '/usr/local/bin/my-own-hook.sh', + } + ] + } + ] + } + }; + const originalContent = JSON.stringify(userOnlySettings, null, 2) + '\n'; + fs.writeFileSync(sharedSettingsPath, originalContent); + + runInstall(false); + + // settings.json must be unchanged (no GSD entries to migrate) + const afterContent = fs.readFileSync(sharedSettingsPath, 'utf-8'); + const afterSettings = JSON.parse(afterContent); + assert.strictEqual( + afterSettings.userKey, + 'user-value', + 'Non-GSD settings.json must be untouched when no GSD entries are present' + ); + // User hook must still be there + const sessionStart = (afterSettings.hooks && afterSettings.hooks.SessionStart) || []; + const hasUserHook = sessionStart.some( + entry => entry && entry.hooks && entry.hooks.some(h => h && h.command === '/usr/local/bin/my-own-hook.sh') + ); + assert.ok( + hasUserHook, + 'User hook in settings.json must be preserved when no migration occurs' + ); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2957-claude-global-postinstall-message.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2957-claude-global-postinstall-message (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #2957: post-install message for `--claude --global` must instruct + * users to restart Claude Code and offer the skill-name fallback, since + * the skills-only install layout (CC 2.1.88+) leaves nothing in + * commands/gsd/ for the slash menu to read on older configurations. + * + * Captures the call to finishInstall(runtime='claude', isGlobal=true) and + * asserts the printed message contains both invocation paths. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const os = require('node:os'); + +const ROOT = path.join(__dirname, '..'); +const SETTINGS_PATH = path.join(os.tmpdir(), `gsd-test-settings-${process.pid}.json`); +const installModule = require(path.join(ROOT, 'bin', 'install.js')); + +function captureFinishInstallOutput(runtime, isGlobal) { + const original = console.log; + const lines = []; + console.log = (...args) => { lines.push(args.join(' ')); }; + try { + installModule.finishInstall( + SETTINGS_PATH, + {}, + null, + false, + runtime, + isGlobal, + null, + ); + } finally { + console.log = original; + } + // Strip ANSI color escapes so message-content assertions don't couple to colors. + // 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 + return lines.join('\n').replace(/\x1B\[[0-9;]*m/g, ''); +} + +describe('Bug #2957: claude+global post-install message', () => { + test('claude+global message tells the user to restart and offers skill-name fallback', () => { + const output = captureFinishInstallOutput('claude', true); + + assert.match(output, /restart claude code/i, 'should mention restart'); + assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project'); + assert.match(output, /gsd-new-project skill/i, 'should mention the skill name fallback'); + assert.doesNotMatch( + output, + /open a blank directory/i, + 'global claude install should replace, not extend, the legacy generic instruction', + ); + }); + + test('claude+local message keeps the original /gsd-new-project instruction', () => { + const output = captureFinishInstallOutput('claude', false); + + assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project'); + assert.doesNotMatch(output, /restart claude code/i, 'local install does not require the skills restart note'); + }); + + test('non-claude runtimes keep their original message format', () => { + const output = captureFinishInstallOutput('opencode', true); + + assert.match(output, /Open a blank directory/, 'opencode message should be unchanged'); + assert.doesNotMatch(output, /restart/i, 'opencode message should not have the claude-specific restart note'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-505-remove-dead-sdk-verification.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-505-remove-dead-sdk-verification (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression guard for #505: dead SDK-shim verification subsystem removed. + * + * Post-ADR-0174 the `@opengsd/gsd-sdk` package was retired; `sdk/` no longer + * ships. `installSdkIfNeeded` and all functions it transitively called are + * dead code with no live callers. This test asserts: + * + * 1. All removed symbols are NO LONGER exported from bin/install.js. + * 2. The two live stale-standalone-SDK helpers (detectStaleStandaloneSdk, + * formatStaleStandaloneSdkWarning) are STILL exported as functions — they + * handle a real user-facing condition (#3406) and MUST NOT be removed. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const inst = require('../bin/install.js'); + +describe('bug #505: dead SDK verification subsystem removed from bin/install.js', () => { + // ---------------------------------------------------------------- + // Dead symbols — must NOT be exported after removal + // ---------------------------------------------------------------- + const deadSymbols = [ + 'installSdkIfNeeded', + 'classifySdkInstall', + 'buildSdkFailFastReport', + 'renderSdkFailFastReport', + 'buildGsdSdkVersionMismatchReport', + 'renderGsdSdkVersionMismatchReport', + 'readGsdSdkVersion', + 'parseGsdSdkVersion', + 'findGsdSdkOnPath', + 'isGsdSdkOnPath', + 'isLegacyGsdSdkShim', + 'trySelfLinkGsdSdk', + 'trySelfLinkGsdSdkWindows', + 'filterNpxFromPath', + 'getUserShellPath', + 'getUserShellWindowsPersistentPath', + ]; + + for (const sym of deadSymbols) { + test(`dead symbol '${sym}' is not exported`, () => { + assert.equal( + typeof inst[sym], + 'undefined', + `'${sym}' should have been removed (post #505 dead-code removal) but is still exported as ${typeof inst[sym]}`, + ); + }); + } + + // ---------------------------------------------------------------- + // The stale-standalone-SDK helpers (detectStaleStandaloneSdk, + // formatStaleStandaloneSdkWarning) and the gsd-sdk shim contract surface + // (buildWindowsShimTriple, formatSdkPathDiagnostic) that #505 kept were + // removed when the gsd-sdk shim itself was retired (#191). Their absence is + // covered by the dead-symbol assertions above. + // ---------------------------------------------------------------- +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-376-claude-js-hook-gsd-rewriter (consolidation epic #1969 B1 #1970)", () => { +'use strict'; + +/** + * Regression for bug #376 — Claude-installed hook JS files ship with raw + * /gsd: command literals because the hook-copy loop in install.js had + * no /gsd: → /gsd- rewrite for the claude runtime. + * + * Fix: the `.js` branch of the hook-copy loop now applies + * `content.replace(/gsd:/gi, 'gsd-')` when + * `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` is true (covers + * claude, qwen, hermes). + * + * Test plan: + * 1. Claude install to tmp prefix — installed .js hook files must contain + * no user-facing /gsd: literals (// comment occurrences exempted). + * 2. Cursor install regression — still rewrites correctly (pre-existing + * branch must remain intact). + * 3. Source files in hooks/ must be byte-identical before and after both + * installs (install-time rewrite only, no in-tree mutation). + */ + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js'); +const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); + +/** + * Ensure hooks/dist is populated before any suite that reads it. + * hooks/dist/ is gitignored and only produced by `npm run build:hooks`. + * In CI the scoped/windows test jobs do NOT run build:hooks before running + * tests, so the first test that needs hooks/dist would fail. This mirrors + * the pattern used in bug-3357-codex-legacy-hooks-json-migration.test.cjs. + */ +function ensureHooksDist() { + if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { + execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Run `node install.js <...args>` from cwd. + * GSD_TEST_MODE is cleared so the install() main block executes. + */ +function runInstall(cwd, args) { + const env = { ...process.env }; + delete env.GSD_TEST_MODE; + execFileSync(process.execPath, [INSTALL_PATH, ...args], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env, + timeout: 60000, + }); +} + +/** + * Return an array of { rel, path } for all .js files under dir. + */ +function findJsFiles(dir) { + const results = []; + function walk(d) { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const full = path.join(d, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.js') || entry.name.endsWith('.cjs')) { + results.push({ rel: path.relative(dir, full), full }); + } + } + } + walk(dir); + return results; +} + +/** + * Split a JS file's lines into comment and non-comment buckets. + * A line is treated as a comment if it starts with optional whitespace + * followed by // (single-line comment). Block comments are not checked + * since none of the hook files use them for command refs. + */ +function nonCommentLines(content) { + return content.split('\n').filter(line => !/^\s*\/\//.test(line)); +} + +/** + * Return lines (from nonCommentLines) that contain a user-facing /gsd: ref. + */ +function colonRefs(content) { + return nonCommentLines(content).filter(line => /\/gsd:/.test(line)); +} + +// --------------------------------------------------------------------------- +// Prerequisite: hooks/dist must exist (built by `npm run build:hooks`) +// --------------------------------------------------------------------------- +describe('bug #376 — prerequisite: hooks/dist is present', () => { + before(() => { + // hooks/dist is gitignored; build it on demand so this test is + // deterministic in CI scoped/windows jobs that don't pre-run build:hooks. + ensureHooksDist(); + }); + + test('hooks/dist directory exists (run npm run build:hooks if missing)', () => { + assert.ok( + fs.existsSync(HOOKS_DIST_DIR), + `hooks/dist not found at ${HOOKS_DIST_DIR}. Run: npm run build:hooks`, + ); + }); + + test('hooks/dist contains at least one .js hook file with a /gsd: literal', () => { + const jsFiles = findJsFiles(HOOKS_DIST_DIR); + assert.ok(jsFiles.length > 0, 'hooks/dist must contain .js files'); + + const withColonRef = jsFiles.filter(({ full }) => { + const content = fs.readFileSync(full, 'utf-8'); + return colonRefs(content).length > 0; + }); + + assert.ok( + withColonRef.length > 0, + 'Expected at least one hooks/dist .js file with a non-comment /gsd: literal ' + + '— this confirms the test is guarding a real regression surface. ' + + `Files checked: ${jsFiles.map(f => f.rel).join(', ')}`, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Suite 1 — Claude install: no /gsd: colon refs in installed .js hook files +// --------------------------------------------------------------------------- +describe('bug #376 — Suite 1: Claude install rewrites /gsd: → /gsd- in hook .js files', () => { + let tmpDir; + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-376-claude-')); + runInstall(tmpDir, ['--claude', '--local', '--no-sdk']); + }); + + after(() => { + cleanup(tmpDir); + }); + + test('1a: hooks/ directory is created by the Claude local install', () => { + const hooksDir = path.join(tmpDir, '.claude', 'hooks'); + assert.ok( + fs.existsSync(hooksDir), + `hooks/ must be created at ${hooksDir} by Claude local install`, + ); + }); + + test('1b: installed .js hook files contain no user-facing /gsd: colon refs', () => { + const hooksDir = path.join(tmpDir, '.claude', 'hooks'); + if (!fs.existsSync(hooksDir)) { + // If hooks/ wasn't created (hooks/dist missing at install time), skip gracefully + return; + } + + const jsFiles = findJsFiles(hooksDir); + assert.ok(jsFiles.length > 0, 'At least one .js hook file must be installed'); + + const offenders = []; + for (const { rel, full } of jsFiles) { + const content = fs.readFileSync(full, 'utf-8'); + const badLines = colonRefs(content); + if (badLines.length > 0) { + offenders.push({ rel, lines: badLines }); + } + } + + assert.deepEqual( + offenders, + [], + 'Installed Claude hook .js files must not contain /gsd: colon refs ' + + '(non-comment occurrences). The install-time rewriter must replace these with /gsd-. ' + + 'Offenders: ' + JSON.stringify(offenders, null, 2), + ); + }); + + test('1c: installed .js hook files DO contain the hyphen form /gsd- (rewrite happened)', () => { + const hooksDir = path.join(tmpDir, '.claude', 'hooks'); + if (!fs.existsSync(hooksDir)) return; + + const jsFiles = findJsFiles(hooksDir); + const withHyphen = jsFiles.filter(({ full }) => { + const content = fs.readFileSync(full, 'utf-8'); + return /\/gsd-/.test(content); + }); + + assert.ok( + withHyphen.length > 0, + 'At least one installed .js hook file must contain /gsd- (confirming rewrite ran). ' + + `Files checked: ${jsFiles.map(f => f.rel).join(', ')}`, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Suite 2 — Cursor install regression: /gsd: → /gsd- still works (pre-existing) +// +// Note: Cursor installs its own hooks (gsd-cursor-session-start.js and +// gsd-cursor-post-tool.js) via the cursor-hooks-json installSurface (issue #777). +// It does NOT install the bundled Claude-style hooks/dist files (no gsd-session-state.sh +// etc.). The Cursor /gsd: rewrite applies in `copyWithPathReplacement` to JS files +// under the agent/skill tree (.cursor/gsd-core/*.js etc). We verify that Cursor's +// installed .js files under .cursor/ have no /gsd: colon refs, and that the hooks/ +// directory contains only the Cursor-specific managed hooks. +// --------------------------------------------------------------------------- +describe('bug #376 — Suite 2: Cursor install still rewrites /gsd: → /gsd- (regression)', () => { + let tmpDir; + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-376-cursor-')); + runInstall(tmpDir, ['--cursor', '--local', '--no-sdk']); + }); + + after(() => { + cleanup(tmpDir); + }); + + test('2a: .cursor/ directory is created by the Cursor local install', () => { + const cursorDir = path.join(tmpDir, '.cursor'); + assert.ok( + fs.existsSync(cursorDir), + `Cursor install must create .cursor/ directory at ${cursorDir}`, + ); + }); + + test('2b: Cursor-installed .js files contain no user-facing /gsd: colon refs', () => { + const cursorDir = path.join(tmpDir, '.cursor'); + if (!fs.existsSync(cursorDir)) return; + + // Infrastructure files whose /gsd: occurrences are intentional implementation + // details — NOT user-facing command references that Cursor would invoke. + // + // scripts/fix-slash-commands.cjs is the slash-command rewriter engine, required + // by gsd-core/bin/lib/command-roster.cjs on ALL runtimes (including Cursor). + // It must be installed verbatim and must NOT be content-rewritten: it needs to + // emit `/gsd:${cmd}` for non-Cursor runtimes, and its /gsd: strings are internal + // implementation/docs (transform patterns, regex literals, template literals), + // not commands a Cursor user would type. Rewriting it would corrupt the transformer. + const INFRA_BASENAMES = new Set(['fix-slash-commands.cjs']); + + const jsFiles = findJsFiles(cursorDir); + // Cursor may not install any .js files depending on what agent/skill content exists; + // if none, skip gracefully. + if (jsFiles.length === 0) return; + + const offenders = []; + for (const { rel, full } of jsFiles) { + // Skip infrastructure files whose /gsd: strings are intentional (see above). + if (INFRA_BASENAMES.has(path.basename(full))) continue; + const content = fs.readFileSync(full, 'utf-8'); + const badLines = colonRefs(content); + if (badLines.length > 0) { + offenders.push({ rel, lines: badLines }); + } + } + + assert.deepEqual( + offenders, + [], + 'Cursor-installed .js files must not contain /gsd: colon refs. ' + + 'The existing Cursor branch in copyWithPathReplacement must still apply /gsd:/gi → gsd- rewrite. ' + + 'Offenders: ' + JSON.stringify(offenders, null, 2), + ); + }); + + test('2c: Cursor install creates a hooks/ directory with only Cursor-specific managed hooks', () => { + // Since issue #777, Cursor installs gsd-cursor-session-start.js and + // gsd-cursor-post-tool.js into /hooks/. These are Cursor-native + // hooks — NOT the bundled Claude-style hooks (no gsd-session-state.sh etc.). + // Verify: hooks/ exists AND does NOT contain any Claude-bundled hooks. + const hooksDir = path.join(tmpDir, '.cursor', 'hooks'); + assert.ok( + fs.existsSync(hooksDir), + 'Cursor install must create a hooks/ directory for its managed hook scripts (#777)', + ); + const CLAUDE_BUNDLED_HOOKS = ['gsd-session-state.sh', 'gsd-context-monitor.js', 'gsd-statusline.js']; + for (const hook of CLAUDE_BUNDLED_HOOKS) { + assert.strictEqual( + fs.existsSync(path.join(hooksDir, hook)), + false, + `Cursor hooks/ must NOT contain Claude-bundled hook ${hook} — only Cursor-native hooks are installed`, + ); + } + // The two Cursor-specific managed hooks must be present. + assert.ok( + fs.existsSync(path.join(hooksDir, 'gsd-cursor-session-start.js')), + 'gsd-cursor-session-start.js must be installed in .cursor/hooks/ (#777)', + ); + assert.ok( + fs.existsSync(path.join(hooksDir, 'gsd-cursor-post-tool.js')), + 'gsd-cursor-post-tool.js must be installed in .cursor/hooks/ (#777)', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Suite 3 — Source files in hooks/ are untouched +// --------------------------------------------------------------------------- +describe('bug #376 — Suite 3: hooks/ source files are unchanged by install', () => { + let snapshotBefore; + + before(() => { + // Ensure hooks/dist is built before snapshotting; it may be absent in CI + // scoped/windows jobs that don't pre-run build:hooks (#777 fix). + ensureHooksDist(); + // Snapshot hooks/dist JS files before any install in this suite + snapshotBefore = {}; + if (fs.existsSync(HOOKS_DIST_DIR)) { + for (const { rel, full } of findJsFiles(HOOKS_DIST_DIR)) { + snapshotBefore[rel] = fs.readFileSync(full, 'utf-8'); + } + } + }); + + test('3a: hooks/dist .js source files still contain /gsd: literals (not mutated)', () => { + // The source must remain in colon form — the rewrite is install-time only + const jsFiles = findJsFiles(HOOKS_DIST_DIR); + const withColonRef = jsFiles.filter(({ full }) => { + const content = fs.readFileSync(full, 'utf-8'); + return colonRefs(content).length > 0; + }); + + // We know from the prerequisite suite that at least one file had a colon ref; + // if the source was mutated by install, this would now be zero. + assert.ok( + withColonRef.length > 0, + 'hooks/dist .js files must still contain /gsd: literals after install — ' + + 'the install-time rewrite must NOT modify the source tree. ' + + `Files that still have colon refs: ${withColonRef.map(f => f.rel).join(', ')}`, + ); + }); + + test('3b: hooks/dist .js source file contents match pre-test snapshot (byte-identical)', () => { + if (Object.keys(snapshotBefore).length === 0) { + // hooks/dist was absent before; skip + return; + } + + for (const [rel, before] of Object.entries(snapshotBefore)) { + const full = path.join(HOOKS_DIST_DIR, rel); + const after = fs.readFileSync(full, 'utf-8'); + assert.strictEqual( + after, + before, + `hooks/dist/${rel} was mutated by install — install must only rewrite the installed copy, not the source`, + ); + } + }); +}); + +// --------------------------------------------------------------------------- +// Suite 4 — Pure-function: shouldNormalizeHyphenNamespaceInAgentBody covers claude +// --------------------------------------------------------------------------- +describe('bug #376 — Suite 4: shouldNormalizeHyphenNamespaceInAgentBody covers claude', () => { + const install = require(INSTALL_PATH); + + test('4a: shouldNormalizeHyphenNamespaceInAgentBody is exported', () => { + assert.strictEqual( + typeof install.shouldNormalizeHyphenNamespaceInAgentBody, + 'function', + 'install.js must export shouldNormalizeHyphenNamespaceInAgentBody', + ); + }); + + test('4b: claude is in the hyphen-namespace set', () => { + assert.strictEqual( + install.shouldNormalizeHyphenNamespaceInAgentBody('claude'), + true, + 'claude must be a hyphen-namespace runtime', + ); + }); + + test('4c: qwen is in the hyphen-namespace set', () => { + assert.strictEqual( + install.shouldNormalizeHyphenNamespaceInAgentBody('qwen'), + true, + ); + }); + + test('4d: hermes is in the hyphen-namespace set', () => { + assert.strictEqual( + install.shouldNormalizeHyphenNamespaceInAgentBody('hermes'), + true, + ); + }); + + test('4e: gemini is NOT in the hyphen-namespace set', () => { + assert.strictEqual( + install.shouldNormalizeHyphenNamespaceInAgentBody('gemini'), + false, + 'gemini intentionally keeps colon namespace and must not be in the hyphen set', + ); + }); +}); + }); +} diff --git a/tests/path-replacement.test.cjs b/tests/path-replacement.test.cjs index ef6df8336..b8362bda9 100644 --- a/tests/path-replacement.test.cjs +++ b/tests/path-replacement.test.cjs @@ -206,3 +206,235 @@ describe('containsResolvedHomedir predicate (#3503)', () => { assert.strictEqual(containsResolvedHomedir('$HOME/.claude/', '$HOME'), false); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2376-opencode-windows-home-path.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2376-opencode-windows-home-path (consolidation epic #1969 B1 #1970)", () => { +/** + * Regression test for #2376: @$HOME not correctly mapped in OpenCode on Windows. + * + * On Windows, $HOME is not expanded by PowerShell/cmd.exe, so OpenCode cannot + * resolve @$HOME/... file references in installed command files. + * + * Fix: install.js must use the absolute path (not $HOME-relative) when installing + * for OpenCode. (Generalized to all platforms in #2831 — OpenCode `@file` + * references are not shell-expanded on any platform.) + */ + +'use strict'; + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); + +let computePathPrefix; + +before(() => { + process.env.GSD_TEST_MODE = '1'; + // Re-require fresh in case other tests already loaded it. + delete require.cache[require.resolve('../bin/install.js')]; + ({ computePathPrefix } = require('../bin/install.js')); +}); + +after(() => { + delete process.env.GSD_TEST_MODE; +}); + +describe('bug-2376: OpenCode on Windows must use absolute path, not $HOME', () => { + test('computePathPrefix is exported by install.js', () => { + assert.equal(typeof computePathPrefix, 'function'); + }); + + test('OpenCode on Windows: pathPrefix is absolute (no $HOME substitution)', () => { + const pathPrefix = computePathPrefix({ + isGlobal: true, + isOpencode: true, + isWindowsHost: true, + resolvedTarget: 'C:/Users/user/.config/opencode', + homeDir: 'C:/Users/user', + }); + assert.strictEqual(pathPrefix, 'C:/Users/user/.config/opencode/'); + assert.ok(!pathPrefix.includes('$HOME')); + }); + + test('Claude Code on Windows: pathPrefix still uses $HOME (unaffected)', () => { + const pathPrefix = computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: true, + resolvedTarget: 'C:/Users/user/.claude', + homeDir: 'C:/Users/user', + }); + assert.strictEqual(pathPrefix, '$HOME/.claude/'); + }); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-2831-opencode-home-path-prefix.test.cjs — consolidation epic #1969 (B1 #1970) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-2831-opencode-home-path-prefix (consolidation epic #1969 B1 #1970)", () => { +// allow-test-rule: source-text-is-the-product (see #2831) +// Workflow .md / agent .md / command .md / reference .md files — their text +// IS what the runtime loads. Testing text content tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Regression test for #2831: OpenCode @file references contain literal `$HOME` + * which OpenCode does not expand — `@$HOME/.config/opencode/...` is resolved + * as a path relative to the config command/ dir, producing + * `command/$HOME/.config/opencode/...` (file not found). + * + * Root cause: install.js pathPrefix used `$HOME`-relative paths for OpenCode on + * non-Windows hosts (only Windows was guarded by #2376). OpenCode's `@file` + * include syntax does NOT shell-expand `$HOME` on any platform. + * + * Fix: pathPrefix must use the absolute path for OpenCode on all platforms. + * + * Tests exercise install.js's exported `computePathPrefix` directly (no source + * grepping) and additionally simulate the `copyFlattenedCommands` substitution + * pipeline on a temp tree to verify no `$HOME` literal leaks into emitted files. + */ + +'use strict'; + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { cleanup } = require('./helpers.cjs'); + +let computePathPrefix; + +before(() => { + process.env.GSD_TEST_MODE = '1'; + delete require.cache[require.resolve('../bin/install.js')]; + ({ computePathPrefix } = require('../bin/install.js')); +}); + +after(() => { + delete process.env.GSD_TEST_MODE; +}); + +describe('bug-2831: OpenCode pathPrefix uses absolute path on all platforms', () => { + test('computePathPrefix is exported by install.js', () => { + assert.equal(typeof computePathPrefix, 'function'); + }); + + test('OpenCode on macOS: pathPrefix is absolute (no $HOME)', () => { + const pathPrefix = computePathPrefix({ + isGlobal: true, + isOpencode: true, + isWindowsHost: false, + homeDir: '/Users/alice', + resolvedTarget: '/Users/alice/.config/opencode', + }); + assert.strictEqual(pathPrefix, '/Users/alice/.config/opencode/'); + assert.ok(!pathPrefix.includes('$HOME')); + }); + + test('OpenCode on Linux: pathPrefix is absolute (no $HOME)', () => { + const pathPrefix = computePathPrefix({ + isGlobal: true, + isOpencode: true, + isWindowsHost: false, + homeDir: '/home/bob', + resolvedTarget: '/home/bob/.config/opencode', + }); + assert.strictEqual(pathPrefix, '/home/bob/.config/opencode/'); + assert.ok(!pathPrefix.includes('$HOME')); + }); + + test('OpenCode on Windows: pathPrefix is absolute (preserves #2376)', () => { + const pathPrefix = computePathPrefix({ + isGlobal: true, + isOpencode: true, + isWindowsHost: true, + homeDir: 'C:/Users/carol', + resolvedTarget: 'C:/Users/carol/.config/opencode', + }); + assert.strictEqual(pathPrefix, 'C:/Users/carol/.config/opencode/'); + }); + + test('Claude Code on macOS: pathPrefix still uses $HOME (unaffected)', () => { + const pathPrefix = computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: false, + homeDir: '/Users/alice', + resolvedTarget: '/Users/alice/.claude', + }); + assert.strictEqual(pathPrefix, '$HOME/.claude/'); + }); + + test('Local install (non-global): pathPrefix uses absolute path regardless of runtime', () => { + const pathPrefix = computePathPrefix({ + isGlobal: false, + isOpencode: false, + isWindowsHost: false, + homeDir: '/Users/alice', + resolvedTarget: '/Users/alice/projects/foo/.claude', + }); + assert.strictEqual(pathPrefix, '/Users/alice/projects/foo/.claude/'); + }); + + test('Substitution pipeline simulation: OpenCode emits no @$HOME literal', () => { + // This validates the same regex substitution pipeline used by + // copyFlattenedCommands when writing OpenCode command files. We invoke the + // real exported computePathPrefix; the regex passes mirror the install.js + // call sites (globalClaudeRegex / globalClaudeHomeRegex). + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2831-')); + try { + const srcRoot = path.join(tmp, 'src'); + const targetRoot = path.join(tmp, 'home', '.config', 'opencode'); + const srcCmdDir = path.join(srcRoot, 'commands', 'gsd'); + fs.mkdirSync(srcCmdDir, { recursive: true }); + fs.mkdirSync(targetRoot, { recursive: true }); + + const srcFile = path.join(srcCmdDir, 'autonomous.md'); + fs.writeFileSync( + srcFile, + '---\nname: autonomous\n---\n\n@~/.claude/gsd-core/workflows/autonomous.md\n@$HOME/.claude/gsd-core/references/ui-brand.md\n\n' + ); + + const homeDir = path.join(tmp, 'home').replace(/\\/g, '/'); + const resolvedTarget = targetRoot.replace(/\\/g, '/'); + const pathPrefix = computePathPrefix({ + isGlobal: true, + isOpencode: true, + isWindowsHost: false, + homeDir, + resolvedTarget, + }); + + let content = fs.readFileSync(srcFile, 'utf8'); + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + + assert.ok( + !/@\$HOME\b/.test(content), + `output must not contain @$HOME literal; got:\n${content}` + ); + assert.ok( + !/\$HOME\b/.test(content), + `output must not contain $HOME literal; got:\n${content}` + ); + assert.ok( + content.includes(`@${resolvedTarget}/`), + `output should include absolute path with @ prefix; got:\n${content}` + ); + } finally { + cleanup(tmp); + } + }); +}); + }); +}