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.
- package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -0
- package/bin/install.mjs +1 -0
- package/bin/install.test.mjs +3 -2
- package/hooks/blocking/CLAUDE.md +3 -2
- package/hooks/blocking/code_rules_docstrings.py +609 -16
- package/hooks/blocking/code_rules_enforcer.py +37 -0
- package/hooks/blocking/code_rules_naming_collection.py +76 -1
- package/hooks/blocking/code_rules_paired_test.py +240 -22
- package/hooks/blocking/code_rules_test_assertions.py +159 -1
- package/hooks/blocking/env_var_table_code_drift_blocker.py +475 -0
- package/hooks/blocking/package_inventory_stale_blocker.py +54 -15
- package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +129 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +198 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +160 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_data_schema_scope.py +82 -0
- package/hooks/blocking/test_code_rules_enforcer_paired_test.py +190 -0
- package/hooks/blocking/test_code_rules_enforcer_polarity_name_contradiction.py +76 -0
- package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -0
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +94 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +46 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +7 -7
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/blocking_check_limits.py +78 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
- package/hooks/hooks_constants/env_var_table_code_drift_constants.py +64 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +20 -9
- package/hooks/hooks_constants/paired_test_coverage_constants.py +11 -3
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +56 -54
- package/rules/env-var-table-code-drift.md +24 -0
- package/rules/package-inventory-stale-entry.md +4 -4
- 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
|
|
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 ``
|
|
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,
|
|
286
|
-
|
|
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:
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
inventory
|
|
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
|
-
|
|
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,
|
|
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
|
|
349
|
+
if os.path.basename(file_path) in named_basenames:
|
|
311
350
|
return None
|
|
312
|
-
return _InventorySurvey(
|
|
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) == []
|