claude-dev-env 1.78.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 (35) 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 +3 -2
  5. package/hooks/blocking/code_rules_docstrings.py +609 -16
  6. package/hooks/blocking/code_rules_enforcer.py +37 -0
  7. package/hooks/blocking/code_rules_naming_collection.py +76 -1
  8. package/hooks/blocking/code_rules_paired_test.py +240 -22
  9. package/hooks/blocking/code_rules_test_assertions.py +159 -1
  10. package/hooks/blocking/env_var_table_code_drift_blocker.py +475 -0
  11. package/hooks/blocking/package_inventory_stale_blocker.py +54 -15
  12. package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +129 -0
  13. package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +198 -0
  14. package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
  15. package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +160 -0
  16. package/hooks/blocking/test_code_rules_enforcer_module_docstring_data_schema_scope.py +82 -0
  17. package/hooks/blocking/test_code_rules_enforcer_paired_test.py +190 -0
  18. package/hooks/blocking/test_code_rules_enforcer_polarity_name_contradiction.py +76 -0
  19. package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -0
  20. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +94 -0
  21. package/hooks/blocking/test_package_inventory_stale_blocker.py +46 -0
  22. package/hooks/blocking/test_pre_tool_use_dispatcher.py +7 -7
  23. package/hooks/hooks_constants/CLAUDE.md +1 -0
  24. package/hooks/hooks_constants/blocking_check_limits.py +78 -0
  25. package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
  26. package/hooks/hooks_constants/env_var_table_code_drift_constants.py +64 -0
  27. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +20 -9
  28. package/hooks/hooks_constants/paired_test_coverage_constants.py +11 -3
  29. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  30. package/package.json +1 -1
  31. package/rules/CLAUDE.md +1 -0
  32. package/rules/docstring-prose-matches-implementation.md +56 -54
  33. package/rules/env-var-table-code-drift.md +24 -0
  34. package/rules/package-inventory-stale-entry.md +4 -4
  35. package/rules/paired-test-coverage.md +12 -5
@@ -2,7 +2,8 @@
2
2
  """PreToolUse hook: blocks a new production file absent from its package inventory.
3
3
 
4
4
  A package directory documents its own files in a sibling inventory document — a
5
- ``README.md`` Layout table or a ``CLAUDE.md`` "Key files" list whose entries
5
+ ``README.md`` Layout table, a ``CLAUDE.md`` "Key files" list, or a skill
6
+ ``SKILL.md`` Layout table that maps the ``scripts/`` subdirectory — whose entries
6
7
  name each file in backticks. When a Write creates a new production code file in a
7
8
  directory whose inventory already names two or more sibling files but carries no
8
9
  entry naming the new file, the inventory and the directory disagree on the
@@ -24,6 +25,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
24
25
  if _hooks_dir not in sys.path:
25
26
  sys.path.insert(0, _hooks_dir)
26
27
 
28
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
27
29
  from hooks_constants.package_inventory_stale_blocker_constants import ( # noqa: E402
28
30
  ALL_EXEMPT_BASENAMES,
29
31
  ALL_EXEMPT_DIRECTORY_NAMES,
@@ -37,11 +39,12 @@ from hooks_constants.package_inventory_stale_blocker_constants import ( # noqa:
37
39
  MINIMUM_INVENTORY_ENTRY_COUNT,
38
40
  NON_FILENAME_TOKEN_PATTERN,
39
41
  PYTHON_FILE_EXTENSION,
42
+ SCRIPTS_SUBDIRECTORY_NAME,
43
+ SKILL_INVENTORY_DOCUMENT_NAME,
40
44
  STALE_INVENTORY_ADDITIONAL_CONTEXT,
41
45
  STALE_INVENTORY_MESSAGE_TEMPLATE,
42
46
  STALE_INVENTORY_SYSTEM_MESSAGE,
43
47
  )
44
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
45
48
  from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
46
49
  read_hook_input_dictionary_from_stdin,
47
50
  )
@@ -159,7 +162,7 @@ class _InventorySurvey:
159
162
 
160
163
  Attributes:
161
164
  present_inventory_names: The inventory document basenames present in the
162
- directory (``README.md`` and/or ``CLAUDE.md``).
165
+ directory (``README.md``, ``CLAUDE.md``, and/or ``SKILL.md``).
163
166
  named_basenames: Every bare filename the present inventories name.
164
167
  """
165
168
 
@@ -279,18 +282,48 @@ def _sibling_named_basenames(
279
282
  return sibling_basenames
280
283
 
281
284
 
285
+ def _parent_skill_inventory(package_directory: Path) -> _InventorySurvey | None:
286
+ """Return the parent skill ``SKILL.md`` survey for a ``scripts/`` directory.
287
+
288
+ A skill package keeps its ``SKILL.md`` at the skill root and maps the
289
+ ``scripts/`` subdirectory in a Layout table whose rows name files by their
290
+ ``scripts/<name>`` path. A production file landing in that ``scripts/``
291
+ directory is governed by the parent ``SKILL.md``, which sits one level up
292
+ rather than beside the file. This reads that parent ``SKILL.md`` and reports
293
+ the basenames it names. Any directory not named ``scripts/`` and any missing
294
+ or unreadable parent ``SKILL.md`` yield None.
295
+
296
+ Args:
297
+ package_directory: The directory that holds the file being written.
298
+
299
+ Returns:
300
+ The parent ``SKILL.md`` survey, or None when there is none to read.
301
+ """
302
+ if package_directory.name != SCRIPTS_SUBDIRECTORY_NAME:
303
+ return None
304
+ skill_inventory_path = package_directory.parent / SKILL_INVENTORY_DOCUMENT_NAME
305
+ inventory_content = _read_inventory_content(skill_inventory_path)
306
+ if inventory_content is None:
307
+ return None
308
+ return _InventorySurvey(
309
+ [SKILL_INVENTORY_DOCUMENT_NAME], inventory_named_basenames(inventory_content)
310
+ )
311
+
312
+
282
313
  def find_stale_inventory(file_path: str) -> _InventorySurvey | None:
283
314
  """Return the maintained inventory survey a new file is absent from, or None.
284
315
 
285
- The file's directory inventories are surveyed, then the named basenames are
286
- filtered to those that exist as files in the directory — the inventory's own
316
+ The file's own directory inventories are surveyed and, when the file sits in
317
+ a ``scripts/`` subdirectory, the parent skill ``SKILL.md`` Layout table is
318
+ surveyed too; the named basenames union across both. They are then filtered
319
+ to those that exist as files in the file's directory — the inventory's own
287
320
  sibling files. The survey reports a stale inventory only when every condition
288
- holds: the directory carries at least one inventory document, those documents
289
- together name at least the minimum entry count of on-disk sibling files
290
- (marking them a maintained inventory rather than incidental prose that
291
- mentions files living elsewhere), and the inventory does not already name this
292
- file's basename. When any condition fails the file is in step with its
293
- inventory (or there is no inventory to be out of step with), so None results.
321
+ holds: at least one inventory document is present, those documents together
322
+ name at least the minimum entry count of on-disk sibling files (marking them
323
+ a maintained inventory rather than incidental prose that mentions files living
324
+ elsewhere), and no inventory already names this file's basename. When any
325
+ condition fails the file is in step with its inventory (or there is no
326
+ inventory to be out of step with), so None results.
294
327
 
295
328
  Args:
296
329
  file_path: The destination path of the write.
@@ -302,14 +335,20 @@ def find_stale_inventory(file_path: str) -> _InventorySurvey | None:
302
335
  if not package_directory.is_dir():
303
336
  return None
304
337
  survey = survey_directory_inventories(package_directory)
305
- if not survey.present_inventory_names:
338
+ present_inventory_names = list(survey.present_inventory_names)
339
+ named_basenames = set(survey.named_basenames)
340
+ parent_skill_survey = _parent_skill_inventory(package_directory)
341
+ if parent_skill_survey is not None:
342
+ present_inventory_names += parent_skill_survey.present_inventory_names
343
+ named_basenames |= parent_skill_survey.named_basenames
344
+ if not present_inventory_names:
306
345
  return None
307
- sibling_basenames = _sibling_named_basenames(package_directory, survey.named_basenames)
346
+ sibling_basenames = _sibling_named_basenames(package_directory, named_basenames)
308
347
  if len(sibling_basenames) < MINIMUM_INVENTORY_ENTRY_COUNT:
309
348
  return None
310
- if os.path.basename(file_path) in survey.named_basenames:
349
+ if os.path.basename(file_path) in named_basenames:
311
350
  return None
312
- return _InventorySurvey(survey.present_inventory_names, sibling_basenames)
351
+ return _InventorySurvey(present_inventory_names, sibling_basenames)
313
352
 
314
353
 
315
354
  def _build_block_payload(file_path: str, survey: _InventorySurvey) -> dict:
@@ -0,0 +1,129 @@
1
+ """Tests for check_docstring_field_runmode_outcome — O6 run-mode-vs-per-record drift.
2
+
3
+ A dataclass field whose name marks a run-mode flag (``is_dry_run``) is documented
4
+ in the class Attributes block with per-record write-outcome prose ("True when no
5
+ STP was written"), while the value is set the same way for every record from the
6
+ run mode (``is_dry_run=not is_execute``). An already-OK record in an execute run
7
+ writes no file yet still stores ``False``, so the per-record prose misleads every
8
+ reader. This is the deterministic single-file slice of Category O6 drift.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib.util
14
+ from pathlib import Path
15
+ from types import ModuleType
16
+
17
+
18
+ def _load_enforcer_module() -> ModuleType:
19
+ module_path = Path(__file__).parent / "code_rules_enforcer.py"
20
+ spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
21
+ assert spec is not None
22
+ assert spec.loader is not None
23
+ module = importlib.util.module_from_spec(spec)
24
+ spec.loader.exec_module(module)
25
+ return module
26
+
27
+
28
+ code_rules_enforcer = _load_enforcer_module()
29
+
30
+
31
+ def check_docstring_field_runmode_outcome(content: str, file_path: str) -> list[str]:
32
+ return code_rules_enforcer.check_docstring_field_runmode_outcome(content, file_path)
33
+
34
+
35
+ PRODUCTION_FILE_PATH = "/project/src/jsonl_writer.py"
36
+ TEST_FILE_PATH = "/project/src/test_jsonl_writer.py"
37
+ HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/example.py"
38
+
39
+
40
+ def _drifted_record() -> str:
41
+ return (
42
+ "@dataclass(frozen=True)\n"
43
+ "class PerThemeJsonlRecord:\n"
44
+ ' """Exact schema for the per-theme JSONL line.\n'
45
+ "\n"
46
+ " Attributes:\n"
47
+ " theme_name: Theme directory basename.\n"
48
+ " is_dry_run: True when no STP was written.\n"
49
+ ' """\n'
50
+ "\n"
51
+ " theme_name: str\n"
52
+ " is_dry_run: bool\n"
53
+ )
54
+
55
+
56
+ def _run_mode_record() -> str:
57
+ return (
58
+ "@dataclass(frozen=True)\n"
59
+ "class PerThemeJsonlRecord:\n"
60
+ ' """Exact schema for the per-theme JSONL line.\n'
61
+ "\n"
62
+ " Attributes:\n"
63
+ " theme_name: Theme directory basename.\n"
64
+ " is_dry_run: True for a dry run; False for an execute run.\n"
65
+ ' """\n'
66
+ "\n"
67
+ " theme_name: str\n"
68
+ " is_dry_run: bool\n"
69
+ )
70
+
71
+
72
+ def test_flags_run_mode_field_documented_as_per_record_write_outcome() -> None:
73
+ issues = check_docstring_field_runmode_outcome(_drifted_record(), PRODUCTION_FILE_PATH)
74
+ assert len(issues) == 1
75
+ assert "is_dry_run" in issues[0]
76
+ assert "run-mode" in issues[0]
77
+
78
+
79
+ def test_run_mode_phrasing_is_left_alone() -> None:
80
+ issues = check_docstring_field_runmode_outcome(_run_mode_record(), PRODUCTION_FILE_PATH)
81
+ assert issues == []
82
+
83
+
84
+ def test_multiline_description_continuation_still_flags() -> None:
85
+ source = (
86
+ "@dataclass\n"
87
+ "class Record:\n"
88
+ ' """Schema.\n'
89
+ "\n"
90
+ " Attributes:\n"
91
+ " is_dry_run: True for the record when\n"
92
+ " no STP was written.\n"
93
+ ' """\n'
94
+ "\n"
95
+ " is_dry_run: bool\n"
96
+ )
97
+ issues = check_docstring_field_runmode_outcome(source, PRODUCTION_FILE_PATH)
98
+ assert len(issues) == 1
99
+
100
+
101
+ def test_non_run_mode_field_with_write_outcome_is_left_alone() -> None:
102
+ source = (
103
+ "@dataclass\n"
104
+ "class Record:\n"
105
+ ' """Schema.\n'
106
+ "\n"
107
+ " Attributes:\n"
108
+ " stp_path: Path the STP was written to disk.\n"
109
+ ' """\n'
110
+ "\n"
111
+ " stp_path: str\n"
112
+ )
113
+ issues = check_docstring_field_runmode_outcome(source, PRODUCTION_FILE_PATH)
114
+ assert issues == []
115
+
116
+
117
+ def test_test_file_is_exempt() -> None:
118
+ issues = check_docstring_field_runmode_outcome(_drifted_record(), TEST_FILE_PATH)
119
+ assert issues == []
120
+
121
+
122
+ def test_hook_infrastructure_is_exempt() -> None:
123
+ issues = check_docstring_field_runmode_outcome(_drifted_record(), HOOK_INFRASTRUCTURE_PATH)
124
+ assert issues == []
125
+
126
+
127
+ def test_syntax_error_returns_no_issues() -> None:
128
+ issues = check_docstring_field_runmode_outcome("class Record(\n", PRODUCTION_FILE_PATH)
129
+ assert issues == []
@@ -0,0 +1,198 @@
1
+ """Tests for check_docstring_length_constant_superlative_vs_exact_gate.
2
+
3
+ A config module defines an integer ``*_LENGTH`` constant and its docstring
4
+ describes it with a superlative word ("the longest color string the swatch
5
+ accepts"), while the only consumer treats the constant as an exact-length
6
+ equality gate ("len(hex_color) != COLOR_AARRGGBB_LENGTH") in a sibling module.
7
+ A shorter valid string is rejected, not accepted at a shorter length, so the
8
+ "longest" prose claims a range of accepted lengths the code never allows. This
9
+ is the deterministic exact-gate slice of Category O6/O8
10
+ docstring-vs-implementation drift.
11
+
12
+ The package trees these tests build live under a neutral OS temp directory
13
+ rather than the pytest ``tmp_path`` fixture: the production check exempts any
14
+ path containing "test", and the pytest fixture root carries that substring, so a
15
+ fixture-rooted package would be skipped as test code and prove nothing.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import importlib.util
21
+ import tempfile
22
+ from pathlib import Path
23
+ from types import ModuleType
24
+
25
+
26
+ def _load_enforcer_module() -> ModuleType:
27
+ module_path = Path(__file__).parent / "code_rules_enforcer.py"
28
+ spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
29
+ assert spec is not None
30
+ assert spec.loader is not None
31
+ module = importlib.util.module_from_spec(spec)
32
+ spec.loader.exec_module(module)
33
+ return module
34
+
35
+
36
+ code_rules_enforcer = _load_enforcer_module()
37
+
38
+
39
+ def check_length_gate(content: str, file_path: str) -> list[str]:
40
+ return code_rules_enforcer.check_docstring_length_constant_superlative_vs_exact_gate(
41
+ content, file_path
42
+ )
43
+
44
+
45
+ SUPERLATIVE_SWATCH_MODULE = '''"""Color-swatch rendering constants for the HTML run report.
46
+
47
+ These constants name the longest color string the swatch accepts and the
48
+ channel maximum the alpha byte is divided by.
49
+ """
50
+
51
+ from typing import Final
52
+
53
+ COLOR_AARRGGBB_LENGTH: Final[int] = 9
54
+ '''
55
+
56
+
57
+ def _run_gate_over_package(
58
+ swatch_content: str, consumer_body: str, swatch_basename: str = "color_swatch.py"
59
+ ) -> list[str]:
60
+ with tempfile.TemporaryDirectory() as temp_root:
61
+ config_directory = Path(temp_root) / "stp_contrast_fix" / "config"
62
+ config_directory.mkdir(parents=True)
63
+ swatch_path = config_directory / swatch_basename
64
+ swatch_path.write_text(swatch_content, encoding="utf-8")
65
+ consumer_path = config_directory.parent / "html_report_fragments.py"
66
+ consumer_path.write_text(consumer_body, encoding="utf-8")
67
+ return check_length_gate(swatch_content, str(swatch_path))
68
+
69
+
70
+ def test_superlative_describing_exact_length_gate_is_flagged() -> None:
71
+ consumer_body = (
72
+ "from config.color_swatch import COLOR_AARRGGBB_LENGTH\n\n\n"
73
+ "def to_css(hex_color: str) -> str | None:\n"
74
+ ' if not hex_color.startswith("#") or len(hex_color) != '
75
+ "COLOR_AARRGGBB_LENGTH:\n"
76
+ " return None\n"
77
+ " return hex_color\n"
78
+ )
79
+ issues = _run_gate_over_package(SUPERLATIVE_SWATCH_MODULE, consumer_body)
80
+ assert len(issues) == 1
81
+ assert "COLOR_AARRGGBB_LENGTH" in issues[0]
82
+
83
+
84
+ def test_ordered_comparison_treated_as_maximum_is_not_flagged() -> None:
85
+ consumer_body = (
86
+ "from config.color_swatch import COLOR_AARRGGBB_LENGTH\n\n\n"
87
+ "def truncated(hex_color: str) -> bool:\n"
88
+ " return len(hex_color) <= COLOR_AARRGGBB_LENGTH\n"
89
+ )
90
+ assert _run_gate_over_package(SUPERLATIVE_SWATCH_MODULE, consumer_body) == []
91
+
92
+
93
+ def test_no_superlative_phrase_is_not_flagged() -> None:
94
+ plain_module = (
95
+ '"""The exact required #AARRGGBB length the swatch renders."""\n\n'
96
+ "from typing import Final\n\n"
97
+ "COLOR_AARRGGBB_LENGTH: Final[int] = 9\n"
98
+ )
99
+ consumer_body = (
100
+ "from config.color_swatch import COLOR_AARRGGBB_LENGTH\n\n\n"
101
+ "def to_css(hex_color: str) -> bool:\n"
102
+ " return len(hex_color) == COLOR_AARRGGBB_LENGTH\n"
103
+ )
104
+ assert _run_gate_over_package(plain_module, consumer_body) == []
105
+
106
+
107
+ def test_constant_without_length_suffix_is_not_flagged() -> None:
108
+ non_length_module = (
109
+ '"""The longest swatch threshold value used by the renderer."""\n\n'
110
+ "from typing import Final\n\n"
111
+ "COLOR_CHANNEL_MAXIMUM: Final[int] = 255\n"
112
+ )
113
+ consumer_body = (
114
+ "from config.color_swatch import COLOR_CHANNEL_MAXIMUM\n\n\n"
115
+ "def saturated(channel: str) -> bool:\n"
116
+ " return len(channel) == COLOR_CHANNEL_MAXIMUM\n"
117
+ )
118
+ assert _run_gate_over_package(non_length_module, consumer_body) == []
119
+
120
+
121
+ def test_constant_with_no_length_comparison_is_not_flagged() -> None:
122
+ consumer_body = (
123
+ "from config.color_swatch import COLOR_AARRGGBB_LENGTH\n\n\n"
124
+ "def pad(hex_color: str) -> str:\n"
125
+ " return hex_color.ljust(COLOR_AARRGGBB_LENGTH)\n"
126
+ )
127
+ assert _run_gate_over_package(SUPERLATIVE_SWATCH_MODULE, consumer_body) == []
128
+
129
+
130
+ def test_superlative_in_function_docstring_is_not_flagged() -> None:
131
+ function_docstring_module = (
132
+ '"""The exact #AARRGGBB length the swatch renders."""\n\n'
133
+ "from typing import Final\n\n"
134
+ "COLOR_AARRGGBB_LENGTH: Final[int] = 9\n\n\n"
135
+ "def describe() -> str:\n"
136
+ ' """Return the longest color string the swatch accepts."""\n'
137
+ ' return "swatch"\n'
138
+ )
139
+ consumer_body = (
140
+ "from config.color_swatch import COLOR_AARRGGBB_LENGTH\n\n\n"
141
+ "def to_css(hex_color: str) -> bool:\n"
142
+ " return len(hex_color) == COLOR_AARRGGBB_LENGTH\n"
143
+ )
144
+ assert _run_gate_over_package(function_docstring_module, consumer_body) == []
145
+
146
+
147
+ def test_superlative_bound_to_other_constant_is_not_flagged() -> None:
148
+ two_constant_module = (
149
+ '"""Color-swatch rendering constants for the HTML run report.\n\n'
150
+ "COLOR_AARRGGBB_LENGTH is the exact #AARRGGBB length the swatch renders.\n"
151
+ "MAX_NAME_LENGTH is the longest swatch label the legend lays out.\n"
152
+ '"""\n\n'
153
+ "from typing import Final\n\n"
154
+ "COLOR_AARRGGBB_LENGTH: Final[int] = 9\n"
155
+ "MAX_NAME_LENGTH: Final[int] = 32\n"
156
+ )
157
+ consumer_body = (
158
+ "from config.color_swatch import COLOR_AARRGGBB_LENGTH, MAX_NAME_LENGTH\n\n\n"
159
+ "def to_css(hex_color: str) -> str | None:\n"
160
+ " if len(hex_color) != COLOR_AARRGGBB_LENGTH:\n"
161
+ " return None\n"
162
+ " return hex_color\n\n\n"
163
+ "def fits(label: str) -> bool:\n"
164
+ " return len(label) <= MAX_NAME_LENGTH\n"
165
+ )
166
+ assert _run_gate_over_package(two_constant_module, consumer_body) == []
167
+
168
+
169
+ def test_superlative_bound_to_exact_gated_constant_is_flagged() -> None:
170
+ two_constant_module = (
171
+ '"""Color-swatch rendering constants for the HTML run report.\n\n'
172
+ "COLOR_AARRGGBB_LENGTH is the longest #AARRGGBB string the swatch accepts.\n"
173
+ "MAX_NAME_LENGTH is the exact swatch label width the legend lays out.\n"
174
+ '"""\n\n'
175
+ "from typing import Final\n\n"
176
+ "COLOR_AARRGGBB_LENGTH: Final[int] = 9\n"
177
+ "MAX_NAME_LENGTH: Final[int] = 32\n"
178
+ )
179
+ consumer_body = (
180
+ "from config.color_swatch import COLOR_AARRGGBB_LENGTH, MAX_NAME_LENGTH\n\n\n"
181
+ "def to_css(hex_color: str) -> str | None:\n"
182
+ " if len(hex_color) != COLOR_AARRGGBB_LENGTH:\n"
183
+ " return None\n"
184
+ " return hex_color\n\n\n"
185
+ "def fits(label: str) -> bool:\n"
186
+ " return len(label) <= MAX_NAME_LENGTH\n"
187
+ )
188
+ issues = _run_gate_over_package(two_constant_module, consumer_body)
189
+ assert len(issues) == 1
190
+ assert "COLOR_AARRGGBB_LENGTH" in issues[0]
191
+ assert "MAX_NAME_LENGTH" not in issues[0]
192
+
193
+
194
+ def test_syntax_error_returns_empty() -> None:
195
+ issues = check_length_gate(
196
+ "def broken(\n", "/stp_contrast_fix/config/color_swatch.py"
197
+ )
198
+ assert issues == []
@@ -0,0 +1,115 @@
1
+ """Tests for check_docstring_no_network_claim_with_metadata_access — Category O6.
2
+
3
+ A docstring promising a code path returns "without touching the network" drifts
4
+ when the body calls a path-metadata method (``is_file``, ``stat``, ...): on a
5
+ network share each metadata call is a round-trip over the wire, so the no-network
6
+ claim is false. This is the deterministic slice of Category O6 (docstring prose
7
+ versus implementation drift) for a no-network claim.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.util
13
+ from pathlib import Path
14
+ from types import ModuleType
15
+
16
+
17
+ def _load_enforcer_module() -> ModuleType:
18
+ module_path = Path(__file__).parent / "code_rules_enforcer.py"
19
+ spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
20
+ assert spec is not None
21
+ assert spec.loader is not None
22
+ module = importlib.util.module_from_spec(spec)
23
+ spec.loader.exec_module(module)
24
+ return module
25
+
26
+
27
+ code_rules_enforcer = _load_enforcer_module()
28
+
29
+
30
+ def check_docstring_no_network_claim(content: str, file_path: str) -> list[str]:
31
+ return code_rules_enforcer.check_docstring_no_network_claim_with_metadata_access(
32
+ content, file_path
33
+ )
34
+
35
+
36
+ PRODUCTION_FILE_PATH = "/project/shared_utils/bws/secret_fetch.py"
37
+ TEST_FILE_PATH = "/project/shared_utils/bws/tests/test_secret_fetch.py"
38
+ HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/example.py"
39
+
40
+
41
+ def test_flags_no_network_claim_with_is_file_and_stat() -> None:
42
+ content = (
43
+ "def _ensure_local_bws_cache() -> str:\n"
44
+ ' """Return the local cache path, repopulating when stale.\n'
45
+ "\n"
46
+ " An existing cache is returned without touching the network; a\n"
47
+ " missing or size-mismatched cache is repopulated from the bundled\n"
48
+ " executable.\n"
49
+ ' """\n'
50
+ " if BUNDLED_BWS_EXECUTABLE_PATH.is_file():\n"
51
+ " bundled_size = BUNDLED_BWS_EXECUTABLE_PATH.stat().st_size\n"
52
+ " return str(LOCAL_BWS_CACHE_PATH)\n"
53
+ " return str(LOCAL_BWS_CACHE_PATH)\n"
54
+ )
55
+ issues = check_docstring_no_network_claim(content, PRODUCTION_FILE_PATH)
56
+ assert len(issues) == 1
57
+ assert "_ensure_local_bws_cache" in issues[0]
58
+ assert "without touching the network" in issues[0]
59
+
60
+
61
+ def test_flags_no_network_access_phrase_with_exists() -> None:
62
+ content = (
63
+ "def read_warm_cache(cache_path) -> str:\n"
64
+ ' """Serve the warm cache with no network access."""\n'
65
+ " if cache_path.exists():\n"
66
+ " return cache_path.read_text()\n"
67
+ " return ''\n"
68
+ )
69
+ assert len(check_docstring_no_network_claim(content, PRODUCTION_FILE_PATH)) == 1
70
+
71
+
72
+ def test_passes_when_claim_present_but_no_metadata_access() -> None:
73
+ content = (
74
+ "def read_warm_cache(cache_path) -> str:\n"
75
+ ' """Serve the warm cache without touching the network."""\n'
76
+ " return cache_path.read_text()\n"
77
+ )
78
+ assert check_docstring_no_network_claim(content, PRODUCTION_FILE_PATH) == []
79
+
80
+
81
+ def test_passes_when_metadata_access_but_no_network_claim() -> None:
82
+ content = (
83
+ "def _ensure_local_bws_cache(share_path) -> str:\n"
84
+ ' """Return the local cache path, repopulating when stale.\n'
85
+ "\n"
86
+ " The bundled share is stat-checked on every call to validate the\n"
87
+ " cache against the bundled executable size.\n"
88
+ ' """\n'
89
+ " if share_path.is_file():\n"
90
+ " return str(share_path)\n"
91
+ " return ''\n"
92
+ )
93
+ assert check_docstring_no_network_claim(content, PRODUCTION_FILE_PATH) == []
94
+
95
+
96
+ def test_test_files_are_exempt() -> None:
97
+ content = (
98
+ "def _ensure_local_bws_cache(share_path) -> str:\n"
99
+ ' """Return the cache without touching the network."""\n'
100
+ " if share_path.is_file():\n"
101
+ " return str(share_path)\n"
102
+ " return ''\n"
103
+ )
104
+ assert check_docstring_no_network_claim(content, TEST_FILE_PATH) == []
105
+
106
+
107
+ def test_hook_infrastructure_is_exempt() -> None:
108
+ content = (
109
+ "def _ensure_local_bws_cache(share_path) -> str:\n"
110
+ ' """Return the cache without touching the network."""\n'
111
+ " if share_path.is_file():\n"
112
+ " return str(share_path)\n"
113
+ " return ''\n"
114
+ )
115
+ assert check_docstring_no_network_claim(content, HOOK_INFRASTRUCTURE_PATH) == []