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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Skip-decorator, existence-only, constant-equality, stale-test-name,
|
|
1
|
+
"""Skip-decorator, existence-only, constant-equality, stale-test-name, flag-gated scenario, and vacuous cleanup-assertion test-quality checks."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
import sys
|
|
@@ -6,6 +6,37 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
_SCENARIO_NAME_CLAUSES = ("_when_", "_passes", "_succeeds", "_on_clean")
|
|
8
8
|
_MINIMUM_SIBLING_PATCH_COUNT = 2
|
|
9
|
+
_CLEANUP_REMOVAL_NAME_TOKENS = (
|
|
10
|
+
"remove",
|
|
11
|
+
"removes",
|
|
12
|
+
"removed",
|
|
13
|
+
"cleanup",
|
|
14
|
+
"clean_up",
|
|
15
|
+
"deletes",
|
|
16
|
+
"discards",
|
|
17
|
+
"no_tmp",
|
|
18
|
+
"no_temp",
|
|
19
|
+
"no_leftover",
|
|
20
|
+
"leftover",
|
|
21
|
+
)
|
|
22
|
+
_FAILURE_CONDITION_NAME_TOKENS = (
|
|
23
|
+
"on_failure",
|
|
24
|
+
"_failure",
|
|
25
|
+
"on_error",
|
|
26
|
+
"_fails",
|
|
27
|
+
"_failed",
|
|
28
|
+
"raises",
|
|
29
|
+
"bad_source",
|
|
30
|
+
)
|
|
31
|
+
_ARRANGEMENT_METHOD_NAMES = (
|
|
32
|
+
"setattr",
|
|
33
|
+
"mkdir",
|
|
34
|
+
"write_text",
|
|
35
|
+
"write_bytes",
|
|
36
|
+
"touch",
|
|
37
|
+
"symlink_to",
|
|
38
|
+
"exists",
|
|
39
|
+
)
|
|
9
40
|
|
|
10
41
|
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
11
42
|
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
@@ -233,6 +264,133 @@ def check_constant_equality_tests(content: str, file_path: str) -> list[str]:
|
|
|
233
264
|
return issues
|
|
234
265
|
|
|
235
266
|
|
|
267
|
+
def _name_signals_cleanup_on_failure(test_name: str) -> bool:
|
|
268
|
+
"""Return True when a test name pairs a removal token with a failure token."""
|
|
269
|
+
lowered_name = test_name.lower()
|
|
270
|
+
has_removal_token = any(
|
|
271
|
+
each_token in lowered_name for each_token in _CLEANUP_REMOVAL_NAME_TOKENS
|
|
272
|
+
)
|
|
273
|
+
has_failure_token = any(
|
|
274
|
+
each_token in lowered_name for each_token in _FAILURE_CONDITION_NAME_TOKENS
|
|
275
|
+
)
|
|
276
|
+
return has_removal_token and has_failure_token
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _is_absence_assertion(assert_node: ast.Assert) -> bool:
|
|
280
|
+
"""Return True when an assertion only checks that a collection is empty."""
|
|
281
|
+
test_expr = assert_node.test
|
|
282
|
+
if isinstance(test_expr, ast.UnaryOp) and isinstance(test_expr.op, ast.Not):
|
|
283
|
+
return True
|
|
284
|
+
if not isinstance(test_expr, ast.Compare):
|
|
285
|
+
return False
|
|
286
|
+
if len(test_expr.ops) != 1 or not isinstance(test_expr.ops[0], ast.Eq):
|
|
287
|
+
return False
|
|
288
|
+
comparator = test_expr.comparators[0]
|
|
289
|
+
if (
|
|
290
|
+
isinstance(comparator, ast.Constant)
|
|
291
|
+
and isinstance(comparator.value, int)
|
|
292
|
+
and not isinstance(comparator.value, bool)
|
|
293
|
+
and comparator.value == 0
|
|
294
|
+
):
|
|
295
|
+
return True
|
|
296
|
+
return isinstance(comparator, ast.List) and not comparator.elts
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _body_calls_attribute(
|
|
300
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef, attribute_name: str
|
|
301
|
+
) -> bool:
|
|
302
|
+
"""Return True when the function body calls a method with the given attribute name."""
|
|
303
|
+
for each_node in ast.walk(function_node):
|
|
304
|
+
if not isinstance(each_node, ast.Call):
|
|
305
|
+
continue
|
|
306
|
+
callee = each_node.func
|
|
307
|
+
if isinstance(callee, ast.Attribute) and callee.attr == attribute_name:
|
|
308
|
+
return True
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _body_arranges_temp_existence(
|
|
313
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
314
|
+
) -> bool:
|
|
315
|
+
"""Return True when the body creates a temp file or arranges a post-creation failure.
|
|
316
|
+
|
|
317
|
+
A sound cleanup-on-failure test proves the temp existed before asserting its
|
|
318
|
+
removal: it monkeypatches the operation to fail after the temp is created, or
|
|
319
|
+
it creates the temp directly and checks its presence. Either signal means the
|
|
320
|
+
leftover-absence assertion is not vacuous.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
function_node: The test function whose body is inspected.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
True when the body references ``monkeypatch`` or calls a file-creation or
|
|
327
|
+
existence method that establishes the temp file before the cleanup runs.
|
|
328
|
+
"""
|
|
329
|
+
for each_node in ast.walk(function_node):
|
|
330
|
+
if isinstance(each_node, ast.Name) and each_node.id == "monkeypatch":
|
|
331
|
+
return True
|
|
332
|
+
if not isinstance(each_node, ast.Call):
|
|
333
|
+
continue
|
|
334
|
+
callee = each_node.func
|
|
335
|
+
if isinstance(callee, ast.Attribute) and callee.attr in _ARRANGEMENT_METHOD_NAMES:
|
|
336
|
+
return True
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def check_vacuous_cleanup_assertion_tests(content: str, file_path: str) -> list[str]:
|
|
341
|
+
"""Flag a cleanup-on-failure test whose leftover-absence assertion is vacuous.
|
|
342
|
+
|
|
343
|
+
A test named for removal on failure that globs a directory and asserts the
|
|
344
|
+
result is empty, yet never creates the temp file nor arranges a failure after
|
|
345
|
+
the temp exists, passes even when the on-failure cleanup is entirely broken —
|
|
346
|
+
the leftover set is empty because nothing ever staged a leftover. The gate
|
|
347
|
+
fires when the test name pairs a removal token with a failure token, the body
|
|
348
|
+
globs a directory, every assertion only checks emptiness, and the body neither
|
|
349
|
+
monkeypatches a post-creation failure nor creates and checks the temp first.
|
|
350
|
+
Only applies to test files; production files are exempt.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
content: The file body under validation.
|
|
354
|
+
file_path: Path to the file, used for the test-file gate.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
One issue per test whose cleanup-on-failure assertion passes vacuously.
|
|
358
|
+
"""
|
|
359
|
+
if not is_test_file(file_path):
|
|
360
|
+
return []
|
|
361
|
+
try:
|
|
362
|
+
syntax_tree = ast.parse(content)
|
|
363
|
+
except SyntaxError:
|
|
364
|
+
return []
|
|
365
|
+
|
|
366
|
+
issues: list[str] = []
|
|
367
|
+
for each_node in ast.walk(syntax_tree):
|
|
368
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
369
|
+
continue
|
|
370
|
+
if not each_node.name.startswith("test"):
|
|
371
|
+
continue
|
|
372
|
+
if not _name_signals_cleanup_on_failure(each_node.name):
|
|
373
|
+
continue
|
|
374
|
+
if not _body_calls_attribute(each_node, "glob"):
|
|
375
|
+
continue
|
|
376
|
+
if _body_arranges_temp_existence(each_node):
|
|
377
|
+
continue
|
|
378
|
+
body_assertions = _collect_body_assertions(each_node.body)
|
|
379
|
+
if not body_assertions:
|
|
380
|
+
continue
|
|
381
|
+
if not all(_is_absence_assertion(each_assert) for each_assert in body_assertions):
|
|
382
|
+
continue
|
|
383
|
+
issues.append(
|
|
384
|
+
f"Line {each_node.lineno}: vacuous cleanup-on-failure test"
|
|
385
|
+
f" — asserts no leftover temp file but never proves the temp was created,"
|
|
386
|
+
f" so it passes even when cleanup is broken; arrange a post-creation"
|
|
387
|
+
f" failure (e.g. monkeypatch os.replace to raise after the temp exists)"
|
|
388
|
+
f" then assert the temp was removed"
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
return issues
|
|
392
|
+
|
|
393
|
+
|
|
236
394
|
def _flag_symbol_from_setattr_target(target_node: ast.expr) -> str | None:
|
|
237
395
|
"""Return the UPPER_SNAKE flag symbol a monkeypatch.setattr target names.
|
|
238
396
|
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: blocks a markdown env-var table that names a non-reading code file.
|
|
3
|
+
|
|
4
|
+
A documentation file often carries a "Summary: Environment Variables" table whose
|
|
5
|
+
rows pair an environment variable with the code file that reads it, written as
|
|
6
|
+
``| `GOOGLE_APPLICATION_CREDENTIALS` | `auth/google_auth.py` | ... |``. When that
|
|
7
|
+
code file exists under the repository yet its source never references the
|
|
8
|
+
variable name, the row is stale: the table claims a consumer relationship the
|
|
9
|
+
code does not have, so a reader trusts the doc to a behavior the code lost. This
|
|
10
|
+
hook fires on Write, Edit, and MultiEdit targeting a ``.md`` file and blocks the
|
|
11
|
+
write when a row attributes a variable to a code file that exists yet does not
|
|
12
|
+
read it. A row whose code file cannot be resolved under the scan root is left
|
|
13
|
+
alone (the hook cannot prove the drift), and for an Edit the drift the file
|
|
14
|
+
already held on an untouched row is excluded so only drift the edit introduces is
|
|
15
|
+
reported.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TextIO
|
|
23
|
+
|
|
24
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
25
|
+
if _hooks_dir not in sys.path:
|
|
26
|
+
sys.path.insert(0, _hooks_dir)
|
|
27
|
+
|
|
28
|
+
from hooks_constants.env_var_table_code_drift_constants import ( # noqa: E402
|
|
29
|
+
ALL_CODE_FILE_EXTENSIONS,
|
|
30
|
+
ALL_NOISE_DIRECTORY_NAMES,
|
|
31
|
+
BACKTICK_TOKEN_PATTERN,
|
|
32
|
+
CODE_FENCE_PATTERN,
|
|
33
|
+
DRIFT_ADDITIONAL_CONTEXT,
|
|
34
|
+
DRIFT_MESSAGE_TEMPLATE,
|
|
35
|
+
DRIFT_SYSTEM_MESSAGE,
|
|
36
|
+
ENV_VAR_NAME_PATTERN,
|
|
37
|
+
GIT_DIRECTORY_NAME,
|
|
38
|
+
MARKDOWN_FILE_EXTENSION,
|
|
39
|
+
MAX_DRIFT_ISSUES,
|
|
40
|
+
MAX_SUBTREE_FILES_SCANNED,
|
|
41
|
+
MINIMUM_ENV_VAR_ROW_CELL_COUNT,
|
|
42
|
+
SEPARATOR_CELL_PATTERN,
|
|
43
|
+
TABLE_ROW_PATTERN,
|
|
44
|
+
)
|
|
45
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
46
|
+
from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
|
|
47
|
+
apply_edits,
|
|
48
|
+
edits_for_tool,
|
|
49
|
+
)
|
|
50
|
+
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
51
|
+
read_hook_input_dictionary_from_stdin,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_markdown_file(file_path: str) -> bool:
|
|
56
|
+
"""Return whether file_path names a markdown file.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
file_path: The destination path of the write or edit.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True when the path extension is ``.md`` (case-insensitive).
|
|
63
|
+
"""
|
|
64
|
+
_, extension = os.path.splitext(file_path)
|
|
65
|
+
return extension.lower() == MARKDOWN_FILE_EXTENSION
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _row_cells(table_line: str) -> list[str]:
|
|
69
|
+
"""Return the trimmed cells of one markdown table row.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
table_line: A single line that begins with a pipe character.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
The text of each pipe-delimited cell, stripped, with the empty leading
|
|
76
|
+
and trailing segments a bounding pipe produces removed.
|
|
77
|
+
"""
|
|
78
|
+
stripped_line = table_line.strip()
|
|
79
|
+
inner = stripped_line.strip("|")
|
|
80
|
+
return [each_cell.strip() for each_cell in inner.split("|")]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _first_backtick_token(cell_text: str) -> str | None:
|
|
84
|
+
"""Return the first backtick-wrapped token in a cell, when it has one.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
cell_text: The trimmed text of a table cell.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The text inside the first pair of backticks, or None when the cell
|
|
91
|
+
carries no backtick-wrapped token.
|
|
92
|
+
"""
|
|
93
|
+
token_match = BACKTICK_TOKEN_PATTERN.search(cell_text)
|
|
94
|
+
if token_match is None:
|
|
95
|
+
return None
|
|
96
|
+
inner_text = token_match.group(1).strip()
|
|
97
|
+
return inner_text or None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _env_var_name_in_cell(cell_text: str) -> str | None:
|
|
101
|
+
"""Return the environment-variable name a cell names, when it names one.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
cell_text: The trimmed text of a table cell.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The UPPER_SNAKE variable name inside the first backticks, or None when
|
|
108
|
+
the cell names no variable-shaped token.
|
|
109
|
+
"""
|
|
110
|
+
token = _first_backtick_token(cell_text)
|
|
111
|
+
if token is None:
|
|
112
|
+
return None
|
|
113
|
+
if ENV_VAR_NAME_PATTERN.match(token) is None:
|
|
114
|
+
return None
|
|
115
|
+
return token
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _code_file_reference_in_cell(cell_text: str) -> str | None:
|
|
119
|
+
"""Return the code-file path a cell names, when it names one.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
cell_text: The trimmed text of a table cell.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The relative code-file path inside the first backticks, or None when the
|
|
126
|
+
token carries no recognized code-file extension.
|
|
127
|
+
"""
|
|
128
|
+
token = _first_backtick_token(cell_text)
|
|
129
|
+
if token is None:
|
|
130
|
+
return None
|
|
131
|
+
_, extension = os.path.splitext(token)
|
|
132
|
+
if extension.lower() not in ALL_CODE_FILE_EXTENSIONS:
|
|
133
|
+
return None
|
|
134
|
+
return token
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _is_separator_row(all_cells: list[str]) -> bool:
|
|
138
|
+
"""Return whether every cell is a markdown table header-separator cell.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
all_cells: The trimmed cells of one table row.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
True when each cell holds only dashes, colons, and whitespace.
|
|
145
|
+
"""
|
|
146
|
+
return all(SEPARATOR_CELL_PATTERN.match(each_cell) is not None for each_cell in all_cells)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _resolve_scan_root(markdown_directory: Path) -> Path:
|
|
150
|
+
"""Return the repository directory whose subtree bounds the code-file search.
|
|
151
|
+
|
|
152
|
+
Walks up from the markdown directory to the nearest ancestor holding a
|
|
153
|
+
``.git`` entry, so a relative code path resolves against the repository root.
|
|
154
|
+
When no ancestor holds ``.git``, the markdown directory parent (or the
|
|
155
|
+
directory itself when it has no distinct parent) bounds the search.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
markdown_directory: The directory that holds the target markdown file.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The directory to walk when resolving a referenced code file.
|
|
162
|
+
"""
|
|
163
|
+
for each_directory in (markdown_directory, *markdown_directory.parents):
|
|
164
|
+
if (each_directory / GIT_DIRECTORY_NAME).exists():
|
|
165
|
+
return each_directory
|
|
166
|
+
parent_directory = markdown_directory.parent
|
|
167
|
+
if parent_directory == markdown_directory:
|
|
168
|
+
return markdown_directory
|
|
169
|
+
return parent_directory
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _is_under_noise_directory(scan_root: Path, candidate_path: Path) -> bool:
|
|
173
|
+
"""Return whether candidate_path lies inside a pruned noise directory.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
scan_root: The directory the walk descends from.
|
|
177
|
+
candidate_path: A path the walk yielded under the scan root.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True when any path segment below scan_root names a noise directory.
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
relative_segments = candidate_path.relative_to(scan_root).parts
|
|
184
|
+
except ValueError:
|
|
185
|
+
relative_segments = candidate_path.parts
|
|
186
|
+
return any(each_segment in ALL_NOISE_DIRECTORY_NAMES for each_segment in relative_segments)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _resolve_code_file(scan_root: Path, code_reference: str) -> Path | None:
|
|
190
|
+
"""Return the on-disk code file a row reference names, when one resolves.
|
|
191
|
+
|
|
192
|
+
Resolves the relative reference against the scan root first; when that exact
|
|
193
|
+
path is absent, searches the subtree for a file whose path ends with the
|
|
194
|
+
referenced segments, so ``auth/google_auth.py`` resolves under a nested
|
|
195
|
+
package. A match inside a noise directory is pruned.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
scan_root: The directory whose subtree bounds the search.
|
|
199
|
+
code_reference: The relative code-file path a table row names.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
The resolved code file, or None when no file matches under the scan root.
|
|
203
|
+
"""
|
|
204
|
+
normalized_reference = code_reference.replace("\\", "/").lstrip("/")
|
|
205
|
+
direct_path = scan_root / normalized_reference
|
|
206
|
+
if direct_path.is_file():
|
|
207
|
+
return direct_path
|
|
208
|
+
reference_basename = os.path.basename(normalized_reference)
|
|
209
|
+
if not reference_basename:
|
|
210
|
+
return None
|
|
211
|
+
suffix_marker = "/" + normalized_reference
|
|
212
|
+
scanned_count = 0
|
|
213
|
+
for each_match in scan_root.rglob(reference_basename):
|
|
214
|
+
if _is_under_noise_directory(scan_root, each_match):
|
|
215
|
+
continue
|
|
216
|
+
scanned_count += 1
|
|
217
|
+
if scanned_count > MAX_SUBTREE_FILES_SCANNED:
|
|
218
|
+
return None
|
|
219
|
+
try:
|
|
220
|
+
if not each_match.is_file():
|
|
221
|
+
continue
|
|
222
|
+
except OSError:
|
|
223
|
+
continue
|
|
224
|
+
match_text = "/" + str(each_match).replace("\\", "/")
|
|
225
|
+
if match_text.endswith(suffix_marker):
|
|
226
|
+
return each_match
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _code_file_reads_variable(code_file: Path, variable_name: str) -> bool | None:
|
|
231
|
+
"""Return whether a code file source references variable_name.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
code_file: The resolved on-disk code file.
|
|
235
|
+
variable_name: The environment-variable name to look for.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
True when the file text contains the variable name, False when it does
|
|
239
|
+
not, and None when the file cannot be read.
|
|
240
|
+
"""
|
|
241
|
+
try:
|
|
242
|
+
source_text = code_file.read_text(encoding="utf-8")
|
|
243
|
+
except (OSError, UnicodeDecodeError):
|
|
244
|
+
return None
|
|
245
|
+
return variable_name in source_text
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def find_drift_rows(content: str, markdown_directory: Path) -> list[str]:
|
|
249
|
+
"""Return each env-var table row whose code file does not read the variable.
|
|
250
|
+
|
|
251
|
+
Walks the markdown content line by line, skipping lines inside a fenced code
|
|
252
|
+
block. A table row counts when its first cell names an UPPER_SNAKE variable and
|
|
253
|
+
a later cell names a code file with a recognized extension. The row drifts when
|
|
254
|
+
that code file resolves under the scan root yet its source never references the
|
|
255
|
+
variable name; a row whose code file does not resolve, or cannot be read, is
|
|
256
|
+
left alone. Each finding is rendered as ``VARIABLE -> code/path``.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
content: The markdown content being written.
|
|
260
|
+
markdown_directory: The directory that holds the target markdown file.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Each drifted row in first-seen order with duplicates removed, capped at
|
|
264
|
+
the issue budget.
|
|
265
|
+
"""
|
|
266
|
+
scan_root = _resolve_scan_root(markdown_directory)
|
|
267
|
+
drift_rows: list[str] = []
|
|
268
|
+
already_reported: set[str] = set()
|
|
269
|
+
is_inside_code_fence = False
|
|
270
|
+
for each_line in content.splitlines():
|
|
271
|
+
if CODE_FENCE_PATTERN.match(each_line) is not None:
|
|
272
|
+
is_inside_code_fence = not is_inside_code_fence
|
|
273
|
+
continue
|
|
274
|
+
if is_inside_code_fence:
|
|
275
|
+
continue
|
|
276
|
+
if TABLE_ROW_PATTERN.match(each_line) is None:
|
|
277
|
+
continue
|
|
278
|
+
each_finding = _drift_finding_for_row(each_line, scan_root)
|
|
279
|
+
if each_finding is None or each_finding in already_reported:
|
|
280
|
+
continue
|
|
281
|
+
already_reported.add(each_finding)
|
|
282
|
+
drift_rows.append(each_finding)
|
|
283
|
+
if len(drift_rows) >= MAX_DRIFT_ISSUES:
|
|
284
|
+
break
|
|
285
|
+
return drift_rows
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _drift_finding_for_row(table_line: str, scan_root: Path) -> str | None:
|
|
289
|
+
"""Return the drift finding one table row produces, when it drifts.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
table_line: A single markdown table row.
|
|
293
|
+
scan_root: The directory whose subtree bounds the code-file search.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
A ``VARIABLE -> code/path`` finding when the code file resolves yet does
|
|
297
|
+
not read the variable, or None otherwise.
|
|
298
|
+
"""
|
|
299
|
+
all_cells = _row_cells(table_line)
|
|
300
|
+
if len(all_cells) < MINIMUM_ENV_VAR_ROW_CELL_COUNT or _is_separator_row(all_cells):
|
|
301
|
+
return None
|
|
302
|
+
variable_name = _env_var_name_in_cell(all_cells[0])
|
|
303
|
+
if variable_name is None:
|
|
304
|
+
return None
|
|
305
|
+
for each_cell in all_cells[1:]:
|
|
306
|
+
code_reference = _code_file_reference_in_cell(each_cell)
|
|
307
|
+
if code_reference is None:
|
|
308
|
+
continue
|
|
309
|
+
code_file = _resolve_code_file(scan_root, code_reference)
|
|
310
|
+
if code_file is None:
|
|
311
|
+
return None
|
|
312
|
+
reads_variable = _code_file_reads_variable(code_file, variable_name)
|
|
313
|
+
if reads_variable is None or reads_variable:
|
|
314
|
+
return None
|
|
315
|
+
return variable_name + " -> " + code_reference
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _read_existing_file_content(file_path: str) -> str | None:
|
|
320
|
+
"""Return the current on-disk content of file_path, or None when unreadable.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
file_path: The path of the file the edit targets.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
The file text, or None when the file is missing or cannot be decoded.
|
|
327
|
+
"""
|
|
328
|
+
try:
|
|
329
|
+
return Path(file_path).read_text(encoding="utf-8")
|
|
330
|
+
except (OSError, UnicodeDecodeError):
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _joined_edit_fragments(all_edits: list[dict]) -> str:
|
|
335
|
+
"""Return the MultiEdit replacement fragments joined into one scannable block.
|
|
336
|
+
|
|
337
|
+
Each ``new_string`` value present as a non-empty string is kept, in list
|
|
338
|
+
order, and the kept fragments are joined with newlines so the resulting text
|
|
339
|
+
carries every table row the edit introduces. Used as the scan fallback when
|
|
340
|
+
the existing file cannot be read.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
all_edits: The MultiEdit ``edits`` list.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
The joined replacement text, empty when no fragment is a non-empty string.
|
|
347
|
+
"""
|
|
348
|
+
kept_fragments = [
|
|
349
|
+
each_edit.get("new_string", "")
|
|
350
|
+
for each_edit in all_edits
|
|
351
|
+
if isinstance(each_edit, dict)
|
|
352
|
+
and isinstance(each_edit.get("new_string", ""), str)
|
|
353
|
+
and each_edit.get("new_string", "")
|
|
354
|
+
]
|
|
355
|
+
return "\n".join(kept_fragments)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _candidate_content_and_baseline(
|
|
359
|
+
tool_name: str, tool_input: dict, file_path: str, markdown_directory: Path
|
|
360
|
+
) -> tuple[str | None, set[str]]:
|
|
361
|
+
"""Return the content to scan and the drift the file already held.
|
|
362
|
+
|
|
363
|
+
For Write the candidate is the full new content with no baseline, so every
|
|
364
|
+
drift it names is introduced. For Edit and MultiEdit the candidate is the
|
|
365
|
+
existing file with the replacements applied, and the baseline is the drift the
|
|
366
|
+
existing file already held, so a pre-existing drift on an untouched row is
|
|
367
|
+
excluded. When the existing file cannot be read, the joined new_string
|
|
368
|
+
fragments are scanned with no baseline.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
tool_name: The intercepted tool, one of Write, Edit, MultiEdit.
|
|
372
|
+
tool_input: The tool input payload.
|
|
373
|
+
file_path: The destination path of the write or edit.
|
|
374
|
+
markdown_directory: The directory that holds the target markdown file.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
The candidate content to scan paired with the baseline drift set.
|
|
378
|
+
"""
|
|
379
|
+
if tool_name == "Write":
|
|
380
|
+
content = tool_input.get("content", "")
|
|
381
|
+
return (content if isinstance(content, str) and content else None, set())
|
|
382
|
+
all_edits = edits_for_tool(tool_name, tool_input)
|
|
383
|
+
existing_content = _read_existing_file_content(file_path)
|
|
384
|
+
if existing_content is None:
|
|
385
|
+
joined_fragments = _joined_edit_fragments(all_edits)
|
|
386
|
+
return (joined_fragments or None, set())
|
|
387
|
+
baseline_drift = set(find_drift_rows(existing_content, markdown_directory))
|
|
388
|
+
return (apply_edits(existing_content, all_edits), baseline_drift)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _build_block_payload(all_drift_rows: list[str], file_path: str) -> dict:
|
|
392
|
+
"""Build the PreToolUse deny payload listing each drifted row.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
all_drift_rows: The VARIABLE -> code/path findings to report.
|
|
396
|
+
file_path: The destination path of the markdown file.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
The hook-result dictionary the harness reads to deny the write.
|
|
400
|
+
"""
|
|
401
|
+
formatted_drift = ", ".join("`" + each_row + "`" for each_row in all_drift_rows)
|
|
402
|
+
reason = DRIFT_MESSAGE_TEMPLATE.format(file=file_path, drift=formatted_drift)
|
|
403
|
+
return {
|
|
404
|
+
"hookSpecificOutput": {
|
|
405
|
+
"hookEventName": "PreToolUse",
|
|
406
|
+
"permissionDecision": "deny",
|
|
407
|
+
"permissionDecisionReason": reason,
|
|
408
|
+
"additionalContext": DRIFT_ADDITIONAL_CONTEXT,
|
|
409
|
+
},
|
|
410
|
+
"systemMessage": DRIFT_SYSTEM_MESSAGE,
|
|
411
|
+
"suppressOutput": True,
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
|
|
416
|
+
"""Write the hook result JSON to the given output stream.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
all_hook_data: The hook-result dictionary to serialize.
|
|
420
|
+
output_stream: The stream the harness reads the decision from.
|
|
421
|
+
"""
|
|
422
|
+
output_stream.write(json.dumps(all_hook_data) + "\n")
|
|
423
|
+
output_stream.flush()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def main() -> None:
|
|
427
|
+
"""Read the PreToolUse payload from stdin and block a drifted env-var table."""
|
|
428
|
+
input_data = read_hook_input_dictionary_from_stdin()
|
|
429
|
+
if input_data is None:
|
|
430
|
+
sys.exit(0)
|
|
431
|
+
|
|
432
|
+
tool_name = input_data.get("tool_name", "")
|
|
433
|
+
if not isinstance(tool_name, str) or tool_name not in ("Write", "Edit", "MultiEdit"):
|
|
434
|
+
sys.exit(0)
|
|
435
|
+
|
|
436
|
+
tool_input = input_data.get("tool_input", {})
|
|
437
|
+
if not isinstance(tool_input, dict):
|
|
438
|
+
sys.exit(0)
|
|
439
|
+
|
|
440
|
+
file_path = tool_input.get("file_path", "")
|
|
441
|
+
if not isinstance(file_path, str) or not is_markdown_file(file_path):
|
|
442
|
+
sys.exit(0)
|
|
443
|
+
|
|
444
|
+
markdown_directory = Path(file_path).resolve().parent
|
|
445
|
+
if not markdown_directory.is_dir():
|
|
446
|
+
sys.exit(0)
|
|
447
|
+
|
|
448
|
+
candidate_content, baseline_drift = _candidate_content_and_baseline(
|
|
449
|
+
tool_name, tool_input, file_path, markdown_directory
|
|
450
|
+
)
|
|
451
|
+
if candidate_content is None:
|
|
452
|
+
sys.exit(0)
|
|
453
|
+
|
|
454
|
+
drift_rows = [
|
|
455
|
+
each_row
|
|
456
|
+
for each_row in find_drift_rows(candidate_content, markdown_directory)
|
|
457
|
+
if each_row not in baseline_drift
|
|
458
|
+
]
|
|
459
|
+
if not drift_rows:
|
|
460
|
+
sys.exit(0)
|
|
461
|
+
|
|
462
|
+
block_payload = _build_block_payload(drift_rows, file_path)
|
|
463
|
+
log_hook_block(
|
|
464
|
+
calling_hook_name="env_var_table_code_drift_blocker.py",
|
|
465
|
+
hook_event="PreToolUse",
|
|
466
|
+
block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
|
|
467
|
+
tool_name=tool_name,
|
|
468
|
+
offending_input_preview=file_path,
|
|
469
|
+
)
|
|
470
|
+
_emit_hook_result(block_payload, sys.stdout)
|
|
471
|
+
sys.exit(0)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
if __name__ == "__main__":
|
|
475
|
+
main()
|