claude-dev-env 1.95.0 → 2.0.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/_shared/advisor/CLAUDE.md +2 -2
- package/_shared/advisor/advisor-protocol.md +20 -20
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -12
- package/_shared/advisor/scripts/model_tier_run_validator.py +11 -10
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +25 -19
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
- package/_shared/advisor/scripts/tier_model_ids.py +18 -18
- package/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
- package/_shared/pr-loop/scripts/README.md +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
- package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
- package/_shared/pr-loop/worker-spawn.md +186 -0
- package/agents/code-verifier.md +1 -1
- package/bin/ever-shipped-skills.mjs +3 -0
- package/bin/expand_home_directory_tokens.mjs +1 -1
- package/bin/install.mjs +5 -2
- package/hooks/advisory/refactor_guard.py +3 -4
- package/hooks/blocking/CLAUDE.md +7 -1
- package/hooks/blocking/block_main_commit.py +2 -2
- package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
- package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
- package/hooks/blocking/code_rules_mock_completeness.py +1 -1
- package/hooks/blocking/code_rules_optional_params.py +2 -2
- package/hooks/blocking/code_rules_shared.py +1 -1
- package/hooks/blocking/code_rules_test_assertions.py +1 -1
- package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
- package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
- package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
- package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
- package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
- package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
- package/hooks/blocking/inventory_intent_records/records.py +271 -0
- package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
- package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
- package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
- package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
- package/hooks/blocking/pii_payload_scan.py +138 -42
- package/hooks/blocking/pii_prevention_blocker.py +185 -291
- package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
- package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
- package/hooks/blocking/pr_description_command_parser.py +8 -4
- package/hooks/blocking/precommit_code_rules_gate.py +3 -3
- package/hooks/blocking/tdd_enforcer.py +97 -608
- package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
- package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
- package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
- package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
- package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
- package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
- package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
- package/hooks/blocking/test_pii_payload_scan.py +168 -0
- package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
- package/hooks/blocking/tests/conftest.py +10 -0
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
- package/hooks/blocking/tests/test_repository_exemption.py +105 -0
- package/hooks/blocking/tests/test_repository_resolution.py +108 -0
- package/hooks/diagnostic/hook_log_extractor.py +12 -10
- package/hooks/git-hooks/post_commit.py +3 -4
- package/hooks/hooks_constants/CLAUDE.md +2 -2
- package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
- package/hooks/hooks_constants/local_identity.py +59 -8
- package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
- package/hooks/hooks_constants/test_local_identity.py +105 -3
- package/hooks/pyproject.toml +13 -36
- package/hooks/session/plugin_data_dir_cleanup.py +0 -1
- package/hooks/validation/mypy_validator.py +2 -2
- package/hooks/validators/health_check.py +1 -0
- package/hooks/validators/mypy_integration.py +2 -0
- package/hooks/validators/ruff_integration.py +3 -0
- package/hooks/workflow/auto_formatter.py +5 -4
- package/package.json +1 -1
- package/scripts/CLAUDE.md +4 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
- package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
- package/scripts/dev_env_scripts_constants/timing.py +7 -1
- package/scripts/grok_headless_runner.py +294 -0
- package/scripts/grok_worker_preflight.py +410 -0
- package/scripts/invoke_code_review.py +463 -0
- package/scripts/resolve_worker_spawn.py +619 -0
- package/scripts/spawn_grok_batch.py +672 -0
- package/scripts/test_grok_headless_runner.py +626 -0
- package/scripts/test_grok_worker_preflight.py +1054 -0
- package/scripts/test_invoke_code_review.py +672 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -0
- package/scripts/test_spawn_grok_batch.py +1017 -0
- package/skills/CLAUDE.md +5 -3
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
- package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
- package/skills/autoconverge/CLAUDE.md +3 -3
- package/skills/autoconverge/SKILL.md +9 -3
- package/skills/autoconverge/reference/CLAUDE.md +2 -2
- package/skills/autoconverge/reference/convergence.md +33 -11
- package/skills/autoconverge/reference/stop-conditions.md +16 -5
- package/skills/autoconverge/workflow/CLAUDE.md +2 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
- package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.mjs +200 -16
- package/skills/bugteam/CLAUDE.md +2 -2
- package/skills/bugteam/CONSTRAINTS.md +3 -2
- package/skills/bugteam/PROMPTS.md +7 -6
- package/skills/bugteam/SKILL.md +18 -13
- package/skills/bugteam/reference/audit-and-teammates.md +215 -35
- package/skills/bugteam/reference/design-rationale.md +1 -1
- package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
- package/skills/bugteam/reference/team-setup.md +8 -2
- package/skills/codex-review/CLAUDE.md +46 -0
- package/skills/codex-review/SKILL.md +181 -0
- package/skills/codex-review/reference/CLAUDE.md +15 -0
- package/skills/codex-review/reference/cli-contract.md +253 -0
- package/skills/codex-review/reference/loop-integration.md +118 -0
- package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
- package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
- package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
- package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
- package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
- package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
- package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
- package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
- package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
- package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
- package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
- package/skills/codex-review/scripts/run_codex_review.py +415 -0
- package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
- package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
- package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
- package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
- package/skills/codex-review/test_skill_scaffold.py +192 -0
- package/skills/grok-spawn/CLAUDE.md +28 -0
- package/skills/grok-spawn/SKILL.md +226 -0
- package/skills/grok-spawn/reference/flag-profiles.md +132 -0
- package/skills/grok-spawn/reference/worker-briefs.md +152 -0
- package/skills/grokify/SKILL.md +9 -1
- package/skills/grokify/capability-claims.test.mjs +28 -0
- package/skills/grokify/evals/README.md +72 -0
- package/skills/grokify/evals/parse-payload.test.mjs +171 -0
- package/skills/grokify/evals/run-capability-evals.mjs +545 -0
- package/skills/orchestrator/SKILL.md +15 -11
- package/skills/orchestrator-refresh/SKILL.md +5 -5
- package/skills/pr-converge/SKILL.md +34 -13
- package/skills/pr-converge/reference/convergence-gates.md +42 -15
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +1 -1
- package/skills/pr-converge/reference/per-tick.md +130 -42
- package/skills/pr-converge/reference/state-schema.md +10 -0
- package/skills/pr-converge/scripts/CLAUDE.md +2 -0
- package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
- package/skills/pr-converge/scripts/check_convergence.py +605 -29
- package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
- package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
- package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
- package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
- package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
- package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
- package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
- package/skills/pr-converge/test_step5_host_branch.py +106 -0
- package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
- package/skills/reviewer-gates/SKILL.md +7 -5
- package/skills/team-advisor/SKILL.md +7 -7
|
@@ -17,691 +17,81 @@ rows or that fence's commands are left alone — the exemption is scoped to the
|
|
|
17
17
|
region, not the whole file.
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
|
-
import json
|
|
21
|
-
import os
|
|
22
20
|
import sys
|
|
23
21
|
from pathlib import Path
|
|
24
|
-
from typing import TextIO
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
MAX_ORPHAN_FILE_ISSUES,
|
|
38
|
-
MAX_SUBTREE_FILES_SCANNED,
|
|
39
|
-
ORPHAN_FILE_ADDITIONAL_CONTEXT,
|
|
40
|
-
ORPHAN_FILE_MESSAGE_TEMPLATE,
|
|
41
|
-
ORPHAN_FILE_SYSTEM_MESSAGE,
|
|
42
|
-
REGION_BOUNDARY_PATTERN,
|
|
43
|
-
RELATIVE_PATH_SOURCE_PATTERN,
|
|
44
|
-
RUN_COMMAND_SCRIPT_PATTERN,
|
|
45
|
-
SEPARATOR_CELL_PATTERN,
|
|
46
|
-
TABLE_ROW_PATTERN,
|
|
47
|
-
)
|
|
48
|
-
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
49
|
-
from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
|
|
50
|
-
apply_edits,
|
|
51
|
-
edits_for_tool,
|
|
52
|
-
)
|
|
53
|
-
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
54
|
-
read_hook_input_dictionary_from_stdin,
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def is_claude_md_file(file_path: str) -> bool:
|
|
59
|
-
"""Return whether *file_path* names a per-directory ``CLAUDE.md`` file.
|
|
60
|
-
|
|
61
|
-
Args:
|
|
62
|
-
file_path: The destination path of the write or edit.
|
|
63
|
-
|
|
64
|
-
Returns:
|
|
65
|
-
True when the path's basename is exactly ``CLAUDE.md``.
|
|
66
|
-
"""
|
|
67
|
-
return os.path.basename(file_path) == CLAUDE_MD_FILENAME
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _first_table_cell(table_line: str) -> str:
|
|
71
|
-
"""Return the trimmed text of the first column cell in a markdown table row.
|
|
72
|
-
|
|
73
|
-
Args:
|
|
74
|
-
table_line: A single line that begins with a pipe character.
|
|
75
|
-
|
|
76
|
-
Returns:
|
|
77
|
-
The text between the leading pipe and the next pipe, stripped of
|
|
78
|
-
surrounding whitespace; an empty string when no cell is present.
|
|
79
|
-
"""
|
|
80
|
-
after_leading_pipe = table_line.strip().lstrip("|")
|
|
81
|
-
first_cell, _, _ = after_leading_pipe.partition("|")
|
|
82
|
-
return first_cell.strip()
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _referenced_filename_in_cell(cell_text: str) -> str | None:
|
|
86
|
-
"""Return the bare filename a table cell references, when it has one.
|
|
87
|
-
|
|
88
|
-
A cell references a bare filename only when its first backticked token has no
|
|
89
|
-
path separator, is not a slash-command, and carries a known file extension.
|
|
90
|
-
Subdirectory cells (trailing slash) and paths fall outside this scope and
|
|
91
|
-
yield None.
|
|
92
|
-
|
|
93
|
-
Args:
|
|
94
|
-
cell_text: The trimmed text of a first-column table cell.
|
|
95
|
-
|
|
96
|
-
Returns:
|
|
97
|
-
The bare filename to verify in the subtree, or None when the cell names
|
|
98
|
-
no bare file.
|
|
99
|
-
"""
|
|
100
|
-
backtick_match = FIRST_COLUMN_BACKTICK_PATTERN.search(cell_text)
|
|
101
|
-
if backtick_match is None:
|
|
102
|
-
return None
|
|
103
|
-
inner_text = backtick_match.group(1).strip()
|
|
104
|
-
if not inner_text:
|
|
105
|
-
return None
|
|
106
|
-
if inner_text.startswith("/"):
|
|
107
|
-
return None
|
|
108
|
-
if "/" in inner_text or "\\" in inner_text:
|
|
109
|
-
return None
|
|
110
|
-
_, extension = os.path.splitext(inner_text)
|
|
111
|
-
if extension.lower() not in ALL_REFERENCED_FILE_EXTENSIONS:
|
|
112
|
-
return None
|
|
113
|
-
return inner_text
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _filename_in_table_row(table_line: str) -> str | None:
|
|
117
|
-
"""Return the bare filename a markdown table row references, when it has one.
|
|
118
|
-
|
|
119
|
-
Args:
|
|
120
|
-
table_line: A single line that begins with a pipe character.
|
|
121
|
-
|
|
122
|
-
Returns:
|
|
123
|
-
The bare filename in the row's first column, or None when the row is a
|
|
124
|
-
header-separator row or names no bare file.
|
|
125
|
-
"""
|
|
126
|
-
first_cell = _first_table_cell(table_line)
|
|
127
|
-
if not first_cell or SEPARATOR_CELL_PATTERN.match(first_cell):
|
|
128
|
-
return None
|
|
129
|
-
return _referenced_filename_in_cell(first_cell)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def _declares_relative_path_source(text: str) -> bool:
|
|
133
|
-
"""Return whether *text* declares an explicit relative-path file source.
|
|
134
|
-
|
|
135
|
-
A ``../`` token signals that a table documents files in a sibling tree,
|
|
136
|
-
referenced by path rather than living in the CLAUDE.md's own subtree. The
|
|
137
|
-
block that carries such a token is out of scope, since its files legitimately
|
|
138
|
-
sit outside the subtree.
|
|
139
|
-
|
|
140
|
-
Args:
|
|
141
|
-
text: A table block together with the prose that introduces it.
|
|
142
|
-
|
|
143
|
-
Returns:
|
|
144
|
-
True when *text* contains a ``../`` relative-path token.
|
|
145
|
-
"""
|
|
146
|
-
return RELATIVE_PATH_SOURCE_PATTERN.search(text) is not None
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def find_referenced_filenames(content: str) -> list[str]:
|
|
150
|
-
"""Return each bare filename a CLAUDE.md table references, in order.
|
|
151
|
-
|
|
152
|
-
Walks the content line by line, grouping it into table blocks. A table block
|
|
153
|
-
is a maximal run of consecutive markdown table rows; the prose region since
|
|
154
|
-
the most recent heading introduces it, so prose under one section never
|
|
155
|
-
introduces a table under a later section. A line inside a fenced code block
|
|
156
|
-
(between a ``` or ~~~
|
|
157
|
-
fence pair) is example or sample text, not a live table, so it contributes
|
|
158
|
-
nothing and never ends a block. A block whose introducing region or own rows
|
|
159
|
-
declare an explicit relative-path source (a ``../`` token) documents files in
|
|
160
|
-
a sibling tree, so its rows are skipped — the exemption is scoped to that
|
|
161
|
-
region and block, not the whole file. Every remaining block contributes the
|
|
162
|
-
bare filename each first-column cell names.
|
|
163
|
-
|
|
164
|
-
Args:
|
|
165
|
-
content: The CLAUDE.md content being written.
|
|
166
|
-
|
|
167
|
-
Returns:
|
|
168
|
-
Each referenced filename from a non-exempt table block, in the order it
|
|
169
|
-
appears; duplicates preserved.
|
|
170
|
-
"""
|
|
171
|
-
referenced_filenames: list[str] = []
|
|
172
|
-
pending_region: list[str] = []
|
|
173
|
-
current_block: list[str] = []
|
|
174
|
-
is_inside_code_fence = False
|
|
175
|
-
for each_line in content.splitlines():
|
|
176
|
-
if CODE_FENCE_PATTERN.match(each_line) is not None:
|
|
177
|
-
is_inside_code_fence = not is_inside_code_fence
|
|
178
|
-
continue
|
|
179
|
-
if is_inside_code_fence:
|
|
180
|
-
continue
|
|
181
|
-
if TABLE_ROW_PATTERN.match(each_line) is not None:
|
|
182
|
-
current_block.append(each_line)
|
|
183
|
-
continue
|
|
184
|
-
if current_block:
|
|
185
|
-
referenced_filenames.extend(_block_filenames(pending_region, current_block))
|
|
186
|
-
current_block = []
|
|
187
|
-
pending_region = []
|
|
188
|
-
if REGION_BOUNDARY_PATTERN.match(each_line) is not None:
|
|
189
|
-
pending_region = []
|
|
190
|
-
pending_region.append(each_line)
|
|
191
|
-
referenced_filenames.extend(_block_filenames(pending_region, current_block))
|
|
192
|
-
return referenced_filenames
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def _block_filenames(all_region_lines: list[str], all_block_lines: list[str]) -> list[str]:
|
|
196
|
-
"""Return the bare filenames a table block contributes, honoring its exemption.
|
|
197
|
-
|
|
198
|
-
Args:
|
|
199
|
-
all_region_lines: The prose lines accumulated before this block.
|
|
200
|
-
all_block_lines: The consecutive table rows that form the block.
|
|
201
|
-
|
|
202
|
-
Returns:
|
|
203
|
-
Each bare filename the block's first-column cells name, or an empty list
|
|
204
|
-
when the block (with its introducing region) declares a ``../`` source.
|
|
205
|
-
"""
|
|
206
|
-
if not all_block_lines:
|
|
207
|
-
return []
|
|
208
|
-
block_region = "\n".join(all_region_lines + all_block_lines)
|
|
209
|
-
if _declares_relative_path_source(block_region):
|
|
210
|
-
return []
|
|
211
|
-
block_filenames: list[str] = []
|
|
212
|
-
for each_line in all_block_lines:
|
|
213
|
-
each_filename = _filename_in_table_row(each_line)
|
|
214
|
-
if each_filename is not None:
|
|
215
|
-
block_filenames.append(each_filename)
|
|
216
|
-
return block_filenames
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def _script_basename_from_token(script_token: str) -> str | None:
|
|
220
|
-
"""Return the bare basename one captured script token names, when it has one.
|
|
221
|
-
|
|
222
|
-
A path-qualified script keeps only its final segment, so ``tools/build_bundle.mjs``
|
|
223
|
-
yields ``build_bundle.mjs`` — the basename the directory file would match. A
|
|
224
|
-
script named by an explicit relative-path source (a ``../`` token in the token
|
|
225
|
-
itself, as in ``../shared/preflight.py``) lives outside the subtree by design, so
|
|
226
|
-
it is exempt and yields None. This token-scoped ``../`` check is one of two
|
|
227
|
-
relative-path exemptions: ``find_run_command_filenames`` also skips a whole fence
|
|
228
|
-
whose introducing prose region declares a ``../`` source, which is the
|
|
229
|
-
region-scoped counterpart to the table-cell exemption. A token whose basename
|
|
230
|
-
carries no recognized script extension yields None.
|
|
231
|
-
|
|
232
|
-
Args:
|
|
233
|
-
script_token: One script path captured from an interpreter invocation.
|
|
234
|
-
|
|
235
|
-
Returns:
|
|
236
|
-
The bare script basename, or None when the token is relative-path-sourced
|
|
237
|
-
or carries no recognized extension.
|
|
238
|
-
"""
|
|
239
|
-
if _declares_relative_path_source(script_token):
|
|
240
|
-
return None
|
|
241
|
-
basename = os.path.basename(script_token.replace("\\", "/").rstrip("/"))
|
|
242
|
-
if not basename:
|
|
243
|
-
return None
|
|
244
|
-
_, extension = os.path.splitext(basename)
|
|
245
|
-
if extension.lower() not in ALL_RUN_COMMAND_SCRIPT_EXTENSIONS:
|
|
246
|
-
return None
|
|
247
|
-
return basename
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
def _command_text_before_comment(fenced_line: str) -> str:
|
|
251
|
-
"""Return the runnable portion of a fenced line, with any shell comment removed.
|
|
252
|
-
|
|
253
|
-
A shell comment starts at a ``#`` that begins a word — at the line's start or
|
|
254
|
-
after whitespace — outside any quoted span, and runs to end of line. The text
|
|
255
|
-
after it documents a removed or alternative command rather than a runnable
|
|
256
|
-
contract, so it is dropped: a full-line comment yields an empty string, and an
|
|
257
|
-
inline trailing comment (``python real.py # was: python old.py``) yields only
|
|
258
|
-
the part before the ``#``. A ``#`` inside single or double quotes, or one
|
|
259
|
-
glued to a preceding non-space character, stays in the runnable text.
|
|
260
|
-
|
|
261
|
-
Args:
|
|
262
|
-
fenced_line: A single line drawn from inside a fenced code block.
|
|
263
|
-
|
|
264
|
-
Returns:
|
|
265
|
-
The line truncated at its first unquoted word-leading ``#``, or the whole
|
|
266
|
-
line when it carries no such comment marker.
|
|
267
|
-
"""
|
|
268
|
-
open_quote_character = ""
|
|
269
|
-
previous_character = ""
|
|
270
|
-
for each_index, each_character in enumerate(fenced_line):
|
|
271
|
-
if open_quote_character:
|
|
272
|
-
if each_character == open_quote_character:
|
|
273
|
-
open_quote_character = ""
|
|
274
|
-
elif each_character in ("'", '"'):
|
|
275
|
-
open_quote_character = each_character
|
|
276
|
-
elif each_character == "#" and (each_index == 0 or previous_character.isspace()):
|
|
277
|
-
return fenced_line[:each_index]
|
|
278
|
-
previous_character = each_character
|
|
279
|
-
return fenced_line
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
def _run_command_filenames_in_line(fenced_line: str) -> list[str]:
|
|
283
|
-
"""Return each script basename the interpreter invocations on this line name.
|
|
284
|
-
|
|
285
|
-
A fenced run-command line such as ``python tools/verify.py --flag`` invokes a
|
|
286
|
-
script file; this returns that script's bare basename. A line that chains
|
|
287
|
-
several invocations with a shell separator (``python deploy.py && node build.mjs``,
|
|
288
|
-
``python first.py; python second.py``) contributes each invocation's basename in
|
|
289
|
-
order. A shell comment — a ``#`` that begins a word outside any quoted span,
|
|
290
|
-
whether it opens the line or trails a command (``python real.py # was python
|
|
291
|
-
old.py``) — documents a removed or alternative command rather than a runnable
|
|
292
|
-
contract, so the text from that ``#`` to end of line contributes nothing. A line
|
|
293
|
-
with no interpreter invocation contributes nothing.
|
|
294
|
-
|
|
295
|
-
Args:
|
|
296
|
-
fenced_line: A single line drawn from inside a fenced code block.
|
|
297
|
-
|
|
298
|
-
Returns:
|
|
299
|
-
Each bare script basename the line's invocations name, in order; empty when
|
|
300
|
-
the line is a comment or names no runnable script.
|
|
301
|
-
"""
|
|
302
|
-
runnable_text = _command_text_before_comment(fenced_line)
|
|
303
|
-
line_filenames: list[str] = []
|
|
304
|
-
for each_match in RUN_COMMAND_SCRIPT_PATTERN.finditer(runnable_text):
|
|
305
|
-
each_basename = _script_basename_from_token(each_match.group(1).strip())
|
|
306
|
-
if each_basename is not None:
|
|
307
|
-
line_filenames.append(each_basename)
|
|
308
|
-
return line_filenames
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
def find_run_command_filenames(content: str) -> list[str]:
|
|
312
|
-
"""Return each script basename a fenced run command invokes, in order.
|
|
313
|
-
|
|
314
|
-
Walks the content line by line, inspecting only lines inside a fenced code
|
|
315
|
-
block (between a ``` or ~~~ fence pair). A fenced run-command line that invokes
|
|
316
|
-
an interpreter on a script (``python script.py``, ``node bundle.mjs``,
|
|
317
|
-
``pwsh build.ps1``) contributes that script's bare basename, and a line that
|
|
318
|
-
chains several invocations with a shell separator contributes each one. A line
|
|
319
|
-
outside any fence is prose, not a live command, and contributes nothing — an
|
|
320
|
-
inline ``python x.py`` in a sentence is documentation, not a runnable contract.
|
|
321
|
-
A fence whose introducing region declares an explicit relative-path source (a
|
|
322
|
-
``../`` token in the prose accumulated since the prior fence or heading)
|
|
323
|
-
documents commands that run scripts in a sibling tree, so that fence's run
|
|
324
|
-
commands are skipped — mirroring the region-scoped table-cell ``../``
|
|
325
|
-
exemption. The region resets when a fence closes, so the exemption is scoped to
|
|
326
|
-
that fence alone: a second fence under the same heading, introduced by prose
|
|
327
|
-
that names no ``../`` source, is still inspected.
|
|
328
|
-
|
|
329
|
-
Args:
|
|
330
|
-
content: The CLAUDE.md content being written.
|
|
331
|
-
|
|
332
|
-
Returns:
|
|
333
|
-
Each script basename a fenced run command names, in the order it appears;
|
|
334
|
-
duplicates preserved.
|
|
335
|
-
"""
|
|
336
|
-
run_command_filenames: list[str] = []
|
|
337
|
-
pending_region: list[str] = []
|
|
338
|
-
is_inside_code_fence = False
|
|
339
|
-
is_region_relative_path_sourced = False
|
|
340
|
-
for each_line in content.splitlines():
|
|
341
|
-
if CODE_FENCE_PATTERN.match(each_line) is not None:
|
|
342
|
-
if not is_inside_code_fence:
|
|
343
|
-
is_region_relative_path_sourced = _declares_relative_path_source(
|
|
344
|
-
"\n".join(pending_region)
|
|
345
|
-
)
|
|
346
|
-
else:
|
|
347
|
-
pending_region = []
|
|
348
|
-
is_inside_code_fence = not is_inside_code_fence
|
|
349
|
-
continue
|
|
350
|
-
if is_inside_code_fence:
|
|
351
|
-
if not is_region_relative_path_sourced:
|
|
352
|
-
run_command_filenames.extend(_run_command_filenames_in_line(each_line))
|
|
353
|
-
continue
|
|
354
|
-
if REGION_BOUNDARY_PATTERN.match(each_line) is not None:
|
|
355
|
-
pending_region = []
|
|
356
|
-
pending_region.append(each_line)
|
|
357
|
-
return run_command_filenames
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
def _resolve_scan_root(claude_md_directory: Path) -> Path:
|
|
361
|
-
"""Return the directory whose subtree bounds the filename existence search.
|
|
362
|
-
|
|
363
|
-
The search root is the CLAUDE.md directory's parent when that parent exists,
|
|
364
|
-
so a table that documents files in a sibling directory or one level up still
|
|
365
|
-
resolves them. When the directory has no distinct parent, the CLAUDE.md
|
|
366
|
-
directory itself is the root.
|
|
367
|
-
|
|
368
|
-
Args:
|
|
369
|
-
claude_md_directory: The directory that holds the target CLAUDE.md.
|
|
370
|
-
|
|
371
|
-
Returns:
|
|
372
|
-
The directory to walk when collecting candidate filenames.
|
|
373
|
-
"""
|
|
374
|
-
parent_directory = claude_md_directory.parent
|
|
375
|
-
if parent_directory == claude_md_directory:
|
|
376
|
-
return claude_md_directory
|
|
377
|
-
return parent_directory
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
class _SubtreeScan:
|
|
381
|
-
"""The basenames a bounded subtree walk collected and whether it ran complete.
|
|
382
|
-
|
|
383
|
-
Attributes:
|
|
384
|
-
all_basenames: Each file basename the walk reached.
|
|
385
|
-
was_scan_complete: True when the walk visited the whole subtree within the
|
|
386
|
-
budget, so ``all_basenames`` is authoritative; False when the budget
|
|
387
|
-
truncated the walk, so a basename's absence from the set is not proof
|
|
388
|
-
of its absence on disk.
|
|
389
|
-
"""
|
|
390
|
-
|
|
391
|
-
def __init__(self, all_basenames: set[str], was_scan_complete: bool) -> None:
|
|
392
|
-
self.all_basenames = all_basenames
|
|
393
|
-
self.was_scan_complete = was_scan_complete
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
def _is_under_noise_directory(scan_root: Path, candidate_path: Path) -> bool:
|
|
397
|
-
"""Return whether *candidate_path* lies inside a pruned noise directory.
|
|
398
|
-
|
|
399
|
-
A noise directory (``.git``, ``__pycache__``, ``node_modules``, and the test
|
|
400
|
-
and lint caches) holds volatile generated files that no CLAUDE.md table
|
|
401
|
-
documents, so the walk skips them. This keeps generated files out of the
|
|
402
|
-
basename set and keeps them from consuming the scan budget.
|
|
403
|
-
|
|
404
|
-
Args:
|
|
405
|
-
scan_root: The directory the walk descends from.
|
|
406
|
-
candidate_path: A path the walk yielded under the scan root.
|
|
407
|
-
|
|
408
|
-
Returns:
|
|
409
|
-
True when any path segment below *scan_root* names a noise directory.
|
|
410
|
-
"""
|
|
411
|
-
try:
|
|
412
|
-
relative_segments = candidate_path.relative_to(scan_root).parts
|
|
413
|
-
except ValueError:
|
|
414
|
-
relative_segments = candidate_path.parts
|
|
415
|
-
return any(each_segment in ALL_NOISE_DIRECTORY_NAMES for each_segment in relative_segments)
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
def _scan_subtree_basenames(scan_root: Path) -> _SubtreeScan:
|
|
419
|
-
"""Return the bounded basename scan of *scan_root*, skipping unreadable entries.
|
|
420
|
-
|
|
421
|
-
Walks the subtree collecting each file's basename, stopping once the scan
|
|
422
|
-
budget is reached. A path inside a noise directory is pruned, and a per-entry
|
|
423
|
-
stat error skips that entry. The result records whether the walk completed
|
|
424
|
-
within the budget, so the caller knows whether the set is authoritative.
|
|
425
|
-
|
|
426
|
-
Args:
|
|
427
|
-
scan_root: The directory whose subtree bounds the existence search.
|
|
428
|
-
|
|
429
|
-
Returns:
|
|
430
|
-
The collected basenames paired with the scan-completeness flag.
|
|
431
|
-
"""
|
|
432
|
-
all_basenames: set[str] = set()
|
|
433
|
-
scanned_count = 0
|
|
434
|
-
for each_path in scan_root.rglob("*"):
|
|
435
|
-
if _is_under_noise_directory(scan_root, each_path):
|
|
436
|
-
continue
|
|
437
|
-
try:
|
|
438
|
-
if not each_path.is_file():
|
|
439
|
-
continue
|
|
440
|
-
except OSError:
|
|
441
|
-
continue
|
|
442
|
-
all_basenames.add(each_path.name)
|
|
443
|
-
scanned_count += 1
|
|
444
|
-
if scanned_count >= MAX_SUBTREE_FILES_SCANNED:
|
|
445
|
-
return _SubtreeScan(all_basenames, was_scan_complete=False)
|
|
446
|
-
return _SubtreeScan(all_basenames, was_scan_complete=True)
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
def _filename_exists_under(scan_root: Path, filename: str) -> bool:
|
|
450
|
-
"""Return whether a file with basename *filename* exists anywhere under root.
|
|
451
|
-
|
|
452
|
-
A direct probe that resolves one filename deterministically even when the
|
|
453
|
-
bounded subtree walk was truncated. A match inside a noise directory is pruned
|
|
454
|
-
so the probe agrees with the bounded walk, and an unreadable entry mid-walk is
|
|
455
|
-
skipped.
|
|
456
|
-
|
|
457
|
-
Args:
|
|
458
|
-
scan_root: The directory whose subtree bounds the existence search.
|
|
459
|
-
filename: The bare basename to look for.
|
|
460
|
-
|
|
461
|
-
Returns:
|
|
462
|
-
True when at least one matching file is reachable under the scan root.
|
|
463
|
-
"""
|
|
464
|
-
for each_match in scan_root.rglob(filename):
|
|
465
|
-
if _is_under_noise_directory(scan_root, each_match):
|
|
466
|
-
continue
|
|
467
|
-
try:
|
|
468
|
-
if each_match.is_file():
|
|
469
|
-
return True
|
|
470
|
-
except OSError:
|
|
471
|
-
continue
|
|
472
|
-
return False
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
def _present_referenced_filenames(
|
|
476
|
-
all_referenced_filenames: list[str], scan_root: Path
|
|
477
|
-
) -> set[str]:
|
|
478
|
-
"""Return the referenced filenames that exist under the scan root.
|
|
479
|
-
|
|
480
|
-
A complete bounded walk yields an authoritative basename set, so membership in
|
|
481
|
-
it decides presence. When the budget truncated the walk, a name absent from
|
|
482
|
-
the partial set is probed directly with ``rglob`` so a truncated slice never
|
|
483
|
-
produces a false-missing verdict.
|
|
484
|
-
|
|
485
|
-
Args:
|
|
486
|
-
all_referenced_filenames: The bare filenames a CLAUDE.md table names.
|
|
487
|
-
scan_root: The directory whose subtree bounds the existence search.
|
|
488
|
-
|
|
489
|
-
Returns:
|
|
490
|
-
The subset of *all_referenced_filenames* that resolve to an existing file.
|
|
491
|
-
"""
|
|
492
|
-
subtree_scan = _scan_subtree_basenames(scan_root)
|
|
493
|
-
present_filenames: set[str] = set()
|
|
494
|
-
for each_filename in all_referenced_filenames:
|
|
495
|
-
if each_filename in subtree_scan.all_basenames:
|
|
496
|
-
present_filenames.add(each_filename)
|
|
497
|
-
continue
|
|
498
|
-
if subtree_scan.was_scan_complete:
|
|
499
|
-
continue
|
|
500
|
-
if _filename_exists_under(scan_root, each_filename):
|
|
501
|
-
present_filenames.add(each_filename)
|
|
502
|
-
return present_filenames
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
def find_missing_filenames(content: str, claude_md_directory: Path) -> list[str]:
|
|
506
|
-
"""Return the referenced filenames absent from the CLAUDE.md's scan root.
|
|
507
|
-
|
|
508
|
-
A referenced filename comes from two sources: a bare filename a table cell
|
|
509
|
-
names, and a script a fenced run command invokes (``python script.py``). It is
|
|
510
|
-
missing when it exists nowhere under the scan root — the CLAUDE.md directory's
|
|
511
|
-
parent (or the directory itself when it has no distinct parent), which covers
|
|
512
|
-
the directory, its subdirectories, and its siblings. A table block that
|
|
513
|
-
declares an explicit relative-path source (a ``../`` token in the block or the
|
|
514
|
-
prose that introduces it) yields no findings for that block's rows, since those
|
|
515
|
-
files legitimately live elsewhere; an unrelated block in the same file is still
|
|
516
|
-
checked. When the content references no bare filename, no findings result and
|
|
517
|
-
the subtree walk is skipped. A filesystem error that halts the whole subtree
|
|
518
|
-
walk yields no findings (fail open), so an unreadable tree never blocks a write.
|
|
519
|
-
|
|
520
|
-
Args:
|
|
521
|
-
content: The CLAUDE.md content being written.
|
|
522
|
-
claude_md_directory: The directory that holds the target CLAUDE.md.
|
|
523
|
-
|
|
524
|
-
Returns:
|
|
525
|
-
Each referenced filename with no matching file under the scan root, in
|
|
526
|
-
first-seen order with duplicates removed, capped at the issue budget.
|
|
527
|
-
"""
|
|
528
|
-
referenced_filenames = find_referenced_filenames(content) + find_run_command_filenames(
|
|
529
|
-
content
|
|
23
|
+
try:
|
|
24
|
+
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
25
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
26
|
+
for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
|
|
27
|
+
if each_bootstrap_directory not in sys.path:
|
|
28
|
+
sys.path.insert(0, each_bootstrap_directory)
|
|
29
|
+
from claude_md_orphan_file_blocker_parts import (
|
|
30
|
+
decision,
|
|
31
|
+
references,
|
|
32
|
+
scan_plan,
|
|
33
|
+
subtree_scan,
|
|
530
34
|
)
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
present_filenames = _present_referenced_filenames(referenced_filenames, scan_root)
|
|
536
|
-
except OSError:
|
|
537
|
-
return []
|
|
538
|
-
missing_filenames: list[str] = []
|
|
539
|
-
already_reported: set[str] = set()
|
|
540
|
-
for each_filename in referenced_filenames:
|
|
541
|
-
if each_filename in already_reported:
|
|
542
|
-
continue
|
|
543
|
-
if each_filename in present_filenames:
|
|
544
|
-
continue
|
|
545
|
-
already_reported.add(each_filename)
|
|
546
|
-
missing_filenames.append(each_filename)
|
|
547
|
-
if len(missing_filenames) >= MAX_ORPHAN_FILE_ISSUES:
|
|
548
|
-
break
|
|
549
|
-
return missing_filenames
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
def _read_existing_file_content(file_path: str) -> str | None:
|
|
553
|
-
"""Return the current on-disk content of *file_path*, or None when unreadable.
|
|
554
|
-
|
|
555
|
-
Args:
|
|
556
|
-
file_path: The path of the file the edit targets.
|
|
557
|
-
|
|
558
|
-
Returns:
|
|
559
|
-
The file's text, or None when the file is missing or cannot be decoded.
|
|
560
|
-
"""
|
|
561
|
-
try:
|
|
562
|
-
return Path(file_path).read_text(encoding="utf-8")
|
|
563
|
-
except (OSError, UnicodeDecodeError):
|
|
564
|
-
return None
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
def _edit_fragments(all_edits: list[dict]) -> list[str]:
|
|
568
|
-
"""Return each MultiEdit ``new_string`` fragment present as a non-empty string.
|
|
569
|
-
|
|
570
|
-
Args:
|
|
571
|
-
all_edits: The MultiEdit ``edits`` list.
|
|
572
|
-
|
|
573
|
-
Returns:
|
|
574
|
-
Every ``new_string`` value that is a non-empty string, in list order.
|
|
575
|
-
"""
|
|
576
|
-
all_fragments: list[str] = []
|
|
577
|
-
for each_edit in all_edits:
|
|
578
|
-
if not isinstance(each_edit, dict):
|
|
579
|
-
continue
|
|
580
|
-
new_string = each_edit.get("new_string", "")
|
|
581
|
-
if isinstance(new_string, str) and new_string:
|
|
582
|
-
all_fragments.append(new_string)
|
|
583
|
-
return all_fragments
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
class _OrphanScanPlan:
|
|
587
|
-
"""The contents to scan for orphans and the pre-existing orphans to exclude.
|
|
588
|
-
|
|
589
|
-
Attributes:
|
|
590
|
-
candidate_contents: Each content string whose table rows are scanned.
|
|
591
|
-
baseline_missing_filenames: The orphan filenames the file already held
|
|
592
|
-
before this edit; reporting excludes them so an unrelated edit over a
|
|
593
|
-
pre-existing orphan on an untouched line is not blocked. Empty for a
|
|
594
|
-
Write (the whole file is replaced) and for an edit whose existing file
|
|
595
|
-
cannot be read.
|
|
596
|
-
"""
|
|
597
|
-
|
|
598
|
-
def __init__(
|
|
599
|
-
self, all_candidate_contents: list[str], all_baseline_missing_filenames: set[str]
|
|
600
|
-
) -> None:
|
|
601
|
-
self.candidate_contents = all_candidate_contents
|
|
602
|
-
self.baseline_missing_filenames = all_baseline_missing_filenames
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
def _build_orphan_scan_plan(
|
|
606
|
-
tool_name: str, tool_input: dict, file_path: str, claude_md_directory: Path
|
|
607
|
-
) -> _OrphanScanPlan:
|
|
608
|
-
"""Return the contents to scan and the pre-existing orphans to exclude.
|
|
609
|
-
|
|
610
|
-
For Write the candidate is the full new content with no baseline, so every
|
|
611
|
-
orphan it names is introduced by the write. For Edit and MultiEdit the
|
|
612
|
-
candidate is the existing file with the replacements applied, so a ``../``
|
|
613
|
-
source line outside the edited rows still exempts that table block; the
|
|
614
|
-
baseline is the orphan set the existing file already held, so a pre-existing
|
|
615
|
-
orphan on an untouched line is excluded and only an orphan the edit introduces
|
|
616
|
-
is reported. When the existing file cannot be read, the raw ``new_string``
|
|
617
|
-
fragment(s) are scanned with no baseline, so an orphan the edit itself adds is
|
|
618
|
-
still caught.
|
|
619
|
-
|
|
620
|
-
Args:
|
|
621
|
-
tool_name: The intercepted tool — ``Write``, ``Edit``, or ``MultiEdit``.
|
|
622
|
-
tool_input: The tool's input payload.
|
|
623
|
-
file_path: The destination path of the write or edit.
|
|
624
|
-
claude_md_directory: The directory that holds the target CLAUDE.md.
|
|
625
|
-
|
|
626
|
-
Returns:
|
|
627
|
-
The scan plan pairing candidate contents with the baseline orphan set.
|
|
628
|
-
"""
|
|
629
|
-
if tool_name == "Write":
|
|
630
|
-
content = tool_input.get("content", "")
|
|
631
|
-
candidate_contents = [content] if isinstance(content, str) and content else []
|
|
632
|
-
return _OrphanScanPlan(candidate_contents, set())
|
|
633
|
-
all_edits = edits_for_tool(tool_name, tool_input)
|
|
634
|
-
existing_content = _read_existing_file_content(file_path)
|
|
635
|
-
if existing_content is None:
|
|
636
|
-
return _OrphanScanPlan(_edit_fragments(all_edits), set())
|
|
637
|
-
baseline_missing = set(find_missing_filenames(existing_content, claude_md_directory))
|
|
638
|
-
return _OrphanScanPlan([apply_edits(existing_content, all_edits)], baseline_missing)
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
def _collect_missing_filenames(scan_plan: _OrphanScanPlan, claude_md_directory: Path) -> list[str]:
|
|
642
|
-
"""Return every orphan filename the scan plan introduces, excluding baselines.
|
|
643
|
-
|
|
644
|
-
An orphan the file already held before the edit (a member of the plan's
|
|
645
|
-
baseline set) is excluded, so an unrelated edit over a pre-existing orphan on
|
|
646
|
-
an untouched line reports nothing.
|
|
647
|
-
|
|
648
|
-
Args:
|
|
649
|
-
scan_plan: The candidate contents to scan paired with the baseline orphan
|
|
650
|
-
set to exclude.
|
|
651
|
-
claude_md_directory: The directory that holds the target CLAUDE.md.
|
|
652
|
-
|
|
653
|
-
Returns:
|
|
654
|
-
Each introduced orphan filename in first-seen order with duplicates
|
|
655
|
-
removed, capped at the issue budget.
|
|
656
|
-
"""
|
|
657
|
-
missing_filenames: list[str] = []
|
|
658
|
-
already_reported: set[str] = set()
|
|
659
|
-
for each_content in scan_plan.candidate_contents:
|
|
660
|
-
for each_filename in find_missing_filenames(each_content, claude_md_directory):
|
|
661
|
-
if each_filename in scan_plan.baseline_missing_filenames:
|
|
662
|
-
continue
|
|
663
|
-
if each_filename in already_reported:
|
|
664
|
-
continue
|
|
665
|
-
already_reported.add(each_filename)
|
|
666
|
-
missing_filenames.append(each_filename)
|
|
667
|
-
if len(missing_filenames) >= MAX_ORPHAN_FILE_ISSUES:
|
|
668
|
-
return missing_filenames
|
|
669
|
-
return missing_filenames
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
def _build_block_payload(all_missing_filenames: list[str], directory: str) -> dict:
|
|
673
|
-
"""Build the PreToolUse deny payload listing each missing filename.
|
|
674
|
-
|
|
675
|
-
Args:
|
|
676
|
-
all_missing_filenames: The referenced filenames absent from the subtree.
|
|
677
|
-
directory: The directory that holds the target CLAUDE.md.
|
|
678
|
-
|
|
679
|
-
Returns:
|
|
680
|
-
The hook-result dictionary the harness reads to deny the write.
|
|
681
|
-
"""
|
|
682
|
-
formatted_missing = ", ".join(f"`{each_name}`" for each_name in all_missing_filenames)
|
|
683
|
-
reason = ORPHAN_FILE_MESSAGE_TEMPLATE.format(directory=directory, missing=formatted_missing)
|
|
684
|
-
return {
|
|
685
|
-
"hookSpecificOutput": {
|
|
686
|
-
"hookEventName": "PreToolUse",
|
|
687
|
-
"permissionDecision": "deny",
|
|
688
|
-
"permissionDecisionReason": reason,
|
|
689
|
-
"additionalContext": ORPHAN_FILE_ADDITIONAL_CONTEXT,
|
|
690
|
-
},
|
|
691
|
-
"systemMessage": ORPHAN_FILE_SYSTEM_MESSAGE,
|
|
692
|
-
"suppressOutput": True,
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
def _emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
|
|
697
|
-
"""Write the hook result JSON to the given output stream.
|
|
35
|
+
from claude_md_orphan_file_blocker_parts.subtree_scan import (
|
|
36
|
+
MAX_SUBTREE_FILES_SCANNED, # noqa: F401
|
|
37
|
+
)
|
|
38
|
+
from inventory_intent_records import records
|
|
698
39
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
40
|
+
from hooks_constants.pre_tool_use_stdin import (
|
|
41
|
+
read_hook_input_dictionary_from_stdin,
|
|
42
|
+
)
|
|
43
|
+
except ImportError as import_error:
|
|
44
|
+
raise ImportError(
|
|
45
|
+
"claude_md_orphan_file_blocker: cannot import its parts submodules; "
|
|
46
|
+
"ensure the hooks directory is importable."
|
|
47
|
+
) from import_error
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
is_claude_md_file = references.is_claude_md_file
|
|
51
|
+
find_referenced_filenames = references.find_referenced_filenames
|
|
52
|
+
find_run_command_filenames = references.find_run_command_filenames
|
|
53
|
+
find_missing_filenames = subtree_scan.find_missing_filenames
|
|
54
|
+
build_orphan_scan_plan = scan_plan.build_orphan_scan_plan
|
|
55
|
+
collect_missing_filenames = scan_plan.collect_missing_filenames
|
|
56
|
+
deny_orphan_files = decision.deny_orphan_files
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _partition_by_file_intent(
|
|
60
|
+
session_id: str, directory: str, all_missing_filenames: list[str]
|
|
61
|
+
) -> tuple[list[str], list[str]]:
|
|
62
|
+
"""Split missing filenames into those with a pending file intent and those without."""
|
|
63
|
+
matched_filenames = [
|
|
64
|
+
each_filename
|
|
65
|
+
for each_filename in all_missing_filenames
|
|
66
|
+
if records.has_fresh_file_intent(session_id, directory, each_filename)
|
|
67
|
+
]
|
|
68
|
+
unmatched_filenames = [
|
|
69
|
+
each_filename
|
|
70
|
+
for each_filename in all_missing_filenames
|
|
71
|
+
if each_filename not in matched_filenames
|
|
72
|
+
]
|
|
73
|
+
return matched_filenames, unmatched_filenames
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _emit_orphan_decision(
|
|
77
|
+
input_data: dict,
|
|
78
|
+
tool_name: str,
|
|
79
|
+
file_path: str,
|
|
80
|
+
directory: str,
|
|
81
|
+
all_missing_filenames: list[str],
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Consume covered rows and deny any genuine orphan the change introduces."""
|
|
84
|
+
session_id = str(input_data.get("session_id") or "")
|
|
85
|
+
matched_filenames, unmatched_filenames = _partition_by_file_intent(
|
|
86
|
+
session_id, directory, all_missing_filenames
|
|
87
|
+
)
|
|
88
|
+
if unmatched_filenames:
|
|
89
|
+
for each_filename in unmatched_filenames:
|
|
90
|
+
records.record_row_intent(session_id, directory, each_filename)
|
|
91
|
+
deny_orphan_files(tool_name, file_path, directory, unmatched_filenames)
|
|
92
|
+
return
|
|
93
|
+
for each_filename in matched_filenames:
|
|
94
|
+
records.consume_file_intent(session_id, directory, each_filename)
|
|
705
95
|
|
|
706
96
|
|
|
707
97
|
def main() -> None:
|
|
@@ -709,43 +99,29 @@ def main() -> None:
|
|
|
709
99
|
input_data = read_hook_input_dictionary_from_stdin()
|
|
710
100
|
if input_data is None:
|
|
711
101
|
sys.exit(0)
|
|
712
|
-
|
|
713
102
|
tool_name = input_data.get("tool_name", "")
|
|
714
103
|
if not isinstance(tool_name, str):
|
|
715
104
|
sys.exit(0)
|
|
716
|
-
|
|
717
105
|
tool_input = input_data.get("tool_input", {})
|
|
718
106
|
if not isinstance(tool_input, dict):
|
|
719
107
|
sys.exit(0)
|
|
720
|
-
|
|
721
108
|
if tool_name not in ("Write", "Edit", "MultiEdit"):
|
|
722
109
|
sys.exit(0)
|
|
723
|
-
|
|
724
110
|
file_path = tool_input.get("file_path", "")
|
|
725
111
|
if not isinstance(file_path, str) or not is_claude_md_file(file_path):
|
|
726
112
|
sys.exit(0)
|
|
727
|
-
|
|
728
113
|
claude_md_directory = Path(file_path).resolve().parent
|
|
729
114
|
if not claude_md_directory.is_dir():
|
|
730
115
|
sys.exit(0)
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
if not scan_plan.candidate_contents:
|
|
116
|
+
scan_plan_result = build_orphan_scan_plan(tool_name, tool_input, file_path, claude_md_directory)
|
|
117
|
+
if not scan_plan_result.candidate_contents:
|
|
734
118
|
sys.exit(0)
|
|
735
|
-
|
|
736
|
-
missing_filenames = _collect_missing_filenames(scan_plan, claude_md_directory)
|
|
119
|
+
missing_filenames = collect_missing_filenames(scan_plan_result, claude_md_directory)
|
|
737
120
|
if not missing_filenames:
|
|
738
121
|
sys.exit(0)
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
log_hook_block(
|
|
742
|
-
calling_hook_name="claude_md_orphan_file_blocker.py",
|
|
743
|
-
hook_event="PreToolUse",
|
|
744
|
-
block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
|
|
745
|
-
tool_name=tool_name,
|
|
746
|
-
offending_input_preview=file_path,
|
|
122
|
+
_emit_orphan_decision(
|
|
123
|
+
input_data, tool_name, file_path, str(claude_md_directory), missing_filenames
|
|
747
124
|
)
|
|
748
|
-
_emit_hook_result(block_payload, sys.stdout)
|
|
749
125
|
sys.exit(0)
|
|
750
126
|
|
|
751
127
|
|