claude-dev-env 1.50.4 → 1.52.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/CLAUDE.md +0 -8
- package/_shared/pr-loop/audit-contract.md +3 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/preflight_self_heal_constants.py +28 -0
- package/_shared/pr-loop/scripts/preflight.py +18 -6
- package/_shared/pr-loop/scripts/preflight_self_heal.py +164 -0
- package/_shared/pr-loop/scripts/tests/test_preflight.py +39 -0
- package/_shared/pr-loop/scripts/tests/test_preflight_self_heal.py +273 -0
- package/agents/clean-coder.md +1 -1
- package/agents/code-quality-agent.md +7 -5
- package/audit-rubrics/category_rubrics/category-a-api-contracts.md +3 -0
- package/audit-rubrics/category_rubrics/category-f-silent-failures.md +3 -0
- package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +8 -2
- package/audit-rubrics/category_rubrics/category-n-test-name-scenario-verifier.md +3 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +39 -0
- package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +40 -0
- package/audit-rubrics/prompts/category-a-api-contracts.md +11 -4
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/audit-rubrics/prompts/category-c-resource-cleanup.md +1 -1
- package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +1 -1
- package/audit-rubrics/prompts/category-e-dead-code.md +1 -1
- package/audit-rubrics/prompts/category-f-silent-failures.md +13 -2
- package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +1 -1
- package/audit-rubrics/prompts/category-h-security-boundaries.md +1 -1
- package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
- package/audit-rubrics/prompts/category-k-codebase-conflicts.md +15 -5
- package/audit-rubrics/prompts/category-l-behavior-equivalence.md +1 -1
- package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +1 -1
- package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +10 -3
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +74 -0
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +75 -0
- package/docs/CODE_RULES.md +24 -346
- package/hooks/blocking/code_rules_enforcer.py +367 -42
- package/hooks/blocking/tdd_enforcer.py +211 -19
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +519 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +1 -1
- package/hooks/blocking/test_tdd_enforcer.py +399 -0
- package/hooks/hooks.json +0 -15
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +5 -0
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +2 -41
- package/rules/confirm-implementation-forks.md +3 -44
- package/rules/gh-body-file.md +2 -78
- package/rules/gh-paginate.md +2 -78
- package/rules/plain-language.md +2 -41
- package/rules/prompt-workflow-context-controls.md +9 -38
- package/rules/shell-invocation-policy.md +2 -141
- package/rules/testing.md +10 -0
- package/rules/vault-context.md +3 -32
- package/rules/windows-filesystem-safe.md +3 -87
- package/scripts/sync_to_cursor/rules.py +201 -79
- package/scripts/tests/test_sync_to_cursor.py +122 -26
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +2 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +51 -4
- package/skills/auditing-claude-config/SKILL.md +6 -1
- package/skills/bugteam/CONSTRAINTS.md +1 -1
- package/skills/bugteam/PROMPTS.md +8 -6
- package/skills/bugteam/SKILL.md +5 -5
- package/skills/bugteam/reference/audit-and-teammates.md +1 -1
- package/skills/bugteam/reference/audit-contract.md +4 -4
- package/skills/bugteam/reference/design-rationale.md +1 -1
- package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
- package/skills/bugteam/reference/team-setup.md +17 -5
- package/skills/bugteam/scripts/bugteam_preflight.py +22 -10
- package/skills/bugteam/scripts/test_bugteam_preflight.py +32 -0
- package/skills/copilot-review/SKILL.md +5 -8
- package/skills/doc-gist/SKILL.md +5 -8
- package/skills/fixbugs/SKILL.md +1 -1
- package/skills/gh-paginate/SKILL.md +84 -0
- package/skills/pre-compact/SKILL.md +4 -9
- package/skills/refine/SKILL.md +8 -2
- package/skills/structure-prompt/SKILL.md +5 -10
- package/skills/update/SKILL.md +143 -0
|
@@ -11,6 +11,7 @@ import json
|
|
|
11
11
|
import re
|
|
12
12
|
import sys
|
|
13
13
|
import time
|
|
14
|
+
from collections import Counter
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
|
|
16
17
|
|
|
@@ -124,42 +125,213 @@ def _is_constants_only_python_content(content: str) -> bool:
|
|
|
124
125
|
return True
|
|
125
126
|
|
|
126
127
|
|
|
127
|
-
def _apply_edit_to_content(
|
|
128
|
-
|
|
128
|
+
def _apply_edit_to_content(
|
|
129
|
+
existing_content: str, old_str: str, new_str: str, should_replace_all: bool
|
|
130
|
+
) -> str:
|
|
131
|
+
"""Apply an edit's replacement to content the way the Edit tool would.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
existing_content: The text being edited.
|
|
135
|
+
old_str: The substring the edit replaces.
|
|
136
|
+
new_str: The replacement substring.
|
|
137
|
+
should_replace_all: Replace every occurrence when True (matching the
|
|
138
|
+
Edit tool's ``replace_all`` flag), otherwise only the first.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
The post-edit content.
|
|
142
|
+
"""
|
|
143
|
+
if should_replace_all:
|
|
144
|
+
return existing_content.replace(old_str, new_str)
|
|
129
145
|
return existing_content.replace(old_str, new_str, 1)
|
|
130
146
|
|
|
131
147
|
|
|
148
|
+
def _future_module_name() -> str:
|
|
149
|
+
return "__future__"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_future_import(node: ast.stmt) -> bool:
|
|
153
|
+
"""Return whether a statement is a ``from __future__`` import.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
node: A top-level module statement.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
True when the statement imports from ``__future__``, whose presence
|
|
160
|
+
affects module-wide compilation semantics.
|
|
161
|
+
"""
|
|
162
|
+
return isinstance(node, ast.ImportFrom) and node.module == _future_module_name()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _is_removable_import(node: ast.stmt) -> bool:
|
|
166
|
+
"""Return whether a statement is an import that removes or reorders cleanly.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
node: A top-level module statement.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
True for plain ``import`` and ``from`` imports. ``from __future__``
|
|
173
|
+
imports return False because their presence affects module-wide
|
|
174
|
+
compilation semantics, so the gate treats them as behavior-bearing
|
|
175
|
+
statements rather than removable imports; every non-import statement
|
|
176
|
+
also returns False.
|
|
177
|
+
"""
|
|
178
|
+
if isinstance(node, ast.Import):
|
|
179
|
+
return True
|
|
180
|
+
if isinstance(node, ast.ImportFrom):
|
|
181
|
+
return not _is_future_import(node)
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _future_import_signatures(content: str) -> list[str] | None:
|
|
186
|
+
"""Return the ``ast.dump`` signatures of a module's ``from __future__`` imports.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
content: Python source text to parse.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The future-import signatures in source order, or ``None`` when the
|
|
193
|
+
content does not parse.
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
parsed_tree = ast.parse(content)
|
|
197
|
+
except SyntaxError:
|
|
198
|
+
return None
|
|
199
|
+
return [ast.dump(each_node) for each_node in parsed_tree.body if _is_future_import(each_node)]
|
|
200
|
+
|
|
201
|
+
|
|
132
202
|
def _is_post_edit_constants_only(existing_content: str, tool_name: str, tool_input: dict) -> bool:
|
|
133
203
|
"""Check if post-edit content remains constants-only after Edit or MultiEdit.
|
|
134
204
|
|
|
135
205
|
Both the existing content and the post-edit result must be constants-only
|
|
136
|
-
to prevent edits on files with behavior from bypassing the TDD gate.
|
|
206
|
+
to prevent edits on files with behavior from bypassing the TDD gate. Editing
|
|
207
|
+
a ``from __future__`` import also fails this check, so a future-import edit
|
|
208
|
+
on a constants-only file faces the gate rather than slipping through the
|
|
209
|
+
constants exemption.
|
|
137
210
|
"""
|
|
138
211
|
if not _is_constants_only_python_content(existing_content):
|
|
139
212
|
return False
|
|
140
213
|
|
|
141
214
|
if tool_name == "Edit":
|
|
142
|
-
old_str = tool_input.get("old_string", "")
|
|
215
|
+
old_str = tool_input.get("old_string", "") or ""
|
|
143
216
|
new_str = tool_input.get("new_string", "") or ""
|
|
144
217
|
if not old_str:
|
|
145
218
|
return False
|
|
146
|
-
|
|
147
|
-
|
|
219
|
+
should_replace_all = bool(tool_input.get("replace_all", False))
|
|
220
|
+
post_edit_content = _apply_edit_to_content(existing_content, old_str, new_str, should_replace_all)
|
|
221
|
+
elif tool_name == "MultiEdit":
|
|
222
|
+
all_edits = tool_input.get("edits", []) or []
|
|
223
|
+
post_edit_content = existing_content
|
|
224
|
+
for each_edit in all_edits:
|
|
225
|
+
if not isinstance(each_edit, dict):
|
|
226
|
+
return False
|
|
227
|
+
each_old = each_edit.get("old_string", "") or ""
|
|
228
|
+
each_new = each_edit.get("new_string", "") or ""
|
|
229
|
+
if not each_old:
|
|
230
|
+
return False
|
|
231
|
+
should_replace_all = bool(each_edit.get("replace_all", False))
|
|
232
|
+
post_edit_content = _apply_edit_to_content(
|
|
233
|
+
post_edit_content, each_old, each_new, should_replace_all
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
return False
|
|
148
237
|
|
|
149
|
-
if
|
|
238
|
+
if _future_import_signatures(existing_content) != _future_import_signatures(post_edit_content):
|
|
239
|
+
return False
|
|
240
|
+
return _is_constants_only_python_content(post_edit_content)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _top_level_signatures(content: str) -> tuple[list[str], list[str]] | None:
|
|
244
|
+
"""Split a module's top-level statements into removable-import and other signatures.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
content: Python source text to parse.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
A pair ``(import_signatures, non_import_signatures)`` of ``ast.dump``
|
|
251
|
+
strings in source order, or ``None`` when the content does not parse.
|
|
252
|
+
Plain imports populate the first list; ``from __future__`` imports and
|
|
253
|
+
every non-import statement populate the second, so editing a future
|
|
254
|
+
import reads as a behavior edit rather than a removable-import edit.
|
|
255
|
+
Signatures omit line and column attributes, so statements that only
|
|
256
|
+
shift position compare equal.
|
|
257
|
+
"""
|
|
258
|
+
try:
|
|
259
|
+
parsed_tree = ast.parse(content)
|
|
260
|
+
except SyntaxError:
|
|
261
|
+
return None
|
|
262
|
+
import_signatures: list[str] = []
|
|
263
|
+
non_import_signatures: list[str] = []
|
|
264
|
+
for each_node in parsed_tree.body:
|
|
265
|
+
if _is_removable_import(each_node):
|
|
266
|
+
import_signatures.append(ast.dump(each_node))
|
|
267
|
+
else:
|
|
268
|
+
non_import_signatures.append(ast.dump(each_node))
|
|
269
|
+
return import_signatures, non_import_signatures
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _is_post_edit_import_only(existing_content: str, tool_name: str, tool_input: dict) -> bool:
|
|
273
|
+
"""Check whether an Edit or MultiEdit only removes or reorders imports.
|
|
274
|
+
|
|
275
|
+
The top-level statements are split into imports and the rest, before and
|
|
276
|
+
after applying the edit. The edit is exempt only when the non-import
|
|
277
|
+
statements are unchanged and every post-edit import statement already
|
|
278
|
+
appears among the pre-edit imports, so removing or reordering imports is
|
|
279
|
+
exempt while adding, swapping, or retargeting an import stays gated: those
|
|
280
|
+
can change behavior through a new symbol in scope, a different
|
|
281
|
+
implementation bound to the same name, or `from __future__` semantics. An
|
|
282
|
+
edit that leaves the parsed module identical (a comment- or whitespace-only
|
|
283
|
+
change) also stays gated. Reading the resulting file rather than the edit
|
|
284
|
+
fragments keeps the exemption from firing on import text inside a string
|
|
285
|
+
literal, and lets it fire on edits whose old string carries surrounding
|
|
286
|
+
context for uniqueness.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
existing_content: Current text of the file under edit.
|
|
290
|
+
tool_name: The intercepted tool (Edit or MultiEdit).
|
|
291
|
+
tool_input: The intercepted tool's input payload.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
True when the edit leaves the non-import statements unchanged and the
|
|
295
|
+
post-edit imports are a reordering or removal of the pre-edit imports.
|
|
296
|
+
"""
|
|
297
|
+
existing_signatures = _top_level_signatures(existing_content)
|
|
298
|
+
if existing_signatures is None:
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
if tool_name == "Edit":
|
|
302
|
+
old_str = tool_input.get("old_string", "") or ""
|
|
303
|
+
new_str = tool_input.get("new_string", "") or ""
|
|
304
|
+
if not old_str:
|
|
305
|
+
return False
|
|
306
|
+
should_replace_all = bool(tool_input.get("replace_all", False))
|
|
307
|
+
post_edit_content = _apply_edit_to_content(existing_content, old_str, new_str, should_replace_all)
|
|
308
|
+
elif tool_name == "MultiEdit":
|
|
150
309
|
all_edits = tool_input.get("edits", []) or []
|
|
310
|
+
if not all_edits:
|
|
311
|
+
return False
|
|
151
312
|
post_edit_content = existing_content
|
|
152
313
|
for each_edit in all_edits:
|
|
153
314
|
if not isinstance(each_edit, dict):
|
|
154
315
|
return False
|
|
155
|
-
each_old = each_edit.get("old_string", "")
|
|
316
|
+
each_old = each_edit.get("old_string", "") or ""
|
|
156
317
|
each_new = each_edit.get("new_string", "") or ""
|
|
157
318
|
if not each_old:
|
|
158
319
|
return False
|
|
159
|
-
|
|
160
|
-
|
|
320
|
+
should_replace_all = bool(each_edit.get("replace_all", False))
|
|
321
|
+
post_edit_content = _apply_edit_to_content(
|
|
322
|
+
post_edit_content, each_old, each_new, should_replace_all
|
|
323
|
+
)
|
|
324
|
+
else:
|
|
325
|
+
return False
|
|
161
326
|
|
|
162
|
-
|
|
327
|
+
post_edit_signatures = _top_level_signatures(post_edit_content)
|
|
328
|
+
if post_edit_signatures is None:
|
|
329
|
+
return False
|
|
330
|
+
existing_imports, existing_rest = existing_signatures
|
|
331
|
+
post_imports, post_rest = post_edit_signatures
|
|
332
|
+
if post_rest != existing_rest or post_imports == existing_imports:
|
|
333
|
+
return False
|
|
334
|
+
return Counter(post_imports) <= Counter(existing_imports)
|
|
163
335
|
|
|
164
336
|
|
|
165
337
|
def _tests_directory_name() -> str:
|
|
@@ -194,18 +366,27 @@ def _is_repo_boundary(candidate_directory: Path) -> bool:
|
|
|
194
366
|
return False
|
|
195
367
|
|
|
196
368
|
|
|
197
|
-
def
|
|
369
|
+
def _ancestor_tests_directories(start_directory: Path) -> list[tuple[Path, Path]]:
|
|
370
|
+
"""Collect each ancestor's sibling tests directory up to the repo boundary.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
start_directory: Directory of the production file under edit.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Ordered (ancestor, tests_directory) pairs; nearer ancestors first.
|
|
377
|
+
"""
|
|
378
|
+
all_pairs: list[tuple[Path, Path]] = []
|
|
198
379
|
current_directory = start_directory
|
|
199
380
|
for _ in range(_parent_walk_limit()):
|
|
200
381
|
sibling_tests = current_directory / _tests_directory_name()
|
|
201
382
|
if sibling_tests.is_dir():
|
|
202
|
-
|
|
383
|
+
all_pairs.append((current_directory, sibling_tests))
|
|
203
384
|
if _is_repo_boundary(current_directory):
|
|
204
|
-
|
|
385
|
+
break
|
|
205
386
|
if current_directory.parent == current_directory:
|
|
206
|
-
|
|
387
|
+
break
|
|
207
388
|
current_directory = current_directory.parent
|
|
208
|
-
return
|
|
389
|
+
return all_pairs
|
|
209
390
|
|
|
210
391
|
|
|
211
392
|
def _split_module_stem_prefix() -> str:
|
|
@@ -225,6 +406,11 @@ def _split_family_candidates(directory: Path, stem: str) -> list[Path]:
|
|
|
225
406
|
def candidate_test_paths_for(production_path: Path) -> list[Path]:
|
|
226
407
|
"""Return the test files whose freshness can satisfy the gate for a production file.
|
|
227
408
|
|
|
409
|
+
Every ancestor ``tests`` directory contributes a flat candidate
|
|
410
|
+
(``tests/test_<stem>.py``) and package-mirroring nested candidates
|
|
411
|
+
(``tests/<subpackage path>/test_<stem>.py``), so repos that keep tests
|
|
412
|
+
either beside the package or in a category tree both resolve.
|
|
413
|
+
|
|
228
414
|
For ``code_rules_*`` Python modules the candidate list is extended with the
|
|
229
415
|
sibling split test family (``test_code_rules_enforcer_*.py``), because that
|
|
230
416
|
family collectively covers the split check modules; a fresh edit to any
|
|
@@ -247,9 +433,12 @@ def candidate_test_paths_for(production_path: Path) -> list[Path]:
|
|
|
247
433
|
if extension == ".py":
|
|
248
434
|
all_candidates.append(directory / f"test_{stem}.py")
|
|
249
435
|
all_candidates.append(directory / f"{stem}_test.py")
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
436
|
+
for each_ancestor, each_tests_directory in _ancestor_tests_directories(directory):
|
|
437
|
+
all_candidates.append(each_tests_directory / f"test_{stem}.py")
|
|
438
|
+
nested_directory = each_tests_directory
|
|
439
|
+
for each_relative_part in directory.relative_to(each_ancestor).parts:
|
|
440
|
+
nested_directory = nested_directory / each_relative_part
|
|
441
|
+
all_candidates.append(nested_directory / f"test_{stem}.py")
|
|
253
442
|
all_candidates.extend(_split_family_candidates(directory, stem))
|
|
254
443
|
return all_candidates
|
|
255
444
|
|
|
@@ -423,6 +612,9 @@ def main() -> None:
|
|
|
423
612
|
if tool_name in ("Edit", "MultiEdit") and ext == ".py" and path.exists():
|
|
424
613
|
existing_content = _read_candidate_text(path)
|
|
425
614
|
if existing_content is not None:
|
|
615
|
+
if _is_post_edit_import_only(existing_content, tool_name, tool_input):
|
|
616
|
+
emit_allow()
|
|
617
|
+
sys.exit(0)
|
|
426
618
|
if _is_post_edit_constants_only(existing_content, tool_name, tool_input):
|
|
427
619
|
emit_allow()
|
|
428
620
|
sys.exit(0)
|