claude-dev-env 1.78.0 → 1.80.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/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +7 -0
- package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +242 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota_constants.py +63 -0
- 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 +41 -0
- package/hooks/blocking/code_rules_imports_logging.py +136 -1
- 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/code_rules_unused_imports.py +7 -63
- 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_js_returns_object.py +72 -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_unused_imports.py +96 -15
- package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -0
- package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -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 +79 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +28 -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/hooks/hooks_constants/test_code_rules_enforcer_constants.py +93 -0
- package/hooks/hooks_constants/unused_module_import_constants.py +0 -1
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +57 -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
- package/skills/autoconverge/SKILL.md +26 -5
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +56 -120
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +45 -5
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +50 -46
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +6 -6
- package/skills/autoconverge/workflow/converge.mjs +110 -228
- package/skills/autoconverge/workflow/converge.run-input.test.mjs +11 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +23 -7
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +28 -2
- package/skills/pr-converge/SKILL.md +28 -2
- package/skills/pr-converge/reference/convergence-gates.md +13 -1
- package/skills/pr-converge/reference/state-schema.md +11 -0
|
@@ -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()
|
|
@@ -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:
|