claude-dev-env 1.58.0 → 1.60.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 +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
- package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/bin/install.mjs +100 -27
- package/bin/install.test.mjs +133 -1
- package/docs/CODE_RULES.md +3 -3
- package/hooks/blocking/code_rules_annotations_length.py +153 -0
- package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +439 -0
- package/hooks/blocking/code_rules_enforcer.py +190 -21
- package/hooks/blocking/code_rules_magic_values.py +98 -0
- package/hooks/blocking/code_rules_shared.py +41 -0
- package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
- package/hooks/blocking/config/__init__.py +5 -0
- package/hooks/blocking/config/verified_commit_constants.py +106 -0
- package/hooks/blocking/destructive_command_blocker.py +1027 -12
- package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
- package/hooks/blocking/subprocess_budget_completeness.py +380 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
- package/hooks/blocking/test_destructive_command_blocker.py +622 -3
- package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
- package/hooks/blocking/test_verification_verdict_store.py +278 -0
- package/hooks/blocking/test_verified_commit_gate.py +368 -0
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
- package/hooks/blocking/verification_verdict_store.py +446 -0
- package/hooks/blocking/verified_commit_gate.py +523 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
- package/hooks/blocking/verifier_verdict_minter.py +299 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +58 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +34 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +43 -0
- package/rules/file-global-constants.md +7 -1
- package/rules/hook-prose-matches-detector.md +26 -0
- package/rules/no-cross-skill-duplicate-helpers.md +29 -0
- package/rules/no-inline-destructive-literals.md +11 -0
- package/rules/workflow-substitution-slots.md +7 -0
- package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
- package/skills/autoconverge/SKILL.md +67 -19
- package/skills/autoconverge/reference/closing-report.md +59 -17
- package/skills/autoconverge/reference/convergence.md +7 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -2
- package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
- package/skills/autoconverge/workflow/converge.mjs +234 -42
- package/skills/autoconverge/workflow/convergence_summary.py +110 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
- package/skills/autoconverge/workflow/render_report.py +488 -397
- package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
- package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
- package/skills/autoconverge/workflow/test_render_report.py +488 -259
- package/skills/pr-converge/reference/per-tick.md +28 -8
- package/skills/pr-converge/scripts/check_convergence.py +195 -64
- package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
- package/skills/rebase/SKILL.md +2 -4
- package/skills/update/SKILL.md +37 -5
- package/system-prompts/software-engineer.xml +2 -6
- package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
- package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
- package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
|
@@ -30,6 +30,7 @@ if _HOOKS_DIRECTORY not in sys.path:
|
|
|
30
30
|
|
|
31
31
|
from code_rules_annotations_length import ( # noqa: E402
|
|
32
32
|
check_function_length,
|
|
33
|
+
check_known_pytest_fixture_annotations,
|
|
33
34
|
check_parameter_annotations,
|
|
34
35
|
check_return_annotations,
|
|
35
36
|
)
|
|
@@ -50,10 +51,20 @@ from code_rules_constants_config import ( # noqa: E402
|
|
|
50
51
|
check_constants_outside_config_advisory,
|
|
51
52
|
check_file_global_constants_use_count,
|
|
52
53
|
)
|
|
54
|
+
from code_rules_dead_dataclass_field import ( # noqa: E402
|
|
55
|
+
check_dead_dataclass_fields,
|
|
56
|
+
)
|
|
57
|
+
from code_rules_dead_module_constant import ( # noqa: E402
|
|
58
|
+
check_dead_module_constants,
|
|
59
|
+
)
|
|
53
60
|
from code_rules_docstrings import ( # noqa: E402
|
|
54
61
|
check_docstring_args_match_signature,
|
|
55
62
|
check_docstring_format,
|
|
56
63
|
)
|
|
64
|
+
from code_rules_duplicate_body import ( # noqa: E402
|
|
65
|
+
advise_cross_skill_duplicate_helper,
|
|
66
|
+
check_duplicate_function_body_across_files,
|
|
67
|
+
)
|
|
57
68
|
from code_rules_imports_logging import ( # noqa: E402
|
|
58
69
|
advise_file_line_count,
|
|
59
70
|
check_e2e_test_naming,
|
|
@@ -113,6 +124,7 @@ from code_rules_typeddict_stub import ( # noqa: E402
|
|
|
113
124
|
check_stub_implementations,
|
|
114
125
|
check_thin_wrapper_files,
|
|
115
126
|
check_typed_dict_encode_decode,
|
|
127
|
+
check_zero_payload_function_alias,
|
|
116
128
|
)
|
|
117
129
|
from code_rules_unused_imports import ( # noqa: E402
|
|
118
130
|
check_unused_module_level_imports,
|
|
@@ -122,6 +134,7 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
122
134
|
ALL_CODE_EXTENSIONS,
|
|
123
135
|
ALL_JAVASCRIPT_EXTENSIONS,
|
|
124
136
|
ALL_PYTHON_EXTENSIONS,
|
|
137
|
+
DENY_REASON_ISSUE_PREVIEW_COUNT,
|
|
125
138
|
PRECHECK_USAGE_EXIT_CODE,
|
|
126
139
|
PRECHECK_USAGE_MESSAGE,
|
|
127
140
|
)
|
|
@@ -137,6 +150,7 @@ def validate_content(
|
|
|
137
150
|
full_file_content: str | None = None,
|
|
138
151
|
prior_full_file_content: str = "",
|
|
139
152
|
defer_scope_to_caller: bool = False,
|
|
153
|
+
sibling_directory: Path | None = None,
|
|
140
154
|
) -> list[str]:
|
|
141
155
|
"""Run all applicable validators on content.
|
|
142
156
|
|
|
@@ -167,6 +181,12 @@ def validate_content(
|
|
|
167
181
|
checks return their violations unscoped for the gate to classify.
|
|
168
182
|
PreToolUse new-file or full-file writes leave this False: this
|
|
169
183
|
enforcer is terminal, so it marks every violation in scope.
|
|
184
|
+
sibling_directory: The absolute directory the cross-file duplicate-body
|
|
185
|
+
check scans for sibling modules. The commit/push gate passes the
|
|
186
|
+
resolved file's parent so the on-disk sibling scan stays anchored to
|
|
187
|
+
the repository regardless of the gate process's working directory.
|
|
188
|
+
None (the PreToolUse default) derives the directory from
|
|
189
|
+
``file_path``'s parent, which is already absolute on that path.
|
|
170
190
|
"""
|
|
171
191
|
extension = get_file_extension(file_path)
|
|
172
192
|
all_issues = []
|
|
@@ -188,6 +208,15 @@ def validate_content(
|
|
|
188
208
|
all_issues.extend(check_constants_outside_config(content, file_path))
|
|
189
209
|
all_issues.extend(check_constants_outside_config_advisory(content, file_path))
|
|
190
210
|
all_issues.extend(check_file_global_constants_use_count(content, file_path))
|
|
211
|
+
all_issues.extend(
|
|
212
|
+
check_duplicate_function_body_across_files(
|
|
213
|
+
effective_content,
|
|
214
|
+
file_path,
|
|
215
|
+
all_changed_lines,
|
|
216
|
+
defer_scope_to_caller,
|
|
217
|
+
sibling_directory,
|
|
218
|
+
)
|
|
219
|
+
)
|
|
191
220
|
all_issues.extend(check_type_escape_hatches(effective_content, file_path))
|
|
192
221
|
all_issues.extend(check_banned_identifiers(content, file_path))
|
|
193
222
|
all_issues.extend(
|
|
@@ -204,6 +233,7 @@ def validate_content(
|
|
|
204
233
|
all_issues.extend(check_test_branching_in_production(effective_content, file_path))
|
|
205
234
|
all_issues.extend(check_bare_except(effective_content, file_path))
|
|
206
235
|
all_issues.extend(check_thin_wrapper_files(effective_content, file_path))
|
|
236
|
+
all_issues.extend(check_zero_payload_function_alias(effective_content, file_path))
|
|
207
237
|
all_issues.extend(check_boundary_types(effective_content, file_path))
|
|
208
238
|
all_issues.extend(check_docstring_format(effective_content, file_path))
|
|
209
239
|
all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
|
|
@@ -242,8 +272,15 @@ def validate_content(
|
|
|
242
272
|
all_issues.extend(
|
|
243
273
|
check_unused_module_level_imports(content, file_path, full_file_content)
|
|
244
274
|
)
|
|
275
|
+
all_issues.extend(
|
|
276
|
+
check_dead_dataclass_fields(content, file_path, full_file_content)
|
|
277
|
+
)
|
|
278
|
+
all_issues.extend(
|
|
279
|
+
check_dead_module_constants(content, file_path, full_file_content)
|
|
280
|
+
)
|
|
245
281
|
all_issues.extend(check_library_print(content, file_path))
|
|
246
282
|
all_issues.extend(check_parameter_annotations(content, file_path))
|
|
283
|
+
all_issues.extend(check_known_pytest_fixture_annotations(content, file_path))
|
|
247
284
|
all_issues.extend(check_return_annotations(content, file_path))
|
|
248
285
|
all_issues.extend(
|
|
249
286
|
check_function_length(
|
|
@@ -259,6 +296,7 @@ def validate_content(
|
|
|
259
296
|
all_issues.extend(check_string_literal_magic(content, file_path))
|
|
260
297
|
check_incomplete_mocks(content, file_path)
|
|
261
298
|
check_duplicated_format_patterns(content, file_path)
|
|
299
|
+
advise_cross_skill_duplicate_helper(effective_content, file_path)
|
|
262
300
|
|
|
263
301
|
elif extension in ALL_JAVASCRIPT_EXTENSIONS:
|
|
264
302
|
if not is_test_file(file_path):
|
|
@@ -334,6 +372,69 @@ def _is_validated_target(file_path: str) -> bool:
|
|
|
334
372
|
return get_file_extension(file_path) in ALL_CODE_EXTENSIONS
|
|
335
373
|
|
|
336
374
|
|
|
375
|
+
def _is_hook_infrastructure_python_target(file_path: str) -> bool:
|
|
376
|
+
"""Return whether the path is a hook-infrastructure Python file.
|
|
377
|
+
|
|
378
|
+
The full code-rules suite exempts hook-infrastructure files, but the
|
|
379
|
+
cross-file duplicate-body check must still guard them: a helper copied
|
|
380
|
+
across sibling hook modules is the exact violation it targets. This
|
|
381
|
+
predicate selects the hook ``.py`` files that route to that single check.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
file_path: The destination path of the write, edit, or pre-check target.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
True when the path names a Python file inside hook infrastructure.
|
|
388
|
+
"""
|
|
389
|
+
if not file_path:
|
|
390
|
+
return False
|
|
391
|
+
if not is_hook_infrastructure(file_path):
|
|
392
|
+
return False
|
|
393
|
+
return get_file_extension(file_path) in ALL_PYTHON_EXTENSIONS
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _hook_infrastructure_blocking_issues(
|
|
397
|
+
content: str,
|
|
398
|
+
file_path: str,
|
|
399
|
+
full_file_content: str | None = None,
|
|
400
|
+
prior_full_file_content: str = "",
|
|
401
|
+
) -> list[str]:
|
|
402
|
+
"""Run the checks that still guard a hook Python target.
|
|
403
|
+
|
|
404
|
+
The whole code-rules verdict stays off hook-infrastructure files, so this
|
|
405
|
+
runs the two checks that must still guard them: the cross-file duplicate-body
|
|
406
|
+
check, span-scoped to the lines an edit touched exactly as ``validate_content``
|
|
407
|
+
scopes it for production code; and the zero-payload alias check, whose
|
|
408
|
+
docstring names hook modules as its motivating case, run over the whole
|
|
409
|
+
post-edit file.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
content: The fragment or whole-file body under validation.
|
|
413
|
+
file_path: The hook-infrastructure destination path.
|
|
414
|
+
full_file_content: The reconstructed post-edit file body on an Edit, or
|
|
415
|
+
None for a whole-file Write.
|
|
416
|
+
prior_full_file_content: The file content before the edit applied, used to
|
|
417
|
+
recover the changed lines on an Edit.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
The in-scope duplicate-body violations and the zero-payload alias
|
|
421
|
+
violations for the target.
|
|
422
|
+
"""
|
|
423
|
+
effective_content = content if full_file_content is None else full_file_content
|
|
424
|
+
all_changed_lines = (
|
|
425
|
+
changed_line_numbers(prior_full_file_content, full_file_content)
|
|
426
|
+
if full_file_content is not None
|
|
427
|
+
else None
|
|
428
|
+
)
|
|
429
|
+
all_issues = check_duplicate_function_body_across_files(
|
|
430
|
+
effective_content,
|
|
431
|
+
file_path,
|
|
432
|
+
all_changed_lines,
|
|
433
|
+
)
|
|
434
|
+
all_issues.extend(check_zero_payload_function_alias(effective_content, file_path))
|
|
435
|
+
return all_issues
|
|
436
|
+
|
|
437
|
+
|
|
337
438
|
def _without_line_prefix(violation_text: str) -> str:
|
|
338
439
|
"""Return the violation message body with its ``Line <n>: `` locator removed.
|
|
339
440
|
|
|
@@ -441,15 +542,22 @@ def _run_precheck(
|
|
|
441
542
|
Exit code 1 when any violation exists or the candidate cannot be read,
|
|
442
543
|
and 0 when the candidate is clean or the target is exempt.
|
|
443
544
|
"""
|
|
444
|
-
|
|
545
|
+
runs_full_verdict = _is_validated_target(target_path)
|
|
546
|
+
runs_hook_duplicate_body = _is_hook_infrastructure_python_target(target_path)
|
|
547
|
+
if not runs_full_verdict and not runs_hook_duplicate_body:
|
|
445
548
|
return 0
|
|
446
549
|
candidate_content = _read_existing_file_content(candidate_path)
|
|
447
550
|
if candidate_content is None:
|
|
448
551
|
error_stream.write(f"error: cannot read candidate file: {candidate_path}\n")
|
|
449
552
|
return 1
|
|
450
553
|
candidate_content = candidate_content.lstrip(UTF8_BYTE_ORDER_MARK)
|
|
451
|
-
|
|
452
|
-
|
|
554
|
+
if runs_full_verdict:
|
|
555
|
+
old_content = _read_existing_file_content(target_path) or ""
|
|
556
|
+
all_issues = validate_content(candidate_content, target_path, old_content)
|
|
557
|
+
else:
|
|
558
|
+
all_issues = _hook_infrastructure_blocking_issues(
|
|
559
|
+
candidate_content, target_path
|
|
560
|
+
)
|
|
453
561
|
for each_issue in all_issues:
|
|
454
562
|
violation_stream.write(f"{each_issue}\n")
|
|
455
563
|
return 1 if all_issues else 0
|
|
@@ -576,7 +684,7 @@ def _deny_reason_for_issues(
|
|
|
576
684
|
Returns:
|
|
577
685
|
The complete ``permissionDecisionReason`` text.
|
|
578
686
|
"""
|
|
579
|
-
issue_list = "; ".join(all_blocking_issues[:
|
|
687
|
+
issue_list = "; ".join(all_blocking_issues[:DENY_REASON_ISSUE_PREVIEW_COUNT])
|
|
580
688
|
deny_reason = (
|
|
581
689
|
f"BLOCKED: [CODE_RULES] {len(all_blocking_issues)} violation(s): {issue_list}"
|
|
582
690
|
)
|
|
@@ -593,7 +701,7 @@ def _deny_reason_for_issues(
|
|
|
593
701
|
all_blocking_issues=all_blocking_issues,
|
|
594
702
|
)
|
|
595
703
|
if forecast_issues:
|
|
596
|
-
forecast_list = "; ".join(forecast_issues[:
|
|
704
|
+
forecast_list = "; ".join(forecast_issues[:DENY_REASON_ISSUE_PREVIEW_COUNT])
|
|
597
705
|
deny_reason += (
|
|
598
706
|
f"; FULL-FILE FORECAST — {len(forecast_issues)} additional "
|
|
599
707
|
"violation(s) elsewhere in this file will block future edits "
|
|
@@ -602,6 +710,24 @@ def _deny_reason_for_issues(
|
|
|
602
710
|
return deny_reason + _precheck_hint()
|
|
603
711
|
|
|
604
712
|
|
|
713
|
+
def _write_deny_payload(deny_reason: str, deny_stream: TextIO) -> None:
|
|
714
|
+
"""Write a PreToolUse deny payload carrying the given reason.
|
|
715
|
+
|
|
716
|
+
Args:
|
|
717
|
+
deny_reason: The composed ``permissionDecisionReason`` text.
|
|
718
|
+
deny_stream: The stream the JSON deny payload is written to.
|
|
719
|
+
"""
|
|
720
|
+
deny_payload = {
|
|
721
|
+
"hookSpecificOutput": {
|
|
722
|
+
"hookEventName": "PreToolUse",
|
|
723
|
+
"permissionDecision": "deny",
|
|
724
|
+
"permissionDecisionReason": deny_reason,
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
deny_stream.write(json.dumps(deny_payload) + "\n")
|
|
728
|
+
deny_stream.flush()
|
|
729
|
+
|
|
730
|
+
|
|
605
731
|
def _report_blocking_violations(
|
|
606
732
|
content: str,
|
|
607
733
|
tool_name: str,
|
|
@@ -632,21 +758,53 @@ def _report_blocking_violations(
|
|
|
632
758
|
)
|
|
633
759
|
if not all_blocking_issues:
|
|
634
760
|
return
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
761
|
+
_write_deny_payload(
|
|
762
|
+
_deny_reason_for_issues(
|
|
763
|
+
all_blocking_issues,
|
|
764
|
+
tool_name,
|
|
765
|
+
file_path,
|
|
766
|
+
full_file_content_after_edit,
|
|
767
|
+
prior_full_file_content,
|
|
768
|
+
),
|
|
769
|
+
deny_stream,
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _report_hook_blocking_issues(
|
|
774
|
+
content: str,
|
|
775
|
+
file_path: str,
|
|
776
|
+
full_file_content_after_edit: str | None,
|
|
777
|
+
prior_full_file_content: str,
|
|
778
|
+
deny_stream: TextIO,
|
|
779
|
+
) -> None:
|
|
780
|
+
"""Write a deny payload when a hook target trips a check that still guards it.
|
|
781
|
+
|
|
782
|
+
The full code-rules verdict stays off hook-infrastructure files; this runs the
|
|
783
|
+
two checks that must still guard them — the cross-file duplicate-body check and
|
|
784
|
+
the zero-payload alias check — and emits the deny payload when either fires.
|
|
785
|
+
|
|
786
|
+
Args:
|
|
787
|
+
content: The fragment or whole-file body under validation.
|
|
788
|
+
file_path: The hook-infrastructure destination path.
|
|
789
|
+
full_file_content_after_edit: The reconstructed post-edit file body,
|
|
790
|
+
or None when the payload is not an Edit.
|
|
791
|
+
prior_full_file_content: The on-disk content before the edit.
|
|
792
|
+
deny_stream: The stream the JSON deny payload is written to.
|
|
793
|
+
"""
|
|
794
|
+
all_blocking_issues = _hook_infrastructure_blocking_issues(
|
|
795
|
+
content,
|
|
796
|
+
file_path,
|
|
797
|
+
full_file_content_after_edit,
|
|
798
|
+
prior_full_file_content,
|
|
799
|
+
)
|
|
800
|
+
if not all_blocking_issues:
|
|
801
|
+
return
|
|
802
|
+
issue_list = "; ".join(all_blocking_issues[:DENY_REASON_ISSUE_PREVIEW_COUNT])
|
|
803
|
+
deny_reason = (
|
|
804
|
+
f"BLOCKED: [CODE_RULES] {len(all_blocking_issues)} violation(s): {issue_list}"
|
|
805
|
+
+ _precheck_hint()
|
|
806
|
+
)
|
|
807
|
+
_write_deny_payload(deny_reason, deny_stream)
|
|
650
808
|
|
|
651
809
|
|
|
652
810
|
def main(all_arguments: list[str]) -> None:
|
|
@@ -671,7 +829,8 @@ def main(all_arguments: list[str]) -> None:
|
|
|
671
829
|
tool_input = pretooluse_payload.get("tool_input", {})
|
|
672
830
|
file_path = tool_input.get("file_path", "")
|
|
673
831
|
|
|
674
|
-
|
|
832
|
+
runs_full_verdict = _is_validated_target(file_path)
|
|
833
|
+
if not runs_full_verdict and not _is_hook_infrastructure_python_target(file_path):
|
|
675
834
|
sys.exit(0)
|
|
676
835
|
|
|
677
836
|
validation_contents = _contents_for_validation(
|
|
@@ -690,6 +849,16 @@ def main(all_arguments: list[str]) -> None:
|
|
|
690
849
|
if not content:
|
|
691
850
|
sys.exit(0)
|
|
692
851
|
|
|
852
|
+
if not runs_full_verdict:
|
|
853
|
+
_report_hook_blocking_issues(
|
|
854
|
+
content,
|
|
855
|
+
file_path,
|
|
856
|
+
full_file_content_after_edit,
|
|
857
|
+
prior_full_file_content,
|
|
858
|
+
sys.stdout,
|
|
859
|
+
)
|
|
860
|
+
sys.exit(0)
|
|
861
|
+
|
|
693
862
|
_report_blocking_violations(
|
|
694
863
|
content,
|
|
695
864
|
tool_name,
|
|
@@ -54,6 +54,101 @@ def _mask_string_literals_preserving_length(source_line: str) -> str:
|
|
|
54
54
|
return string_literal_pattern.sub(_replace_string_literal, source_line)
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def _carries_top_level_slice_colon(bracket_body: str) -> bool:
|
|
58
|
+
"""Return True when ``bracket_body`` holds a slice colon at its own level.
|
|
59
|
+
|
|
60
|
+
The body is the text between one ``[...]`` pair. A slice colon is a ``:``
|
|
61
|
+
that sits at the body's top level — not nested inside any ``(``, ``[``,
|
|
62
|
+
or ``{`` opened within the body — and is not the ``:=`` walrus operator.
|
|
63
|
+
A slice writes ``[:N]`` or ``[1:N]``; a plain subscript (``[K]``), a
|
|
64
|
+
walrus subscript (``[(n := M)]``), and a lambda subscript
|
|
65
|
+
(``[(lambda: V)()]``) carry no such colon.
|
|
66
|
+
"""
|
|
67
|
+
nesting_depth = 0
|
|
68
|
+
for each_position, each_character in enumerate(bracket_body):
|
|
69
|
+
if each_character in "([{":
|
|
70
|
+
nesting_depth += 1
|
|
71
|
+
continue
|
|
72
|
+
if each_character in ")]}":
|
|
73
|
+
nesting_depth -= 1
|
|
74
|
+
continue
|
|
75
|
+
if each_character == ":" and nesting_depth == 0:
|
|
76
|
+
next_position = each_position + 1
|
|
77
|
+
character_after_colon = bracket_body[next_position : next_position + 1]
|
|
78
|
+
follows_walrus = character_after_colon == "="
|
|
79
|
+
if not follows_walrus:
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _innermost_bracket_body_around(masked_line: str, occurrence_position: int) -> str | None:
|
|
85
|
+
"""Return the body of the innermost ``[...]`` pair enclosing a position.
|
|
86
|
+
|
|
87
|
+
Walks the line tracking open-bracket positions on a stack. The bracket
|
|
88
|
+
pair directly enclosing ``occurrence_position`` is the one whose ``[``
|
|
89
|
+
is on top of the stack when that position is reached; its body runs from
|
|
90
|
+
just after that ``[`` to its matching ``]``. Returns ``None`` when the
|
|
91
|
+
position lies outside every ``[...]`` pair.
|
|
92
|
+
"""
|
|
93
|
+
open_bracket_positions: list[int] = []
|
|
94
|
+
enclosing_open_position = -1
|
|
95
|
+
for each_position, each_character in enumerate(masked_line):
|
|
96
|
+
if each_position == occurrence_position:
|
|
97
|
+
enclosing_open_position = open_bracket_positions[-1] if open_bracket_positions else -1
|
|
98
|
+
if each_character == "[":
|
|
99
|
+
open_bracket_positions.append(each_position)
|
|
100
|
+
continue
|
|
101
|
+
if each_character == "]" and open_bracket_positions:
|
|
102
|
+
open_bracket_positions.pop()
|
|
103
|
+
|
|
104
|
+
if enclosing_open_position == -1:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
closing_position = _matching_close_bracket_position(masked_line, enclosing_open_position)
|
|
108
|
+
if closing_position == -1:
|
|
109
|
+
return None
|
|
110
|
+
return masked_line[enclosing_open_position + 1 : closing_position]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _matching_close_bracket_position(masked_line: str, open_position: int) -> int:
|
|
114
|
+
"""Return the index of the ``]`` that closes the ``[`` at ``open_position``.
|
|
115
|
+
|
|
116
|
+
Returns ``-1`` when the opening bracket has no matching close on the line.
|
|
117
|
+
"""
|
|
118
|
+
depth = 0
|
|
119
|
+
for each_position in range(open_position, len(masked_line)):
|
|
120
|
+
each_character = masked_line[each_position]
|
|
121
|
+
if each_character == "[":
|
|
122
|
+
depth += 1
|
|
123
|
+
continue
|
|
124
|
+
if each_character == "]":
|
|
125
|
+
depth -= 1
|
|
126
|
+
if depth == 0:
|
|
127
|
+
return each_position
|
|
128
|
+
return -1
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_magic_number_inside_slice_bound(masked_line: str, number_text: str) -> bool:
|
|
132
|
+
"""Return True when ``number_text`` appears as a slice bound on the line.
|
|
133
|
+
|
|
134
|
+
For each standalone-token occurrence of ``number_text``, the innermost
|
|
135
|
+
``[...]`` pair directly enclosing it is located. The number is a slice
|
|
136
|
+
bound only when that innermost pair carries a top-level slice colon
|
|
137
|
+
(``sha[:N]``, ``ts[1:N]``). A plain subscript index (``items[K]``), the
|
|
138
|
+
inner index of a nested subscript (``a[b[I]:N]`` — the inner index sits
|
|
139
|
+
inside the plain inner pair), a walrus subscript (``arr[(n := M)]``), and
|
|
140
|
+
a lambda subscript (``seq[(lambda: V)()]``) are not slice bounds.
|
|
141
|
+
"""
|
|
142
|
+
token_boundary_pattern = r"(?<![.\w])" + re.escape(number_text) + r"(?![.\w])"
|
|
143
|
+
for each_match in re.finditer(token_boundary_pattern, masked_line):
|
|
144
|
+
bracket_body = _innermost_bracket_body_around(masked_line, each_match.start())
|
|
145
|
+
if bracket_body is None:
|
|
146
|
+
continue
|
|
147
|
+
if _carries_top_level_slice_colon(bracket_body):
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
57
152
|
def check_magic_values(content: str, file_path: str) -> list[str]:
|
|
58
153
|
"""Check for magic values in function bodies."""
|
|
59
154
|
if is_config_file(file_path) or is_test_file(file_path):
|
|
@@ -93,6 +188,9 @@ def check_magic_values(content: str, file_path: str) -> list[str]:
|
|
|
93
188
|
if each_number not in allowed_numbers:
|
|
94
189
|
if "range(" in stripped_without_string_literals or "enumerate(" in stripped_without_string_literals:
|
|
95
190
|
continue
|
|
191
|
+
if _is_magic_number_inside_slice_bound(stripped_without_string_literals, each_number):
|
|
192
|
+
issues.append(f"Line {each_line_number}: Magic value {each_number} - extract to named constant")
|
|
193
|
+
break
|
|
96
194
|
if "[" in stripped_without_string_literals and "]" in stripped_without_string_literals:
|
|
97
195
|
continue
|
|
98
196
|
issues.append(f"Line {each_line_number}: Magic value {each_number} - extract to named constant")
|
|
@@ -115,6 +115,47 @@ def _collect_annotated_arguments(function_node: ast.FunctionDef | ast.AsyncFunct
|
|
|
115
115
|
return all_annotated_arguments
|
|
116
116
|
|
|
117
117
|
|
|
118
|
+
def _collect_fixture_injection_arguments(
|
|
119
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
120
|
+
) -> list[ast.arg]:
|
|
121
|
+
"""Return only the named parameters pytest fills by fixture injection.
|
|
122
|
+
|
|
123
|
+
Pytest passes fixtures by keyword (``testfunction(**testargs)``), so a
|
|
124
|
+
parameter can receive a fixture only when both conditions hold: it is
|
|
125
|
+
reachable by keyword, and pytest is responsible for supplying its value.
|
|
126
|
+
Positional-only parameters are NOT injection slots — a keyword-passed
|
|
127
|
+
fixture can never bind to one, and ``def test_x(tmp_path, /)`` raises a
|
|
128
|
+
missing-argument ``TypeError`` under pytest. A ``*args`` star-argument or
|
|
129
|
+
``**kwargs`` double-star-argument never names a single fixture either.
|
|
130
|
+
A parameter carrying a default is NOT injected — pytest leaves its default
|
|
131
|
+
in place rather than supplying the fixture. So this collector keeps only the
|
|
132
|
+
positional-or-keyword and keyword-only parameters that have no default, and
|
|
133
|
+
omits ``args.posonlyargs``, ``args.vararg``, and ``args.kwarg``.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
function_node: The function definition AST node to inspect.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The undefaulted positional-or-keyword and keyword-only argument nodes,
|
|
140
|
+
in declaration order.
|
|
141
|
+
"""
|
|
142
|
+
arguments = function_node.args
|
|
143
|
+
defaulted_positional_count = len(arguments.defaults)
|
|
144
|
+
undefaulted_positional_arguments = (
|
|
145
|
+
arguments.args[:-defaulted_positional_count]
|
|
146
|
+
if defaulted_positional_count
|
|
147
|
+
else arguments.args
|
|
148
|
+
)
|
|
149
|
+
undefaulted_keyword_only_arguments = [
|
|
150
|
+
each_keyword_argument
|
|
151
|
+
for each_keyword_argument, each_default in zip(
|
|
152
|
+
arguments.kwonlyargs, arguments.kw_defaults
|
|
153
|
+
)
|
|
154
|
+
if each_default is None
|
|
155
|
+
]
|
|
156
|
+
return [*undefaulted_positional_arguments, *undefaulted_keyword_only_arguments]
|
|
157
|
+
|
|
158
|
+
|
|
118
159
|
def _collect_target_names(target: ast.expr) -> list[ast.Name]:
|
|
119
160
|
"""Return every ast.Name reachable through tuple/list/starred unpacking targets."""
|
|
120
161
|
if isinstance(target, ast.Name):
|