claude-dev-env 1.80.0 → 1.82.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 (64) hide show
  1. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
  6. package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
  7. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -0
  8. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
  9. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
  10. package/audit-rubrics/CLAUDE.md +3 -2
  11. package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
  12. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
  13. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
  14. package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
  15. package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
  16. package/audit-rubrics/prompts/CLAUDE.md +2 -1
  17. package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
  18. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
  19. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
  20. package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
  21. package/docs/CODE_RULES.md +2 -2
  22. package/hooks/blocking/CLAUDE.md +3 -0
  23. package/hooks/blocking/code_rules_annotations_length.py +59 -11
  24. package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
  25. package/hooks/blocking/code_rules_command_dispatch.py +140 -0
  26. package/hooks/blocking/code_rules_docstrings.py +93 -0
  27. package/hooks/blocking/code_rules_enforcer.py +54 -4
  28. package/hooks/blocking/code_rules_js_conventions.py +246 -0
  29. package/hooks/blocking/code_rules_naming_collection.py +5 -0
  30. package/hooks/blocking/code_rules_test_assertions.py +3 -0
  31. package/hooks/blocking/code_rules_unused_imports.py +0 -3
  32. package/hooks/blocking/conventional_pr_title_gate.py +444 -0
  33. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
  34. package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
  35. package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
  36. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
  37. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
  38. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
  39. package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
  40. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +16 -3
  41. package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
  42. package/hooks/blocking/test_conventional_pr_title_gate.py +640 -0
  43. package/hooks/hooks.json +5 -0
  44. package/hooks/hooks_constants/CLAUDE.md +3 -0
  45. package/hooks/hooks_constants/blocking_check_limits.py +9 -0
  46. package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
  47. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +58 -0
  48. package/hooks/hooks_constants/js_conventions_constants.py +54 -0
  49. package/package.json +1 -1
  50. package/rules/docstring-prose-matches-implementation.md +2 -1
  51. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
  52. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
  53. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
  54. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
  55. package/skills/autoconverge/SKILL.md +92 -7
  56. package/skills/autoconverge/reference/convergence.md +3 -3
  57. package/skills/autoconverge/reference/gotchas.md +5 -5
  58. package/skills/autoconverge/reference/stop-conditions.md +1 -1
  59. package/skills/autoconverge/workflow/converge.contract.test.mjs +161 -29
  60. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +342 -1
  61. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +2 -2
  62. package/skills/autoconverge/workflow/converge.mjs +178 -32
  63. package/skills/autoconverge/workflow/converge_multi.mjs +6 -2
  64. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +26 -0
@@ -1953,3 +1953,342 @@ def test_main_staged_mode_passes_on_staged_deletion_of_clean_file(
1953
1953
  "a staged deletion has no staged blob; the gate must skip it cleanly "
1954
1954
  "rather than fail closed as if the file were unreadable"
1955
1955
  )
1956
+
1957
+
1958
+ def test_run_staged_test_files_passes_for_a_passing_staged_test(
1959
+ temporary_git_repository: Path,
1960
+ ) -> None:
1961
+ write_file(
1962
+ temporary_git_repository / "test_ok.py",
1963
+ "def test_ok() -> None:\n assert True\n",
1964
+ )
1965
+ stage_file(temporary_git_repository, "test_ok.py")
1966
+
1967
+ exit_code = gate_module.run_staged_test_files(temporary_git_repository)
1968
+
1969
+ assert exit_code == 0
1970
+
1971
+
1972
+ def test_run_staged_test_files_fails_for_a_failing_staged_test(
1973
+ temporary_git_repository: Path,
1974
+ ) -> None:
1975
+ write_file(
1976
+ temporary_git_repository / "test_bad.py",
1977
+ "def test_bad() -> None:\n assert False\n",
1978
+ )
1979
+ stage_file(temporary_git_repository, "test_bad.py")
1980
+
1981
+ exit_code = gate_module.run_staged_test_files(temporary_git_repository)
1982
+
1983
+ assert exit_code != 0
1984
+
1985
+
1986
+ def test_run_staged_test_files_zero_when_no_test_file_staged(
1987
+ temporary_git_repository: Path,
1988
+ ) -> None:
1989
+ write_file(temporary_git_repository / "helper.py", "first_value = 1\n")
1990
+ stage_file(temporary_git_repository, "helper.py")
1991
+
1992
+ assert gate_module.run_staged_test_files(temporary_git_repository) == 0
1993
+
1994
+
1995
+ def test_run_staged_test_files_zero_when_staged_conftest_collects_no_tests(
1996
+ temporary_git_repository: Path,
1997
+ ) -> None:
1998
+ write_file(
1999
+ temporary_git_repository / "conftest.py",
2000
+ "import pytest\n\n\n@pytest.fixture()\ndef sample_fixture() -> int:\n"
2001
+ " return 1\n",
2002
+ )
2003
+ stage_file(temporary_git_repository, "conftest.py")
2004
+
2005
+ assert gate_module.run_staged_test_files(temporary_git_repository) == 0
2006
+
2007
+
2008
+ def test_hunk_header_pattern_captures_new_start_and_count() -> None:
2009
+ header_match = gate_module.hunk_header_pattern().match("@@ -12,3 +45,6 @@ def scope")
2010
+ assert header_match is not None
2011
+ assert header_match.group(1) == "45"
2012
+ assert header_match.group(2) == "6"
2013
+
2014
+
2015
+ def test_hunk_header_pattern_leaves_count_group_none_for_single_line_hunk() -> None:
2016
+ header_match = gate_module.hunk_header_pattern().match("@@ -0,0 +7 @@")
2017
+ assert header_match is not None
2018
+ assert header_match.group(1) == "7"
2019
+ assert header_match.group(2) is None
2020
+
2021
+
2022
+ def test_violation_line_pattern_captures_leading_line_number() -> None:
2023
+ violation_match = gate_module.violation_line_pattern().match(
2024
+ "Line 88: banned identifier"
2025
+ )
2026
+ assert violation_match is not None
2027
+ assert violation_match.group(1) == "88"
2028
+
2029
+
2030
+ def test_violation_line_pattern_does_not_match_unprefixed_text() -> None:
2031
+ assert gate_module.violation_line_pattern().match("Function foo is too long") is None
2032
+
2033
+
2034
+ def test_filter_paths_under_prefixes_keeps_only_nested_paths(tmp_path: Path) -> None:
2035
+ kept_path = tmp_path / "packages" / "core" / "module.py"
2036
+ dropped_path = tmp_path / "docs" / "guide.py"
2037
+ kept_path.parent.mkdir(parents=True)
2038
+ dropped_path.parent.mkdir(parents=True)
2039
+ kept_path.write_text("kept = 1\n", encoding="utf-8")
2040
+ dropped_path.write_text("dropped = 1\n", encoding="utf-8")
2041
+
2042
+ filtered_paths = gate_module.filter_paths_under_prefixes(
2043
+ [kept_path, dropped_path], tmp_path, ["packages/core"]
2044
+ )
2045
+
2046
+ assert filtered_paths == [kept_path]
2047
+
2048
+
2049
+ def test_filter_paths_under_prefixes_returns_input_when_no_prefixes(
2050
+ tmp_path: Path,
2051
+ ) -> None:
2052
+ only_path = tmp_path / "anywhere.py"
2053
+ only_path.write_text("value = 1\n", encoding="utf-8")
2054
+
2055
+ filtered_paths = gate_module.filter_paths_under_prefixes([only_path], tmp_path, [])
2056
+
2057
+ assert filtered_paths == [only_path]
2058
+
2059
+
2060
+ def test_is_code_path_accepts_python_and_typescript_suffixes() -> None:
2061
+ assert gate_module.is_code_path(Path("pkg/module.py"))
2062
+ assert gate_module.is_code_path(Path("pkg/component.tsx"))
2063
+
2064
+
2065
+ def test_is_code_path_rejects_non_code_suffix() -> None:
2066
+ assert not gate_module.is_code_path(Path("docs/readme.md"))
2067
+
2068
+
2069
+ def test_parse_added_line_numbers_collects_every_added_line() -> None:
2070
+ unified_diff_text = (
2071
+ "diff --git a/target.py b/target.py\n"
2072
+ "@@ -1,0 +2,3 @@\n"
2073
+ "+second = 2\n"
2074
+ "+third = 3\n"
2075
+ "+fourth = 4\n"
2076
+ )
2077
+
2078
+ added_line_numbers = gate_module.parse_added_line_numbers(unified_diff_text)
2079
+
2080
+ assert added_line_numbers == {2, 3, 4}
2081
+
2082
+
2083
+ def test_parse_added_line_numbers_ignores_zero_count_hunks() -> None:
2084
+ unified_diff_text = "@@ -5,2 +5,0 @@\n"
2085
+
2086
+ assert gate_module.parse_added_line_numbers(unified_diff_text) == set()
2087
+
2088
+
2089
+ def test_is_file_new_at_base_true_for_file_added_after_base(
2090
+ temporary_git_repository: Path,
2091
+ ) -> None:
2092
+ write_file(temporary_git_repository / "original.py", "first_count = 1\n")
2093
+ commit_all_files(temporary_git_repository, "baseline")
2094
+ base_sha = run_git_in_repository(
2095
+ temporary_git_repository, "rev-parse", "HEAD"
2096
+ ).strip()
2097
+ write_file(temporary_git_repository / "added.py", "second_count = 2\n")
2098
+ commit_all_files(temporary_git_repository, "add second file")
2099
+
2100
+ assert gate_module.is_file_new_at_base(temporary_git_repository, base_sha, "added.py")
2101
+ assert not gate_module.is_file_new_at_base(
2102
+ temporary_git_repository, base_sha, "original.py"
2103
+ )
2104
+
2105
+
2106
+ def test_added_lines_for_file_reports_lines_added_since_base(
2107
+ temporary_git_repository: Path,
2108
+ ) -> None:
2109
+ write_file(temporary_git_repository / "target.py", "first = 1\nsecond = 2\n")
2110
+ commit_all_files(temporary_git_repository, "baseline")
2111
+ base_sha = run_git_in_repository(
2112
+ temporary_git_repository, "rev-parse", "HEAD"
2113
+ ).strip()
2114
+ write_file(
2115
+ temporary_git_repository / "target.py",
2116
+ "first = 1\nsecond = 2\nthird = 3\nfourth = 4\n",
2117
+ )
2118
+ commit_all_files(temporary_git_repository, "append lines")
2119
+
2120
+ added_line_numbers = gate_module.added_lines_for_file(
2121
+ temporary_git_repository, base_sha, "target.py"
2122
+ )
2123
+
2124
+ assert 3 in added_line_numbers
2125
+ assert 4 in added_line_numbers
2126
+ assert 1 not in added_line_numbers
2127
+
2128
+
2129
+ def test_extract_violation_line_number_returns_parsed_line() -> None:
2130
+ assert gate_module.extract_violation_line_number("Line 17: magic value") == 17
2131
+
2132
+
2133
+ def test_extract_violation_line_number_returns_none_for_unprefixed_text() -> None:
2134
+ assert gate_module.extract_violation_line_number("no line prefix here") is None
2135
+
2136
+
2137
+ def test_function_length_span_range_covers_declared_span() -> None:
2138
+ violation_text = "Function 'compute_total' (defined at line 10) is 25 lines - split it"
2139
+
2140
+ span_range = gate_module.function_length_span_range(violation_text)
2141
+
2142
+ assert span_range == range(10, 35)
2143
+
2144
+
2145
+ def test_function_length_span_range_returns_none_for_other_message() -> None:
2146
+ assert gate_module.function_length_span_range("Line 3: banned identifier") is None
2147
+
2148
+
2149
+ def test_isolation_span_range_covers_enclosing_test_span() -> None:
2150
+ violation_text = (
2151
+ "Line 5: Test 'test_probe' (defined at line 12, spanning 8 lines) probes HOME"
2152
+ )
2153
+
2154
+ span_range = gate_module.isolation_span_range(violation_text)
2155
+
2156
+ assert span_range == range(12, 20)
2157
+
2158
+
2159
+ def test_isolation_span_range_returns_none_for_other_message() -> None:
2160
+ assert gate_module.isolation_span_range("Function foo is 5 lines") is None
2161
+
2162
+
2163
+ def test_enclosing_span_range_dispatches_to_function_length_extractor() -> None:
2164
+ violation_text = "Function 'render' (defined at line 4) is 6 lines - too long"
2165
+
2166
+ span_range = gate_module.enclosing_span_range(violation_text)
2167
+
2168
+ assert span_range == range(4, 10)
2169
+
2170
+
2171
+ def test_enclosing_span_range_returns_none_when_no_span_fragment() -> None:
2172
+ assert gate_module.enclosing_span_range("Line 9: plain violation") is None
2173
+
2174
+
2175
+ def test_print_violation_section_writes_header_and_grouped_paths(
2176
+ tmp_path: Path,
2177
+ capsys: pytest.CaptureFixture[str],
2178
+ ) -> None:
2179
+ offending_path = tmp_path / "package" / "module.py"
2180
+ offending_path.parent.mkdir(parents=True)
2181
+ offending_path.write_text("value = 1\n", encoding="utf-8")
2182
+
2183
+ gate_module.print_violation_section(
2184
+ "BLOCKING violations:",
2185
+ {offending_path.resolve(): ["Line 3: magic value", "Line 4: banned name"]},
2186
+ tmp_path,
2187
+ )
2188
+
2189
+ captured = capsys.readouterr()
2190
+ assert "BLOCKING violations:" in captured.err
2191
+ assert "package/module.py" in captured.err.replace("\\", "/")
2192
+ assert "Line 3: magic value" in captured.err
2193
+ assert "Line 4: banned name" in captured.err
2194
+
2195
+
2196
+ def test_read_prior_committed_content_returns_head_version(
2197
+ temporary_git_repository: Path,
2198
+ ) -> None:
2199
+ write_file(temporary_git_repository / "tracked.py", "committed = 1\n")
2200
+ commit_all_files(temporary_git_repository, "commit tracked")
2201
+ write_file(temporary_git_repository / "tracked.py", "working_tree_edit = 2\n")
2202
+
2203
+ committed_content = gate_module.read_prior_committed_content(
2204
+ temporary_git_repository, "tracked.py"
2205
+ )
2206
+
2207
+ assert committed_content == "committed = 1\n"
2208
+
2209
+
2210
+ def test_read_prior_committed_content_returns_empty_for_untracked_path(
2211
+ temporary_git_repository: Path,
2212
+ ) -> None:
2213
+ write_file(temporary_git_repository / "anchor.py", "anchor = 1\n")
2214
+ commit_all_files(temporary_git_repository, "baseline")
2215
+
2216
+ assert (
2217
+ gate_module.read_prior_committed_content(temporary_git_repository, "missing.py")
2218
+ == ""
2219
+ )
2220
+
2221
+
2222
+ def test_read_staged_content_returns_staged_blob(
2223
+ temporary_git_repository: Path,
2224
+ ) -> None:
2225
+ write_file(temporary_git_repository / "staged.py", "staged_value = 1\n")
2226
+ stage_file(temporary_git_repository, "staged.py")
2227
+
2228
+ staged_content = gate_module.read_staged_content(
2229
+ temporary_git_repository, "staged.py"
2230
+ )
2231
+
2232
+ assert staged_content == "staged_value = 1\n"
2233
+
2234
+
2235
+ def test_read_staged_content_returns_none_for_unstaged_path(
2236
+ temporary_git_repository: Path,
2237
+ ) -> None:
2238
+ write_file(temporary_git_repository / "anchor.py", "anchor = 1\n")
2239
+ commit_all_files(temporary_git_repository, "baseline")
2240
+
2241
+ assert (
2242
+ gate_module.read_staged_content(temporary_git_repository, "never_staged.py")
2243
+ is None
2244
+ )
2245
+
2246
+
2247
+ def test_staged_blob_exists_true_for_staged_file(
2248
+ temporary_git_repository: Path,
2249
+ ) -> None:
2250
+ write_file(temporary_git_repository / "present.py", "present_value = 1\n")
2251
+ stage_file(temporary_git_repository, "present.py")
2252
+
2253
+ assert gate_module.staged_blob_exists(temporary_git_repository, "present.py")
2254
+
2255
+
2256
+ def test_staged_blob_exists_false_for_staged_deletion(
2257
+ temporary_git_repository: Path,
2258
+ ) -> None:
2259
+ write_file(temporary_git_repository / "removable.py", "first_count = 1\n")
2260
+ commit_all_files(temporary_git_repository, "initial")
2261
+ run_git_in_repository(temporary_git_repository, "rm", "--", "removable.py")
2262
+
2263
+ assert not gate_module.staged_blob_exists(temporary_git_repository, "removable.py")
2264
+
2265
+
2266
+ def test_parse_arguments_reads_staged_base_and_prefix_flags() -> None:
2267
+ parsed_arguments = gate_module.parse_arguments(
2268
+ [
2269
+ "--staged",
2270
+ "--base",
2271
+ "main",
2272
+ "--repo-root",
2273
+ "some/root",
2274
+ "--only-under",
2275
+ "packages/core",
2276
+ "explicit_file.py",
2277
+ ]
2278
+ )
2279
+
2280
+ assert parsed_arguments.staged is True
2281
+ assert parsed_arguments.base == "main"
2282
+ assert parsed_arguments.repo_root == Path("some/root")
2283
+ assert parsed_arguments.only_under == ["packages/core"]
2284
+ assert parsed_arguments.paths == [Path("explicit_file.py")]
2285
+
2286
+
2287
+ def test_parse_arguments_applies_documented_defaults() -> None:
2288
+ parsed_arguments = gate_module.parse_arguments([])
2289
+
2290
+ assert parsed_arguments.staged is False
2291
+ assert parsed_arguments.base == "origin/main"
2292
+ assert parsed_arguments.repo_root is None
2293
+ assert parsed_arguments.only_under == []
2294
+ assert parsed_arguments.paths == []
@@ -0,0 +1,297 @@
1
+ """Behavioral tests for the commit-time terminology sweep."""
2
+
3
+ import importlib.util
4
+ import subprocess
5
+ from pathlib import Path
6
+ from types import ModuleType
7
+
8
+
9
+ def _load_sweep_module() -> ModuleType:
10
+ module_path = Path(__file__).parent.parent / "terminology_sweep.py"
11
+ spec = importlib.util.spec_from_file_location("terminology_sweep", module_path)
12
+ assert spec is not None
13
+ assert spec.loader is not None
14
+ module = importlib.util.module_from_spec(spec)
15
+ spec.loader.exec_module(module)
16
+ return module
17
+
18
+
19
+ sweep_module = _load_sweep_module()
20
+ sweep_diff = sweep_module.sweep_diff
21
+ staged_terminology_findings = sweep_module.staged_terminology_findings
22
+ main = sweep_module.main
23
+ parse_added_lines = sweep_module._parse_added_lines
24
+
25
+
26
+ def _init_git_repository(repository_path: Path) -> None:
27
+ for each_command in (
28
+ ["git", "init"],
29
+ ["git", "config", "user.email", "test@example.com"],
30
+ ["git", "config", "user.name", "Test"],
31
+ ):
32
+ subprocess.run(each_command, cwd=repository_path, check=True, capture_output=True)
33
+
34
+
35
+ CODE_AND_PROSE_DIFF = (
36
+ "diff --git a/api/quota.py b/api/quota.py\n"
37
+ "--- a/api/quota.py\n"
38
+ "+++ b/api/quota.py\n"
39
+ "@@ -0,0 +1,2 @@\n"
40
+ "+def read_quota(account):\n"
41
+ '+ return account["premium_interactions"]\n'
42
+ "diff --git a/docs/README.md b/docs/README.md\n"
43
+ "--- a/docs/README.md\n"
44
+ "+++ b/docs/README.md\n"
45
+ "@@ -0,0 +1,1 @@\n"
46
+ "+The premium-request budget gates the run.\n"
47
+ )
48
+
49
+
50
+ def test_flags_prose_near_miss_of_code_identifier() -> None:
51
+ findings = sweep_diff(CODE_AND_PROSE_DIFF)
52
+ assert len(findings) == 1
53
+ assert "docs/README.md:1" in findings[0]
54
+ assert "premium-request" in findings[0]
55
+ assert "premium_interactions" in findings[0]
56
+
57
+
58
+ def test_does_not_flag_exact_hyphen_variant() -> None:
59
+ diff = (
60
+ "diff --git a/api/quota.py b/api/quota.py\n"
61
+ "--- a/api/quota.py\n"
62
+ "+++ b/api/quota.py\n"
63
+ "@@ -0,0 +1,1 @@\n"
64
+ "+premium_interactions = 5\n"
65
+ "diff --git a/docs/README.md b/docs/README.md\n"
66
+ "--- a/docs/README.md\n"
67
+ "+++ b/docs/README.md\n"
68
+ "@@ -0,0 +1,1 @@\n"
69
+ "+The premium-interactions budget gates the run.\n"
70
+ )
71
+ assert sweep_diff(diff) == []
72
+
73
+
74
+ def _code_and_prose_collision_diff(code_identifier: str, prose_compound: str) -> str:
75
+ return (
76
+ "diff --git a/api/quota.py b/api/quota.py\n"
77
+ "--- a/api/quota.py\n"
78
+ "+++ b/api/quota.py\n"
79
+ "@@ -0,0 +1,1 @@\n"
80
+ f"+{code_identifier} = 5\n"
81
+ "diff --git a/docs/README.md b/docs/README.md\n"
82
+ "--- a/docs/README.md\n"
83
+ "+++ b/docs/README.md\n"
84
+ "@@ -0,0 +1,1 @@\n"
85
+ f"+This is a {prose_compound} cache.\n"
86
+ )
87
+
88
+
89
+ def test_does_not_flag_english_compound_tail_read_only() -> None:
90
+ diff = _code_and_prose_collision_diff("read_config", "read-only")
91
+ assert sweep_diff(diff) == []
92
+
93
+
94
+ def test_does_not_flag_english_compound_tail_data_driven() -> None:
95
+ diff = _code_and_prose_collision_diff("data_source", "data-driven")
96
+ assert sweep_diff(diff) == []
97
+
98
+
99
+ def test_does_not_flag_english_compound_tail_type_safe() -> None:
100
+ diff = _code_and_prose_collision_diff("type_check", "type-safe")
101
+ assert sweep_diff(diff) == []
102
+
103
+
104
+ def test_does_not_flag_english_compound_tail_test_driven() -> None:
105
+ diff = _code_and_prose_collision_diff("test_case", "test-driven")
106
+ assert sweep_diff(diff) == []
107
+
108
+
109
+ def test_does_not_flag_english_compound_tail_high_quality() -> None:
110
+ diff = _code_and_prose_collision_diff("high_level", "high-quality")
111
+ assert sweep_diff(diff) == []
112
+
113
+
114
+ def test_does_not_flag_unrelated_hyphenated_prose() -> None:
115
+ diff = (
116
+ "diff --git a/api/quota.py b/api/quota.py\n"
117
+ "--- a/api/quota.py\n"
118
+ "+++ b/api/quota.py\n"
119
+ "@@ -0,0 +1,1 @@\n"
120
+ "+premium_interactions = 5\n"
121
+ "diff --git a/docs/README.md b/docs/README.md\n"
122
+ "--- a/docs/README.md\n"
123
+ "+++ b/docs/README.md\n"
124
+ "@@ -0,0 +1,1 @@\n"
125
+ "+A well-known open-source project.\n"
126
+ )
127
+ assert sweep_diff(diff) == []
128
+
129
+
130
+ def test_does_not_flag_plural_prose_of_singular_identifier() -> None:
131
+ diff = (
132
+ "diff --git a/api/quota.py b/api/quota.py\n"
133
+ "--- a/api/quota.py\n"
134
+ "+++ b/api/quota.py\n"
135
+ "@@ -0,0 +1,1 @@\n"
136
+ "+test_file = 5\n"
137
+ "diff --git a/docs/README.md b/docs/README.md\n"
138
+ "--- a/docs/README.md\n"
139
+ "+++ b/docs/README.md\n"
140
+ "@@ -0,0 +1,1 @@\n"
141
+ "+We scan the test files for coverage.\n"
142
+ )
143
+ assert sweep_diff(diff) == []
144
+
145
+
146
+ def test_does_not_flag_plural_prose_of_singular_identifier_audit_prompt() -> None:
147
+ diff = (
148
+ "diff --git a/api/quota.py b/api/quota.py\n"
149
+ "--- a/api/quota.py\n"
150
+ "+++ b/api/quota.py\n"
151
+ "@@ -0,0 +1,1 @@\n"
152
+ "+audit_prompt = 5\n"
153
+ "diff --git a/docs/README.md b/docs/README.md\n"
154
+ "--- a/docs/README.md\n"
155
+ "+++ b/docs/README.md\n"
156
+ "@@ -0,0 +1,1 @@\n"
157
+ "+The audit prompts drive each round.\n"
158
+ )
159
+ assert sweep_diff(diff) == []
160
+
161
+
162
+ def test_does_not_flag_singular_prose_of_plural_identifier() -> None:
163
+ diff = (
164
+ "diff --git a/api/quota.py b/api/quota.py\n"
165
+ "--- a/api/quota.py\n"
166
+ "+++ b/api/quota.py\n"
167
+ "@@ -0,0 +1,1 @@\n"
168
+ "+audit_prompts = 5\n"
169
+ "diff --git a/docs/README.md b/docs/README.md\n"
170
+ "--- a/docs/README.md\n"
171
+ "+++ b/docs/README.md\n"
172
+ "@@ -0,0 +1,1 @@\n"
173
+ "+The audit prompt drives each round.\n"
174
+ )
175
+ assert sweep_diff(diff) == []
176
+
177
+
178
+ def test_does_not_flag_y_to_ies_plural_variant() -> None:
179
+ diff = (
180
+ "diff --git a/api/quota.py b/api/quota.py\n"
181
+ "--- a/api/quota.py\n"
182
+ "+++ b/api/quota.py\n"
183
+ "@@ -0,0 +1,1 @@\n"
184
+ "+retry_policy = 5\n"
185
+ "diff --git a/docs/README.md b/docs/README.md\n"
186
+ "--- a/docs/README.md\n"
187
+ "+++ b/docs/README.md\n"
188
+ "@@ -0,0 +1,1 @@\n"
189
+ "+The retry policies bound each attempt.\n"
190
+ )
191
+ assert sweep_diff(diff) == []
192
+
193
+
194
+ def test_still_flags_non_plural_divergent_tail() -> None:
195
+ diff = (
196
+ "diff --git a/api/quota.py b/api/quota.py\n"
197
+ "--- a/api/quota.py\n"
198
+ "+++ b/api/quota.py\n"
199
+ "@@ -0,0 +1,1 @@\n"
200
+ "+premium_interactions = 5\n"
201
+ "diff --git a/docs/README.md b/docs/README.md\n"
202
+ "--- a/docs/README.md\n"
203
+ "+++ b/docs/README.md\n"
204
+ "@@ -0,0 +1,1 @@\n"
205
+ "+The premium request budget gates the run.\n"
206
+ )
207
+ findings = sweep_diff(diff)
208
+ assert len(findings) == 1
209
+ assert "premium request" in findings[0]
210
+
211
+
212
+ def test_flags_near_miss_inside_code_comment() -> None:
213
+ diff = (
214
+ "diff --git a/api/quota.py b/api/quota.py\n"
215
+ "--- a/api/quota.py\n"
216
+ "+++ b/api/quota.py\n"
217
+ "@@ -0,0 +1,2 @@\n"
218
+ "+premium_interactions = 5\n"
219
+ "+# the premium-request path resets the counter\n"
220
+ )
221
+ findings = sweep_diff(diff)
222
+ assert len(findings) == 1
223
+ assert "premium-request" in findings[0]
224
+
225
+
226
+ def test_no_findings_when_no_multiword_identifier_introduced() -> None:
227
+ diff = (
228
+ "diff --git a/docs/README.md b/docs/README.md\n"
229
+ "--- a/docs/README.md\n"
230
+ "+++ b/docs/README.md\n"
231
+ "@@ -0,0 +1,1 @@\n"
232
+ "+The premium-request budget gates the run.\n"
233
+ )
234
+ assert sweep_diff(diff) == []
235
+
236
+
237
+ def test_main_exits_one_when_findings(tmp_path: Path) -> None:
238
+ diff_file = tmp_path / "change.diff"
239
+ diff_file.write_text(CODE_AND_PROSE_DIFF, encoding="utf-8")
240
+ exit_code = main(["--diff-file", str(diff_file)])
241
+ assert exit_code == 1
242
+
243
+
244
+ def test_main_exits_zero_when_clean(tmp_path: Path) -> None:
245
+ diff_file = tmp_path / "clean.diff"
246
+ diff_file.write_text(
247
+ "diff --git a/docs/README.md b/docs/README.md\n"
248
+ "--- a/docs/README.md\n"
249
+ "+++ b/docs/README.md\n"
250
+ "@@ -0,0 +1,1 @@\n"
251
+ "+Nothing to see here.\n",
252
+ encoding="utf-8",
253
+ )
254
+ exit_code = main(["--diff-file", str(diff_file)])
255
+ assert exit_code == 0
256
+
257
+
258
+ def test_staged_terminology_findings_flags_staged_prose(tmp_path: Path) -> None:
259
+ _init_git_repository(tmp_path)
260
+ (tmp_path / "quota.py").write_text(
261
+ "premium_interactions = 5\n", encoding="utf-8"
262
+ )
263
+ (tmp_path / "README.md").write_text(
264
+ "The premium-request budget gates the run.\n", encoding="utf-8"
265
+ )
266
+ subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True, capture_output=True)
267
+ findings = staged_terminology_findings(tmp_path)
268
+ assert any("premium-request" in each_finding for each_finding in findings)
269
+
270
+
271
+ def test_staged_terminology_findings_empty_when_clean(tmp_path: Path) -> None:
272
+ _init_git_repository(tmp_path)
273
+ (tmp_path / "README.md").write_text("Nothing notable here.\n", encoding="utf-8")
274
+ subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True, capture_output=True)
275
+ assert staged_terminology_findings(tmp_path) == []
276
+
277
+
278
+ def test_parse_added_lines_counts_pre_increment_content_as_added_line() -> None:
279
+ diff_with_pre_increment = (
280
+ "diff --git a/app.mjs b/app.mjs\n"
281
+ "--- a/app.mjs\n"
282
+ "+++ b/app.mjs\n"
283
+ "@@ -0,0 +1,3 @@\n"
284
+ "+++counter;\n"
285
+ "+let first_total = read_first_value();\n"
286
+ "+let second_total = read_second_value();\n"
287
+ )
288
+
289
+ all_added_lines = parse_added_lines(diff_with_pre_increment)
290
+
291
+ assert [
292
+ (each_line_number, each_text) for _, each_line_number, each_text in all_added_lines
293
+ ] == [
294
+ (1, "++counter;"),
295
+ (2, "let first_total = read_first_value();"),
296
+ (3, "let second_total = read_second_value();"),
297
+ ]
@@ -1,6 +1,6 @@
1
1
  # audit-rubrics
2
2
 
3
- Audit rubrics for the PR-loop code-review suite. The rubrics define the 16 bug categories (A–P), their sub-bucket decompositions, and the prompt templates agents use during an audit pass. Installed into `~/.claude/audit-rubrics/` by `bin/install.mjs`.
3
+ Audit rubrics for the PR-loop code-review suite. The rubrics define the 17 bug categories (A–Q), their sub-bucket decompositions, and the prompt templates agents use during an audit pass. Installed into `~/.claude/audit-rubrics/` by `bin/install.mjs`.
4
4
 
5
5
  ## Key file
6
6
 
@@ -12,7 +12,7 @@ Audit rubrics for the PR-loop code-review suite. The rubrics define the 16 bug c
12
12
 
13
13
  | Entry | Description |
14
14
  |---|---|
15
- | `category_rubrics/` | One `.md` per category (A–P): defines what the category audits, example findings, and a sub-bucket decomposition table |
15
+ | `category_rubrics/` | One `.md` per category (A–Q): defines what the category audits, example findings, and a sub-bucket decomposition table |
16
16
  | `prompts/` | One `.md` per category: the ready-to-use audit prompt template an agent inlines the artifact into |
17
17
 
18
18
  ## Categories
@@ -35,6 +35,7 @@ Audit rubrics for the PR-loop code-review suite. The rubrics define the 16 bug c
35
35
  | N | Test name / scenario verifier |
36
36
  | O | Docstring vs implementation drift |
37
37
  | P | Name vs behavior contract |
38
+ | Q | Cross-surface claim consistency |
38
39
 
39
40
  ## Breaking-change rule
40
41
 
@@ -1,6 +1,6 @@
1
1
  # audit-rubrics/category_rubrics
2
2
 
3
- One rubric file per audit category (A–P). Each file defines what the category covers, gives concrete examples of findings, and provides the sub-bucket decomposition an audit agent uses to structure its pass.
3
+ One rubric file per audit category (A–Q). Each file defines what the category covers, gives concrete examples of findings, and provides the sub-bucket decomposition an audit agent uses to structure its pass.
4
4
 
5
5
  ## Files
6
6
 
@@ -22,6 +22,7 @@ One rubric file per audit category (A–P). Each file defines what the category
22
22
  | `category-n-test-name-scenario-verifier.md` | N — Test name / scenario verifier |
23
23
  | `category-o-docstring-vs-impl-drift.md` | O — Docstring vs implementation drift |
24
24
  | `category-p-name-vs-behavior-contract.md` | P — Name vs behavior contract |
25
+ | `category-q-cross-surface-claims.md` | Q — Cross-surface claim consistency |
25
26
 
26
27
  ## Rubric structure
27
28