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
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# claude_md_orphan_file_blocker_parts
|
|
2
|
+
|
|
3
|
+
The concern modules `claude_md_orphan_file_blocker.py` wires together to block a
|
|
4
|
+
per-directory `CLAUDE.md` that names a file absent from its subtree. The entry
|
|
5
|
+
hook imports them and re-exports their surface for the test suite.
|
|
6
|
+
|
|
7
|
+
## Modules
|
|
8
|
+
|
|
9
|
+
| File | Purpose |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `references.py` | Extracts the bare filenames a table cell names and the scripts a fenced run command invokes, honoring the `../` relative-path exemptions |
|
|
12
|
+
| `subtree_scan.py` | Resolves the scan root and reports which referenced filenames are absent from it, with a bounded walk plus a direct probe fallback |
|
|
13
|
+
| `scan_plan.py` | Builds the Write/Edit/MultiEdit scan plan and collects the orphan filenames the change introduces, excluding pre-existing orphans |
|
|
14
|
+
| `decision.py` | Builds the deny payload listing the missing files and closing with the retry hint, and emits the decision JSON |
|
|
15
|
+
| `__init__.py` | Package marker |
|
|
16
|
+
|
|
17
|
+
## Subdirectories
|
|
18
|
+
|
|
19
|
+
| Entry | Description |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `config/` | The region-join newline, the retry hint the deny reason closes with, and the missing-name join separator (`orphan_blocker_constants.py`) |
|
|
22
|
+
| `tests/` | pytest suite with one test module per module above |
|
|
23
|
+
|
|
24
|
+
## Running tests
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
python -m pytest packages/claude-dev-env/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/
|
|
28
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Concern modules the ``claude_md_orphan_file_blocker`` entry hook wires together."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration home for the CLAUDE.md orphan-file blocker parts constants."""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Constants for the CLAUDE.md orphan-file blocker parts.
|
|
2
|
+
|
|
3
|
+
Holds the newline that joins a region's lines before scanning it for a
|
|
4
|
+
relative-path source, the separator that joins the missing filenames in the deny
|
|
5
|
+
reason, and the retry hint the deny reason closes with once the denied inventory
|
|
6
|
+
row has been recorded as a pending intent.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
NEWLINE_JOIN_SEPARATOR: str = "\n"
|
|
12
|
+
|
|
13
|
+
MISSING_NAME_JOIN_SEPARATOR: str = ", "
|
|
14
|
+
|
|
15
|
+
ROW_FIRST_RETRY_HINT: str = (
|
|
16
|
+
" This inventory row has been recorded: create the file it names now, and the "
|
|
17
|
+
"row will be allowed when you retry it."
|
|
18
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Build, record, and emit the CLAUDE.md orphan-file blocker's deny decision.
|
|
2
|
+
|
|
3
|
+
The deny reason lists each missing file and closes with the retry hint telling
|
|
4
|
+
the author to create the file now, since this row has been recorded.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from typing import TextIO
|
|
10
|
+
|
|
11
|
+
from claude_md_orphan_file_blocker_parts.config.orphan_blocker_constants import (
|
|
12
|
+
MISSING_NAME_JOIN_SEPARATOR,
|
|
13
|
+
ROW_FIRST_RETRY_HINT,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from hooks_constants.claude_md_orphan_file_blocker_constants import (
|
|
17
|
+
ORPHAN_FILE_ADDITIONAL_CONTEXT,
|
|
18
|
+
ORPHAN_FILE_MESSAGE_TEMPLATE,
|
|
19
|
+
ORPHAN_FILE_SYSTEM_MESSAGE,
|
|
20
|
+
)
|
|
21
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def deny_orphan_files(
|
|
25
|
+
tool_name: str, file_path: str, directory: str, all_missing_filenames: list[str]
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Build, record, and emit the deny decision listing the missing files.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
tool_name: The intercepted tool name, for the block log record.
|
|
31
|
+
file_path: The destination path of the CLAUDE.md.
|
|
32
|
+
directory: The resolved CLAUDE.md directory.
|
|
33
|
+
all_missing_filenames: The referenced filenames absent from the subtree.
|
|
34
|
+
"""
|
|
35
|
+
block_payload = build_block_payload(all_missing_filenames, directory)
|
|
36
|
+
log_hook_block(
|
|
37
|
+
calling_hook_name="claude_md_orphan_file_blocker.py",
|
|
38
|
+
hook_event="PreToolUse",
|
|
39
|
+
block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
|
|
40
|
+
tool_name=tool_name,
|
|
41
|
+
offending_input_preview=file_path,
|
|
42
|
+
)
|
|
43
|
+
emit_hook_result(block_payload, sys.stdout)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_block_payload(all_missing_filenames: list[str], directory: str) -> dict:
|
|
47
|
+
"""Build the PreToolUse deny payload listing each missing filename.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
all_missing_filenames: The referenced filenames absent from the subtree.
|
|
51
|
+
directory: The directory that holds the target CLAUDE.md.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The hook-result dictionary the harness reads to deny the write, whose
|
|
55
|
+
reason closes with the retry hint.
|
|
56
|
+
"""
|
|
57
|
+
formatted_missing = MISSING_NAME_JOIN_SEPARATOR.join(
|
|
58
|
+
f"`{each_name}`" for each_name in all_missing_filenames
|
|
59
|
+
)
|
|
60
|
+
reason = ORPHAN_FILE_MESSAGE_TEMPLATE.format(directory=directory, missing=formatted_missing)
|
|
61
|
+
return {
|
|
62
|
+
"hookSpecificOutput": {
|
|
63
|
+
"hookEventName": "PreToolUse",
|
|
64
|
+
"permissionDecision": "deny",
|
|
65
|
+
"permissionDecisionReason": reason + ROW_FIRST_RETRY_HINT,
|
|
66
|
+
"additionalContext": ORPHAN_FILE_ADDITIONAL_CONTEXT,
|
|
67
|
+
},
|
|
68
|
+
"systemMessage": ORPHAN_FILE_SYSTEM_MESSAGE,
|
|
69
|
+
"suppressOutput": True,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
|
|
74
|
+
"""Write the hook result JSON to the given output stream.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
all_hook_data: The hook-result dictionary to serialize.
|
|
78
|
+
output_stream: The stream the harness reads the decision from.
|
|
79
|
+
"""
|
|
80
|
+
output_stream.write(json.dumps(all_hook_data) + "\n")
|
|
81
|
+
output_stream.flush()
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Extract the file references a per-directory CLAUDE.md makes.
|
|
2
|
+
|
|
3
|
+
Two kinds of reference are collected: a bare filename a first-column table cell
|
|
4
|
+
names in backticks, and a script an interpreter invocation inside a fenced run
|
|
5
|
+
command names (``python script.py``). A table block or run-command fence that
|
|
6
|
+
declares an explicit ``../`` relative-path source is left alone.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from claude_md_orphan_file_blocker_parts.config.orphan_blocker_constants import (
|
|
12
|
+
NEWLINE_JOIN_SEPARATOR,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from hooks_constants.claude_md_orphan_file_blocker_constants import (
|
|
16
|
+
ALL_REFERENCED_FILE_EXTENSIONS,
|
|
17
|
+
ALL_RUN_COMMAND_SCRIPT_EXTENSIONS,
|
|
18
|
+
CLAUDE_MD_FILENAME,
|
|
19
|
+
CODE_FENCE_PATTERN,
|
|
20
|
+
FIRST_COLUMN_BACKTICK_PATTERN,
|
|
21
|
+
REGION_BOUNDARY_PATTERN,
|
|
22
|
+
RELATIVE_PATH_SOURCE_PATTERN,
|
|
23
|
+
RUN_COMMAND_SCRIPT_PATTERN,
|
|
24
|
+
SEPARATOR_CELL_PATTERN,
|
|
25
|
+
TABLE_ROW_PATTERN,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_claude_md_file(file_path: str) -> bool:
|
|
30
|
+
"""Return whether *file_path* names a per-directory ``CLAUDE.md`` file.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
file_path: The destination path of the write or edit.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True when the path's basename is exactly ``CLAUDE.md``.
|
|
37
|
+
"""
|
|
38
|
+
return os.path.basename(file_path) == CLAUDE_MD_FILENAME
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _first_table_cell(table_line: str) -> str:
|
|
42
|
+
"""Return the trimmed text of the first column cell in a markdown table row.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
table_line: A single line that begins with a pipe character.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The text between the leading pipe and the next pipe, stripped.
|
|
49
|
+
"""
|
|
50
|
+
after_leading_pipe = table_line.strip().lstrip("|")
|
|
51
|
+
first_cell, _, _ = after_leading_pipe.partition("|")
|
|
52
|
+
return first_cell.strip()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _referenced_filename_in_cell(cell_text: str) -> str | None:
|
|
56
|
+
"""Return the bare filename a table cell references, when it has one.
|
|
57
|
+
|
|
58
|
+
A cell references a bare filename only when its first backticked token has no
|
|
59
|
+
path separator, is not a slash-command, and carries a known file extension.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
cell_text: The trimmed text of a first-column table cell.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The bare filename to verify in the subtree, or None when the cell names
|
|
66
|
+
no bare file.
|
|
67
|
+
"""
|
|
68
|
+
backtick_match = FIRST_COLUMN_BACKTICK_PATTERN.search(cell_text)
|
|
69
|
+
if backtick_match is None:
|
|
70
|
+
return None
|
|
71
|
+
inner_text = backtick_match.group(1).strip()
|
|
72
|
+
if not inner_text or inner_text.startswith("/"):
|
|
73
|
+
return None
|
|
74
|
+
if "/" in inner_text or "\\" in inner_text:
|
|
75
|
+
return None
|
|
76
|
+
_, extension = os.path.splitext(inner_text)
|
|
77
|
+
if extension.lower() not in ALL_REFERENCED_FILE_EXTENSIONS:
|
|
78
|
+
return None
|
|
79
|
+
return inner_text
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _filename_in_table_row(table_line: str) -> str | None:
|
|
83
|
+
"""Return the bare filename a markdown table row references, when it has one.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
table_line: A single line that begins with a pipe character.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The bare filename in the row's first column, or None when the row is a
|
|
90
|
+
header-separator row or names no bare file.
|
|
91
|
+
"""
|
|
92
|
+
first_cell = _first_table_cell(table_line)
|
|
93
|
+
if not first_cell or SEPARATOR_CELL_PATTERN.match(first_cell):
|
|
94
|
+
return None
|
|
95
|
+
return _referenced_filename_in_cell(first_cell)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _declares_relative_path_source(text: str) -> bool:
|
|
99
|
+
"""Return whether *text* declares an explicit relative-path file source.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
text: A table block together with the prose that introduces it.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
True when *text* contains a ``../`` relative-path token.
|
|
106
|
+
"""
|
|
107
|
+
return RELATIVE_PATH_SOURCE_PATTERN.search(text) is not None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _block_filenames(all_region_lines: list[str], all_block_lines: list[str]) -> list[str]:
|
|
111
|
+
"""Return the bare filenames a table block contributes, honoring its exemption.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
all_region_lines: The prose lines accumulated before this block.
|
|
115
|
+
all_block_lines: The consecutive table rows that form the block.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Each bare filename the block's first-column cells name, or an empty list
|
|
119
|
+
when the block (with its introducing region) declares a ``../`` source.
|
|
120
|
+
"""
|
|
121
|
+
if not all_block_lines:
|
|
122
|
+
return []
|
|
123
|
+
block_region = NEWLINE_JOIN_SEPARATOR.join(all_region_lines + all_block_lines)
|
|
124
|
+
if _declares_relative_path_source(block_region):
|
|
125
|
+
return []
|
|
126
|
+
block_filenames: list[str] = []
|
|
127
|
+
for each_line in all_block_lines:
|
|
128
|
+
each_filename = _filename_in_table_row(each_line)
|
|
129
|
+
if each_filename is not None:
|
|
130
|
+
block_filenames.append(each_filename)
|
|
131
|
+
return block_filenames
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def find_referenced_filenames(content: str) -> list[str]:
|
|
135
|
+
"""Return each bare filename a CLAUDE.md table references, in order.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
content: The CLAUDE.md content being written.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Each referenced filename from a non-exempt table block, in order.
|
|
142
|
+
"""
|
|
143
|
+
referenced_filenames: list[str] = []
|
|
144
|
+
pending_region: list[str] = []
|
|
145
|
+
current_block: list[str] = []
|
|
146
|
+
is_inside_code_fence = False
|
|
147
|
+
for each_line in content.splitlines():
|
|
148
|
+
if CODE_FENCE_PATTERN.match(each_line) is not None:
|
|
149
|
+
is_inside_code_fence = not is_inside_code_fence
|
|
150
|
+
continue
|
|
151
|
+
if is_inside_code_fence:
|
|
152
|
+
continue
|
|
153
|
+
if TABLE_ROW_PATTERN.match(each_line) is not None:
|
|
154
|
+
current_block.append(each_line)
|
|
155
|
+
continue
|
|
156
|
+
if current_block:
|
|
157
|
+
referenced_filenames.extend(_block_filenames(pending_region, current_block))
|
|
158
|
+
current_block, pending_region = [], []
|
|
159
|
+
if REGION_BOUNDARY_PATTERN.match(each_line) is not None:
|
|
160
|
+
pending_region = []
|
|
161
|
+
pending_region.append(each_line)
|
|
162
|
+
referenced_filenames.extend(_block_filenames(pending_region, current_block))
|
|
163
|
+
return referenced_filenames
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _script_basename_from_token(script_token: str) -> str | None:
|
|
167
|
+
"""Return the bare basename one captured script token names, when it has one.
|
|
168
|
+
|
|
169
|
+
A path-qualified script keeps only its final segment. A script named by an
|
|
170
|
+
explicit ``../`` relative-path source is exempt and yields None, as does a
|
|
171
|
+
token whose basename carries no recognized script extension.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
script_token: One script path captured from an interpreter invocation.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The bare script basename, or None when the token is relative-path-sourced
|
|
178
|
+
or carries no recognized extension.
|
|
179
|
+
"""
|
|
180
|
+
if _declares_relative_path_source(script_token):
|
|
181
|
+
return None
|
|
182
|
+
basename = os.path.basename(script_token.replace("\\", "/").rstrip("/"))
|
|
183
|
+
if not basename:
|
|
184
|
+
return None
|
|
185
|
+
_, extension = os.path.splitext(basename)
|
|
186
|
+
if extension.lower() not in ALL_RUN_COMMAND_SCRIPT_EXTENSIONS:
|
|
187
|
+
return None
|
|
188
|
+
return basename
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _next_quote_state(open_quote_character: str, each_character: str) -> str:
|
|
192
|
+
"""Return the open-quote state after reading one character.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
open_quote_character: The currently open quote character, or empty.
|
|
196
|
+
each_character: The character just read.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
The empty string when a matching close quote is read, the character when
|
|
200
|
+
it opens a quote, or the unchanged state otherwise.
|
|
201
|
+
"""
|
|
202
|
+
if open_quote_character:
|
|
203
|
+
return "" if each_character == open_quote_character else open_quote_character
|
|
204
|
+
if each_character in ("'", '"'):
|
|
205
|
+
return each_character
|
|
206
|
+
return open_quote_character
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _command_text_before_comment(fenced_line: str) -> str:
|
|
210
|
+
"""Return the runnable portion of a fenced line, with any shell comment removed.
|
|
211
|
+
|
|
212
|
+
A shell comment starts at a ``#`` that begins a word outside any quoted span
|
|
213
|
+
and runs to end of line; the text from that ``#`` is dropped.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
fenced_line: A single line drawn from inside a fenced code block.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
The line truncated at its first unquoted word-leading ``#``, or the whole
|
|
220
|
+
line when it carries no such comment marker.
|
|
221
|
+
"""
|
|
222
|
+
open_quote_character = ""
|
|
223
|
+
previous_character = ""
|
|
224
|
+
for each_index, each_character in enumerate(fenced_line):
|
|
225
|
+
starts_word = each_index == 0 or previous_character.isspace()
|
|
226
|
+
if not open_quote_character and each_character == "#" and starts_word:
|
|
227
|
+
return fenced_line[:each_index]
|
|
228
|
+
open_quote_character = _next_quote_state(open_quote_character, each_character)
|
|
229
|
+
previous_character = each_character
|
|
230
|
+
return fenced_line
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _run_command_filenames_in_line(fenced_line: str) -> list[str]:
|
|
234
|
+
"""Return each script basename the interpreter invocations on this line name.
|
|
235
|
+
|
|
236
|
+
A shell comment's text contributes nothing; a chained line contributes each
|
|
237
|
+
invocation's basename in order.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
fenced_line: A single line drawn from inside a fenced code block.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Each bare script basename the line's invocations name, in order.
|
|
244
|
+
"""
|
|
245
|
+
runnable_text = _command_text_before_comment(fenced_line)
|
|
246
|
+
line_filenames: list[str] = []
|
|
247
|
+
for each_match in RUN_COMMAND_SCRIPT_PATTERN.finditer(runnable_text):
|
|
248
|
+
each_basename = _script_basename_from_token(each_match.group(1).strip())
|
|
249
|
+
if each_basename is not None:
|
|
250
|
+
line_filenames.append(each_basename)
|
|
251
|
+
return line_filenames
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class _RunCommandScan:
|
|
255
|
+
"""Walks CLAUDE.md lines and collects the script basenames fenced run commands name.
|
|
256
|
+
|
|
257
|
+
Attributes:
|
|
258
|
+
filenames: The script basenames collected so far, in order.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
def __init__(self) -> None:
|
|
262
|
+
self.filenames: list[str] = []
|
|
263
|
+
self._pending_region: list[str] = []
|
|
264
|
+
self._is_inside_fence = False
|
|
265
|
+
self._is_region_relative_path_sourced = False
|
|
266
|
+
|
|
267
|
+
def _toggle_fence(self) -> None:
|
|
268
|
+
"""Open or close the current fence, capturing its region's ``../`` source flag."""
|
|
269
|
+
if not self._is_inside_fence:
|
|
270
|
+
self._is_region_relative_path_sourced = _declares_relative_path_source(
|
|
271
|
+
NEWLINE_JOIN_SEPARATOR.join(self._pending_region)
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
self._pending_region = []
|
|
275
|
+
self._is_inside_fence = not self._is_inside_fence
|
|
276
|
+
|
|
277
|
+
def consume(self, each_line: str) -> None:
|
|
278
|
+
"""Fold one CLAUDE.md line into the scan state.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
each_line: The next line of the CLAUDE.md content.
|
|
282
|
+
"""
|
|
283
|
+
if CODE_FENCE_PATTERN.match(each_line) is not None:
|
|
284
|
+
self._toggle_fence()
|
|
285
|
+
return
|
|
286
|
+
if self._is_inside_fence:
|
|
287
|
+
if not self._is_region_relative_path_sourced:
|
|
288
|
+
self.filenames.extend(_run_command_filenames_in_line(each_line))
|
|
289
|
+
return
|
|
290
|
+
if REGION_BOUNDARY_PATTERN.match(each_line) is not None:
|
|
291
|
+
self._pending_region = []
|
|
292
|
+
self._pending_region.append(each_line)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def find_run_command_filenames(content: str) -> list[str]:
|
|
296
|
+
"""Return each script basename a fenced run command invokes, in order.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
content: The CLAUDE.md content being written.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Each script basename a fenced run command names, in the order it appears.
|
|
303
|
+
"""
|
|
304
|
+
scan = _RunCommandScan()
|
|
305
|
+
for each_line in content.splitlines():
|
|
306
|
+
scan.consume(each_line)
|
|
307
|
+
return scan.filenames
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Build the orphan scan plan for a Write, Edit, or MultiEdit of a CLAUDE.md.
|
|
2
|
+
|
|
3
|
+
For a Write the plan scans the full new content with no baseline. For an Edit or
|
|
4
|
+
MultiEdit it scans the reconstructed post-edit file and records the orphans the
|
|
5
|
+
file already held, so a pre-existing orphan on an untouched line is excluded and
|
|
6
|
+
only an orphan the edit introduces is reported.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from claude_md_orphan_file_blocker_parts.subtree_scan import find_missing_filenames
|
|
12
|
+
|
|
13
|
+
from hooks_constants.claude_md_orphan_file_blocker_constants import (
|
|
14
|
+
MAX_ORPHAN_FILE_ISSUES,
|
|
15
|
+
)
|
|
16
|
+
from hooks_constants.multi_edit_reconstruction import (
|
|
17
|
+
apply_edits,
|
|
18
|
+
edits_for_tool,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read_existing_file_content(file_path: str) -> str | None:
|
|
23
|
+
"""Return the current on-disk content of *file_path*, or None when unreadable.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
file_path: The path of the file the edit targets.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The file's text, or None when the file is missing or cannot be decoded.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
return Path(file_path).read_text(encoding="utf-8")
|
|
33
|
+
except (OSError, UnicodeDecodeError):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _edit_fragments(all_edits: list[dict]) -> list[str]:
|
|
38
|
+
"""Return each MultiEdit ``new_string`` fragment present as a non-empty string.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
all_edits: The MultiEdit ``edits`` list.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Every ``new_string`` value that is a non-empty string, in list order.
|
|
45
|
+
"""
|
|
46
|
+
all_fragments: list[str] = []
|
|
47
|
+
for each_edit in all_edits:
|
|
48
|
+
if not isinstance(each_edit, dict):
|
|
49
|
+
continue
|
|
50
|
+
new_string = each_edit.get("new_string", "")
|
|
51
|
+
if isinstance(new_string, str) and new_string:
|
|
52
|
+
all_fragments.append(new_string)
|
|
53
|
+
return all_fragments
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _OrphanScanPlan:
|
|
57
|
+
"""The contents to scan for orphans and the pre-existing orphans to exclude.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
candidate_contents: Each content string whose table rows are scanned.
|
|
61
|
+
baseline_missing_filenames: The orphan filenames the file already held
|
|
62
|
+
before this edit; reporting excludes them.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self, all_candidate_contents: list[str], all_baseline_missing_filenames: set[str]
|
|
67
|
+
) -> None:
|
|
68
|
+
self.candidate_contents = all_candidate_contents
|
|
69
|
+
self.baseline_missing_filenames = all_baseline_missing_filenames
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_orphan_scan_plan(
|
|
73
|
+
tool_name: str, tool_input: dict, file_path: str, claude_md_directory: Path
|
|
74
|
+
) -> _OrphanScanPlan:
|
|
75
|
+
"""Return the contents to scan and the pre-existing orphans to exclude.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
tool_name: The intercepted tool — ``Write``, ``Edit``, or ``MultiEdit``.
|
|
79
|
+
tool_input: The tool's input payload.
|
|
80
|
+
file_path: The destination path of the write or edit.
|
|
81
|
+
claude_md_directory: The directory that holds the target CLAUDE.md.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The scan plan pairing candidate contents with the baseline orphan set.
|
|
85
|
+
"""
|
|
86
|
+
if tool_name == "Write":
|
|
87
|
+
content = tool_input.get("content", "")
|
|
88
|
+
candidate_contents = [content] if isinstance(content, str) and content else []
|
|
89
|
+
return _OrphanScanPlan(candidate_contents, set())
|
|
90
|
+
all_edits = edits_for_tool(tool_name, tool_input)
|
|
91
|
+
existing_content = _read_existing_file_content(file_path)
|
|
92
|
+
if existing_content is None:
|
|
93
|
+
return _OrphanScanPlan(_edit_fragments(all_edits), set())
|
|
94
|
+
baseline_missing = set(find_missing_filenames(existing_content, claude_md_directory))
|
|
95
|
+
return _OrphanScanPlan([apply_edits(existing_content, all_edits)], baseline_missing)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def collect_missing_filenames(scan_plan: _OrphanScanPlan, claude_md_directory: Path) -> list[str]:
|
|
99
|
+
"""Return every orphan filename the scan plan introduces, excluding baselines.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
scan_plan: The candidate contents to scan paired with the baseline orphan
|
|
103
|
+
set to exclude.
|
|
104
|
+
claude_md_directory: The directory that holds the target CLAUDE.md.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Each introduced orphan filename in first-seen order with duplicates
|
|
108
|
+
removed, capped at the issue budget.
|
|
109
|
+
"""
|
|
110
|
+
all_candidate_missing: list[str] = []
|
|
111
|
+
for each_content in scan_plan.candidate_contents:
|
|
112
|
+
all_candidate_missing.extend(find_missing_filenames(each_content, claude_md_directory))
|
|
113
|
+
missing_filenames: list[str] = []
|
|
114
|
+
already_reported: set[str] = set()
|
|
115
|
+
for each_filename in all_candidate_missing:
|
|
116
|
+
if each_filename in scan_plan.baseline_missing_filenames:
|
|
117
|
+
continue
|
|
118
|
+
if each_filename in already_reported:
|
|
119
|
+
continue
|
|
120
|
+
already_reported.add(each_filename)
|
|
121
|
+
missing_filenames.append(each_filename)
|
|
122
|
+
if len(missing_filenames) >= MAX_ORPHAN_FILE_ISSUES:
|
|
123
|
+
break
|
|
124
|
+
return missing_filenames
|