claude-dev-env 1.77.0 → 1.79.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.
Files changed (46) hide show
  1. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -0
  2. package/bin/install.mjs +1 -0
  3. package/bin/install.test.mjs +3 -2
  4. package/hooks/blocking/CLAUDE.md +5 -2
  5. package/hooks/blocking/code_rules_dead_module_constant.py +215 -59
  6. package/hooks/blocking/code_rules_dead_split_branch.py +225 -0
  7. package/hooks/blocking/code_rules_docstrings.py +951 -6
  8. package/hooks/blocking/code_rules_enforcer.py +64 -0
  9. package/hooks/blocking/code_rules_naming_collection.py +76 -1
  10. package/hooks/blocking/code_rules_paired_test.py +517 -0
  11. package/hooks/blocking/code_rules_string_magic.py +71 -1
  12. package/hooks/blocking/code_rules_test_assertions.py +159 -1
  13. package/hooks/blocking/convergence_gate_blocker.py +24 -15
  14. package/hooks/blocking/env_var_table_code_drift_blocker.py +475 -0
  15. package/hooks/blocking/package_inventory_stale_blocker.py +54 -15
  16. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +89 -2
  17. package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +105 -0
  18. package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +129 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +198 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +262 -0
  21. package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
  22. package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +226 -0
  23. package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +160 -0
  24. package/hooks/blocking/test_code_rules_enforcer_module_docstring_data_schema_scope.py +82 -0
  25. package/hooks/blocking/test_code_rules_enforcer_paired_test.py +339 -0
  26. package/hooks/blocking/test_code_rules_enforcer_polarity_name_contradiction.py +76 -0
  27. package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -0
  28. package/hooks/blocking/test_code_rules_enforcer_whitespace_indentation_magic.py +74 -0
  29. package/hooks/blocking/test_convergence_gate_blocker.py +71 -0
  30. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +94 -0
  31. package/hooks/blocking/test_package_inventory_stale_blocker.py +46 -0
  32. package/hooks/blocking/test_pre_tool_use_dispatcher.py +7 -7
  33. package/hooks/hooks_constants/CLAUDE.md +2 -0
  34. package/hooks/hooks_constants/blocking_check_limits.py +102 -0
  35. package/hooks/hooks_constants/code_rules_enforcer_constants.py +28 -0
  36. package/hooks/hooks_constants/env_var_table_code_drift_constants.py +64 -0
  37. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +20 -9
  38. package/hooks/hooks_constants/paired_test_coverage_constants.py +35 -0
  39. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  40. package/package.json +1 -1
  41. package/rules/CLAUDE.md +2 -0
  42. package/rules/docstring-prose-matches-implementation.md +56 -53
  43. package/rules/env-var-table-code-drift.md +24 -0
  44. package/rules/file-global-constants.md +2 -2
  45. package/rules/package-inventory-stale-entry.md +4 -4
  46. package/rules/paired-test-coverage.md +35 -0
@@ -0,0 +1,132 @@
1
+ """Tests for vacuous cleanup-on-failure assertion detection.
2
+
3
+ A cleanup-on-failure test that asserts no leftover temp file is left behind,
4
+ yet never proves the temp file was created, passes vacuously: the assertion
5
+ holds even when the on-failure cleanup is entirely broken. The gate flags that
6
+ shape so the author arranges a post-creation failure and asserts real removal.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.util
12
+ from pathlib import Path
13
+ from types import ModuleType
14
+
15
+
16
+ def _load_enforcer_module() -> ModuleType:
17
+ module_path = Path(__file__).parent / "code_rules_enforcer.py"
18
+ spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
19
+ assert spec is not None
20
+ assert spec.loader is not None
21
+ module = importlib.util.module_from_spec(spec)
22
+ spec.loader.exec_module(module)
23
+ return module
24
+
25
+
26
+ code_rules_enforcer = _load_enforcer_module()
27
+
28
+ TEST_FILE_PATH = "packages/app/tests/test_archive_rewrite.py"
29
+ PRODUCTION_FILE_PATH = "packages/app/services/archive_rewrite.py"
30
+
31
+
32
+ def test_should_flag_glob_emptiness_cleanup_test_without_temp_creation() -> None:
33
+ source = (
34
+ "def test_rewrite_removes_tmp_file_on_failure(tmp_path: Path) -> None:\n"
35
+ " stp_path = build_corrupt_stp(stp_path=tmp_path / 'corrupt.stp')\n"
36
+ " rewrite_properties_xml_atomically(stp_path=stp_path, patched='<x/>')\n"
37
+ " all_tmp_siblings = list(tmp_path.glob('*.tmp'))\n"
38
+ " assert len(all_tmp_siblings) == 0\n"
39
+ )
40
+ issues = code_rules_enforcer.check_vacuous_cleanup_assertion_tests(source, TEST_FILE_PATH)
41
+ assert any("vacuous" in issue.lower() for issue in issues), (
42
+ f"Expected a vacuous-cleanup issue, got: {issues}"
43
+ )
44
+
45
+
46
+ def test_should_not_flag_when_post_creation_failure_is_arranged() -> None:
47
+ source = (
48
+ "def test_rewrite_removes_tmp_file_on_replace_failure(tmp_path, monkeypatch)"
49
+ " -> None:\n"
50
+ " stp_path = build_valid_stp(stp_path=tmp_path / 'theme.stp')\n"
51
+ " monkeypatch.setattr(os, 'replace', _raise_oserror)\n"
52
+ " rewrite_properties_xml_atomically(stp_path=stp_path, patched='<x/>')\n"
53
+ " all_tmp_siblings = list(tmp_path.glob('*.tmp'))\n"
54
+ " assert len(all_tmp_siblings) == 0\n"
55
+ )
56
+ issues = code_rules_enforcer.check_vacuous_cleanup_assertion_tests(source, TEST_FILE_PATH)
57
+ assert issues == [], f"Expected no issue when failure is arranged, got: {issues}"
58
+
59
+
60
+ def test_should_not_flag_when_temp_existence_is_proven_first() -> None:
61
+ source = (
62
+ "def test_cleanup_removes_temp_on_failure(tmp_path: Path) -> None:\n"
63
+ " temporary_path = tmp_path / 'theme.stp.tmp'\n"
64
+ " temporary_path.write_bytes(b'partial')\n"
65
+ " assert temporary_path.exists()\n"
66
+ " run_cleanup_after_failure(temporary_path)\n"
67
+ " assert not list(tmp_path.glob('*.tmp'))\n"
68
+ )
69
+ issues = code_rules_enforcer.check_vacuous_cleanup_assertion_tests(source, TEST_FILE_PATH)
70
+ assert issues == [], f"Expected no issue when temp existence proven, got: {issues}"
71
+
72
+
73
+ def test_should_not_flag_success_named_cleanup_assertion() -> None:
74
+ source = (
75
+ "def test_rewrite_leaves_no_tmp_sibling_on_success(tmp_path: Path) -> None:\n"
76
+ " stp_path = build_valid_stp(stp_path=tmp_path / 'theme.stp')\n"
77
+ " rewrite_properties_xml_atomically(stp_path=stp_path, patched='<x/>')\n"
78
+ " all_tmp_siblings = list(tmp_path.glob('*.tmp'))\n"
79
+ " assert len(all_tmp_siblings) == 0\n"
80
+ )
81
+ issues = code_rules_enforcer.check_vacuous_cleanup_assertion_tests(source, TEST_FILE_PATH)
82
+ assert issues == [], f"Expected no issue for a success-named test, got: {issues}"
83
+
84
+
85
+ def test_should_not_flag_failure_test_asserting_real_behavior() -> None:
86
+ source = (
87
+ "def test_rewrite_reports_cleanup_failure_on_directory_tmp("
88
+ "tmp_path: Path) -> None:\n"
89
+ " temporary_path = tmp_path / 'theme.stp.tmp'\n"
90
+ " temporary_path.mkdir()\n"
91
+ " write_outcome = rewrite_properties_xml_atomically("
92
+ "stp_path=tmp_path / 't.stp', patched='<x/>')\n"
93
+ " assert write_outcome.temporary_cleanup_error_message is not None\n"
94
+ )
95
+ issues = code_rules_enforcer.check_vacuous_cleanup_assertion_tests(source, TEST_FILE_PATH)
96
+ assert issues == [], f"Expected no issue for a behavior assertion, got: {issues}"
97
+
98
+
99
+ def test_should_not_flag_mixed_assertion_cleanup_test() -> None:
100
+ source = (
101
+ "def test_rewrite_removes_tmp_and_reports_failure(tmp_path: Path) -> None:\n"
102
+ " stp_path = build_corrupt_stp(stp_path=tmp_path / 'corrupt.stp')\n"
103
+ " write_outcome = rewrite_properties_xml_atomically("
104
+ "stp_path=stp_path, patched='<x/>')\n"
105
+ " assert write_outcome.is_write_successful is False\n"
106
+ " all_tmp_siblings = list(tmp_path.glob('*.tmp'))\n"
107
+ " assert len(all_tmp_siblings) == 0\n"
108
+ )
109
+ issues = code_rules_enforcer.check_vacuous_cleanup_assertion_tests(source, TEST_FILE_PATH)
110
+ assert issues == [], f"Expected no issue for a mixed-assertion test, got: {issues}"
111
+
112
+
113
+ def test_should_not_flag_in_production_files() -> None:
114
+ source = (
115
+ "def test_rewrite_removes_tmp_file_on_failure(tmp_path: Path) -> None:\n"
116
+ " all_tmp_siblings = list(tmp_path.glob('*.tmp'))\n"
117
+ " assert len(all_tmp_siblings) == 0\n"
118
+ )
119
+ issues = code_rules_enforcer.check_vacuous_cleanup_assertion_tests(source, PRODUCTION_FILE_PATH)
120
+ assert issues == [], f"Expected no issue in a production file, got: {issues}"
121
+
122
+
123
+ def test_should_include_line_number_in_issue() -> None:
124
+ source = (
125
+ "def test_rewrite_removes_tmp_file_on_failure(tmp_path: Path) -> None:\n"
126
+ " all_tmp_siblings = list(tmp_path.glob('*.tmp'))\n"
127
+ " assert all_tmp_siblings == []\n"
128
+ )
129
+ issues = code_rules_enforcer.check_vacuous_cleanup_assertion_tests(source, TEST_FILE_PATH)
130
+ assert any("Line 1" in issue for issue in issues), (
131
+ f"Expected a line number in the issue, got: {issues}"
132
+ )
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+
6
+ ENFORCER_PATH = Path(__file__).resolve().parent / "code_rules_enforcer.py"
7
+ specification = importlib.util.spec_from_file_location("code_rules_enforcer", ENFORCER_PATH)
8
+ assert specification is not None and specification.loader is not None
9
+ code_rules_enforcer = importlib.util.module_from_spec(specification)
10
+ specification.loader.exec_module(code_rules_enforcer)
11
+
12
+ PRODUCTION_PATH = "C:/project/pkg/menu_info_colors.py"
13
+
14
+
15
+ def _check(source: str) -> list[str]:
16
+ return code_rules_enforcer.check_whitespace_indentation_magic(source, PRODUCTION_PATH)
17
+
18
+
19
+ def test_flags_twelve_space_indent_constant() -> None:
20
+ source = 'def fallback_indent() -> str:\n return " "\n'
21
+ issues = _check(source)
22
+ assert len(issues) == 1
23
+ assert "Line 2" in issues[0]
24
+
25
+
26
+ def test_flags_indent_fragment_inside_fstring() -> None:
27
+ source = 'def build(value: str) -> str:\n return value + f"\\n {value}"\n'
28
+ issues = _check(source)
29
+ assert len(issues) == 1
30
+ assert "Line 2" in issues[0]
31
+
32
+
33
+ def test_flags_tab_indent_constant() -> None:
34
+ source = 'def tabbed() -> str:\n return "\\t\\t"\n'
35
+ assert len(_check(source)) == 1
36
+
37
+
38
+ def test_does_not_flag_single_tab_delimiter() -> None:
39
+ source = 'def split_columns(text: str) -> list[str]:\n return text.split("\\t")\n'
40
+ assert _check(source) == []
41
+
42
+
43
+ def test_does_not_flag_single_tab_join_delimiter() -> None:
44
+ source = 'def join_rows(rows: list[str]) -> str:\n return "\\t".join(rows)\n'
45
+ assert _check(source) == []
46
+
47
+
48
+ def test_does_not_flag_single_space() -> None:
49
+ source = 'def join_words(left: str, right: str) -> str:\n return left + " " + right\n'
50
+ assert _check(source) == []
51
+
52
+
53
+ def test_does_not_flag_newline_only_fragment() -> None:
54
+ source = 'def build(value: str) -> str:\n return f"\\n{value}"\n'
55
+ assert _check(source) == []
56
+
57
+
58
+ def test_does_not_flag_docstring_with_spaced_words() -> None:
59
+ source = (
60
+ "def documented() -> str:\n"
61
+ ' """A docstring with spaced words."""\n'
62
+ ' return documented.__doc__ or ""\n'
63
+ )
64
+ assert _check(source) == []
65
+
66
+
67
+ def test_does_not_flag_in_config_file() -> None:
68
+ source = 'def fallback_indent() -> str:\n return " "\n'
69
+ assert (
70
+ code_rules_enforcer.check_whitespace_indentation_magic(
71
+ source, "C:/project/pkg/config/indents.py"
72
+ )
73
+ == []
74
+ )
@@ -1,9 +1,14 @@
1
1
  """Unit tests for convergence-gate-blocker PreToolUse hook."""
2
2
 
3
3
  import importlib.util
4
+ import io
5
+ import json
4
6
  import pathlib
7
+ import subprocess
5
8
  import sys
6
9
 
10
+ import pytest
11
+
7
12
  _HOOK_DIR = pathlib.Path(__file__).parent
8
13
  if str(_HOOK_DIR) not in sys.path:
9
14
  sys.path.insert(0, str(_HOOK_DIR))
@@ -61,3 +66,69 @@ def test_returns_none_when_no_number_and_no_repo() -> None:
61
66
 
62
67
  def test_matches_gh_pr_ready_in_compound_command() -> None:
63
68
  assert not _GH_PR_READY_PATTERN.search("gh pr ready --undo && gh pr create")
69
+
70
+
71
+ def test_run_convergence_check_forwards_cwd_to_subprocess(
72
+ monkeypatch: pytest.MonkeyPatch,
73
+ ) -> None:
74
+ captured_cwd: list[object] = []
75
+
76
+ def fake_run(*_run_args: object, **run_keywords: object) -> subprocess.CompletedProcess[str]:
77
+ captured_cwd.append(run_keywords.get("cwd"))
78
+ return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
79
+
80
+ monkeypatch.setattr(hook_module.subprocess, "run", fake_run)
81
+ hook_module._run_convergence_check(
82
+ "check_convergence.py", "owner", "repo", 783, "C:/worktrees/pr-783"
83
+ )
84
+ assert captured_cwd == ["C:/worktrees/pr-783"]
85
+
86
+
87
+ def test_run_convergence_check_forwards_none_cwd_as_none(
88
+ monkeypatch: pytest.MonkeyPatch,
89
+ ) -> None:
90
+ captured_cwd: list[object] = []
91
+
92
+ def fake_run(*_run_args: object, **run_keywords: object) -> subprocess.CompletedProcess[str]:
93
+ captured_cwd.append(run_keywords.get("cwd"))
94
+ return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
95
+
96
+ monkeypatch.setattr(hook_module.subprocess, "run", fake_run)
97
+ hook_module._run_convergence_check("check_convergence.py", "owner", "repo", 783, None)
98
+ assert captured_cwd == [None]
99
+
100
+
101
+ def test_main_reads_cwd_from_top_level_payload(
102
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
103
+ ) -> None:
104
+ convergence_script = (
105
+ tmp_path / ".claude" / "skills" / "pr-converge" / "scripts" / "check_convergence.py"
106
+ )
107
+ convergence_script.parent.mkdir(parents=True)
108
+ convergence_script.write_text("")
109
+ monkeypatch.setattr(hook_module.Path, "home", classmethod(lambda _cls: tmp_path))
110
+
111
+ worktree_path = str(tmp_path / "worktrees" / "pr-783")
112
+ monkeypatch.setattr(hook_module, "_resolve_owner_repo", lambda _cwd: ("jl-cmd", "repo"))
113
+
114
+ captured_cwd: list[object] = []
115
+
116
+ def fake_check(
117
+ _script: str, _owner: str, _repo: str, _pr_number: int, cwd: str | None
118
+ ) -> subprocess.CompletedProcess[str]:
119
+ captured_cwd.append(cwd)
120
+ return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
121
+
122
+ monkeypatch.setattr(hook_module, "_run_convergence_check", fake_check)
123
+
124
+ payload = {
125
+ "tool_name": "Bash",
126
+ "cwd": worktree_path,
127
+ "tool_input": {"command": "gh pr ready 783", "cwd": "C:/wrong/session/dir"},
128
+ }
129
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
130
+
131
+ with pytest.raises(SystemExit):
132
+ hook_module.main()
133
+
134
+ assert captured_cwd == [worktree_path]
@@ -0,0 +1,94 @@
1
+ """Tests for the env-var-table code-drift blocker.
2
+
3
+ Each test builds a real on-disk directory holding a code file and a markdown
4
+ doc, then drives the blocker's detection function against that tree. The drift
5
+ the blocker catches: a markdown env-var summary table row attributes an
6
+ environment variable to a code file whose source never references that variable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ _blocking_dir = str(Path(__file__).resolve().parent)
15
+ if _blocking_dir not in sys.path:
16
+ sys.path.insert(0, _blocking_dir)
17
+
18
+ from env_var_table_code_drift_blocker import ( # noqa: E402
19
+ find_drift_rows,
20
+ is_markdown_file,
21
+ )
22
+
23
+
24
+ def _write(file_path: Path, content: str) -> None:
25
+ file_path.parent.mkdir(parents=True, exist_ok=True)
26
+ file_path.write_text(content, encoding="utf-8")
27
+
28
+
29
+ def _anchor_repo_root(repo_root: Path) -> None:
30
+ (repo_root / ".git").mkdir(parents=True, exist_ok=True)
31
+
32
+
33
+ def test_is_markdown_file_accepts_md_and_rejects_python() -> None:
34
+ assert is_markdown_file("docs/configuration.md") is True
35
+ assert is_markdown_file("auth/google_auth.py") is False
36
+
37
+
38
+ def test_flags_variable_absent_from_named_code_file(tmp_path: Path) -> None:
39
+ _anchor_repo_root(tmp_path)
40
+ _write(tmp_path / "auth" / "google_auth.py", "def load():\n return read_bws_secret()\n")
41
+ doc_path = tmp_path / "docs" / "configuration.md"
42
+ content = (
43
+ "## Summary: Environment Variables\n\n"
44
+ "| Variable | Used By | Purpose |\n"
45
+ "|----------|---------|---------|\n"
46
+ "| `GOOGLE_APPLICATION_CREDENTIALS` | `auth/google_auth.py` | Path to JSON |\n"
47
+ )
48
+ drift_rows = find_drift_rows(content, doc_path.parent)
49
+ assert drift_rows == ["GOOGLE_APPLICATION_CREDENTIALS -> auth/google_auth.py"]
50
+
51
+
52
+ def test_passes_when_variable_present_in_code_file(tmp_path: Path) -> None:
53
+ _anchor_repo_root(tmp_path)
54
+ _write(
55
+ tmp_path / "automation_logging" / "__init__.py",
56
+ 'topic = os.environ["NTFY_TOPIC"]\n',
57
+ )
58
+ doc_path = tmp_path / "docs" / "configuration.md"
59
+ content = (
60
+ "| Variable | Used By | Purpose |\n"
61
+ "|----------|---------|---------|\n"
62
+ "| `NTFY_TOPIC` | `automation_logging/__init__.py` | ntfy topic |\n"
63
+ )
64
+ assert find_drift_rows(content, doc_path.parent) == []
65
+
66
+
67
+ def test_skips_row_whose_code_file_is_absent(tmp_path: Path) -> None:
68
+ _anchor_repo_root(tmp_path)
69
+ doc_path = tmp_path / "docs" / "configuration.md"
70
+ content = (
71
+ "| Variable | Used By | Purpose |\n"
72
+ "|----------|---------|---------|\n"
73
+ "| `MISSING_VAR` | `nowhere/ghost.py` | unresolved |\n"
74
+ )
75
+ assert find_drift_rows(content, doc_path.parent) == []
76
+
77
+
78
+ def test_ignores_rows_inside_a_fenced_code_block(tmp_path: Path) -> None:
79
+ _anchor_repo_root(tmp_path)
80
+ _write(tmp_path / "auth" / "google_auth.py", "no variable here\n")
81
+ doc_path = tmp_path / "docs" / "configuration.md"
82
+ content = "```\n| `GOOGLE_APPLICATION_CREDENTIALS` | `auth/google_auth.py` | sample |\n```\n"
83
+ assert find_drift_rows(content, doc_path.parent) == []
84
+
85
+
86
+ def test_ignores_row_whose_second_cell_is_not_a_code_file(tmp_path: Path) -> None:
87
+ _anchor_repo_root(tmp_path)
88
+ doc_path = tmp_path / "docs" / "configuration.md"
89
+ content = (
90
+ "| Variable | Purpose | Default |\n"
91
+ "|----------|---------|---------|\n"
92
+ "| `SOME_FLAG` | `enables the thing` | off |\n"
93
+ )
94
+ assert find_drift_rows(content, doc_path.parent) == []
@@ -311,6 +311,52 @@ def test_is_inventoried_production_file_accepts_production_file(tmp_path: Path):
311
311
  assert is_inventoried_production_file(str(production_file_path)) is True
312
312
 
313
313
 
314
+ SKILL_MD_SCRIPTS_LAYOUT = (
315
+ "# base-theme-match\n\n"
316
+ "## Layout\n\n"
317
+ "| Path | Role |\n"
318
+ "|---|---|\n"
319
+ "| `scripts/discover_candidates.py` | Discovers candidates. |\n"
320
+ "| `scripts/resolve_stp.py` | Resolves the STP filename. |\n"
321
+ "| `scripts/select_stp.py` | Selects the STP filename. |\n"
322
+ )
323
+
324
+
325
+ def _skill_scripts_directory(tmp_path: Path) -> Path:
326
+ """Return a skill package's scripts/ directory under a SKILL.md Layout table."""
327
+ skill_directory = tmp_path / "base-theme-match"
328
+ skill_directory.mkdir()
329
+ (skill_directory / "SKILL.md").write_text(SKILL_MD_SCRIPTS_LAYOUT, encoding="utf-8")
330
+ scripts_directory = skill_directory / "scripts"
331
+ scripts_directory.mkdir()
332
+ _write_sibling_files(scripts_directory, ["discover_candidates.py", "resolve_stp.py"])
333
+ return scripts_directory
334
+
335
+
336
+ def test_blocks_new_script_absent_from_parent_skill_layout(tmp_path: Path):
337
+ scripts_directory = _skill_scripts_directory(tmp_path)
338
+ new_file_path = scripts_directory / "stp_selection.py"
339
+ result = _run_hook(
340
+ "Write",
341
+ {"file_path": str(new_file_path), "content": "x = 1\n"},
342
+ )
343
+ assert result.returncode == 0
344
+ payload = json.loads(result.stdout)
345
+ assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
346
+ assert "stp_selection.py" in payload["hookSpecificOutput"]["permissionDecisionReason"]
347
+
348
+
349
+ def test_allows_new_script_named_in_parent_skill_layout(tmp_path: Path):
350
+ scripts_directory = _skill_scripts_directory(tmp_path)
351
+ new_file_path = scripts_directory / "select_stp.py"
352
+ result = _run_hook(
353
+ "Write",
354
+ {"file_path": str(new_file_path), "content": "x = 1\n"},
355
+ )
356
+ assert result.returncode == 0
357
+ assert result.stdout.strip() == ""
358
+
359
+
314
360
  def test_find_stale_inventory_returns_survey_for_omission(tmp_path: Path):
315
361
  package_directory = _package_directory_with_readme(tmp_path, README_LISTING_TWO_FILES)
316
362
  _write_sibling_files(package_directory, ["dialer_compose.py", "compose_dialer_cli.py"])
@@ -632,24 +632,24 @@ def test_dispatcher_write_applies_both_groups() -> None:
632
632
  assert "blocking/plain_language_blocker.py" in all_write_script_paths, (
633
633
  "plain_language_blocker (Group B) must be in Write applicable set"
634
634
  )
635
- assert len(all_write_entries) == 18, (
636
- f"Write tool must apply to all 18 hosted hooks, got {len(all_write_entries)}"
635
+ assert len(all_write_entries) == 19, (
636
+ f"Write tool must apply to all 19 hosted hooks, got {len(all_write_entries)}"
637
637
  )
638
638
 
639
639
 
640
640
  def test_dispatcher_edit_applies_both_groups() -> None:
641
641
  """Edit tool triggers both Group A and Group B hooks through the dispatcher."""
642
642
  all_edit_entries = _applicable_entries_for_tool(EDIT_TOOL_NAME)
643
- assert len(all_edit_entries) == 18, (
644
- f"Edit tool must apply to all 18 hosted hooks, got {len(all_edit_entries)}"
643
+ assert len(all_edit_entries) == 19, (
644
+ f"Edit tool must apply to all 19 hosted hooks, got {len(all_edit_entries)}"
645
645
  )
646
646
 
647
647
 
648
648
  def test_dispatcher_multi_edit_applies_only_group_b() -> None:
649
- """MultiEdit tool triggers only Group B (7 hooks), not Group A."""
649
+ """MultiEdit tool triggers only Group B (8 hooks), not Group A."""
650
650
  all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
651
- assert len(all_multi_edit_entries) == 7, (
652
- f"MultiEdit tool must apply to exactly 7 Group-B hooks, got {len(all_multi_edit_entries)}"
651
+ assert len(all_multi_edit_entries) == 8, (
652
+ f"MultiEdit tool must apply to exactly 8 Group-B hooks, got {len(all_multi_edit_entries)}"
653
653
  )
654
654
 
655
655
 
@@ -13,6 +13,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
13
13
  | `bot_mention_comment_blocker_constants.py` | Patterns for detecting bot @-mentions in PR comments |
14
14
  | `claude_md_orphan_file_blocker_constants.py` | Table patterns, file extensions, scan budget, and block-message text for the CLAUDE.md orphan-file blocker |
15
15
  | `code_rules_enforcer_constants.py` | File-extension sets, test-path patterns, advisory line thresholds, boolean-name prefixes |
16
+ | `env_var_table_code_drift_constants.py` | Table patterns, env-var-name and code-file recognizers, scan budget, and block-message text for the env-var-table code-drift blocker |
16
17
  | `code_rules_path_utils_constants.py` | Path-matching helpers used by the code-rules check modules |
17
18
  | `code_verifier_spawn_preflight_gate_constants.py` | Subagent type, merge-tree command flags, timeouts, and deny-message text for the code-verifier spawn pre-flight gate |
18
19
  | `convergence_branch_constants.py` | Branch and worktree naming patterns for the convergence gate |
@@ -39,6 +40,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
39
40
  | `open_questions_in_plans_blocker_constants.py` | Patterns for detecting unresolved open questions in plan documents |
40
41
  | `orphan_css_class_constants.py` | Scan radius and selector patterns for the orphan-CSS-class check |
41
42
  | `package_inventory_stale_blocker_constants.py` | Inventory document names, production code extensions, backtick token pattern, smallest inventory size, exempt names, scan budget, and block-message text for the package-inventory stale-entry blocker |
43
+ | `paired_test_coverage_constants.py` | Test-directory name, stem-test filename affixes, test-file globs, exempt public-function names, scan budget, coverage threshold, and guidance text for the public-function paired-test coverage check |
42
44
  | `path_rewriter_constants.py` | Path rewriting patterns for the Everything-search path rewriter |
43
45
  | `plain_language_blocker_constants.py` | The list of heavy words and their everyday replacements |
44
46
  | `pr_converge_bugteam_enforcer_constants.py` | State keys and timing config for the bugteam-parallel enforcer |
@@ -51,18 +51,73 @@ MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES: int = 3
51
51
  DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT: int = 2
52
52
  MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES: int = 3
53
53
  MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES: int = 3
54
+ MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES: int = 3
54
55
  MAX_STALE_TEST_NAME_TARGET_ISSUES: int = 3
55
56
  STALE_TEST_NAME_MINIMUM_SHARED_TOKEN_COUNT: int = 2
56
57
  MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES: int = 5
57
58
  MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER: int = 2
58
59
  MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES: int = 5
59
60
  MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION: int = 2
61
+ MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES: int = 5
62
+ MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION: int = 2
63
+ MAX_COMPANION_MODULE_RESOLUTION_DEPTH: int = 6
64
+ PYTHON_MODULE_FILE_SUFFIX: str = ".py"
65
+ WORD_BOUNDARY_REGEX: str = r"\b"
66
+ ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES: dict[str, tuple[str, ...]] = {
67
+ "—": ("em-dash", "em dash"),
68
+ "–": ("en-dash", "en dash"),
69
+ "--": ("double-hyphen", "double hyphen", "spaced double-hyphen"),
70
+ ";": ("semicolon",),
71
+ ":": ("colon",),
72
+ ",": ("comma",),
73
+ "/": ("slash", "forward slash"),
74
+ "|": ("pipe", "vertical bar"),
75
+ "&": ("ampersand",),
76
+ }
60
77
  MAX_DOCSTRING_STEP_DISPATCH_ISSUES: int = 5
61
78
  MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK: int = 2
62
79
  MINIMUM_TOKENS_FOR_DISPATCH_CALLEE: int = 2
63
80
  MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES: int = 3
64
81
  MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES: int = 5
65
82
  SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT: int = 1
83
+ MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES: int = 3
84
+ LENGTH_GATE_PACKAGE_SCAN_FILE_LIMIT: int = 400
85
+ LENGTH_CONFIG_SUBDIRECTORY_NAME: str = "config"
86
+ ALL_LENGTH_CONSTANT_NAME_SUFFIXES: tuple[str, ...] = ("_LENGTH", "_LEN")
87
+ ALL_LENGTH_SUPERLATIVE_RANGE_PHRASES: tuple[str, ...] = (
88
+ "longest",
89
+ "maximum length",
90
+ "no longer than",
91
+ )
92
+ MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES: int = 5
93
+ ALL_DOCSTRING_RUNMODE_FLAG_FIELD_NAME_TOKENS: tuple[str, ...] = ("dry_run",)
94
+ ALL_DOCSTRING_PER_RECORD_WRITE_OUTCOME_PHRASES: tuple[str, ...] = (
95
+ "was written",
96
+ "is written",
97
+ "were written",
98
+ "nothing was written",
99
+ "no file was written",
100
+ "wrote",
101
+ "written to disk",
102
+ "written to the file",
103
+ )
104
+ ALL_DOCSTRING_RUN_MODE_PHRASES: tuple[str, ...] = (
105
+ "dry run",
106
+ "dry-run",
107
+ "execute run",
108
+ "--execute",
109
+ "run mode",
110
+ "live run",
111
+ "not an execute",
112
+ )
113
+ MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES: int = 5
114
+ DOCSTRING_LARGE_ZIP_FILE_EXCEPTION_NAME: str = "LargeZipFile"
115
+ ZIPFILE_WRITER_CLASS_NAME: str = "ZipFile"
116
+ ZIPFILE_MODE_KEYWORD: str = "mode"
117
+ ZIPFILE_MODE_POSITIONAL_INDEX: int = 1
118
+ ZIPFILE_ALLOW_ZIP64_KEYWORD: str = "allowZip64"
119
+ ZIPFILE_ALLOW_ZIP64_POSITIONAL_INDEX: int = 3
120
+ ALL_ZIPFILE_WRITE_MODE_VALUES: frozenset[str] = frozenset({"w", "a", "x"})
66
121
  MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES: int = 3
67
122
  ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES: tuple[str, ...] = (
68
123
  "anchor line is among the changed lines",
@@ -163,6 +218,26 @@ ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES: tuple[str, ...] = (
163
218
  "malformed payload yields none",
164
219
  )
165
220
 
221
+ ALL_DOCSTRING_NO_NETWORK_CLAIM_PHRASES: tuple[str, ...] = (
222
+ "without touching the network",
223
+ "without touching the share",
224
+ "without touching the network share",
225
+ "without hitting the network",
226
+ "without hitting the share",
227
+ "without a network call",
228
+ "without any network call",
229
+ "without network access",
230
+ "without a network round-trip",
231
+ "without a network round trip",
232
+ "no network access",
233
+ "no network round-trip",
234
+ "no network round trip",
235
+ )
236
+
237
+ ALL_PATH_METADATA_ACCESS_METHOD_NAMES: frozenset[str] = frozenset(
238
+ {"is_file", "is_dir", "exists", "stat", "lstat"}
239
+ )
240
+
166
241
  MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES: int = 3
167
242
  ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES: tuple[str, ...] = (
168
243
  "no literals appear inline",
@@ -204,3 +279,30 @@ ALL_TEST_INDICATING_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
204
279
  "UNIT_TEST",
205
280
  }
206
281
  )
282
+
283
+ MAX_MODULE_DOCSTRING_DATA_SCHEMA_SCOPE_ISSUES: int = 1
284
+ MODULE_DOCSTRING_DATA_SCHEMA_CONSTANT_SAMPLE_LIMIT: int = 6
285
+ ALL_USER_FACING_TEXT_SCOPE_DOCSTRING_PHRASES: tuple[str, ...] = (
286
+ "user-facing",
287
+ "user facing",
288
+ )
289
+ ALL_DATA_SCHEMA_CONSTANT_NAME_MARKERS: tuple[str, ...] = (
290
+ "_FIELD_",
291
+ "_KEY_",
292
+ "_SCHEMA_",
293
+ "_ENCODING",
294
+ "_FORMAT_STRING",
295
+ )
296
+ ALL_DATA_SCHEMA_DOCSTRING_ACKNOWLEDGEMENT_PHRASES: tuple[str, ...] = (
297
+ "field key",
298
+ "schema key",
299
+ "schema",
300
+ "metadata",
301
+ "encoding",
302
+ "format string",
303
+ "runtime config",
304
+ "runtime-config",
305
+ "data schema",
306
+ "data-schema",
307
+ "config constant",
308
+ )
@@ -39,6 +39,20 @@ DENY_REASON_ISSUE_PREVIEW_COUNT = 10
39
39
  ALL_BOOLEAN_NAME_PREFIXES: tuple[str, ...] = ("is_", "has_", "should_", "can_", "was_", "did_")
40
40
  UPPER_SNAKE_CONSTANT_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
41
41
 
42
+ ALL_POLARITY_ANTONYM_TOKEN_PAIRS: tuple[tuple[str, str], ...] = (
43
+ ("allowed", "forbidden"),
44
+ ("allowed", "denied"),
45
+ ("allowed", "blocked"),
46
+ ("permitted", "forbidden"),
47
+ ("permitted", "denied"),
48
+ ("included", "excluded"),
49
+ ("valid", "invalid"),
50
+ ("enabled", "disabled"),
51
+ ("visible", "hidden"),
52
+ ("present", "missing"),
53
+ )
54
+ POLARITY_TOKEN_BOUNDARY_PATTERN: str = r"(?:^|_)%s(?:_|$)"
55
+
42
56
  ALL_MUST_CHECK_RETURN_FUNCTION_NAMES: frozenset[str] = frozenset({"find_and_click", "write_outcome"})
43
57
 
44
58
  DOCSTRING_ARG_ENTRY_PATTERN: re.Pattern[str] = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*[:(]")
@@ -137,6 +151,20 @@ MINIMUM_FSTRING_LITERAL_LENGTH = 2
137
151
  MAX_FSTRING_STRUCTURAL_LITERAL_ISSUES = 100
138
152
  ALL_ALLOWED_MAGIC_NUMBER_LITERALS: frozenset[str] = frozenset({"0", "1", "-1", "0.0", "1.0"})
139
153
  ALL_NON_MAGIC_FSTRING_STRIPPED_VALUES: frozenset[str] = frozenset({"", "True", "False"})
154
+ INDENTATION_MAGIC_MINIMUM_SPACE_RUN = 4
155
+ INDENTATION_MAGIC_MINIMUM_TAB_RUN = 2
156
+ MAX_WHITESPACE_INDENTATION_MAGIC_ISSUES = 100
157
+ WHITESPACE_INDENTATION_MAGIC_MESSAGE_SUFFIX: str = (
158
+ "whitespace indentation literal in a function body - extract to a named "
159
+ "indent constant in config/"
160
+ )
161
+ ALL_ALWAYS_NONEMPTY_SPLIT_METHOD_NAMES: frozenset[str] = frozenset({"split", "rsplit"})
162
+ MAX_DEAD_SPLIT_BRANCH_ISSUES = 100
163
+ DEAD_SPLIT_BRANCH_MESSAGE_SUFFIX: str = (
164
+ "is bound from a str.split() call with a separator, which never returns an "
165
+ "empty list, so this truthiness test's falsy branch is unreachable dead "
166
+ "code - remove the dead branch"
167
+ )
140
168
  DUPLICATED_FORMAT_MINIMUM_REPETITION_COUNT = 3
141
169
  DUPLICATED_FORMAT_MINIMUM_LITERAL_CHARACTER_COUNT = 5
142
170
  FILE_GLOBAL_UPPER_SNAKE_PATTERN = re.compile(r"^_?[A-Z][A-Z0-9_]*$")