claude-dev-env 1.59.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/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/hooks/blocking/code_rules_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +152 -0
- package/hooks/blocking/code_rules_enforcer.py +30 -15
- 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/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -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_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/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/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +43 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/package.json +1 -1
- package/rules/file-global-constants.md +7 -1
- package/rules/no-cross-skill-duplicate-helpers.md +29 -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 +54 -17
- package/skills/autoconverge/reference/closing-report.md +59 -17
- 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.mjs +128 -6
- 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/rebase/SKILL.md +2 -4
- 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
|
@@ -54,11 +54,15 @@ from code_rules_constants_config import ( # noqa: E402
|
|
|
54
54
|
from code_rules_dead_dataclass_field import ( # noqa: E402
|
|
55
55
|
check_dead_dataclass_fields,
|
|
56
56
|
)
|
|
57
|
+
from code_rules_dead_module_constant import ( # noqa: E402
|
|
58
|
+
check_dead_module_constants,
|
|
59
|
+
)
|
|
57
60
|
from code_rules_docstrings import ( # noqa: E402
|
|
58
61
|
check_docstring_args_match_signature,
|
|
59
62
|
check_docstring_format,
|
|
60
63
|
)
|
|
61
64
|
from code_rules_duplicate_body import ( # noqa: E402
|
|
65
|
+
advise_cross_skill_duplicate_helper,
|
|
62
66
|
check_duplicate_function_body_across_files,
|
|
63
67
|
)
|
|
64
68
|
from code_rules_imports_logging import ( # noqa: E402
|
|
@@ -120,6 +124,7 @@ from code_rules_typeddict_stub import ( # noqa: E402
|
|
|
120
124
|
check_stub_implementations,
|
|
121
125
|
check_thin_wrapper_files,
|
|
122
126
|
check_typed_dict_encode_decode,
|
|
127
|
+
check_zero_payload_function_alias,
|
|
123
128
|
)
|
|
124
129
|
from code_rules_unused_imports import ( # noqa: E402
|
|
125
130
|
check_unused_module_level_imports,
|
|
@@ -228,6 +233,7 @@ def validate_content(
|
|
|
228
233
|
all_issues.extend(check_test_branching_in_production(effective_content, file_path))
|
|
229
234
|
all_issues.extend(check_bare_except(effective_content, file_path))
|
|
230
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))
|
|
231
237
|
all_issues.extend(check_boundary_types(effective_content, file_path))
|
|
232
238
|
all_issues.extend(check_docstring_format(effective_content, file_path))
|
|
233
239
|
all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
|
|
@@ -269,6 +275,9 @@ def validate_content(
|
|
|
269
275
|
all_issues.extend(
|
|
270
276
|
check_dead_dataclass_fields(content, file_path, full_file_content)
|
|
271
277
|
)
|
|
278
|
+
all_issues.extend(
|
|
279
|
+
check_dead_module_constants(content, file_path, full_file_content)
|
|
280
|
+
)
|
|
272
281
|
all_issues.extend(check_library_print(content, file_path))
|
|
273
282
|
all_issues.extend(check_parameter_annotations(content, file_path))
|
|
274
283
|
all_issues.extend(check_known_pytest_fixture_annotations(content, file_path))
|
|
@@ -287,6 +296,7 @@ def validate_content(
|
|
|
287
296
|
all_issues.extend(check_string_literal_magic(content, file_path))
|
|
288
297
|
check_incomplete_mocks(content, file_path)
|
|
289
298
|
check_duplicated_format_patterns(content, file_path)
|
|
299
|
+
advise_cross_skill_duplicate_helper(effective_content, file_path)
|
|
290
300
|
|
|
291
301
|
elif extension in ALL_JAVASCRIPT_EXTENSIONS:
|
|
292
302
|
if not is_test_file(file_path):
|
|
@@ -383,18 +393,20 @@ def _is_hook_infrastructure_python_target(file_path: str) -> bool:
|
|
|
383
393
|
return get_file_extension(file_path) in ALL_PYTHON_EXTENSIONS
|
|
384
394
|
|
|
385
395
|
|
|
386
|
-
def
|
|
396
|
+
def _hook_infrastructure_blocking_issues(
|
|
387
397
|
content: str,
|
|
388
398
|
file_path: str,
|
|
389
399
|
full_file_content: str | None = None,
|
|
390
400
|
prior_full_file_content: str = "",
|
|
391
401
|
) -> list[str]:
|
|
392
|
-
"""Run
|
|
402
|
+
"""Run the checks that still guard a hook Python target.
|
|
393
403
|
|
|
394
404
|
The whole code-rules verdict stays off hook-infrastructure files, so this
|
|
395
|
-
runs the
|
|
396
|
-
an edit touched exactly as ``validate_content``
|
|
397
|
-
code
|
|
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.
|
|
398
410
|
|
|
399
411
|
Args:
|
|
400
412
|
content: The fragment or whole-file body under validation.
|
|
@@ -405,7 +417,8 @@ def _hook_infrastructure_duplicate_body_issues(
|
|
|
405
417
|
recover the changed lines on an Edit.
|
|
406
418
|
|
|
407
419
|
Returns:
|
|
408
|
-
The in-scope duplicate-body violations
|
|
420
|
+
The in-scope duplicate-body violations and the zero-payload alias
|
|
421
|
+
violations for the target.
|
|
409
422
|
"""
|
|
410
423
|
effective_content = content if full_file_content is None else full_file_content
|
|
411
424
|
all_changed_lines = (
|
|
@@ -413,11 +426,13 @@ def _hook_infrastructure_duplicate_body_issues(
|
|
|
413
426
|
if full_file_content is not None
|
|
414
427
|
else None
|
|
415
428
|
)
|
|
416
|
-
|
|
429
|
+
all_issues = check_duplicate_function_body_across_files(
|
|
417
430
|
effective_content,
|
|
418
431
|
file_path,
|
|
419
432
|
all_changed_lines,
|
|
420
433
|
)
|
|
434
|
+
all_issues.extend(check_zero_payload_function_alias(effective_content, file_path))
|
|
435
|
+
return all_issues
|
|
421
436
|
|
|
422
437
|
|
|
423
438
|
def _without_line_prefix(violation_text: str) -> str:
|
|
@@ -540,7 +555,7 @@ def _run_precheck(
|
|
|
540
555
|
old_content = _read_existing_file_content(target_path) or ""
|
|
541
556
|
all_issues = validate_content(candidate_content, target_path, old_content)
|
|
542
557
|
else:
|
|
543
|
-
all_issues =
|
|
558
|
+
all_issues = _hook_infrastructure_blocking_issues(
|
|
544
559
|
candidate_content, target_path
|
|
545
560
|
)
|
|
546
561
|
for each_issue in all_issues:
|
|
@@ -755,18 +770,18 @@ def _report_blocking_violations(
|
|
|
755
770
|
)
|
|
756
771
|
|
|
757
772
|
|
|
758
|
-
def
|
|
773
|
+
def _report_hook_blocking_issues(
|
|
759
774
|
content: str,
|
|
760
775
|
file_path: str,
|
|
761
776
|
full_file_content_after_edit: str | None,
|
|
762
777
|
prior_full_file_content: str,
|
|
763
778
|
deny_stream: TextIO,
|
|
764
779
|
) -> None:
|
|
765
|
-
"""Write a deny payload when a hook target
|
|
780
|
+
"""Write a deny payload when a hook target trips a check that still guards it.
|
|
766
781
|
|
|
767
|
-
The full code-rules verdict stays off hook-infrastructure files; this runs
|
|
768
|
-
|
|
769
|
-
payload when
|
|
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.
|
|
770
785
|
|
|
771
786
|
Args:
|
|
772
787
|
content: The fragment or whole-file body under validation.
|
|
@@ -776,7 +791,7 @@ def _report_hook_duplicate_body(
|
|
|
776
791
|
prior_full_file_content: The on-disk content before the edit.
|
|
777
792
|
deny_stream: The stream the JSON deny payload is written to.
|
|
778
793
|
"""
|
|
779
|
-
all_blocking_issues =
|
|
794
|
+
all_blocking_issues = _hook_infrastructure_blocking_issues(
|
|
780
795
|
content,
|
|
781
796
|
file_path,
|
|
782
797
|
full_file_content_after_edit,
|
|
@@ -835,7 +850,7 @@ def main(all_arguments: list[str]) -> None:
|
|
|
835
850
|
sys.exit(0)
|
|
836
851
|
|
|
837
852
|
if not runs_full_verdict:
|
|
838
|
-
|
|
853
|
+
_report_hook_blocking_issues(
|
|
839
854
|
content,
|
|
840
855
|
file_path,
|
|
841
856
|
full_file_content_after_edit,
|
|
@@ -26,6 +26,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
26
26
|
MAX_STUB_IMPLEMENTATION_ISSUES,
|
|
27
27
|
MAX_THIN_WRAPPER_ISSUES,
|
|
28
28
|
MAX_TYPED_DICT_PAIR_ISSUES,
|
|
29
|
+
MAX_ZERO_PAYLOAD_ALIAS_ISSUES,
|
|
29
30
|
)
|
|
30
31
|
|
|
31
32
|
def _pascal_to_snake_case(pascal_name: str) -> str:
|
|
@@ -58,6 +59,16 @@ def _collect_module_function_names(parsed_tree: ast.AST) -> set[str]:
|
|
|
58
59
|
return module_function_names
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
def _collect_module_function_nodes_by_name(
|
|
63
|
+
parsed_tree: ast.AST,
|
|
64
|
+
) -> dict[str, ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
65
|
+
function_node_by_name: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {}
|
|
66
|
+
for each_statement in parsed_tree.body:
|
|
67
|
+
if isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
68
|
+
function_node_by_name[each_statement.name] = each_statement
|
|
69
|
+
return function_node_by_name
|
|
70
|
+
|
|
71
|
+
|
|
61
72
|
def _is_init_file(file_path: str) -> bool:
|
|
62
73
|
return file_path.replace("\\", "/").rsplit("/", 1)[-1] == "__init__.py"
|
|
63
74
|
|
|
@@ -126,6 +137,167 @@ def check_thin_wrapper_files(content: str, file_path: str) -> list[str]:
|
|
|
126
137
|
return issues[:MAX_THIN_WRAPPER_ISSUES]
|
|
127
138
|
|
|
128
139
|
|
|
140
|
+
def _function_parameter_names_in_order(
|
|
141
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
142
|
+
) -> list[str]:
|
|
143
|
+
arguments = function_node.args
|
|
144
|
+
positional_arguments = [*arguments.posonlyargs, *arguments.args]
|
|
145
|
+
return [each_argument.arg for each_argument in positional_arguments]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _has_only_positional_parameters(
|
|
149
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
150
|
+
) -> bool:
|
|
151
|
+
arguments = function_node.args
|
|
152
|
+
has_no_parameter_defaults = not arguments.defaults and not arguments.kw_defaults
|
|
153
|
+
return (
|
|
154
|
+
not arguments.kwonlyargs
|
|
155
|
+
and arguments.vararg is None
|
|
156
|
+
and arguments.kwarg is None
|
|
157
|
+
and has_no_parameter_defaults
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _single_return_call(
|
|
162
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
163
|
+
) -> ast.Call | None:
|
|
164
|
+
body_statements = function_node.body
|
|
165
|
+
statements_after_docstring = (
|
|
166
|
+
body_statements[1:]
|
|
167
|
+
if body_statements and _statement_is_docstring(body_statements[0])
|
|
168
|
+
else body_statements
|
|
169
|
+
)
|
|
170
|
+
if len(statements_after_docstring) != 1:
|
|
171
|
+
return None
|
|
172
|
+
only_statement = statements_after_docstring[0]
|
|
173
|
+
if not isinstance(only_statement, ast.Return):
|
|
174
|
+
return None
|
|
175
|
+
return only_statement.value if isinstance(only_statement.value, ast.Call) else None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _forwards_parameters_unchanged(call_node: ast.Call, all_parameter_names: list[str]) -> bool:
|
|
179
|
+
if call_node.keywords:
|
|
180
|
+
return False
|
|
181
|
+
if len(call_node.args) != len(all_parameter_names):
|
|
182
|
+
return False
|
|
183
|
+
for each_argument, each_parameter_name in zip(call_node.args, all_parameter_names):
|
|
184
|
+
if not isinstance(each_argument, ast.Name) or each_argument.id != each_parameter_name:
|
|
185
|
+
return False
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _function_is_async(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
190
|
+
return isinstance(function_node, ast.AsyncFunctionDef)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _alias_target_name(call_node: ast.Call, all_sibling_function_names: set[str]) -> str:
|
|
194
|
+
callee = call_node.func
|
|
195
|
+
if not isinstance(callee, ast.Name):
|
|
196
|
+
return ""
|
|
197
|
+
return callee.id if callee.id in all_sibling_function_names else ""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _module_string_literal_values(parsed_tree: ast.AST) -> set[str]:
|
|
201
|
+
string_literal_values: set[str] = set()
|
|
202
|
+
for each_node in ast.walk(parsed_tree):
|
|
203
|
+
if isinstance(each_node, ast.Constant) and isinstance(each_node.value, str):
|
|
204
|
+
string_literal_values.add(each_node.value)
|
|
205
|
+
return string_literal_values
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _target_accepts_forwarded_positional_call(
|
|
209
|
+
target_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
210
|
+
forwarded_argument_count: int,
|
|
211
|
+
) -> bool:
|
|
212
|
+
arguments = target_node.args
|
|
213
|
+
if any(default is None for default in arguments.kw_defaults):
|
|
214
|
+
return False
|
|
215
|
+
positional_parameters = [*arguments.posonlyargs, *arguments.args]
|
|
216
|
+
total_positional_count = len(positional_parameters)
|
|
217
|
+
required_positional_count = total_positional_count - len(arguments.defaults)
|
|
218
|
+
if arguments.vararg is not None:
|
|
219
|
+
return forwarded_argument_count >= required_positional_count
|
|
220
|
+
return required_positional_count <= forwarded_argument_count <= total_positional_count
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def check_zero_payload_function_alias(content: str, file_path: str) -> list[str]:
|
|
224
|
+
"""Flag a module-level function that only forwards its parameters to a sibling.
|
|
225
|
+
|
|
226
|
+
A function whose entire body (after an optional docstring) is a single
|
|
227
|
+
`return sibling_function(first_param, second_param, ...)` that forwards its
|
|
228
|
+
own parameters unchanged to another function defined in the same module is a
|
|
229
|
+
second name for one behavior — indirection without payload, which CODE_RULES
|
|
230
|
+
discourages. Callers should invoke the sibling directly. Both `def` and
|
|
231
|
+
`async def` forwarders are inspected.
|
|
232
|
+
|
|
233
|
+
A forwarder is left unflagged when any of these makes a direct call to the
|
|
234
|
+
target not equivalent to the alias: a decorator (caching, `@property`, route
|
|
235
|
+
registration); a parameter carrying a default value the target rejects; a
|
|
236
|
+
keyword-only / `*args` / `**kwargs` parameter on the alias; an awaitability
|
|
237
|
+
mismatch where one of the alias and target is `async def` and the other is
|
|
238
|
+
not; a forwarded positional call the live target's signature rejects (the
|
|
239
|
+
target has a keyword-only parameter without a default, or its positional
|
|
240
|
+
arity does not admit the forwarded argument count); or a name dispatched by a
|
|
241
|
+
string literal elsewhere in the module, where the named handler must exist for
|
|
242
|
+
a registry to resolve it.
|
|
243
|
+
|
|
244
|
+
Hook infrastructure is intentionally NOT exempt — pass-through aliases inside
|
|
245
|
+
hook modules are the motivating case. Test files and config files are exempt
|
|
246
|
+
because re-binding aliases are legitimate scaffolding there.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
content: The source under inspection.
|
|
250
|
+
file_path: Path to the file, used for the test and config exemptions.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
One issue string per pass-through alias, capped at
|
|
254
|
+
MAX_ZERO_PAYLOAD_ALIAS_ISSUES.
|
|
255
|
+
"""
|
|
256
|
+
if is_test_file(file_path) or is_config_file(file_path):
|
|
257
|
+
return []
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
parsed_tree = ast.parse(content)
|
|
261
|
+
except SyntaxError:
|
|
262
|
+
return []
|
|
263
|
+
|
|
264
|
+
function_node_by_name = _collect_module_function_nodes_by_name(parsed_tree)
|
|
265
|
+
all_sibling_function_names = set(function_node_by_name)
|
|
266
|
+
all_string_literal_values = _module_string_literal_values(parsed_tree)
|
|
267
|
+
issues: list[str] = []
|
|
268
|
+
for each_statement in parsed_tree.body:
|
|
269
|
+
if not isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
270
|
+
continue
|
|
271
|
+
if each_statement.decorator_list:
|
|
272
|
+
continue
|
|
273
|
+
if each_statement.name in all_string_literal_values:
|
|
274
|
+
continue
|
|
275
|
+
if not _has_only_positional_parameters(each_statement):
|
|
276
|
+
continue
|
|
277
|
+
call_node = _single_return_call(each_statement)
|
|
278
|
+
if call_node is None:
|
|
279
|
+
continue
|
|
280
|
+
target_name = _alias_target_name(call_node, all_sibling_function_names)
|
|
281
|
+
if not target_name or target_name == each_statement.name:
|
|
282
|
+
continue
|
|
283
|
+
target_node = function_node_by_name[target_name]
|
|
284
|
+
if _function_is_async(each_statement) != _function_is_async(target_node):
|
|
285
|
+
continue
|
|
286
|
+
forwarded_parameter_names = _function_parameter_names_in_order(each_statement)
|
|
287
|
+
if not _forwards_parameters_unchanged(call_node, forwarded_parameter_names):
|
|
288
|
+
continue
|
|
289
|
+
if not _target_accepts_forwarded_positional_call(
|
|
290
|
+
target_node, len(forwarded_parameter_names)
|
|
291
|
+
):
|
|
292
|
+
continue
|
|
293
|
+
issues.append(
|
|
294
|
+
f"Line {each_statement.lineno}: {file_path}: zero-payload alias — "
|
|
295
|
+
f"{each_statement.name} only forwards its parameters to {target_name}; "
|
|
296
|
+
f"callers should call {target_name} directly (indirection without payload)"
|
|
297
|
+
)
|
|
298
|
+
return issues[:MAX_ZERO_PAYLOAD_ALIAS_ISSUES]
|
|
299
|
+
|
|
300
|
+
|
|
129
301
|
def check_typed_dict_encode_decode(content: str, file_path: str) -> list[str]:
|
|
130
302
|
"""Flag TypedDict declarations missing companion `_encode_*` / `_decode_*` functions."""
|
|
131
303
|
if (
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Constants for the verified-commit gate hook family.
|
|
2
|
+
|
|
3
|
+
Shared by ``verification_verdict_store.py``, ``verified_commit_gate.py``,
|
|
4
|
+
and ``verifier_verdict_minter.py`` so every tunable lives in one place.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
GIT_TIMEOUT_SECONDS = 30
|
|
10
|
+
ROOT_KEY_HEX_LENGTH = 16
|
|
11
|
+
VERDICT_JSON_INDENT = 2
|
|
12
|
+
CLAUDE_HOME_DIRECTORY_NAME = ".claude"
|
|
13
|
+
VERDICT_DIRECTORY_NAME = "verification"
|
|
14
|
+
VERDICT_DIRECTORY_NAME_SEPARATOR_PATTERN = r"['\"\\/,\s]+"
|
|
15
|
+
VERDICT_DIRECTORY_PATH_BOUNDARY_PATTERN = r"(?=['\"]*[\\/,])"
|
|
16
|
+
RELATIVE_VERDICT_DIRECTORY_PATTERN = r"(?:^|(?<=[\s;&|(='\"]))verification[\\/]"
|
|
17
|
+
VERDICT_PATH_GLUE_PATTERN = r"['\"+\\/\s]*[\\/]['\"+\\/\s]*"
|
|
18
|
+
VERDICT_DIRECTORY_CHANGE_TARGET_PATTERN = r"[ \t]+['\"]?verification[\\/]?['\"]?(?=[\s;&|]|$)"
|
|
19
|
+
VERDICT_PATH_JOINED_VARIABLE_PATTERN = r"\$\{?(\w+)\}?[\\/]|[\\/]\$\{?(\w+)\}?"
|
|
20
|
+
VERDICT_PATH_VARIABLE_ASSIGNMENT_PATTERN = r"(?:^|(?<=[\s;&|(]))%s=(\S+)"
|
|
21
|
+
VERDICT_FILE_RELATIVE_REFERENCE_PATTERN = (
|
|
22
|
+
rf"(?:^|(?<=[\s;&|(='\"\\/]))verification[\\/][0-9a-f]{{{ROOT_KEY_HEX_LENGTH}}}\.json"
|
|
23
|
+
)
|
|
24
|
+
PATH_OBFUSCATION_PRIMITIVE_PATTERN = (
|
|
25
|
+
r"chr\s*\(|bytes\.fromhex\s*\(|b64decode\s*\(|codecs\.decode\s*\("
|
|
26
|
+
r"|(?:bytes|bytearray)\s*\(\s*\[|\[char\[?\]?\]"
|
|
27
|
+
)
|
|
28
|
+
ALL_VERDICT_PATH_SEGMENT_NAMES = (".claude", "verification")
|
|
29
|
+
ALL_VERDICT_PATH_SEGMENT_BODIES = ("claude", "verification")
|
|
30
|
+
HEX_TOKEN_PATTERN = r"(?<![0-9a-fx])([0-9a-f]{6,})(?![0-9a-f])"
|
|
31
|
+
BASE64_TOKEN_PATTERN = r"[A-Za-z0-9+/]{8,}={0,2}"
|
|
32
|
+
CHARACTER_CODE_SEQUENCE_PATTERN = r"\d{1,3}(?:\s*,\s*\d{1,3})+"
|
|
33
|
+
CHR_CALL_CHAIN_PATTERN = r"chr\(\s*\d{1,3}\s*\)(?:\s*\+\s*chr\(\s*\d{1,3}\s*\))+"
|
|
34
|
+
CHR_CALL_CODE_PATTERN = r"chr\(\s*(\d{1,3})\s*\)"
|
|
35
|
+
HEX_DIGITS_PER_BYTE = 2
|
|
36
|
+
FILE_WRITE_PRIMITIVE_PATTERN = (
|
|
37
|
+
r"\bopen\s*\(|\.write_text\s*\(|\.write_bytes\s*\("
|
|
38
|
+
r"|Out-File|Set-Content|Add-Content|\btee\b|>"
|
|
39
|
+
)
|
|
40
|
+
NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN = (
|
|
41
|
+
r"\bopen\s*\(|\.write_text\s*\(|\.write_bytes\s*\("
|
|
42
|
+
r"|Out-File|Set-Content|Add-Content|\btee\b"
|
|
43
|
+
)
|
|
44
|
+
WRITE_CALL_REGION_PATTERN = (
|
|
45
|
+
r"(?:\bopen\s*\(|\.write_text\s*\(|\.write_bytes\s*\("
|
|
46
|
+
r"|Out-File|Set-Content|Add-Content|\btee\b)[^;&|\n]*"
|
|
47
|
+
)
|
|
48
|
+
VERDICT_KEY_ALL_PASS = "all_pass"
|
|
49
|
+
VERDICT_KEY_MANIFEST_SHA256 = "manifest_sha256"
|
|
50
|
+
DOCS_ONLY_EXTENSIONS = frozenset(
|
|
51
|
+
{".md", ".txt", ".rst", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico"}
|
|
52
|
+
)
|
|
53
|
+
PYTHON_EXTENSION = ".py"
|
|
54
|
+
TEST_FILE_PREFIX = "test_"
|
|
55
|
+
TEST_FILE_SUFFIX = "_test.py"
|
|
56
|
+
CONFTEST_FILE_NAME = "conftest.py"
|
|
57
|
+
MINIMUM_STATUS_FIELD_COUNT = 2
|
|
58
|
+
ALL_FALLBACK_BASE_REFERENCES = ("origin/main", "origin/master")
|
|
59
|
+
ALL_TOOLING_STATE_PREFIXES = (
|
|
60
|
+
".claude/verification/",
|
|
61
|
+
".claude/worktrees/",
|
|
62
|
+
".claude/daemon/",
|
|
63
|
+
".claude/teams/",
|
|
64
|
+
".claude/sessions/",
|
|
65
|
+
".cursor/worktrees/",
|
|
66
|
+
)
|
|
67
|
+
GATED_GIT_SUBCOMMANDS = frozenset({"commit", "push"})
|
|
68
|
+
ALL_GIT_BINARY_NAMES = frozenset({"git", "git.exe"})
|
|
69
|
+
VALUE_TAKING_GIT_OPTIONS = frozenset({"-C", "-c", "--git-dir", "--work-tree", "--namespace"})
|
|
70
|
+
REPO_DIRECTORY_OPTION = "-C"
|
|
71
|
+
WORK_TREE_OPTION = "--work-tree"
|
|
72
|
+
DIRECTORY_CHANGE_VERBS = frozenset({"cd", "pushd", "set-location", "sl"})
|
|
73
|
+
DIRECTORY_CHANGE_PATH_OPTIONS = frozenset({"-path", "-literalpath"})
|
|
74
|
+
DIRECTORY_CHANGE_OPTION_TERMINATOR = "--"
|
|
75
|
+
DIRECTORY_CHANGE_PATTERN_PREFIX = r"(?:^|(?<=[\s;&|(]))(?:"
|
|
76
|
+
DIRECTORY_CHANGE_PATTERN_SUFFIX = r")(?=\s|$)"
|
|
77
|
+
DIRECTORY_CHANGE_OPTION_PREFIX_PATTERN = r"(?:[ \t]+(?:%s)(?=\s|$))*"
|
|
78
|
+
DIRECTORY_CHANGE_TARGET_PATTERN = r"[ \t]+['\"]?\S*"
|
|
79
|
+
CLAUDE_HOME_TARGET_BOUNDARY_PATTERN = r"[\\/]?['\"]?(?=[\s;&|]|$)"
|
|
80
|
+
VERDICT_DIRECTORY_TARGET_BOUNDARY_PATTERN = r"[\\/]?['\"]?(?=[\s;&|]|$)"
|
|
81
|
+
COMMAND_AFTER_DIRECTORY_CHANGE_PATTERN = r"[;&|\n][\s]*\S"
|
|
82
|
+
OPTION_WITH_VALUE_STEP = 2
|
|
83
|
+
ALL_GATED_TOOL_NAMES = ("Bash", "PowerShell")
|
|
84
|
+
HASH_PREVIEW_LENGTH = 16
|
|
85
|
+
MINTING_AGENT_TYPE = "code-verifier"
|
|
86
|
+
SPAWN_LOOKUP_ATTEMPT_COUNT = 3
|
|
87
|
+
SPAWN_LOOKUP_RETRY_DELAY_SECONDS = 0.1
|
|
88
|
+
VERDICT_DIRECTORY_GUARD_MESSAGE = (
|
|
89
|
+
"BLOCKED: [VERDICT_DIRECTORY_GUARD] Shell access to the verification "
|
|
90
|
+
"verdict directory (~/.claude/verification/) is denied. Only the "
|
|
91
|
+
"verifier_verdict_minter.py SubagentStop hook writes verdict files; a "
|
|
92
|
+
"shell write here would forge a passing verdict and defeat the "
|
|
93
|
+
"verified-commit gate. Spawn the code-verifier agent to earn a verdict "
|
|
94
|
+
"instead of writing one."
|
|
95
|
+
)
|
|
96
|
+
CORRECTIVE_MESSAGE = (
|
|
97
|
+
"BLOCKED: [VERIFIED_COMMIT_GATE] This branch surface has no passing "
|
|
98
|
+
"verification verdict. Spawn the code-verifier agent (Agent tool, "
|
|
99
|
+
"subagent_type 'code-verifier') with the task texts, the diff scope, "
|
|
100
|
+
"and recorded baselines; when it finishes with a clean verdict the "
|
|
101
|
+
"SubagentStop hook mints the verdict and this command will pass. Any "
|
|
102
|
+
"file change after verification invalidates the verdict, so verify "
|
|
103
|
+
"last. Exempt automatically: docs/image files, pytest test files, and "
|
|
104
|
+
"Python files whose docstring- and comment-stripped AST is unchanged "
|
|
105
|
+
"(comment-only edits in non-Python files are not exempt)."
|
|
106
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Tests for the cross-skill duplicate-helper advisory.
|
|
2
|
+
|
|
3
|
+
PR #233 on JonEcho/python-automation copied a Chrome-launch helper from the
|
|
4
|
+
``stp-recolor`` skill's ``scripts`` directory into the ``iconize-and-recolor-stp``
|
|
5
|
+
skill's ``scripts`` directory. The two skills install on their own, so a shared
|
|
6
|
+
module would couple them and break independent install; the copy is a defensible
|
|
7
|
+
skill-isolation tradeoff. ``advise_cross_skill_duplicate_helper`` surfaces that
|
|
8
|
+
copy as a non-blocking ``[CODE_RULES advisory]`` on stderr so a reviewer confirms
|
|
9
|
+
the copy was intentional, without denying the write.
|
|
10
|
+
|
|
11
|
+
The tests build a real ``skills/<name>/scripts`` layout on disk and run the
|
|
12
|
+
advisory against it, so they exercise the on-disk cross-skill scan rather than a
|
|
13
|
+
stubbed view of the filesystem.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import importlib.util
|
|
19
|
+
import pathlib
|
|
20
|
+
import shutil
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
_HOOK_DIRECTORY = pathlib.Path(__file__).parent
|
|
28
|
+
if str(_HOOK_DIRECTORY) not in sys.path:
|
|
29
|
+
sys.path.insert(0, str(_HOOK_DIRECTORY))
|
|
30
|
+
|
|
31
|
+
_hook_spec = importlib.util.spec_from_file_location(
|
|
32
|
+
"code_rules_duplicate_body",
|
|
33
|
+
_HOOK_DIRECTORY / "code_rules_duplicate_body.py",
|
|
34
|
+
)
|
|
35
|
+
assert _hook_spec is not None
|
|
36
|
+
assert _hook_spec.loader is not None
|
|
37
|
+
_hook_module = importlib.util.module_from_spec(_hook_spec)
|
|
38
|
+
_hook_spec.loader.exec_module(_hook_module)
|
|
39
|
+
advise_cross_skill_duplicate_helper = _hook_module.advise_cross_skill_duplicate_helper
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
CHROME_HELPER_SOURCE = (
|
|
43
|
+
"import winreg\n"
|
|
44
|
+
"from pathlib import Path\n"
|
|
45
|
+
"\n"
|
|
46
|
+
"chrome_app_paths_key = r'SOFTWARE\\Microsoft\\App Paths\\chrome.exe'\n"
|
|
47
|
+
"chrome_fallback_paths = ('C:/chrome.exe',)\n"
|
|
48
|
+
"\n"
|
|
49
|
+
"def _chrome_executable() -> Path | None:\n"
|
|
50
|
+
" for each_root in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE):\n"
|
|
51
|
+
" try:\n"
|
|
52
|
+
" registered = winreg.QueryValue(each_root, chrome_app_paths_key)\n"
|
|
53
|
+
" except OSError:\n"
|
|
54
|
+
" continue\n"
|
|
55
|
+
" registered_path = Path(registered)\n"
|
|
56
|
+
" if registered_path.exists():\n"
|
|
57
|
+
" return registered_path\n"
|
|
58
|
+
" for each_fallback in chrome_fallback_paths:\n"
|
|
59
|
+
" fallback_path = Path(each_fallback)\n"
|
|
60
|
+
" if fallback_path.exists():\n"
|
|
61
|
+
" return fallback_path\n"
|
|
62
|
+
" return None\n"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.fixture
|
|
67
|
+
def skills_root() -> Iterator[pathlib.Path]:
|
|
68
|
+
base_directory = pathlib.Path(tempfile.mkdtemp())
|
|
69
|
+
skills_directory = base_directory / "skills"
|
|
70
|
+
skills_directory.mkdir()
|
|
71
|
+
try:
|
|
72
|
+
yield skills_directory
|
|
73
|
+
finally:
|
|
74
|
+
shutil.rmtree(base_directory, ignore_errors=False)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _make_skill_scripts(skills_directory: pathlib.Path, skill_name: str) -> pathlib.Path:
|
|
78
|
+
scripts_directory = skills_directory / skill_name / "scripts"
|
|
79
|
+
scripts_directory.mkdir(parents=True)
|
|
80
|
+
return scripts_directory
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_advises_when_helper_copied_from_another_skill(
|
|
84
|
+
skills_root: pathlib.Path, capsys: pytest.CaptureFixture[str]
|
|
85
|
+
) -> None:
|
|
86
|
+
source_scripts = _make_skill_scripts(skills_root, "stp-recolor")
|
|
87
|
+
(source_scripts / "palette_board.py").write_text(CHROME_HELPER_SOURCE, encoding="utf-8")
|
|
88
|
+
target_scripts = _make_skill_scripts(skills_root, "iconize-and-recolor-stp")
|
|
89
|
+
target_file = target_scripts / "combine_report.py"
|
|
90
|
+
|
|
91
|
+
advise_cross_skill_duplicate_helper(CHROME_HELPER_SOURCE, str(target_file))
|
|
92
|
+
|
|
93
|
+
captured = capsys.readouterr()
|
|
94
|
+
assert "[CODE_RULES advisory]" in captured.err, (
|
|
95
|
+
f"Expected a cross-skill advisory on stderr, got: {captured.err!r}"
|
|
96
|
+
)
|
|
97
|
+
assert "_chrome_executable" in captured.err, (
|
|
98
|
+
f"Expected the duplicated function named, got: {captured.err!r}"
|
|
99
|
+
)
|
|
100
|
+
assert "stp-recolor" in captured.err, f"Expected the source skill named, got: {captured.err!r}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_advisory_does_not_block(
|
|
104
|
+
skills_root: pathlib.Path, capsys: pytest.CaptureFixture[str]
|
|
105
|
+
) -> None:
|
|
106
|
+
source_scripts = _make_skill_scripts(skills_root, "stp-recolor")
|
|
107
|
+
(source_scripts / "palette_board.py").write_text(CHROME_HELPER_SOURCE, encoding="utf-8")
|
|
108
|
+
target_scripts = _make_skill_scripts(skills_root, "iconize-and-recolor-stp")
|
|
109
|
+
target_file = target_scripts / "combine_report.py"
|
|
110
|
+
|
|
111
|
+
returned = advise_cross_skill_duplicate_helper(CHROME_HELPER_SOURCE, str(target_file))
|
|
112
|
+
|
|
113
|
+
assert returned is None, "The advisory returns nothing so it never enters the deny path"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_no_advisory_within_one_skill(
|
|
117
|
+
skills_root: pathlib.Path, capsys: pytest.CaptureFixture[str]
|
|
118
|
+
) -> None:
|
|
119
|
+
scripts_directory = _make_skill_scripts(skills_root, "stp-recolor")
|
|
120
|
+
(scripts_directory / "palette_board.py").write_text(CHROME_HELPER_SOURCE, encoding="utf-8")
|
|
121
|
+
sibling_file = scripts_directory / "combine_report.py"
|
|
122
|
+
|
|
123
|
+
advise_cross_skill_duplicate_helper(CHROME_HELPER_SOURCE, str(sibling_file))
|
|
124
|
+
|
|
125
|
+
captured = capsys.readouterr()
|
|
126
|
+
assert captured.err == "", (
|
|
127
|
+
"Within one skill the blocking gate covers the copy; the cross-skill "
|
|
128
|
+
f"advisory must stay silent, got: {captured.err!r}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_no_advisory_outside_a_skill_scripts_directory(
|
|
133
|
+
skills_root: pathlib.Path, capsys: pytest.CaptureFixture[str]
|
|
134
|
+
) -> None:
|
|
135
|
+
source_scripts = _make_skill_scripts(skills_root, "stp-recolor")
|
|
136
|
+
(source_scripts / "palette_board.py").write_text(CHROME_HELPER_SOURCE, encoding="utf-8")
|
|
137
|
+
non_skill_directory = skills_root.parent / "elsewhere"
|
|
138
|
+
non_skill_directory.mkdir()
|
|
139
|
+
target_file = non_skill_directory / "combine_report.py"
|
|
140
|
+
|
|
141
|
+
advise_cross_skill_duplicate_helper(CHROME_HELPER_SOURCE, str(target_file))
|
|
142
|
+
|
|
143
|
+
captured = capsys.readouterr()
|
|
144
|
+
assert captured.err == "", (
|
|
145
|
+
f"A file outside a skill scripts directory draws no advisory, got: {captured.err!r}"
|
|
146
|
+
)
|