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
@@ -1,4 +1,4 @@
1
- """Skip-decorator, existence-only, constant-equality, stale-test-name, and flag-gated scenario test-quality checks."""
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()