claude-dev-env 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +53 -48
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +4 -2
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/CLAUDE.md +3 -1
- package/agents/caveman.md +0 -1
- package/agents/clasp-deployment-orchestrator.md +0 -1
- package/agents/clean-coder.md +0 -1
- package/agents/code-advisor.md +0 -1
- package/agents/code-quality-agent.md +1 -2
- package/agents/code-verifier.md +3 -4
- package/agents/deep-research.md +0 -1
- package/agents/docs-agent.md +0 -1
- package/agents/git-commit-crafter.md +0 -1
- package/agents/issue-tracker.md +42 -0
- package/agents/plan-packet-validator.md +0 -1
- package/agents/pr-description-writer.md +0 -1
- package/agents/skill-writer-agent.md +84 -0
- package/agents/test_agent_frontmatter.py +67 -18
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +105 -3
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
- package/bin/CLAUDE.md +68 -5
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install-constants.mjs +88 -0
- package/bin/install.mjs +1138 -114
- package/bin/install.prune.test.mjs +869 -19
- package/bin/install.test.mjs +906 -2
- package/commands/implement.md +1 -1
- package/commands/right-size.md +1 -1
- package/docs/CLAUDE.md +2 -0
- package/docs/host-pool-health-monitor.md +102 -0
- package/docs/references/CLAUDE.md +5 -2
- package/docs/references/advisor-tool.md +13 -0
- package/docs/references/code-review-enforcement.md +107 -0
- package/docs/references/team-advisor-skill.md +14 -0
- package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
- package/hooks/blocking/CLAUDE.md +9 -1
- package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
- package/hooks/blocking/code_review_gate_deny.py +74 -0
- package/hooks/blocking/code_review_pr_create_gate.py +198 -0
- package/hooks/blocking/code_review_push_gate.py +145 -0
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +348 -0
- package/hooks/blocking/code_review_stamp_store.py +233 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
- package/hooks/blocking/config/__init__.py +5 -5
- package/hooks/blocking/config/code_review_enforcement_constants.py +113 -0
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +113 -0
- package/hooks/blocking/config/verified_commit_constants.py +160 -155
- package/hooks/blocking/conftest.py +2 -0
- package/hooks/blocking/convergence_gate_blocker.py +112 -23
- package/hooks/blocking/destructive_command_blocker.py +19 -6
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
- package/hooks/blocking/pr_description_proof_of_work.py +52 -34
- package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
- package/hooks/blocking/test_code_review_gate_deny.py +54 -0
- package/hooks/blocking/test_code_review_pr_create_gate.py +199 -0
- package/hooks/blocking/test_code_review_push_gate.py +205 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +199 -0
- package/hooks/blocking/test_code_review_stamp_store.py +205 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
- package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
- package/hooks/blocking/test_destructive_command_blocker.py +1 -1
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +17 -8
- package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
- package/hooks/blocking/test_verification_verdict_store.py +974 -903
- package/hooks/blocking/test_verified_commit_gate.py +581 -581
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
- package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
- package/hooks/blocking/verification_verdict_store.py +1039 -1014
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
- package/hooks/blocking/verifier_verdict_minter.py +280 -280
- package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
- package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
- package/hooks/git-hooks/pre_push.py +89 -2
- package/hooks/git-hooks/test_pre_push.py +128 -0
- package/hooks/hooks.json +26 -1
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
- package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
- package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
- package/hooks/pyproject.toml +1 -0
- package/hooks/validators/CLAUDE.md +2 -0
- package/hooks/validators/config/__init__.py +0 -0
- package/hooks/validators/config/directory_exemption_constants.py +183 -0
- package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
- package/hooks/validators/conftest.py +4 -0
- package/hooks/validators/mypy_integration.py +63 -50
- package/hooks/validators/pyproject_config_discovery.py +101 -0
- package/hooks/validators/ruff_integration.py +257 -25
- package/hooks/validators/run_all_validators.py +223 -19
- package/hooks/validators/test_directory_exemption_constants.py +185 -0
- package/hooks/validators/test_mypy_integration.py +32 -0
- package/hooks/validators/test_pyproject_config_discovery.py +94 -0
- package/hooks/validators/test_python_antipattern_checks.py +110 -5
- package/hooks/validators/test_ruff_integration.py +160 -2
- package/hooks/validators/test_run_all_validators.py +140 -68
- package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
- package/package.json +10 -2
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +45 -67
- package/rules/durable-post-artifacts.md +7 -0
- package/rules/state-what-is.md +25 -0
- package/rules/verified-commit-gate-skip.md +1 -1
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Capture-PoolHealth.ps1 +410 -0
- package/scripts/_code_review_test_support.py +404 -0
- package/scripts/claude_chain_runner.py +141 -1
- package/scripts/codec_forwarding_test_support.py +83 -0
- package/scripts/conftest.py +23 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -2
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +53 -1
- package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
- package/scripts/invoke_code_review.py +550 -38
- package/scripts/resolve_worker_spawn.py +626 -619
- package/scripts/spawn_grok_batch.py +672 -672
- package/scripts/test_claude_chain_runner.py +131 -0
- package/scripts/test_invoke_code_review_chain.py +70 -0
- package/scripts/test_invoke_code_review_cli.py +192 -0
- package/scripts/test_invoke_code_review_codec.py +77 -0
- package/scripts/test_invoke_code_review_contract.py +256 -0
- package/scripts/test_invoke_code_review_git.py +123 -0
- package/scripts/test_invoke_code_review_mode.py +99 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -1014
- package/scripts/test_resolve_worker_spawn_codec.py +101 -0
- package/skills/CLAUDE.md +2 -0
- package/skills/auditing-claude-config/SKILL.md +114 -114
- package/skills/autoconverge/SKILL.md +427 -421
- package/skills/autoconverge/reference/convergence.md +26 -4
- package/skills/autoconverge/reference/multi-pr.md +6 -1
- package/skills/autoconverge/reference/stop-conditions.md +16 -10
- package/skills/autoconverge/workflow/CLAUDE.md +1 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1244
- package/skills/autoconverge/workflow/converge.mjs +191 -8
- package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
- package/skills/closeout/SKILL.md +33 -50
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
- package/skills/codex-review/scripts/run_codex_review.py +233 -1
- package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
- package/skills/condensing-instructions/SKILL.md +81 -0
- package/skills/copilot-review/SKILL.md +119 -119
- package/skills/e-code-review/SKILL.md +52 -0
- package/skills/e-code-review/reference/fix.md +54 -0
- package/skills/e-code-review/reference/loop.md +43 -0
- package/skills/e-code-review/reference/low.md +57 -0
- package/skills/e-code-review/reference/medium.md +153 -0
- package/skills/e-code-review/reference/xhigh.md +182 -0
- package/skills/e-simplify/SKILL.md +97 -0
- package/skills/fresh-branch/CLAUDE.md +2 -0
- package/skills/fresh-branch/SKILL.md +2 -0
- package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
- package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
- package/skills/fresh-branch/scripts/pytest.ini +4 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
- package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
- package/skills/issue-tracker/SKILL.md +92 -0
- package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
- package/skills/issue-tracker/reference/handoff-schema.md +64 -0
- package/skills/issue-tracker/reference/operation-matrix.md +41 -0
- package/skills/orchestrator/SKILL.md +162 -21
- package/skills/orchestrator/scripts/status_gate.py +625 -0
- package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
- package/skills/orchestrator/scripts/test_status_gate.py +439 -0
- package/skills/orchestrator-refresh/SKILL.md +110 -35
- package/skills/plan-to-pr/SKILL.md +155 -0
- package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
- package/skills/plan-to-pr/reference/model-routing.md +36 -0
- package/skills/plan-to-pr/reference/packet-contract.md +43 -0
- package/skills/plan-to-pr/reference/packet-schema.json +57 -0
- package/skills/plan-to-pr/reference/process-inventory.md +22 -0
- package/skills/plan-to-pr/reference/review-loop.md +33 -0
- package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
- package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
- package/skills/plan-to-pr/reference/task-seeds.md +14 -0
- package/skills/plan-to-pr/reference/task-ticket.md +38 -0
- package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
- package/skills/plan-to-pr/scripts/config/constants.py +193 -0
- package/skills/plan-to-pr/scripts/create_packet.py +173 -0
- package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
- package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
- package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
- package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
- package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
- package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
- package/skills/plan-to-pr/scripts/validate_run.py +173 -0
- package/skills/plan-to-pr/test_skill_contract.py +207 -0
- package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
- package/skills/pr-converge/SKILL.md +472 -469
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +7 -4
- package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/pr-fix-protocol/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -68
- package/skills/prototype/SKILL.md +86 -0
- package/skills/prototype/reference/honest-limitations.md +23 -0
- package/skills/prototype/reference/promotion-tasks.md +23 -0
- package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
- package/skills/prototype/scripts/conftest.py +15 -0
- package/skills/prototype/scripts/launch_sandbox.py +205 -0
- package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
- package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
- package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
- package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
- package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
- package/skills/prototype/workflows/promotion.md +27 -0
- package/skills/prototype/workflows/sandbox.md +35 -0
- package/skills/release-notes-html/SKILL.md +164 -0
- package/skills/skill-builder/CLAUDE.md +3 -3
- package/skills/skill-builder/SKILL.md +5 -5
- package/skills/skill-builder/references/CLAUDE.md +1 -1
- package/skills/skill-builder/references/delegation-map.md +3 -3
- package/skills/skill-builder/references/description-field.md +1 -1
- package/skills/skill-builder/references/skill-modularity.md +2 -3
- package/skills/skill-builder/workflows/CLAUDE.md +1 -1
- package/skills/skill-builder/workflows/improve-skill.md +1 -1
- package/skills/skill-builder/workflows/new-skill.md +2 -2
- package/skills/task-build/CLAUDE.md +8 -7
- package/skills/task-build/SKILL.md +16 -8
- package/skills/task-build/reference/tool-routing.md +19 -0
- package/skills/team-advisor/SKILL.md +2 -2
- package/scripts/test_invoke_code_review.py +0 -672
- package/skills/closeout/reference/issue-body-templates.md +0 -108
package/bin/install.test.mjs
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import { strict as assert } from 'node:assert';
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
symlinkSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
copyFileSync,
|
|
14
|
+
} from 'node:fs';
|
|
5
15
|
import { tmpdir } from 'node:os';
|
|
6
|
-
import { join } from 'node:path';
|
|
16
|
+
import { join, dirname } from 'node:path';
|
|
7
17
|
import { pathToFileURL } from 'node:url';
|
|
8
18
|
|
|
9
19
|
import {
|
|
10
20
|
collectPackageSourceConflicts,
|
|
11
21
|
CONTENT_DIRECTORIES,
|
|
12
22
|
CORE_INCLUDE_DIRECTORIES,
|
|
23
|
+
CORE_SKILLS,
|
|
24
|
+
INSTALL_GROUPS,
|
|
13
25
|
FOLDED_HOOK_RELATIVE_PATHS,
|
|
14
26
|
POST_FOLDED_HOOK_RELATIVE_PATHS,
|
|
15
27
|
pythonCandidatesForPlatform,
|
|
@@ -21,6 +33,14 @@ import {
|
|
|
21
33
|
commandReferencesManagedHook,
|
|
22
34
|
mergeHooksIntoSettings,
|
|
23
35
|
pruneManagedHooksFromSettings,
|
|
36
|
+
pruneStaleInstalledFiles,
|
|
37
|
+
comparisonKeyForPath,
|
|
38
|
+
collectFiles,
|
|
39
|
+
copyTree,
|
|
40
|
+
caseOnlyRenameSourceName,
|
|
41
|
+
retiredManagedHookRelativePaths,
|
|
42
|
+
pruneRetiredHookEntriesFromSettings,
|
|
43
|
+
retainNewestRunBackupOnly,
|
|
24
44
|
} from './install.mjs';
|
|
25
45
|
import {
|
|
26
46
|
expandHomeDirectoryTokens,
|
|
@@ -167,6 +187,23 @@ test('core includeDirectories ships _shared and scripts for advisor protocol and
|
|
|
167
187
|
});
|
|
168
188
|
|
|
169
189
|
|
|
190
|
+
test('CORE_SKILLS ships issue-tracker so the core group installs the skill the SessionStart injector needs', () => {
|
|
191
|
+
assert.ok(
|
|
192
|
+
CORE_SKILLS.includes('issue-tracker'),
|
|
193
|
+
'issue-tracker must be in CORE_SKILLS so --only core ships it alongside the SessionStart hooks',
|
|
194
|
+
);
|
|
195
|
+
assert.ok(
|
|
196
|
+
INSTALL_GROUPS.core.skills.includes('issue-tracker'),
|
|
197
|
+
'the resolved core group must ship issue-tracker',
|
|
198
|
+
);
|
|
199
|
+
assert.equal(
|
|
200
|
+
INSTALL_GROUPS.core.skills,
|
|
201
|
+
CORE_SKILLS,
|
|
202
|
+
'the core group skills array must read CORE_SKILLS so the two never drift',
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
|
|
170
207
|
test('CONTENT_DIRECTORIES includes audit-rubrics so installer copies category rubrics and prompts to ~/.claude/audit-rubrics/', () => {
|
|
171
208
|
assert.ok(
|
|
172
209
|
CONTENT_DIRECTORIES.includes('audit-rubrics'),
|
|
@@ -1231,3 +1268,870 @@ test('mergeHooksIntoSettings prunes the inline run_all_validators runner when th
|
|
|
1231
1268
|
const dispatcherCommands = dispatcherGroup.hooks.filter(hook => isPreToolUseDispatcherCommand(hook.command));
|
|
1232
1269
|
assert.equal(dispatcherCommands.length, 1, 'the PreToolUse dispatcher must remain exactly once');
|
|
1233
1270
|
});
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
const README_BASENAME_PATTERN = /^readme\.md$/i;
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* Build a sandbox holding an installed skills root and a run backup root.
|
|
1278
|
+
*
|
|
1279
|
+
* @param {object} installedFiles Forward-slash relative paths under the skills root mapped to contents.
|
|
1280
|
+
* @returns {{root: string, skillsRoot: string, backupRoot: string}} The sandbox paths.
|
|
1281
|
+
*/
|
|
1282
|
+
function createStalePruneSandbox(installedFiles) {
|
|
1283
|
+
const root = mkdtempSync(join(tmpdir(), 'cdev-stale-prune-'));
|
|
1284
|
+
const skillsRoot = join(root, 'skills');
|
|
1285
|
+
const backupRoot = join(root, 'pruned', 'run-timestamp');
|
|
1286
|
+
mkdirSync(skillsRoot, { recursive: true });
|
|
1287
|
+
for (const [relativePath, contents] of Object.entries(installedFiles)) {
|
|
1288
|
+
const targetPath = join(skillsRoot, relativePath);
|
|
1289
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
1290
|
+
writeFileSync(targetPath, contents);
|
|
1291
|
+
}
|
|
1292
|
+
return { root, skillsRoot, backupRoot };
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Run a callable with console.warn captured, returning its value and the warnings.
|
|
1298
|
+
*
|
|
1299
|
+
* @param {Function} runnable The zero-argument callable to run.
|
|
1300
|
+
* @returns {{returnedValue: *, allWarnings: string[]}} The result and captured warnings.
|
|
1301
|
+
*/
|
|
1302
|
+
function captureWarnings(runnable) {
|
|
1303
|
+
const originalWarn = console.warn;
|
|
1304
|
+
const allWarnings = [];
|
|
1305
|
+
console.warn = (warningMessage) => allWarnings.push(String(warningMessage));
|
|
1306
|
+
try {
|
|
1307
|
+
return { returnedValue: runnable(), allWarnings };
|
|
1308
|
+
} finally {
|
|
1309
|
+
console.warn = originalWarn;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
test('pruneStaleInstalledFiles moves a file the prior manifest recorded and this run no longer writes', () => {
|
|
1315
|
+
const sandbox = createStalePruneSandbox({
|
|
1316
|
+
'demo/SKILL.md': '# demo\n',
|
|
1317
|
+
'demo/scripts/retired_module.py': 'from constants import OLD_NAME\n',
|
|
1318
|
+
});
|
|
1319
|
+
try {
|
|
1320
|
+
const keptFilePath = join(sandbox.skillsRoot, 'demo', 'SKILL.md');
|
|
1321
|
+
const staleFilePath = join(sandbox.skillsRoot, 'demo', 'scripts', 'retired_module.py');
|
|
1322
|
+
|
|
1323
|
+
const pruneOutcome = pruneStaleInstalledFiles(
|
|
1324
|
+
[keptFilePath, staleFilePath],
|
|
1325
|
+
[keptFilePath],
|
|
1326
|
+
sandbox.skillsRoot,
|
|
1327
|
+
sandbox.backupRoot,
|
|
1328
|
+
{ managedHomeDirectory: sandbox.root },
|
|
1329
|
+
);
|
|
1330
|
+
|
|
1331
|
+
assert.equal(pruneOutcome.prunedCount, 1, 'exactly the one no-longer-written file moves');
|
|
1332
|
+
assert.deepEqual(pruneOutcome.failedPaths, [], 'a move that succeeds reports no failed path');
|
|
1333
|
+
assert.equal(existsSync(staleFilePath), false, 'the stale file leaves the installed tree');
|
|
1334
|
+
assert.equal(
|
|
1335
|
+
existsSync(join(sandbox.backupRoot, 'demo', 'scripts', 'retired_module.py')),
|
|
1336
|
+
true,
|
|
1337
|
+
'the stale file lands under its mirrored relative path in the backup root',
|
|
1338
|
+
);
|
|
1339
|
+
assert.equal(existsSync(keptFilePath), true, 'a file this run wrote stays in place');
|
|
1340
|
+
} finally {
|
|
1341
|
+
rmSync(sandbox.root, { recursive: true, force: true });
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
test('pruneStaleInstalledFiles leaves a __pycache__ artifact the prior manifest never recorded in place', () => {
|
|
1347
|
+
const sandbox = createStalePruneSandbox({
|
|
1348
|
+
'demo/SKILL.md': '# demo\n',
|
|
1349
|
+
'demo/scripts/__pycache__/helper.cpython-312.pyc': 'compiled bytecode\n',
|
|
1350
|
+
'demo/notes.md': 'a file the user authored\n',
|
|
1351
|
+
});
|
|
1352
|
+
try {
|
|
1353
|
+
const shippedFilePath = join(sandbox.skillsRoot, 'demo', 'SKILL.md');
|
|
1354
|
+
const runtimeArtifactPath = join(
|
|
1355
|
+
sandbox.skillsRoot, 'demo', 'scripts', '__pycache__', 'helper.cpython-312.pyc',
|
|
1356
|
+
);
|
|
1357
|
+
const userFilePath = join(sandbox.skillsRoot, 'demo', 'notes.md');
|
|
1358
|
+
|
|
1359
|
+
const pruneOutcome = pruneStaleInstalledFiles(
|
|
1360
|
+
[shippedFilePath],
|
|
1361
|
+
[shippedFilePath],
|
|
1362
|
+
sandbox.skillsRoot,
|
|
1363
|
+
sandbox.backupRoot,
|
|
1364
|
+
{ managedHomeDirectory: sandbox.root },
|
|
1365
|
+
);
|
|
1366
|
+
|
|
1367
|
+
assert.equal(pruneOutcome.prunedCount, 0, 'a file the installer never wrote is outside the diff');
|
|
1368
|
+
assert.equal(existsSync(runtimeArtifactPath), true, 'the compiled bytecode stays in place');
|
|
1369
|
+
assert.equal(existsSync(userFilePath), true, 'the user-authored file stays in place');
|
|
1370
|
+
assert.equal(existsSync(sandbox.backupRoot), false, 'an empty prune creates no backup root');
|
|
1371
|
+
} finally {
|
|
1372
|
+
rmSync(sandbox.root, { recursive: true, force: true });
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
test('pruneStaleInstalledFiles returns zero and moves nothing when the prior manifest record is unknown', () => {
|
|
1378
|
+
const sandbox = createStalePruneSandbox({ 'demo/SKILL.md': '# demo\n' });
|
|
1379
|
+
try {
|
|
1380
|
+
const pruneOutcome = pruneStaleInstalledFiles(null, [], sandbox.skillsRoot, sandbox.backupRoot);
|
|
1381
|
+
|
|
1382
|
+
assert.equal(pruneOutcome.prunedCount, 0, 'an unknown prior record holds the prune for that run');
|
|
1383
|
+
assert.deepEqual(pruneOutcome.failedPaths, [], 'an unknown prior record reports no failed path');
|
|
1384
|
+
assert.equal(existsSync(join(sandbox.skillsRoot, 'demo', 'SKILL.md')), true);
|
|
1385
|
+
} finally {
|
|
1386
|
+
rmSync(sandbox.root, { recursive: true, force: true });
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
test('comparisonKeyForPath folds letter case when the filesystem does', () => {
|
|
1392
|
+
const lowercaseKey = comparisonKeyForPath('/home/user/.claude/skills/demo/readme.md', {
|
|
1393
|
+
isCaseInsensitive: true,
|
|
1394
|
+
});
|
|
1395
|
+
const uppercaseKey = comparisonKeyForPath('/home/user/.claude/skills/demo/README.md', {
|
|
1396
|
+
isCaseInsensitive: true,
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
assert.equal(lowercaseKey, uppercaseKey, 'two spellings of one name share a key');
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
test('comparisonKeyForPath keeps letter case when the filesystem does', () => {
|
|
1404
|
+
const lowercaseKey = comparisonKeyForPath('/home/user/.claude/skills/demo/readme.md', {
|
|
1405
|
+
isCaseInsensitive: false,
|
|
1406
|
+
});
|
|
1407
|
+
const uppercaseKey = comparisonKeyForPath('/home/user/.claude/skills/demo/README.md', {
|
|
1408
|
+
isCaseInsensitive: false,
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
assert.notEqual(lowercaseKey, uppercaseKey, 'two spellings name two distinct files');
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
test('pruneStaleInstalledFiles keeps a case-only renamed readme when keys fold case', () => {
|
|
1416
|
+
const sandbox = createStalePruneSandbox({
|
|
1417
|
+
'demo/README.md': '# demo readme\n',
|
|
1418
|
+
});
|
|
1419
|
+
try {
|
|
1420
|
+
const priorReadmePath = join(sandbox.skillsRoot, 'demo', 'Readme.md');
|
|
1421
|
+
const copiedReadmePath = join(sandbox.skillsRoot, 'demo', 'README.md');
|
|
1422
|
+
|
|
1423
|
+
const pruneOutcome = pruneStaleInstalledFiles(
|
|
1424
|
+
[priorReadmePath],
|
|
1425
|
+
[copiedReadmePath],
|
|
1426
|
+
sandbox.skillsRoot,
|
|
1427
|
+
sandbox.backupRoot,
|
|
1428
|
+
{ isCaseInsensitive: true, managedHomeDirectory: sandbox.root },
|
|
1429
|
+
);
|
|
1430
|
+
|
|
1431
|
+
assert.equal(pruneOutcome.prunedCount, 0, 'the recorded spelling names the file this run wrote');
|
|
1432
|
+
assert.equal(existsSync(copiedReadmePath), true, 'the freshly shipped readme stays in place');
|
|
1433
|
+
assert.equal(existsSync(sandbox.backupRoot), false, 'nothing reaches the backup root');
|
|
1434
|
+
} finally {
|
|
1435
|
+
rmSync(sandbox.root, { recursive: true, force: true });
|
|
1436
|
+
}
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
test('should preserve a case-only rename on a case-insensitive filesystem (host-dependent)', () => {
|
|
1441
|
+
const sandbox = createStalePruneSandbox({
|
|
1442
|
+
'demo/Readme.md': 'the readme an earlier install wrote\n',
|
|
1443
|
+
});
|
|
1444
|
+
try {
|
|
1445
|
+
const shippedReadmeContents = '# demo readme\n';
|
|
1446
|
+
const shippedReadmeSource = join(sandbox.root, 'README.md');
|
|
1447
|
+
writeFileSync(shippedReadmeSource, shippedReadmeContents);
|
|
1448
|
+
const priorReadmePath = join(sandbox.skillsRoot, 'demo', 'Readme.md');
|
|
1449
|
+
const copiedReadmePath = join(sandbox.skillsRoot, 'demo', 'README.md');
|
|
1450
|
+
copyFileSync(shippedReadmeSource, copiedReadmePath);
|
|
1451
|
+
|
|
1452
|
+
pruneStaleInstalledFiles(
|
|
1453
|
+
[priorReadmePath],
|
|
1454
|
+
[copiedReadmePath],
|
|
1455
|
+
sandbox.skillsRoot,
|
|
1456
|
+
sandbox.backupRoot,
|
|
1457
|
+
{ managedHomeDirectory: sandbox.root },
|
|
1458
|
+
);
|
|
1459
|
+
|
|
1460
|
+
const survivingReadmeNames = readdirSync(join(sandbox.skillsRoot, 'demo'))
|
|
1461
|
+
.filter(entryName => README_BASENAME_PATTERN.test(entryName));
|
|
1462
|
+
assert.equal(survivingReadmeNames.length, 1, 'the skill keeps exactly one readme');
|
|
1463
|
+
assert.equal(
|
|
1464
|
+
readFileSync(join(sandbox.skillsRoot, 'demo', survivingReadmeNames[0]), 'utf8'),
|
|
1465
|
+
shippedReadmeContents,
|
|
1466
|
+
'the surviving readme holds the freshly shipped content',
|
|
1467
|
+
);
|
|
1468
|
+
} finally {
|
|
1469
|
+
rmSync(sandbox.root, { recursive: true, force: true });
|
|
1470
|
+
}
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
test('pruneStaleInstalledFiles leaves a file in place and warns when the move fails', () => {
|
|
1475
|
+
const sandbox = createStalePruneSandbox({
|
|
1476
|
+
'demo/scripts/retired_module.py': 'stale module\n',
|
|
1477
|
+
});
|
|
1478
|
+
try {
|
|
1479
|
+
mkdirSync(dirname(sandbox.backupRoot), { recursive: true });
|
|
1480
|
+
writeFileSync(sandbox.backupRoot, 'a file standing where the backup root belongs\n');
|
|
1481
|
+
const staleFilePath = join(sandbox.skillsRoot, 'demo', 'scripts', 'retired_module.py');
|
|
1482
|
+
|
|
1483
|
+
const { returnedValue, allWarnings } = captureWarnings(() => pruneStaleInstalledFiles(
|
|
1484
|
+
[staleFilePath],
|
|
1485
|
+
[],
|
|
1486
|
+
sandbox.skillsRoot,
|
|
1487
|
+
sandbox.backupRoot,
|
|
1488
|
+
{ managedHomeDirectory: sandbox.root },
|
|
1489
|
+
));
|
|
1490
|
+
|
|
1491
|
+
assert.equal(returnedValue.prunedCount, 0, 'a failed move counts as nothing moved');
|
|
1492
|
+
assert.deepEqual(
|
|
1493
|
+
returnedValue.failedPaths,
|
|
1494
|
+
[staleFilePath],
|
|
1495
|
+
'the failed path is reported so the caller keeps it on the manifest record',
|
|
1496
|
+
);
|
|
1497
|
+
assert.equal(existsSync(staleFilePath), true, 'the file stays in the installed tree');
|
|
1498
|
+
assert.equal(allWarnings.length, 1, 'the failed move is reported once');
|
|
1499
|
+
assert.match(allWarnings[0], /leaving in place/, 'the warning states the file was left in place');
|
|
1500
|
+
} finally {
|
|
1501
|
+
rmSync(sandbox.root, { recursive: true, force: true });
|
|
1502
|
+
}
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
test('pruneStaleInstalledFiles leaves a directory standing where a file was recorded and warns', () => {
|
|
1507
|
+
const sandbox = createStalePruneSandbox({
|
|
1508
|
+
'demo/scripts/former_file/inner.py': 'content the user put inside\n',
|
|
1509
|
+
});
|
|
1510
|
+
try {
|
|
1511
|
+
const recordedFilePath = join(sandbox.skillsRoot, 'demo', 'scripts', 'former_file');
|
|
1512
|
+
|
|
1513
|
+
const { returnedValue, allWarnings } = captureWarnings(() => pruneStaleInstalledFiles(
|
|
1514
|
+
[recordedFilePath],
|
|
1515
|
+
[],
|
|
1516
|
+
sandbox.skillsRoot,
|
|
1517
|
+
sandbox.backupRoot,
|
|
1518
|
+
{ managedHomeDirectory: sandbox.root },
|
|
1519
|
+
));
|
|
1520
|
+
|
|
1521
|
+
assert.equal(returnedValue.prunedCount, 0, 'a directory is never renamed into the backup');
|
|
1522
|
+
assert.deepEqual(returnedValue.failedPaths, [], 'a skipped directory is no failed move');
|
|
1523
|
+
assert.equal(existsSync(join(recordedFilePath, 'inner.py')), true, 'the directory keeps its contents');
|
|
1524
|
+
assert.equal(allWarnings.length, 1, 'the skipped path is reported once');
|
|
1525
|
+
} finally {
|
|
1526
|
+
rmSync(sandbox.root, { recursive: true, force: true });
|
|
1527
|
+
}
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
test('pruneStaleInstalledFiles skips a path the user already deleted without warning', () => {
|
|
1532
|
+
const sandbox = createStalePruneSandbox({ 'demo/SKILL.md': '# demo\n' });
|
|
1533
|
+
try {
|
|
1534
|
+
const deletedFilePath = join(sandbox.skillsRoot, 'demo', 'scripts', 'already_gone.py');
|
|
1535
|
+
|
|
1536
|
+
const { returnedValue, allWarnings } = captureWarnings(() => pruneStaleInstalledFiles(
|
|
1537
|
+
[deletedFilePath],
|
|
1538
|
+
[],
|
|
1539
|
+
sandbox.skillsRoot,
|
|
1540
|
+
sandbox.backupRoot,
|
|
1541
|
+
{ managedHomeDirectory: sandbox.root },
|
|
1542
|
+
));
|
|
1543
|
+
|
|
1544
|
+
assert.equal(returnedValue.prunedCount, 0, 'a path that no longer exists moves nothing');
|
|
1545
|
+
assert.deepEqual(returnedValue.failedPaths, [], 'a path that vanished is no failed move');
|
|
1546
|
+
assert.deepEqual(allWarnings, [], 'an already-deleted path is skipped in silence');
|
|
1547
|
+
} finally {
|
|
1548
|
+
rmSync(sandbox.root, { recursive: true, force: true });
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
test('pruneStaleInstalledFiles removes emptied parents up to the destination root and keeps a populated one', () => {
|
|
1554
|
+
const sandbox = createStalePruneSandbox({
|
|
1555
|
+
'kept/SKILL.md': '# kept\n',
|
|
1556
|
+
'kept/scripts/retired_module.py': 'stale module\n',
|
|
1557
|
+
'emptied/nested/only_module.py': 'the sole file under this tree\n',
|
|
1558
|
+
});
|
|
1559
|
+
try {
|
|
1560
|
+
const populatedParentStalePath = join(sandbox.skillsRoot, 'kept', 'scripts', 'retired_module.py');
|
|
1561
|
+
const solitaryStalePath = join(sandbox.skillsRoot, 'emptied', 'nested', 'only_module.py');
|
|
1562
|
+
|
|
1563
|
+
const pruneOutcome = pruneStaleInstalledFiles(
|
|
1564
|
+
[populatedParentStalePath, solitaryStalePath],
|
|
1565
|
+
[join(sandbox.skillsRoot, 'kept', 'SKILL.md')],
|
|
1566
|
+
sandbox.skillsRoot,
|
|
1567
|
+
sandbox.backupRoot,
|
|
1568
|
+
{ managedHomeDirectory: sandbox.root },
|
|
1569
|
+
);
|
|
1570
|
+
|
|
1571
|
+
assert.equal(pruneOutcome.prunedCount, 2, 'both recorded files move');
|
|
1572
|
+
assert.equal(
|
|
1573
|
+
existsSync(join(sandbox.skillsRoot, 'kept', 'scripts')),
|
|
1574
|
+
false,
|
|
1575
|
+
'the emptied scripts directory is removed',
|
|
1576
|
+
);
|
|
1577
|
+
assert.equal(
|
|
1578
|
+
existsSync(join(sandbox.skillsRoot, 'kept')),
|
|
1579
|
+
true,
|
|
1580
|
+
'a parent still holding SKILL.md is left alone',
|
|
1581
|
+
);
|
|
1582
|
+
assert.equal(
|
|
1583
|
+
existsSync(join(sandbox.skillsRoot, 'emptied')),
|
|
1584
|
+
false,
|
|
1585
|
+
'the walk climbs through every emptied parent',
|
|
1586
|
+
);
|
|
1587
|
+
assert.equal(existsSync(sandbox.skillsRoot), true, 'the destination root itself stays');
|
|
1588
|
+
} finally {
|
|
1589
|
+
rmSync(sandbox.root, { recursive: true, force: true });
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
test('pruneStaleInstalledFiles ignores a recorded path outside the destination root', () => {
|
|
1595
|
+
const sandbox = createStalePruneSandbox({ 'demo/SKILL.md': '# demo\n' });
|
|
1596
|
+
try {
|
|
1597
|
+
const outsideFilePath = join(sandbox.root, 'rules', 'some-rule.md');
|
|
1598
|
+
mkdirSync(dirname(outsideFilePath), { recursive: true });
|
|
1599
|
+
writeFileSync(outsideFilePath, 'a rule wired to another root\n');
|
|
1600
|
+
|
|
1601
|
+
const pruneOutcome = pruneStaleInstalledFiles(
|
|
1602
|
+
[outsideFilePath],
|
|
1603
|
+
[],
|
|
1604
|
+
sandbox.skillsRoot,
|
|
1605
|
+
sandbox.backupRoot,
|
|
1606
|
+
{ managedHomeDirectory: sandbox.root },
|
|
1607
|
+
);
|
|
1608
|
+
|
|
1609
|
+
assert.equal(pruneOutcome.prunedCount, 0, 'only the wired root is pruned');
|
|
1610
|
+
assert.equal(existsSync(outsideFilePath), true, 'content under another root stays in place');
|
|
1611
|
+
} finally {
|
|
1612
|
+
rmSync(sandbox.root, { recursive: true, force: true });
|
|
1613
|
+
}
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
test('collectFiles returns the source file and skips the build artifacts beside it', () => {
|
|
1618
|
+
const sourceRoot = mkdtempSync(join(tmpdir(), 'cdev-collect-files-'));
|
|
1619
|
+
try {
|
|
1620
|
+
const sourceFilePath = join(sourceRoot, 'scripts', 'helper.py');
|
|
1621
|
+
mkdirSync(dirname(sourceFilePath), { recursive: true });
|
|
1622
|
+
writeFileSync(sourceFilePath, 'the module the package ships\n');
|
|
1623
|
+
const bytecodePath = join(sourceRoot, 'scripts', '__pycache__', 'helper.cpython-313.pyc');
|
|
1624
|
+
mkdirSync(dirname(bytecodePath), { recursive: true });
|
|
1625
|
+
writeFileSync(bytecodePath, 'compiled bytecode\n');
|
|
1626
|
+
|
|
1627
|
+
const collectedFiles = collectFiles(sourceRoot);
|
|
1628
|
+
|
|
1629
|
+
assert.deepEqual(
|
|
1630
|
+
collectedFiles,
|
|
1631
|
+
[sourceFilePath],
|
|
1632
|
+
'the walk returns the source file alone',
|
|
1633
|
+
);
|
|
1634
|
+
} finally {
|
|
1635
|
+
rmSync(sourceRoot, { recursive: true, force: true });
|
|
1636
|
+
}
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
test('collectFiles skips every named cache directory and loose bytecode file', () => {
|
|
1641
|
+
const sourceRoot = mkdtempSync(join(tmpdir(), 'cdev-collect-caches-'));
|
|
1642
|
+
try {
|
|
1643
|
+
const sourceFilePath = join(sourceRoot, 'SKILL.md');
|
|
1644
|
+
writeFileSync(sourceFilePath, '# a shipped skill\n');
|
|
1645
|
+
for (const cacheDirectoryName of ['.ruff_cache', '.pytest_cache', '.mypy_cache', 'node_modules']) {
|
|
1646
|
+
const cachedFilePath = join(sourceRoot, cacheDirectoryName, 'entry.json');
|
|
1647
|
+
mkdirSync(dirname(cachedFilePath), { recursive: true });
|
|
1648
|
+
writeFileSync(cachedFilePath, '{}\n');
|
|
1649
|
+
}
|
|
1650
|
+
writeFileSync(join(sourceRoot, '.DS_Store'), 'finder metadata\n');
|
|
1651
|
+
writeFileSync(join(sourceRoot, 'loose_module.pyc'), 'bytecode outside a cache\n');
|
|
1652
|
+
writeFileSync(join(sourceRoot, 'loose_module.pyo'), 'optimized bytecode\n');
|
|
1653
|
+
|
|
1654
|
+
const collectedFiles = collectFiles(sourceRoot);
|
|
1655
|
+
|
|
1656
|
+
assert.deepEqual(collectedFiles, [sourceFilePath], 'only the shipped file survives the walk');
|
|
1657
|
+
} finally {
|
|
1658
|
+
rmSync(sourceRoot, { recursive: true, force: true });
|
|
1659
|
+
}
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
const SHIPPED_README_NAME = 'README.md';
|
|
1664
|
+
const INSTALLED_README_NAME = 'Readme.md';
|
|
1665
|
+
const RETIRED_HOOK_RELATIVE_PATH = 'blocking/retired_gate.py';
|
|
1666
|
+
const SANDBOX_HOOKS_ROOT = join('/home', 'user', '.claude', 'hooks');
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
test('caseOnlyRenameSourceName names the installed entry when the filesystem folds letter case', () => {
|
|
1670
|
+
const caseOnlyMatchName = caseOnlyRenameSourceName(
|
|
1671
|
+
SHIPPED_README_NAME,
|
|
1672
|
+
[INSTALLED_README_NAME, 'SKILL.md'],
|
|
1673
|
+
{ isCaseInsensitive: true },
|
|
1674
|
+
);
|
|
1675
|
+
|
|
1676
|
+
assert.equal(
|
|
1677
|
+
caseOnlyMatchName,
|
|
1678
|
+
INSTALLED_README_NAME,
|
|
1679
|
+
'the entry the copy would fill through its installed spelling is named for the rename',
|
|
1680
|
+
);
|
|
1681
|
+
});
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
test('caseOnlyRenameSourceName renames nothing when the filesystem keeps letter case', () => {
|
|
1685
|
+
const caseOnlyMatchName = caseOnlyRenameSourceName(
|
|
1686
|
+
SHIPPED_README_NAME,
|
|
1687
|
+
[INSTALLED_README_NAME],
|
|
1688
|
+
{ isCaseInsensitive: false },
|
|
1689
|
+
);
|
|
1690
|
+
|
|
1691
|
+
assert.equal(caseOnlyMatchName, null, 'two spellings name two files, so the copy writes its own');
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
test('caseOnlyRenameSourceName renames nothing when the shipped spelling already sits on disk', () => {
|
|
1696
|
+
const caseOnlyMatchName = caseOnlyRenameSourceName(
|
|
1697
|
+
SHIPPED_README_NAME,
|
|
1698
|
+
[SHIPPED_README_NAME, INSTALLED_README_NAME],
|
|
1699
|
+
{ isCaseInsensitive: true },
|
|
1700
|
+
);
|
|
1701
|
+
|
|
1702
|
+
assert.equal(caseOnlyMatchName, null, 'the destination already carries the shipped spelling');
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
test('copyTree gives the destination entry the shipped letter case (host-dependent)', () => {
|
|
1707
|
+
const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-copy-tree-case-'));
|
|
1708
|
+
try {
|
|
1709
|
+
const sourceDirectory = join(sandboxRoot, 'source');
|
|
1710
|
+
const destinationDirectory = join(sandboxRoot, 'destination');
|
|
1711
|
+
mkdirSync(sourceDirectory, { recursive: true });
|
|
1712
|
+
mkdirSync(destinationDirectory, { recursive: true });
|
|
1713
|
+
const shippedContents = '# the readme the package ships\n';
|
|
1714
|
+
writeFileSync(join(sourceDirectory, SHIPPED_README_NAME), shippedContents);
|
|
1715
|
+
writeFileSync(
|
|
1716
|
+
join(destinationDirectory, INSTALLED_README_NAME),
|
|
1717
|
+
'the readme an earlier install wrote\n',
|
|
1718
|
+
);
|
|
1719
|
+
|
|
1720
|
+
copyTree(sourceDirectory, destinationDirectory);
|
|
1721
|
+
|
|
1722
|
+
const survivingReadmeNames = readdirSync(destinationDirectory)
|
|
1723
|
+
.filter(entryName => README_BASENAME_PATTERN.test(entryName));
|
|
1724
|
+
assert.ok(
|
|
1725
|
+
survivingReadmeNames.includes(SHIPPED_README_NAME),
|
|
1726
|
+
'the destination carries the spelling the package ships',
|
|
1727
|
+
);
|
|
1728
|
+
assert.equal(
|
|
1729
|
+
readFileSync(join(destinationDirectory, SHIPPED_README_NAME), 'utf8'),
|
|
1730
|
+
shippedContents,
|
|
1731
|
+
'the entry under the shipped spelling holds the shipped bytes',
|
|
1732
|
+
);
|
|
1733
|
+
} finally {
|
|
1734
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
1735
|
+
}
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
test('retiredManagedHookRelativePaths names the hook scripts a prior install wrote and this run leaves out', () => {
|
|
1740
|
+
const retiredScriptPath = join(SANDBOX_HOOKS_ROOT, 'blocking', 'retired_gate.py');
|
|
1741
|
+
const liveScriptPath = join(SANDBOX_HOOKS_ROOT, 'blocking', 'live_gate.py');
|
|
1742
|
+
const ruleFilePath = join('/home', 'user', '.claude', 'rules', 'a-rule.md');
|
|
1743
|
+
|
|
1744
|
+
const retiredRelativePaths = retiredManagedHookRelativePaths(
|
|
1745
|
+
[retiredScriptPath, liveScriptPath, ruleFilePath],
|
|
1746
|
+
[liveScriptPath],
|
|
1747
|
+
SANDBOX_HOOKS_ROOT,
|
|
1748
|
+
);
|
|
1749
|
+
|
|
1750
|
+
assert.deepEqual(
|
|
1751
|
+
[...retiredRelativePaths],
|
|
1752
|
+
[RETIRED_HOOK_RELATIVE_PATH],
|
|
1753
|
+
'the diff names the retired script alone, leaving the live script and every other root out',
|
|
1754
|
+
);
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
test('retiredManagedHookRelativePaths names nothing when no prior install recorded anything', () => {
|
|
1759
|
+
const retiredRelativePaths = retiredManagedHookRelativePaths(null, [], SANDBOX_HOOKS_ROOT);
|
|
1760
|
+
|
|
1761
|
+
assert.equal(retiredRelativePaths.size, 0, 'with no record to diff, no entry counts as retired');
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
/**
|
|
1766
|
+
* Write a settings.json fixture and return its path.
|
|
1767
|
+
*
|
|
1768
|
+
* @param {object} settings The settings object to serialize.
|
|
1769
|
+
* @param {number} indentWidth The JSON indent width the fixture is written with.
|
|
1770
|
+
* @returns {{settingsPath: string, sandboxRoot: string}} The fixture path and its sandbox root.
|
|
1771
|
+
*/
|
|
1772
|
+
function createSettingsFixture(settings, indentWidth) {
|
|
1773
|
+
const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-retired-hook-settings-'));
|
|
1774
|
+
const settingsPath = join(sandboxRoot, 'settings.json');
|
|
1775
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, indentWidth) + '\n');
|
|
1776
|
+
return { settingsPath, sandboxRoot };
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
test('pruneRetiredHookEntriesFromSettings removes the retired entry and keeps the user-authored one', () => {
|
|
1781
|
+
const retiredCommand = `python3 "$HOME/.claude/hooks/${RETIRED_HOOK_RELATIVE_PATH}"`;
|
|
1782
|
+
const userCommand = 'python3 my_own_gate.py --user-authored';
|
|
1783
|
+
const lookalikeCommand = `python3 "$HOME/.claude/hooks/${RETIRED_HOOK_RELATIVE_PATH}.bak"`;
|
|
1784
|
+
const { settingsPath, sandboxRoot } = createSettingsFixture({
|
|
1785
|
+
hooks: {
|
|
1786
|
+
PreToolUse: [{
|
|
1787
|
+
matcher: 'Write|Edit',
|
|
1788
|
+
hooks: [
|
|
1789
|
+
{ type: 'command', command: retiredCommand },
|
|
1790
|
+
{ type: 'command', command: userCommand },
|
|
1791
|
+
{ type: 'command', command: lookalikeCommand },
|
|
1792
|
+
],
|
|
1793
|
+
}],
|
|
1794
|
+
PreCompact: [{
|
|
1795
|
+
matcher: '*',
|
|
1796
|
+
hooks: [{ type: 'command', command: retiredCommand }],
|
|
1797
|
+
}],
|
|
1798
|
+
},
|
|
1799
|
+
}, 4);
|
|
1800
|
+
try {
|
|
1801
|
+
const removedCount = pruneRetiredHookEntriesFromSettings(
|
|
1802
|
+
settingsPath, new Set([RETIRED_HOOK_RELATIVE_PATH]),
|
|
1803
|
+
);
|
|
1804
|
+
|
|
1805
|
+
assert.equal(removedCount, 2, 'both entries running the retired script are removed');
|
|
1806
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
1807
|
+
assert.deepEqual(
|
|
1808
|
+
settings.hooks.PreToolUse[0].hooks.map(hook => hook.command),
|
|
1809
|
+
[userCommand, lookalikeCommand],
|
|
1810
|
+
'the user entry and the suffix-path entry stay exactly as written',
|
|
1811
|
+
);
|
|
1812
|
+
assert.equal(
|
|
1813
|
+
Object.hasOwn(settings.hooks, 'PreCompact'),
|
|
1814
|
+
false,
|
|
1815
|
+
'an event type the current config leaves out is reached and, left empty, dropped',
|
|
1816
|
+
);
|
|
1817
|
+
} finally {
|
|
1818
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
1819
|
+
}
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
test('pruneRetiredHookEntriesFromSettings keeps every settings shape the installer never wrote and still removes the retired entry', () => {
|
|
1824
|
+
const retiredCommand = `python3 "$HOME/.claude/hooks/${RETIRED_HOOK_RELATIVE_PATH}"`;
|
|
1825
|
+
const entryWithoutCommand = { type: 'command' };
|
|
1826
|
+
const entryWithNumericCommand = { type: 'command', command: 42 };
|
|
1827
|
+
const entryWithObjectCommand = { type: 'command', command: { path: 'gate.py' } };
|
|
1828
|
+
const groupWithoutHooksArray = { matcher: '*' };
|
|
1829
|
+
const eventValueThatIsNotAnArray = { enabled: true };
|
|
1830
|
+
const { settingsPath, sandboxRoot } = createSettingsFixture({
|
|
1831
|
+
hooks: {
|
|
1832
|
+
PreToolUse: [{
|
|
1833
|
+
matcher: 'Write|Edit',
|
|
1834
|
+
hooks: [
|
|
1835
|
+
{ type: 'command', command: retiredCommand },
|
|
1836
|
+
entryWithoutCommand,
|
|
1837
|
+
entryWithNumericCommand,
|
|
1838
|
+
entryWithObjectCommand,
|
|
1839
|
+
],
|
|
1840
|
+
}],
|
|
1841
|
+
Notification: [groupWithoutHooksArray],
|
|
1842
|
+
SessionStart: eventValueThatIsNotAnArray,
|
|
1843
|
+
},
|
|
1844
|
+
}, 4);
|
|
1845
|
+
try {
|
|
1846
|
+
const removedCount = pruneRetiredHookEntriesFromSettings(
|
|
1847
|
+
settingsPath, new Set([RETIRED_HOOK_RELATIVE_PATH]),
|
|
1848
|
+
);
|
|
1849
|
+
|
|
1850
|
+
assert.equal(removedCount, 1, 'the one entry running the retired script is the only removal');
|
|
1851
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
1852
|
+
assert.deepEqual(
|
|
1853
|
+
settings.hooks.PreToolUse[0].hooks,
|
|
1854
|
+
[entryWithoutCommand, entryWithNumericCommand, entryWithObjectCommand],
|
|
1855
|
+
'an entry whose command is absent, a number, or an object counts as unmanaged and stays',
|
|
1856
|
+
);
|
|
1857
|
+
assert.deepEqual(
|
|
1858
|
+
settings.hooks.Notification,
|
|
1859
|
+
[groupWithoutHooksArray],
|
|
1860
|
+
'a matcher group carrying no hooks array is handed back untouched',
|
|
1861
|
+
);
|
|
1862
|
+
assert.deepEqual(
|
|
1863
|
+
settings.hooks.SessionStart,
|
|
1864
|
+
eventValueThatIsNotAnArray,
|
|
1865
|
+
'an event type whose value is not an array of groups is left as the file holds it',
|
|
1866
|
+
);
|
|
1867
|
+
} finally {
|
|
1868
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
test('mergeHooksIntoSettings merges its groups into a settings file holding shapes it never wrote', () => {
|
|
1874
|
+
const entryWithoutCommand = { type: 'command' };
|
|
1875
|
+
const settings = {
|
|
1876
|
+
hooks: {
|
|
1877
|
+
Stop: [{ matcher: '', hooks: [entryWithoutCommand] }],
|
|
1878
|
+
Notification: [{ matcher: '*' }],
|
|
1879
|
+
SessionStart: { enabled: true },
|
|
1880
|
+
},
|
|
1881
|
+
};
|
|
1882
|
+
|
|
1883
|
+
const groupCount = mergeHooksIntoSettings(
|
|
1884
|
+
settings, SAMPLE_HOOKS_CONFIG, '/home/user/.claude', 'python3',
|
|
1885
|
+
);
|
|
1886
|
+
|
|
1887
|
+
assert.equal(groupCount, 2, 'both sample matcher groups merge');
|
|
1888
|
+
assert.deepEqual(
|
|
1889
|
+
settings.hooks.Stop[0].hooks[0],
|
|
1890
|
+
entryWithoutCommand,
|
|
1891
|
+
'the entry carrying no command string keeps its place ahead of the merged hooks',
|
|
1892
|
+
);
|
|
1893
|
+
assert.equal(
|
|
1894
|
+
settings.hooks.Stop[0].hooks.length,
|
|
1895
|
+
3,
|
|
1896
|
+
'the two managed Stop hooks append behind the entry the installer never wrote',
|
|
1897
|
+
);
|
|
1898
|
+
assert.deepEqual(
|
|
1899
|
+
settings.hooks.Notification,
|
|
1900
|
+
[{ matcher: '*' }],
|
|
1901
|
+
'an event the package does not ship stays exactly as the file holds it',
|
|
1902
|
+
);
|
|
1903
|
+
});
|
|
1904
|
+
|
|
1905
|
+
|
|
1906
|
+
test('mergeHooksIntoSettings replaces a non-list value at a shipped event type and warns', () => {
|
|
1907
|
+
const settings = { hooks: { PreToolUse: { enabled: true, note: 'user authored shape' } } };
|
|
1908
|
+
|
|
1909
|
+
const { allWarnings } = captureWarnings(
|
|
1910
|
+
() => mergeHooksIntoSettings(settings, SAMPLE_HOOKS_CONFIG, '/home/user/.claude', 'python3'),
|
|
1911
|
+
);
|
|
1912
|
+
|
|
1913
|
+
assert.deepEqual(
|
|
1914
|
+
settings.hooks.PreToolUse.map(group => group.matcher),
|
|
1915
|
+
['Write'],
|
|
1916
|
+
'the shipped PreToolUse group takes the place of the value the file held',
|
|
1917
|
+
);
|
|
1918
|
+
assert.equal(
|
|
1919
|
+
allWarnings.filter(warning => warning.includes('PreToolUse')).length,
|
|
1920
|
+
1,
|
|
1921
|
+
'one warning names the event type whose value was replaced',
|
|
1922
|
+
);
|
|
1923
|
+
assert.match(
|
|
1924
|
+
allWarnings.find(warning => warning.includes('PreToolUse')),
|
|
1925
|
+
/not a list of hook groups/,
|
|
1926
|
+
'the warning states what the replaced value was',
|
|
1927
|
+
);
|
|
1928
|
+
});
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
test('mergeHooksIntoSettings keeps a non-list value at an event type the package does not ship', () => {
|
|
1932
|
+
const unshippedEventValue = { enabled: true, note: 'user authored shape' };
|
|
1933
|
+
const settings = { hooks: { SessionStart: unshippedEventValue } };
|
|
1934
|
+
|
|
1935
|
+
const { allWarnings } = captureWarnings(
|
|
1936
|
+
() => mergeHooksIntoSettings(settings, SAMPLE_HOOKS_CONFIG, '/home/user/.claude', 'python3'),
|
|
1937
|
+
);
|
|
1938
|
+
|
|
1939
|
+
assert.deepEqual(
|
|
1940
|
+
settings.hooks.SessionStart,
|
|
1941
|
+
unshippedEventValue,
|
|
1942
|
+
'an event type the sample config ships no groups for keeps the value the file holds',
|
|
1943
|
+
);
|
|
1944
|
+
assert.deepEqual(allWarnings, [], 'the merge warns about nothing it leaves in place');
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
|
|
1948
|
+
test('pruneManagedHooksFromSettings keeps every settings shape the installer never wrote', () => {
|
|
1949
|
+
const managedPaths = new Set(['notification/attention_needed_notify.py']);
|
|
1950
|
+
const managedCommand = 'python3 $HOME/.claude/hooks/notification/attention_needed_notify.py';
|
|
1951
|
+
const entryWithoutCommand = { type: 'command' };
|
|
1952
|
+
const settings = {
|
|
1953
|
+
hooks: {
|
|
1954
|
+
Stop: [{
|
|
1955
|
+
matcher: '',
|
|
1956
|
+
hooks: [{ type: 'command', command: managedCommand }, entryWithoutCommand],
|
|
1957
|
+
}],
|
|
1958
|
+
Notification: [{ matcher: '*' }],
|
|
1959
|
+
SessionStart: { enabled: true },
|
|
1960
|
+
},
|
|
1961
|
+
};
|
|
1962
|
+
|
|
1963
|
+
pruneManagedHooksFromSettings(settings, managedPaths);
|
|
1964
|
+
|
|
1965
|
+
assert.deepEqual(
|
|
1966
|
+
settings.hooks.Stop[0].hooks,
|
|
1967
|
+
[entryWithoutCommand],
|
|
1968
|
+
'the managed entry leaves and the entry carrying no command string stays',
|
|
1969
|
+
);
|
|
1970
|
+
assert.deepEqual(settings.hooks.Notification, [{ matcher: '*' }], 'the group with no hooks array stays');
|
|
1971
|
+
assert.deepEqual(settings.hooks.SessionStart, { enabled: true }, 'the non-array event value stays');
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
test('pruneRetiredHookEntriesFromSettings leaves settings.json byte-identical when it retires nothing', () => {
|
|
1976
|
+
const { settingsPath, sandboxRoot } = createSettingsFixture({
|
|
1977
|
+
hooks: {
|
|
1978
|
+
PreToolUse: [{
|
|
1979
|
+
matcher: 'Write|Edit',
|
|
1980
|
+
hooks: [{ type: 'command', command: 'python3 my_own_gate.py --user-authored' }],
|
|
1981
|
+
}],
|
|
1982
|
+
},
|
|
1983
|
+
}, 2);
|
|
1984
|
+
try {
|
|
1985
|
+
const bytesBefore = readFileSync(settingsPath, 'utf8');
|
|
1986
|
+
|
|
1987
|
+
const removedCount = pruneRetiredHookEntriesFromSettings(
|
|
1988
|
+
settingsPath, new Set([RETIRED_HOOK_RELATIVE_PATH]),
|
|
1989
|
+
);
|
|
1990
|
+
|
|
1991
|
+
assert.equal(removedCount, 0, 'no entry runs a retired script');
|
|
1992
|
+
assert.equal(
|
|
1993
|
+
readFileSync(settingsPath, 'utf8'),
|
|
1994
|
+
bytesBefore,
|
|
1995
|
+
'the file keeps its own formatting, so a run that retires nothing writes nothing',
|
|
1996
|
+
);
|
|
1997
|
+
} finally {
|
|
1998
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
1999
|
+
}
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
const PRIOR_RUN_BACKUP_NAMES = ['2020-01-01T00-00-00-000Z', '2021-06-15T12-30-45-123Z'];
|
|
2004
|
+
const THIS_RUN_BACKUP_NAME = '2026-07-25T18-04-11-923Z';
|
|
2005
|
+
const MOVED_SKILL_BACKUP_SEGMENTS = ['skills', 'demo'];
|
|
2006
|
+
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* Build a pruned-backup directory holding two prior run backups and this run's
|
|
2010
|
+
* own root, with the directories a move creates ahead of its rename already there.
|
|
2011
|
+
*
|
|
2012
|
+
* @param {boolean} doesThisRunHoldMovedContent Whether this run's root holds a moved file.
|
|
2013
|
+
* @returns {{root: string, prunedDirectory: string, runBackupRoot: string, movedFilePath: string}} The sandbox paths.
|
|
2014
|
+
*/
|
|
2015
|
+
function createRunBackupSandbox(doesThisRunHoldMovedContent) {
|
|
2016
|
+
const root = mkdtempSync(join(tmpdir(), 'cdev-run-backup-'));
|
|
2017
|
+
const prunedDirectory = join(root, '.claude-dev-env-pruned');
|
|
2018
|
+
for (const priorRunName of PRIOR_RUN_BACKUP_NAMES) {
|
|
2019
|
+
const priorRunDirectory = join(prunedDirectory, priorRunName);
|
|
2020
|
+
mkdirSync(priorRunDirectory, { recursive: true });
|
|
2021
|
+
writeFileSync(join(priorRunDirectory, 'recovered.md'), `content ${priorRunName} holds\n`);
|
|
2022
|
+
}
|
|
2023
|
+
const runBackupRoot = join(prunedDirectory, THIS_RUN_BACKUP_NAME);
|
|
2024
|
+
mkdirSync(join(runBackupRoot, ...MOVED_SKILL_BACKUP_SEGMENTS), { recursive: true });
|
|
2025
|
+
const movedFilePath = join(runBackupRoot, ...MOVED_SKILL_BACKUP_SEGMENTS, 'SKILL.md');
|
|
2026
|
+
if (doesThisRunHoldMovedContent) writeFileSync(movedFilePath, '# a skill moved aside\n');
|
|
2027
|
+
return { root, prunedDirectory, runBackupRoot, movedFilePath };
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
|
|
2031
|
+
/**
|
|
2032
|
+
* List the run backup directory names under a pruned-backup directory, sorted.
|
|
2033
|
+
*
|
|
2034
|
+
* @param {string} prunedDirectory The directory holding the run backups.
|
|
2035
|
+
* @returns {string[]} The directory names in sorted order.
|
|
2036
|
+
*/
|
|
2037
|
+
function listRunBackupNames(prunedDirectory) {
|
|
2038
|
+
return readdirSync(prunedDirectory, { withFileTypes: true })
|
|
2039
|
+
.filter(entry => entry.isDirectory())
|
|
2040
|
+
.map(entry => entry.name)
|
|
2041
|
+
.sort();
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
|
|
2045
|
+
test('retainNewestRunBackupOnly keeps every prior run backup and clears the empty root of a run that moved nothing', () => {
|
|
2046
|
+
const sandbox = createRunBackupSandbox(false);
|
|
2047
|
+
try {
|
|
2048
|
+
retainNewestRunBackupOnly(sandbox.runBackupRoot, false);
|
|
2049
|
+
|
|
2050
|
+
assert.deepEqual(
|
|
2051
|
+
listRunBackupNames(sandbox.prunedDirectory),
|
|
2052
|
+
[...PRIOR_RUN_BACKUP_NAMES].sort(),
|
|
2053
|
+
'every recovery point the user holds stays where it is',
|
|
2054
|
+
);
|
|
2055
|
+
assert.equal(
|
|
2056
|
+
existsSync(sandbox.runBackupRoot),
|
|
2057
|
+
false,
|
|
2058
|
+
'the empty directories the attempted moves created leave the pruned-backup directory',
|
|
2059
|
+
);
|
|
2060
|
+
} finally {
|
|
2061
|
+
rmSync(sandbox.root, { recursive: true, force: true });
|
|
2062
|
+
}
|
|
2063
|
+
});
|
|
2064
|
+
|
|
2065
|
+
|
|
2066
|
+
test('retainNewestRunBackupOnly retires every older run backup once this run holds moved content', () => {
|
|
2067
|
+
const sandbox = createRunBackupSandbox(true);
|
|
2068
|
+
try {
|
|
2069
|
+
retainNewestRunBackupOnly(sandbox.runBackupRoot, true);
|
|
2070
|
+
|
|
2071
|
+
assert.deepEqual(
|
|
2072
|
+
listRunBackupNames(sandbox.prunedDirectory),
|
|
2073
|
+
[THIS_RUN_BACKUP_NAME],
|
|
2074
|
+
'the run that moved content leaves its own backup as the only recovery point',
|
|
2075
|
+
);
|
|
2076
|
+
assert.equal(
|
|
2077
|
+
existsSync(sandbox.movedFilePath),
|
|
2078
|
+
true,
|
|
2079
|
+
'the surviving backup holds the content this run moved aside',
|
|
2080
|
+
);
|
|
2081
|
+
} finally {
|
|
2082
|
+
rmSync(sandbox.root, { recursive: true, force: true });
|
|
2083
|
+
}
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
|
|
2087
|
+
test('retainNewestRunBackupOnly keeps a run backup root that holds content when the run reports no move', () => {
|
|
2088
|
+
const sandbox = createRunBackupSandbox(true);
|
|
2089
|
+
try {
|
|
2090
|
+
retainNewestRunBackupOnly(sandbox.runBackupRoot, false);
|
|
2091
|
+
|
|
2092
|
+
assert.deepEqual(
|
|
2093
|
+
listRunBackupNames(sandbox.prunedDirectory),
|
|
2094
|
+
[...PRIOR_RUN_BACKUP_NAMES, THIS_RUN_BACKUP_NAME].sort(),
|
|
2095
|
+
'every run backup stays, so clearing an empty root never reaches a file',
|
|
2096
|
+
);
|
|
2097
|
+
assert.equal(existsSync(sandbox.movedFilePath), true, 'the file inside the root stays');
|
|
2098
|
+
} finally {
|
|
2099
|
+
rmSync(sandbox.root, { recursive: true, force: true });
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
|
|
2104
|
+
test('pruneStaleInstalledFiles leaves the run backup root standing when the rename fails', () => {
|
|
2105
|
+
const sandbox = createStalePruneSandbox({
|
|
2106
|
+
'demo/scripts/retired_module.py': 'stale module\n',
|
|
2107
|
+
});
|
|
2108
|
+
try {
|
|
2109
|
+
const occupiedDestination = join(sandbox.backupRoot, 'demo', 'scripts', 'retired_module.py');
|
|
2110
|
+
mkdirSync(occupiedDestination, { recursive: true });
|
|
2111
|
+
writeFileSync(join(occupiedDestination, 'inner.md'), 'content standing where the move lands\n');
|
|
2112
|
+
const staleFilePath = join(sandbox.skillsRoot, 'demo', 'scripts', 'retired_module.py');
|
|
2113
|
+
|
|
2114
|
+
const { returnedValue } = captureWarnings(() => pruneStaleInstalledFiles(
|
|
2115
|
+
[staleFilePath],
|
|
2116
|
+
[],
|
|
2117
|
+
sandbox.skillsRoot,
|
|
2118
|
+
sandbox.backupRoot,
|
|
2119
|
+
{ managedHomeDirectory: sandbox.root },
|
|
2120
|
+
));
|
|
2121
|
+
|
|
2122
|
+
assert.equal(returnedValue.prunedCount, 0, 'a rename onto an occupied path moves nothing');
|
|
2123
|
+
assert.deepEqual(
|
|
2124
|
+
returnedValue.failedPaths,
|
|
2125
|
+
[staleFilePath],
|
|
2126
|
+
'the failed path is reported so the caller keeps it on the manifest record',
|
|
2127
|
+
);
|
|
2128
|
+
assert.equal(existsSync(staleFilePath), true, 'the file stays in the installed tree');
|
|
2129
|
+
assert.equal(
|
|
2130
|
+
existsSync(sandbox.backupRoot),
|
|
2131
|
+
true,
|
|
2132
|
+
'the mover creates the run backup root ahead of the rename, so retention reads the move count rather than the directory',
|
|
2133
|
+
);
|
|
2134
|
+
} finally {
|
|
2135
|
+
rmSync(sandbox.root, { recursive: true, force: true });
|
|
2136
|
+
}
|
|
2137
|
+
});
|