claude-dev-env 1.75.0 → 1.77.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/pr-loop/scripts/code_rules_gate.py +60 -5
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/inline_duplicate_body_span_constants.py +22 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +147 -3
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +2 -2
- package/hooks/blocking/claude_md_orphan_file_blocker.py +170 -20
- package/hooks/blocking/code_rules_docstrings.py +378 -0
- package/hooks/blocking/code_rules_duplicate_body.py +378 -26
- package/hooks/blocking/code_rules_enforcer.py +48 -5
- package/hooks/blocking/code_rules_imports_logging.py +679 -1
- package/hooks/blocking/code_rules_shared.py +8 -5
- package/hooks/blocking/code_rules_test_assertions.py +6 -7
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +484 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +174 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
- package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +157 -0
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +466 -0
- package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +11 -9
- package/hooks/blocking/test_code_rules_js_resume_task_enumeration.py +758 -0
- package/hooks/blocking/test_code_rules_logging_printf_tokens.py +134 -0
- package/hooks/blocking/test_verification_verdict_store.py +66 -1
- package/hooks/blocking/test_verifier_verdict_minter.py +64 -5
- package/hooks/blocking/verification_verdict_store.py +19 -5
- package/hooks/blocking/verifier_verdict_minter.py +18 -15
- package/hooks/hooks_constants/blocking_check_limits.py +56 -0
- package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +52 -24
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +76 -1
- package/hooks/hooks_constants/duplicate_function_body_constants.py +21 -5
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/claude-md-orphan-file.md +7 -8
- package/rules/docstring-prose-matches-implementation.md +5 -1
- package/rules/package-inventory-stale-entry.md +16 -0
- package/rules/plain-illustrative-docstrings.md +56 -0
- package/skills/anthropic-plan/CLAUDE.md +1 -1
- package/skills/anthropic-plan/SKILL.md +15 -2
- package/skills/autoconverge/workflow/converge.contract.test.mjs +12 -19
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +71 -0
- package/skills/autoconverge/workflow/converge.mjs +86 -110
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +58 -4
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +9 -0
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +42 -0
|
@@ -4,7 +4,7 @@ import ast
|
|
|
4
4
|
import difflib
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
|
-
from collections.abc import Iterator
|
|
7
|
+
from collections.abc import Collection, Iterator
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
@@ -360,7 +360,7 @@ def changed_line_numbers(prior_content: str, post_edit_content: str) -> set[int]
|
|
|
360
360
|
|
|
361
361
|
|
|
362
362
|
def _scope_violations_to_changed_lines(
|
|
363
|
-
all_violations_in_walk_order: list[tuple[
|
|
363
|
+
all_violations_in_walk_order: list[tuple[Collection[int], str]],
|
|
364
364
|
all_changed_lines: set[int] | None,
|
|
365
365
|
defer_scope_to_caller: bool = False,
|
|
366
366
|
) -> list[str]:
|
|
@@ -381,9 +381,12 @@ def _scope_violations_to_changed_lines(
|
|
|
381
381
|
block a single-file edit.
|
|
382
382
|
|
|
383
383
|
Args:
|
|
384
|
-
all_violations_in_walk_order: ``(
|
|
385
|
-
``ast.walk`` traversal order, where ``
|
|
386
|
-
violation's source lines.
|
|
384
|
+
all_violations_in_walk_order: ``(line_collection, issue_message)`` pairs
|
|
385
|
+
in ``ast.walk`` traversal order, where ``line_collection`` holds the
|
|
386
|
+
violation's source lines. A whole-function violation passes a
|
|
387
|
+
contiguous ``range``; a two-function violation passes the union of
|
|
388
|
+
both functions' lines, so the lines between the two functions stay
|
|
389
|
+
out of scope and an edit to only that gap does not block.
|
|
387
390
|
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
388
391
|
None to treat every violation as in-scope.
|
|
389
392
|
defer_scope_to_caller: When True, return every violation message in walk
|
|
@@ -286,7 +286,7 @@ def _name_encodes_scenario(test_name: str) -> bool:
|
|
|
286
286
|
return any(each_clause in test_name for each_clause in _SCENARIO_NAME_CLAUSES)
|
|
287
287
|
|
|
288
288
|
|
|
289
|
-
def check_flag_gated_scenario_test_naming(content: str, file_path: str) ->
|
|
289
|
+
def check_flag_gated_scenario_test_naming(content: str, file_path: str) -> None:
|
|
290
290
|
"""Flag a scenario-named test that omits a flag its siblings establish.
|
|
291
291
|
|
|
292
292
|
When two or more sibling tests in a file monkeypatch the same module-level
|
|
@@ -302,15 +302,16 @@ def check_flag_gated_scenario_test_naming(content: str, file_path: str) -> list[
|
|
|
302
302
|
file_path: Path to the file, used for the test-file gate.
|
|
303
303
|
|
|
304
304
|
Returns:
|
|
305
|
-
|
|
305
|
+
None; advisories print to stderr and this check never contributes to
|
|
306
|
+
the blocking issue list, so the write proceeds.
|
|
306
307
|
"""
|
|
307
308
|
if not is_test_file(file_path):
|
|
308
|
-
return
|
|
309
|
+
return
|
|
309
310
|
|
|
310
311
|
try:
|
|
311
312
|
syntax_tree = ast.parse(content)
|
|
312
313
|
except SyntaxError:
|
|
313
|
-
return
|
|
314
|
+
return
|
|
314
315
|
|
|
315
316
|
test_functions = [
|
|
316
317
|
each_node
|
|
@@ -333,7 +334,7 @@ def check_flag_gated_scenario_test_naming(content: str, file_path: str) -> list[
|
|
|
333
334
|
if patch_count >= _MINIMUM_SIBLING_PATCH_COUNT
|
|
334
335
|
}
|
|
335
336
|
if not established_flags:
|
|
336
|
-
return
|
|
337
|
+
return
|
|
337
338
|
|
|
338
339
|
for each_test in test_functions:
|
|
339
340
|
if not _name_encodes_scenario(each_test.name):
|
|
@@ -349,8 +350,6 @@ def check_flag_gated_scenario_test_naming(content: str, file_path: str) -> list[
|
|
|
349
350
|
file=sys.stderr,
|
|
350
351
|
)
|
|
351
352
|
|
|
352
|
-
return []
|
|
353
|
-
|
|
354
353
|
|
|
355
354
|
def _called_function_names(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
|
|
356
355
|
"""Return the bare names of every function the test body calls."""
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import subprocess
|
|
6
6
|
import sys
|
|
7
|
+
import time
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
import pytest
|
|
@@ -14,6 +15,7 @@ import claude_md_orphan_file_blocker as blocker_module
|
|
|
14
15
|
from claude_md_orphan_file_blocker import (
|
|
15
16
|
find_missing_filenames,
|
|
16
17
|
find_referenced_filenames,
|
|
18
|
+
find_run_command_filenames,
|
|
17
19
|
)
|
|
18
20
|
from code_rules_annotations_length import check_unused_known_pytest_fixture_parameters
|
|
19
21
|
from code_rules_naming_collection import check_collection_prefix
|
|
@@ -27,6 +29,8 @@ HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "claude_md_orphan_fil
|
|
|
27
29
|
|
|
28
30
|
REPO_ROOT = Path(__file__).resolve().parents[4]
|
|
29
31
|
|
|
32
|
+
ONE_SECOND = 1.0
|
|
33
|
+
|
|
30
34
|
TABLE_WITH_PRESENT_FILE = (
|
|
31
35
|
"# example\n\n| File | What it does |\n|---|---|\n| `present_module.py` | Does a thing |\n"
|
|
32
36
|
)
|
|
@@ -612,6 +616,486 @@ def test_noise_directories_are_excluded_from_the_walk(tmp_path: Path):
|
|
|
612
616
|
assert missing_filenames == ["buried_target.py"]
|
|
613
617
|
|
|
614
618
|
|
|
619
|
+
def test_blocks_write_when_run_command_invokes_absent_script(tmp_path: Path):
|
|
620
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
621
|
+
content = (
|
|
622
|
+
"# example\n\n"
|
|
623
|
+
"## Running / testing\n\n"
|
|
624
|
+
"Check environment readiness before a run:\n\n"
|
|
625
|
+
"```\n"
|
|
626
|
+
"C:\\Python313\\python.exe test_verification_ready.py\n"
|
|
627
|
+
"```\n"
|
|
628
|
+
)
|
|
629
|
+
result = _run_hook(
|
|
630
|
+
"Write",
|
|
631
|
+
{
|
|
632
|
+
"file_path": str(claude_md_path),
|
|
633
|
+
"content": content,
|
|
634
|
+
},
|
|
635
|
+
)
|
|
636
|
+
assert result.returncode == 0
|
|
637
|
+
output = json.loads(result.stdout)
|
|
638
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
639
|
+
assert (
|
|
640
|
+
"test_verification_ready.py"
|
|
641
|
+
in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def test_allows_run_command_invoking_present_script(tmp_path: Path):
|
|
646
|
+
package_directory = tmp_path / "package_directory"
|
|
647
|
+
package_directory.mkdir()
|
|
648
|
+
(package_directory / "verify_ready.py").write_text("x = 1\n", encoding="utf-8")
|
|
649
|
+
claude_md_path = package_directory / "CLAUDE.md"
|
|
650
|
+
content = (
|
|
651
|
+
"# example\n\n"
|
|
652
|
+
"## Running / testing\n\n"
|
|
653
|
+
"```\n"
|
|
654
|
+
"C:\\Python313\\python.exe verify_ready.py\n"
|
|
655
|
+
"```\n"
|
|
656
|
+
)
|
|
657
|
+
result = _run_hook(
|
|
658
|
+
"Write",
|
|
659
|
+
{
|
|
660
|
+
"file_path": str(claude_md_path),
|
|
661
|
+
"content": content,
|
|
662
|
+
},
|
|
663
|
+
)
|
|
664
|
+
assert result.returncode == 0
|
|
665
|
+
assert result.stdout == ""
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def test_run_command_invoking_path_qualified_present_script_is_allowed(tmp_path: Path):
|
|
669
|
+
tools_directory = tmp_path / "tools"
|
|
670
|
+
tools_directory.mkdir()
|
|
671
|
+
(tools_directory / "verify_themes.py").write_text("x = 1\n", encoding="utf-8")
|
|
672
|
+
claude_md_path = tools_directory / "CLAUDE.md"
|
|
673
|
+
content = (
|
|
674
|
+
"# example\n\n"
|
|
675
|
+
"## Running / testing\n\n"
|
|
676
|
+
"```\n"
|
|
677
|
+
'C:\\Python313\\python.exe "tools/verify_themes.py" path/to/theme.apk\n'
|
|
678
|
+
"```\n"
|
|
679
|
+
)
|
|
680
|
+
result = _run_hook(
|
|
681
|
+
"Write",
|
|
682
|
+
{
|
|
683
|
+
"file_path": str(claude_md_path),
|
|
684
|
+
"content": content,
|
|
685
|
+
},
|
|
686
|
+
)
|
|
687
|
+
assert result.returncode == 0
|
|
688
|
+
assert result.stdout == ""
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def test_find_run_command_filenames_reads_only_fenced_interpreter_lines():
|
|
692
|
+
content = (
|
|
693
|
+
"# example\n\n"
|
|
694
|
+
"Run `python live_module.py` inline — this prose line is not a fence.\n\n"
|
|
695
|
+
"```\n"
|
|
696
|
+
"C:\\Python313\\python.exe absent_script.py --flag value\n"
|
|
697
|
+
"node tools/build_bundle.mjs\n"
|
|
698
|
+
"echo not-a-script\n"
|
|
699
|
+
"```\n"
|
|
700
|
+
)
|
|
701
|
+
assert find_run_command_filenames(content) == [
|
|
702
|
+
"absent_script.py",
|
|
703
|
+
"build_bundle.mjs",
|
|
704
|
+
]
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def test_run_command_outside_a_fence_is_not_inspected(tmp_path: Path):
|
|
708
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
709
|
+
content = (
|
|
710
|
+
"# example\n\n"
|
|
711
|
+
"Run `python absent_script.py` to check readiness.\n"
|
|
712
|
+
)
|
|
713
|
+
result = _run_hook(
|
|
714
|
+
"Write",
|
|
715
|
+
{
|
|
716
|
+
"file_path": str(claude_md_path),
|
|
717
|
+
"content": content,
|
|
718
|
+
},
|
|
719
|
+
)
|
|
720
|
+
assert result.returncode == 0
|
|
721
|
+
assert result.stdout == ""
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def test_run_command_orphan_reported_via_edit(tmp_path: Path):
|
|
725
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
726
|
+
(claude_md_path.parent / "kept.py").write_text("x = 1\n", encoding="utf-8")
|
|
727
|
+
claude_md_path.write_text(
|
|
728
|
+
"# example\n\n"
|
|
729
|
+
"## Running / testing\n\n"
|
|
730
|
+
"```\n"
|
|
731
|
+
"C:\\Python313\\python.exe kept.py\n"
|
|
732
|
+
"```\n",
|
|
733
|
+
encoding="utf-8",
|
|
734
|
+
)
|
|
735
|
+
result = _run_hook(
|
|
736
|
+
"Edit",
|
|
737
|
+
{
|
|
738
|
+
"file_path": str(claude_md_path),
|
|
739
|
+
"old_string": "C:\\Python313\\python.exe kept.py",
|
|
740
|
+
"new_string": "C:\\Python313\\python.exe removed_script.py",
|
|
741
|
+
},
|
|
742
|
+
)
|
|
743
|
+
assert result.returncode == 0
|
|
744
|
+
output = json.loads(result.stdout)
|
|
745
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
746
|
+
assert (
|
|
747
|
+
"removed_script.py"
|
|
748
|
+
in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def test_run_command_interpreter_substring_in_word_is_not_matched():
|
|
753
|
+
content = (
|
|
754
|
+
"# example\n\n"
|
|
755
|
+
"```\n"
|
|
756
|
+
"git stash # WIP on parse.py\n"
|
|
757
|
+
"rush build # then run verify.py\n"
|
|
758
|
+
"dash run.py\n"
|
|
759
|
+
"ssh deploy@host ./remote_install.sh\n"
|
|
760
|
+
"git stash list build.sh\n"
|
|
761
|
+
"rebash deploy.sh\n"
|
|
762
|
+
"fish shell run.sh\n"
|
|
763
|
+
"```\n"
|
|
764
|
+
)
|
|
765
|
+
assert find_run_command_filenames(content) == []
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def test_run_command_standalone_interpreter_still_matches():
|
|
769
|
+
content = (
|
|
770
|
+
"# example\n\n"
|
|
771
|
+
"```\n"
|
|
772
|
+
"python deploy.py\n"
|
|
773
|
+
"node build.mjs\n"
|
|
774
|
+
"C:\\Python313\\python.exe verify_ready.py\n"
|
|
775
|
+
"bash setup.sh\n"
|
|
776
|
+
"sh install.sh\n"
|
|
777
|
+
"node tools/build_bundle.mjs\n"
|
|
778
|
+
"```\n"
|
|
779
|
+
)
|
|
780
|
+
assert find_run_command_filenames(content) == [
|
|
781
|
+
"deploy.py",
|
|
782
|
+
"build.mjs",
|
|
783
|
+
"verify_ready.py",
|
|
784
|
+
"setup.sh",
|
|
785
|
+
"install.sh",
|
|
786
|
+
"build_bundle.mjs",
|
|
787
|
+
]
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def test_run_command_prose_mentioning_interpreter_and_script_yields_nothing():
|
|
791
|
+
content = (
|
|
792
|
+
"# example\n\n"
|
|
793
|
+
"```\n"
|
|
794
|
+
"python entrypoint crashed inside order_handler.py during startup\n"
|
|
795
|
+
"python: see config.py for details\n"
|
|
796
|
+
"node --version # build.mjs is the entry\n"
|
|
797
|
+
"python is great; edit build.py later\n"
|
|
798
|
+
"```\n"
|
|
799
|
+
)
|
|
800
|
+
assert find_run_command_filenames(content) == []
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def test_run_command_invokes_absent_script_is_not_blocked_when_prose(tmp_path: Path):
|
|
804
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
805
|
+
content = (
|
|
806
|
+
"# example\n\n"
|
|
807
|
+
"## Running / testing\n\n"
|
|
808
|
+
"```\n"
|
|
809
|
+
"python entrypoint crashed inside order_handler.py during startup\n"
|
|
810
|
+
"```\n"
|
|
811
|
+
)
|
|
812
|
+
result = _run_hook(
|
|
813
|
+
"Write",
|
|
814
|
+
{
|
|
815
|
+
"file_path": str(claude_md_path),
|
|
816
|
+
"content": content,
|
|
817
|
+
},
|
|
818
|
+
)
|
|
819
|
+
assert result.returncode == 0
|
|
820
|
+
assert result.stdout == ""
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def test_run_command_with_module_flag_and_value_still_matches():
|
|
824
|
+
content = (
|
|
825
|
+
"# example\n\n"
|
|
826
|
+
"```\n"
|
|
827
|
+
"python -m pytest tests/test_foo.py\n"
|
|
828
|
+
"python script.py --flag value\n"
|
|
829
|
+
"C:\\Python313\\python.exe \"tools/verify_themes.py\" path/to/theme.apk\n"
|
|
830
|
+
"node tools/build_bundle.mjs\n"
|
|
831
|
+
"pwsh build.ps1\n"
|
|
832
|
+
"```\n"
|
|
833
|
+
)
|
|
834
|
+
assert find_run_command_filenames(content) == [
|
|
835
|
+
"test_foo.py",
|
|
836
|
+
"script.py",
|
|
837
|
+
"verify_themes.py",
|
|
838
|
+
"build_bundle.mjs",
|
|
839
|
+
"build.ps1",
|
|
840
|
+
]
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def test_run_command_single_valueless_flag_before_path_captures_full_basename():
|
|
844
|
+
content = (
|
|
845
|
+
"# example\n\n"
|
|
846
|
+
"```\n"
|
|
847
|
+
"pwsh -File scripts/check.ps1\n"
|
|
848
|
+
"python3 -u app.py\n"
|
|
849
|
+
"python -u verify_ready.py\n"
|
|
850
|
+
"bash -c deploy.sh\n"
|
|
851
|
+
"node --experimental-vm-modules tools/build.mjs\n"
|
|
852
|
+
"```\n"
|
|
853
|
+
)
|
|
854
|
+
assert find_run_command_filenames(content) == [
|
|
855
|
+
"check.ps1",
|
|
856
|
+
"app.py",
|
|
857
|
+
"verify_ready.py",
|
|
858
|
+
"deploy.sh",
|
|
859
|
+
"build.mjs",
|
|
860
|
+
]
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def test_allows_run_command_with_valueless_flag_invoking_present_script(tmp_path: Path):
|
|
864
|
+
scripts_directory = tmp_path / "scripts"
|
|
865
|
+
scripts_directory.mkdir()
|
|
866
|
+
(scripts_directory / "check.ps1").write_text("Write-Host ok\n", encoding="utf-8")
|
|
867
|
+
claude_md_path = scripts_directory / "CLAUDE.md"
|
|
868
|
+
content = (
|
|
869
|
+
"# example\n\n"
|
|
870
|
+
"## Running / testing\n\n"
|
|
871
|
+
"```\n"
|
|
872
|
+
"pwsh -File scripts/check.ps1\n"
|
|
873
|
+
"```\n"
|
|
874
|
+
)
|
|
875
|
+
result = _run_hook(
|
|
876
|
+
"Write",
|
|
877
|
+
{
|
|
878
|
+
"file_path": str(claude_md_path),
|
|
879
|
+
"content": content,
|
|
880
|
+
},
|
|
881
|
+
)
|
|
882
|
+
assert result.returncode == 0
|
|
883
|
+
assert result.stdout == ""
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def test_run_command_script_valued_flag_does_not_shadow_real_script():
|
|
887
|
+
content = (
|
|
888
|
+
"# example\n\n"
|
|
889
|
+
"```\n"
|
|
890
|
+
"node -r ./reg.js app.js\n"
|
|
891
|
+
"node --require setup.js main.js\n"
|
|
892
|
+
"node --import ./loader.mjs entry.mjs\n"
|
|
893
|
+
"node --loader ts-node/esm runner.ts\n"
|
|
894
|
+
"```\n"
|
|
895
|
+
)
|
|
896
|
+
assert find_run_command_filenames(content) == [
|
|
897
|
+
"app.js",
|
|
898
|
+
"main.js",
|
|
899
|
+
"entry.mjs",
|
|
900
|
+
"runner.ts",
|
|
901
|
+
]
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def test_allows_run_command_with_script_valued_flag_invoking_present_script(tmp_path: Path):
|
|
905
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
906
|
+
(claude_md_path.parent / "app.js").write_text("console.log('ok');\n", encoding="utf-8")
|
|
907
|
+
content = (
|
|
908
|
+
"# example\n\n"
|
|
909
|
+
"## Running / testing\n\n"
|
|
910
|
+
"```\n"
|
|
911
|
+
"node -r ./reg.js app.js\n"
|
|
912
|
+
"```\n"
|
|
913
|
+
)
|
|
914
|
+
result = _run_hook(
|
|
915
|
+
"Write",
|
|
916
|
+
{
|
|
917
|
+
"file_path": str(claude_md_path),
|
|
918
|
+
"content": content,
|
|
919
|
+
},
|
|
920
|
+
)
|
|
921
|
+
assert result.returncode == 0
|
|
922
|
+
assert result.stdout == ""
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def test_run_command_chained_commands_each_contribute_their_basename():
|
|
926
|
+
content = (
|
|
927
|
+
"# example\n\n"
|
|
928
|
+
"```\n"
|
|
929
|
+
"python deploy.py && node build.mjs\n"
|
|
930
|
+
"python first.py; python second.py\n"
|
|
931
|
+
"python upstream.py | python downstream.py\n"
|
|
932
|
+
"```\n"
|
|
933
|
+
)
|
|
934
|
+
assert find_run_command_filenames(content) == [
|
|
935
|
+
"deploy.py",
|
|
936
|
+
"build.mjs",
|
|
937
|
+
"first.py",
|
|
938
|
+
"second.py",
|
|
939
|
+
"upstream.py",
|
|
940
|
+
"downstream.py",
|
|
941
|
+
]
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def test_run_command_commented_line_inside_fence_is_skipped():
|
|
945
|
+
content = (
|
|
946
|
+
"# example\n\n"
|
|
947
|
+
"```\n"
|
|
948
|
+
"# python deploy.py (commented out)\n"
|
|
949
|
+
" # node build.mjs\n"
|
|
950
|
+
"```\n"
|
|
951
|
+
)
|
|
952
|
+
assert find_run_command_filenames(content) == []
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def test_run_command_with_relative_path_source_is_exempt():
|
|
956
|
+
content = (
|
|
957
|
+
"# example\n\n"
|
|
958
|
+
"```\n"
|
|
959
|
+
"python ../shared/preflight.py --check\n"
|
|
960
|
+
"python ../../tools/deploy.py\n"
|
|
961
|
+
"```\n"
|
|
962
|
+
)
|
|
963
|
+
assert find_run_command_filenames(content) == []
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def test_run_command_relative_prose_region_exempts_bare_basename_command():
|
|
967
|
+
content = (
|
|
968
|
+
"# example\n\n"
|
|
969
|
+
"## Shared scripts (referenced from `../_shared/scripts/`)\n\n"
|
|
970
|
+
"```\n"
|
|
971
|
+
"python preflight.py\n"
|
|
972
|
+
"```\n"
|
|
973
|
+
)
|
|
974
|
+
assert find_run_command_filenames(content) == []
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def test_run_command_relative_prose_region_exempts_run_command_via_write(tmp_path: Path):
|
|
978
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
979
|
+
content = (
|
|
980
|
+
"# example\n\n"
|
|
981
|
+
"## Shared scripts\n\n"
|
|
982
|
+
"Referenced from `../_shared/scripts/`:\n\n"
|
|
983
|
+
"```\n"
|
|
984
|
+
"python preflight.py\n"
|
|
985
|
+
"```\n"
|
|
986
|
+
)
|
|
987
|
+
result = _run_hook(
|
|
988
|
+
"Write",
|
|
989
|
+
{
|
|
990
|
+
"file_path": str(claude_md_path),
|
|
991
|
+
"content": content,
|
|
992
|
+
},
|
|
993
|
+
)
|
|
994
|
+
assert result.returncode == 0
|
|
995
|
+
assert result.stdout == ""
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def test_run_command_relative_prose_does_not_exempt_distant_later_fence():
|
|
999
|
+
content = (
|
|
1000
|
+
"# example\n\n"
|
|
1001
|
+
"## Shared scripts\n\n"
|
|
1002
|
+
"Referenced from `../_shared/scripts/`:\n\n"
|
|
1003
|
+
"```\n"
|
|
1004
|
+
"python preflight.py\n"
|
|
1005
|
+
"```\n\n"
|
|
1006
|
+
"## Local scripts\n\n"
|
|
1007
|
+
"```\n"
|
|
1008
|
+
"python local_orphan.py\n"
|
|
1009
|
+
"```\n"
|
|
1010
|
+
)
|
|
1011
|
+
assert find_run_command_filenames(content) == ["local_orphan.py"]
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def test_run_command_relative_prose_does_not_exempt_second_fence_under_same_heading():
|
|
1015
|
+
content = (
|
|
1016
|
+
"# example\n\n"
|
|
1017
|
+
"## Shared scripts\n\n"
|
|
1018
|
+
"Referenced from `../_shared/scripts/`:\n\n"
|
|
1019
|
+
"```\n"
|
|
1020
|
+
"python preflight.py\n"
|
|
1021
|
+
"```\n\n"
|
|
1022
|
+
"Run the local check below:\n\n"
|
|
1023
|
+
"```\n"
|
|
1024
|
+
"python local_orphan.py\n"
|
|
1025
|
+
"```\n"
|
|
1026
|
+
)
|
|
1027
|
+
assert find_run_command_filenames(content) == ["local_orphan.py"]
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def test_run_command_long_multi_flag_no_script_line_returns_quickly():
|
|
1031
|
+
fenced_flags = " ".join(f"-x{each_index}" for each_index in range(200))
|
|
1032
|
+
content = "# example\n\n```\n" f"python {fenced_flags} endingword" "\n```\n"
|
|
1033
|
+
started_at = time.perf_counter()
|
|
1034
|
+
found_filenames = find_run_command_filenames(content)
|
|
1035
|
+
elapsed_seconds = time.perf_counter() - started_at
|
|
1036
|
+
assert found_filenames == []
|
|
1037
|
+
assert elapsed_seconds < ONE_SECOND
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def test_run_command_inline_trailing_comment_is_not_scanned():
|
|
1041
|
+
content = (
|
|
1042
|
+
"# example\n\n"
|
|
1043
|
+
"```\n"
|
|
1044
|
+
"python real.py # was: python old_build.py\n"
|
|
1045
|
+
"```\n"
|
|
1046
|
+
)
|
|
1047
|
+
assert find_run_command_filenames(content) == ["real.py"]
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def test_run_command_relative_path_exempt_while_bare_orphan_still_blocks(tmp_path: Path):
|
|
1051
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
1052
|
+
content = (
|
|
1053
|
+
"# example\n\n"
|
|
1054
|
+
"## Running / testing\n\n"
|
|
1055
|
+
"```\n"
|
|
1056
|
+
"python ../shared/preflight.py --check\n"
|
|
1057
|
+
"python absent_script.py\n"
|
|
1058
|
+
"```\n"
|
|
1059
|
+
)
|
|
1060
|
+
result = _run_hook(
|
|
1061
|
+
"Write",
|
|
1062
|
+
{
|
|
1063
|
+
"file_path": str(claude_md_path),
|
|
1064
|
+
"content": content,
|
|
1065
|
+
},
|
|
1066
|
+
)
|
|
1067
|
+
assert result.returncode == 0
|
|
1068
|
+
output = json.loads(result.stdout)
|
|
1069
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
1070
|
+
assert "absent_script.py" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
1071
|
+
assert "preflight.py" not in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def test_run_command_orphan_preexisting_on_untouched_line_is_allowed(tmp_path: Path):
|
|
1075
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
1076
|
+
(claude_md_path.parent / "kept.py").write_text("x = 1\n", encoding="utf-8")
|
|
1077
|
+
claude_md_path.write_text(
|
|
1078
|
+
"# example\n\n"
|
|
1079
|
+
"A prose paragraph with a typoo to fix.\n\n"
|
|
1080
|
+
"## Running / testing\n\n"
|
|
1081
|
+
"```\n"
|
|
1082
|
+
"C:\\Python313\\python.exe kept.py\n"
|
|
1083
|
+
"C:\\Python313\\python.exe already_orphan_script.py\n"
|
|
1084
|
+
"```\n",
|
|
1085
|
+
encoding="utf-8",
|
|
1086
|
+
)
|
|
1087
|
+
result = _run_hook(
|
|
1088
|
+
"Edit",
|
|
1089
|
+
{
|
|
1090
|
+
"file_path": str(claude_md_path),
|
|
1091
|
+
"old_string": "A prose paragraph with a typoo to fix.",
|
|
1092
|
+
"new_string": "A prose paragraph with a typo fixed.",
|
|
1093
|
+
},
|
|
1094
|
+
)
|
|
1095
|
+
assert result.returncode == 0
|
|
1096
|
+
assert result.stdout == ""
|
|
1097
|
+
|
|
1098
|
+
|
|
615
1099
|
def test_blocker_module_has_no_collection_parameter_naming_violations():
|
|
616
1100
|
blocker_source = Path(HOOK_SCRIPT_PATH).read_text(encoding="utf-8")
|
|
617
1101
|
assert check_collection_prefix(blocker_source, HOOK_SCRIPT_PATH) == []
|