claude-dev-env 2.31.0 → 2.32.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/bin/install.test.mjs +11 -3
- package/docs/CODE_RULES.md +2 -1
- package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
- package/hooks/blocking/code_rules_duplicate_body.py +280 -102
- package/hooks/blocking/code_rules_enforcer.py +32 -14
- package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
- package/hooks/blocking/code_rules_shared.py +64 -14
- package/hooks/blocking/pr_description_writer_gate.py +18 -25
- package/hooks/blocking/shell_substitution_blocker.py +175 -8
- package/hooks/blocking/stop_dispatcher.py +6 -9
- package/hooks/blocking/tdd_enforcer.py +11 -5
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
- package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +251 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
- package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +14 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
- package/hooks/blocking/test_state_description_blocker.py +84 -53
- package/hooks/blocking/test_tdd_enforcer.py +131 -1
- package/hooks/hooks.json +2 -2
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
- package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
- package/hooks/hooks_constants/state_file_lock.py +76 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
- package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
- package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
- package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
- package/hooks/observability/session_file_edit_tracker.py +2 -55
- package/hooks/observability/test_failure_recorder.py +219 -0
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
- package/hooks/observability/test_test_failure_recorder.py +173 -0
- package/hooks/validators/abbreviation_checks.py +3 -3
- package/hooks/validators/code_quality_checks.py +4 -4
- package/hooks/validators/config/baseline_identity_constants.py +12 -0
- package/hooks/validators/config/directory_exemption_constants.py +42 -8
- package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
- package/hooks/validators/config/validator_base_constants.py +9 -0
- package/hooks/validators/fast_save_validators.py +23 -80
- package/hooks/validators/mypy_integration.py +10 -36
- package/hooks/validators/pr_reference_checks.py +2 -2
- package/hooks/validators/project_roots.py +58 -0
- package/hooks/validators/python_antipattern_checks.py +3 -3
- package/hooks/validators/python_style_checks.py +3 -2
- package/hooks/validators/react_checks.py +2 -9
- package/hooks/validators/run_all_validators.py +247 -148
- package/hooks/validators/security_checks.py +3 -3
- package/hooks/validators/system_temporary_roots.py +1 -1
- package/hooks/validators/test_directory_exemption_constants.py +30 -24
- package/hooks/validators/test_fast_save_validators.py +16 -16
- package/hooks/validators/test_hook_timing_harness.py +10 -1
- package/hooks/validators/test_run_all_validators.py +22 -0
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
- package/hooks/validators/test_run_all_validators_reporting.py +66 -0
- package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
- package/hooks/validators/test_validator_base.py +51 -2
- package/hooks/validators/todo_checks.py +2 -2
- package/hooks/validators/type_safety_checks.py +3 -3
- package/hooks/validators/useless_test_checks.py +3 -3
- package/hooks/validators/validator_base.py +73 -1
- package/package.json +1 -1
- package/rules/shell-invocation.md +1 -1
- package/rules/testing.md +4 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +19 -0
- package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
- package/scripts/grok_headless_runner.py +15 -12
- package/scripts/shared_tree_paths.py +104 -0
- package/scripts/test_shared_tree_paths.py +173 -0
package/bin/install.test.mjs
CHANGED
|
@@ -925,9 +925,17 @@ function isPreToolUseDispatcherCommand(command) {
|
|
|
925
925
|
return /(?:^|[/\\])pre_tool_use_dispatcher\.py(?![A-Za-z0-9_])/.test(command);
|
|
926
926
|
}
|
|
927
927
|
|
|
928
|
+
function matcherCoversWriteAndEdit(matcher) {
|
|
929
|
+
const allToolNames = String(matcher || '').split('|');
|
|
930
|
+
return allToolNames.includes('Write') && allToolNames.includes('Edit');
|
|
931
|
+
}
|
|
932
|
+
|
|
928
933
|
function countManagedRunAllValidatorsHooks(settings) {
|
|
929
|
-
|
|
930
|
-
|
|
934
|
+
// The runner is registered inline on the write path. Which mutation tools
|
|
935
|
+
// that path lists is the gate's business, so this counts any matcher
|
|
936
|
+
// carrying Write and Edit rather than one exact tool list.
|
|
937
|
+
const writeEditGroups = (settings.hooks.PreToolUse || []).filter(group =>
|
|
938
|
+
matcherCoversWriteAndEdit(group.matcher)
|
|
931
939
|
);
|
|
932
940
|
let runAllValidatorsCount = 0;
|
|
933
941
|
for (const group of writeEditGroups) {
|
|
@@ -1382,7 +1390,7 @@ test('shipped hooks.json matches the dispatcher design: dispatchers registered,
|
|
|
1382
1390
|
assert.equal(
|
|
1383
1391
|
countManagedRunAllValidatorsHooks(shippedHooksConfig),
|
|
1384
1392
|
1,
|
|
1385
|
-
'shipped hooks.json must retain the inline run_all_validators runner
|
|
1393
|
+
'shipped hooks.json must retain the inline run_all_validators runner on the write path',
|
|
1386
1394
|
);
|
|
1387
1395
|
|
|
1388
1396
|
const allPostToolUseGroups = shippedHooksConfig.hooks.PostToolUse || [];
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -126,7 +126,8 @@ Scope assigns the lane. A check that reads a file other than the target runs on
|
|
|
126
126
|
|
|
127
127
|
Hook-infrastructure targets run three checks in the edit lane — `check_same_file_inline_duplicate_body`, `check_zero_payload_function_alias`, `check_unanchored_command_dispatch` — and the whole roster on the full gate. `ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES` holds that set.
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
Three surfaces report on the roster, and each reports a specific thing:
|
|
130
130
|
|
|
131
131
|
- `hooks/validators/hook_timing_harness.py` builds a `Write` payload against a target that already holds content. `_contents_for_validation` returns `None` for that payload, so the harness times interpreter start and hook dispatch. Time an `Edit` payload against a real file to measure the checks.
|
|
132
132
|
- `~/.claude/logs/hook-blocks.log` records the denials raised by fixtures in `test_code_rules_enforcer_*.py` and by the timing harness's default target.
|
|
133
|
+
- `hooks/validators/run_all_validators.py` stages the target under a temporary root and rebuilds the shortest path tail that carries every exemption signal. The walk starts at the target's own project root and skips a directory pytest generated for its own scratch tree, so the staged path reads the same wherever `--basetemp` places that tree.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse dispatcher that hosts the Bash hook chain running after a call.
|
|
3
|
+
|
|
4
|
+
Named ``bash_post_call_dispatcher`` rather than the more obvious
|
|
5
|
+
``bash_post_tool_use_dispatcher``: ``bin/install.test.mjs`` counts every
|
|
6
|
+
PostToolUse command whose text contains the literal substring
|
|
7
|
+
``post_tool_use_dispatcher.py`` and asserts exactly one match, against the
|
|
8
|
+
unrelated Write/Edit dispatcher in ``validation/post_tool_use_dispatcher.py``.
|
|
9
|
+
That check carries no basename anchor (its PreToolUse sibling does), so a
|
|
10
|
+
correctly-named Bash-side sibling here would read as a second match of an
|
|
11
|
+
unrelated dispatcher. This name carries the same meaning without the
|
|
12
|
+
collision, and needs no change to that shared, out-of-scope test file.
|
|
13
|
+
|
|
14
|
+
Reads the tool payload from stdin once, selects the hosted hooks applicable to
|
|
15
|
+
the payload's tool name, and runs each one in-process via the shared
|
|
16
|
+
hosted-hook runner. A hosted PostToolUse hook here carries no permission
|
|
17
|
+
decision -- it is a side-effecting observer, not a gate -- so this dispatcher
|
|
18
|
+
runs the whole roster unconditionally and emits nothing itself, matching every
|
|
19
|
+
hosted hook's own empty-stdout contract.
|
|
20
|
+
|
|
21
|
+
A single hosted hook crash fails open: ``run_hook_capturing_output`` isolates
|
|
22
|
+
it, so it contributes nothing and does not stop the remaining hosted hooks.
|
|
23
|
+
|
|
24
|
+
One interpreter start here serves the whole roster: every hosted hook's
|
|
25
|
+
imports load once into this process rather than once per hook.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
import _path_setup # noqa: F401
|
|
32
|
+
|
|
33
|
+
from hooks_constants.bash_post_call_dispatcher_constants import (
|
|
34
|
+
ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES,
|
|
35
|
+
)
|
|
36
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import BashHostedHookEntry
|
|
37
|
+
from hooks_constants.hosted_hook_runner import (
|
|
38
|
+
run_hook_capturing_output,
|
|
39
|
+
resolved_hook_script_path,
|
|
40
|
+
run_dispatcher_main,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def select_applicable_entries(tool_name: str) -> list[BashHostedHookEntry]:
|
|
45
|
+
"""Return the ordered hosted-hook entries that apply to tool_name."""
|
|
46
|
+
return [
|
|
47
|
+
each_entry
|
|
48
|
+
for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
|
|
49
|
+
if tool_name in each_entry.applicable_tool_names
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def dispatch(payload_text: str, tool_name: str) -> None:
|
|
54
|
+
"""Run every hosted hook applicable to tool_name, in registration order.
|
|
55
|
+
|
|
56
|
+
Each hosted hook's crash is isolated by ``run_hook_capturing_output``, so
|
|
57
|
+
one hook failing never stops a later one in the same roster.
|
|
58
|
+
"""
|
|
59
|
+
for each_entry in select_applicable_entries(tool_name):
|
|
60
|
+
script_path = resolved_hook_script_path(each_entry.script_relative_path)
|
|
61
|
+
run_hook_capturing_output(script_path, payload_text)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main() -> None:
|
|
65
|
+
"""Read stdin once and dispatch the Bash PostToolUse hosted-hook roster."""
|
|
66
|
+
run_dispatcher_main(dispatch)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
main()
|
|
@@ -22,7 +22,6 @@ from __future__ import annotations
|
|
|
22
22
|
import json
|
|
23
23
|
import sys
|
|
24
24
|
from dataclasses import dataclass, field
|
|
25
|
-
from pathlib import Path
|
|
26
25
|
|
|
27
26
|
import _path_setup # noqa: F401
|
|
28
27
|
|
|
@@ -36,8 +35,12 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
|
36
35
|
REASON_JOIN_SEPARATOR,
|
|
37
36
|
BashHostedHookEntry,
|
|
38
37
|
)
|
|
39
|
-
from hooks_constants.hosted_hook_runner import
|
|
40
|
-
|
|
38
|
+
from hooks_constants.hosted_hook_runner import (
|
|
39
|
+
run_dispatcher_main,
|
|
40
|
+
run_hook_capturing_output,
|
|
41
|
+
HostedHookRun,
|
|
42
|
+
resolved_hook_script_path,
|
|
43
|
+
)
|
|
41
44
|
|
|
42
45
|
_PERMISSION_DECISION_KEY = "permissionDecision"
|
|
43
46
|
_PERMISSION_REASON_KEY = "permissionDecisionReason"
|
|
@@ -203,12 +206,6 @@ def aggregate_bash_hook_results(
|
|
|
203
206
|
)
|
|
204
207
|
|
|
205
208
|
|
|
206
|
-
def _resolve_hook_script_path(relative_path: str) -> str:
|
|
207
|
-
"""Resolve a hooks/-relative path to an absolute script path."""
|
|
208
|
-
hooks_root = Path(__file__).resolve().parent.parent
|
|
209
|
-
return str(hooks_root / relative_path)
|
|
210
|
-
|
|
211
|
-
|
|
212
209
|
def _emit_decision(decision: BashDispatcherDecision) -> None:
|
|
213
210
|
"""Write one PreToolUse permission payload to stdout when an outcome exists."""
|
|
214
211
|
if not decision.decision:
|
|
@@ -251,7 +248,7 @@ def dispatch(payload_text: str, tool_name: str) -> None:
|
|
|
251
248
|
applicable_entries = select_applicable_entries(tool_name)
|
|
252
249
|
all_runs: list[HostedHookRun] = []
|
|
253
250
|
for each_entry in applicable_entries:
|
|
254
|
-
script_path =
|
|
251
|
+
script_path = resolved_hook_script_path(each_entry.script_relative_path)
|
|
255
252
|
hook_run = run_hook_capturing_output(script_path, payload_text)
|
|
256
253
|
all_runs.append(hook_run)
|
|
257
254
|
if _run_is_deny(hook_run):
|
|
@@ -264,17 +261,7 @@ def dispatch(payload_text: str, tool_name: str) -> None:
|
|
|
264
261
|
|
|
265
262
|
def main() -> None:
|
|
266
263
|
"""Read stdin once and dispatch the Bash/PowerShell hosted-hook chain."""
|
|
267
|
-
|
|
268
|
-
if payload_dictionary is None:
|
|
269
|
-
sys.exit(0)
|
|
270
|
-
|
|
271
|
-
payload_text = json.dumps(payload_dictionary)
|
|
272
|
-
tool_name = payload_dictionary.get("tool_name", "")
|
|
273
|
-
if not isinstance(tool_name, str) or not tool_name:
|
|
274
|
-
sys.exit(0)
|
|
275
|
-
|
|
276
|
-
dispatch(payload_text, tool_name)
|
|
277
|
-
sys.exit(0)
|
|
264
|
+
run_dispatcher_main(dispatch)
|
|
278
265
|
|
|
279
266
|
|
|
280
267
|
if __name__ == "__main__":
|
|
@@ -54,6 +54,7 @@ import ast
|
|
|
54
54
|
import copy
|
|
55
55
|
import sys
|
|
56
56
|
from pathlib import Path
|
|
57
|
+
from typing import NamedTuple
|
|
57
58
|
|
|
58
59
|
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
59
60
|
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
@@ -84,6 +85,21 @@ from hooks_constants.duplicate_function_body_constants import ( # noqa: E402
|
|
|
84
85
|
)
|
|
85
86
|
|
|
86
87
|
|
|
88
|
+
_ModuleScopeFunction = ast.FunctionDef | ast.AsyncFunctionDef
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _FunctionScanProfile(NamedTuple):
|
|
92
|
+
"""The three readings of one module-scope function the inline scan compares.
|
|
93
|
+
|
|
94
|
+
Every reading is a property of that function alone, so each is taken once per
|
|
95
|
+
file and read by each pairing the scan weighs.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
reachable_statement_count: int
|
|
99
|
+
all_sorted_body_dumps: list[str]
|
|
100
|
+
all_block_dumps: list[list[str]]
|
|
101
|
+
|
|
102
|
+
|
|
87
103
|
def _body_statements_without_docstring(
|
|
88
104
|
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
89
105
|
) -> list[ast.stmt]:
|
|
@@ -384,6 +400,37 @@ def _normalized_statement_dump(statement: ast.stmt) -> str:
|
|
|
384
400
|
return ast.dump(canonical_statement, annotate_fields=False)
|
|
385
401
|
|
|
386
402
|
|
|
403
|
+
def _memoized_statement_dump(
|
|
404
|
+
statement: ast.stmt,
|
|
405
|
+
dump_by_statement: dict[ast.stmt, str],
|
|
406
|
+
) -> str:
|
|
407
|
+
"""Return the normalized dump of one statement, computing it at most once.
|
|
408
|
+
|
|
409
|
+
The inline scan reads the same statement's dump many times over: once for its
|
|
410
|
+
enclosing function's block window, once for that function's twin-test
|
|
411
|
+
multiset, and again for every helper the function is weighed against. Dumping
|
|
412
|
+
deep-copies the statement subtree before canonicalizing string constants, so
|
|
413
|
+
this memo serves the repeat reads.
|
|
414
|
+
|
|
415
|
+
An ``ast`` node defines no ``__eq__``, so the key is the node's identity and
|
|
416
|
+
the dict holds a strong reference to it: a key cannot be freed and a later
|
|
417
|
+
node cannot reuse its slot. The caller owns one memo per scanned file and
|
|
418
|
+
drops it when the scan returns, so no entry outlives its parse tree.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
statement: The statement node to fingerprint.
|
|
422
|
+
dump_by_statement: The file's statement-to-dump memo, mutated in place.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
The value ``_normalized_statement_dump`` returns for this statement.
|
|
426
|
+
"""
|
|
427
|
+
memoized_dump = dump_by_statement.get(statement)
|
|
428
|
+
if memoized_dump is None:
|
|
429
|
+
memoized_dump = _normalized_statement_dump(statement)
|
|
430
|
+
dump_by_statement[statement] = memoized_dump
|
|
431
|
+
return memoized_dump
|
|
432
|
+
|
|
433
|
+
|
|
387
434
|
def _statement_blocks_in_function(
|
|
388
435
|
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
389
436
|
) -> list[list[ast.stmt]]:
|
|
@@ -443,78 +490,9 @@ def _total_reachable_statement_count(
|
|
|
443
490
|
return total_statement_count
|
|
444
491
|
|
|
445
492
|
|
|
446
|
-
def _normalized_body_dump_multiset(
|
|
447
|
-
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
448
|
-
) -> list[str]:
|
|
449
|
-
"""Return the function's docstring-stripped statement dumps, sorted.
|
|
450
|
-
|
|
451
|
-
Used to test whether two functions are structural twins — same statements in
|
|
452
|
-
any arrangement under string normalization. Sorting makes the comparison
|
|
453
|
-
order-independent, so two peer helpers that read different inputs but share
|
|
454
|
-
the same statement shapes compare equal and are left to the cross-file check.
|
|
455
|
-
|
|
456
|
-
Args:
|
|
457
|
-
function_node: The function whose body to fingerprint.
|
|
458
|
-
|
|
459
|
-
Returns:
|
|
460
|
-
The sorted per-statement normalized dumps of the docstring-stripped body.
|
|
461
|
-
"""
|
|
462
|
-
body_statements = _body_statements_without_docstring(function_node)
|
|
463
|
-
return sorted(_normalized_statement_dump(each) for each in body_statements)
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
def _function_inlines_window(
|
|
467
|
-
helper_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
468
|
-
enclosing_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
469
|
-
all_helper_window_dumps: list[str],
|
|
470
|
-
) -> bool:
|
|
471
|
-
"""Return whether a function inlines a helper window inside a larger body.
|
|
472
|
-
|
|
473
|
-
Slides a window the length of ``all_helper_window_dumps`` over each reachable
|
|
474
|
-
statement block in the enclosing function; a window whose per-statement dumps
|
|
475
|
-
match the helper's, in order, is the inlined copy. Two guards keep the report
|
|
476
|
-
to the genuine "helper inlined inside a larger function" shape:
|
|
477
|
-
|
|
478
|
-
- The enclosing function carries more total reachable statements than the
|
|
479
|
-
window, so a copy that fills a whole peer function is not reported here. The
|
|
480
|
-
count spans every nested block, so a window wrapped inside one top-level
|
|
481
|
-
compound — a ``try``/``finally`` cleanup, an ``except`` handler, or a single
|
|
482
|
-
``if`` guard — still clears the guard and is scanned.
|
|
483
|
-
- The two functions are not structural twins — their docstring-stripped
|
|
484
|
-
statement multisets differ — so two peer helpers that share a statement
|
|
485
|
-
shape but read different inputs are left to the cross-file whole-function
|
|
486
|
-
check ``check_duplicate_function_body_across_files`` rather than reported as
|
|
487
|
-
an inline duplicate.
|
|
488
|
-
|
|
489
|
-
Args:
|
|
490
|
-
helper_node: The candidate helper whose window is sought.
|
|
491
|
-
enclosing_node: The candidate enclosing function to scan.
|
|
492
|
-
all_helper_window_dumps: The helper's substantive-block per-statement dumps.
|
|
493
|
-
|
|
494
|
-
Returns:
|
|
495
|
-
True when some block in the enclosing function contains the helper window
|
|
496
|
-
verbatim as a contiguous run inside a strictly larger, non-twin body.
|
|
497
|
-
"""
|
|
498
|
-
window_length = len(all_helper_window_dumps)
|
|
499
|
-
if _total_reachable_statement_count(enclosing_node) <= window_length:
|
|
500
|
-
return False
|
|
501
|
-
if _normalized_body_dump_multiset(helper_node) == _normalized_body_dump_multiset(
|
|
502
|
-
enclosing_node
|
|
503
|
-
):
|
|
504
|
-
return False
|
|
505
|
-
for each_block in _statement_blocks_in_function(enclosing_node):
|
|
506
|
-
if len(each_block) < window_length:
|
|
507
|
-
continue
|
|
508
|
-
block_dumps = [_normalized_statement_dump(each) for each in each_block]
|
|
509
|
-
for each_start_index in range(len(block_dumps) - window_length + 1):
|
|
510
|
-
window = block_dumps[each_start_index : each_start_index + window_length]
|
|
511
|
-
if window == all_helper_window_dumps:
|
|
512
|
-
return True
|
|
513
|
-
return False
|
|
514
|
-
|
|
515
|
-
|
|
516
493
|
def _helper_match_window_dumps(
|
|
517
494
|
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
495
|
+
dump_by_statement: dict[ast.stmt, str],
|
|
518
496
|
) -> list[str] | None:
|
|
519
497
|
"""Return the helper's substantive block as per-statement dumps, or None.
|
|
520
498
|
|
|
@@ -564,7 +542,10 @@ def _helper_match_window_dumps(
|
|
|
564
542
|
)
|
|
565
543
|
if not has_substantial_compound:
|
|
566
544
|
return None
|
|
567
|
-
return [
|
|
545
|
+
return [
|
|
546
|
+
_memoized_statement_dump(each_statement, dump_by_statement)
|
|
547
|
+
for each_statement in match_window
|
|
548
|
+
]
|
|
568
549
|
|
|
569
550
|
|
|
570
551
|
def check_same_file_inline_duplicate_body(
|
|
@@ -616,43 +597,240 @@ def check_same_file_inline_duplicate_body(
|
|
|
616
597
|
for each_node in tree.body
|
|
617
598
|
if isinstance(each_node, ast.FunctionDef | ast.AsyncFunctionDef)
|
|
618
599
|
]
|
|
619
|
-
all_violations_in_walk_order: list[tuple[frozenset[int], str]] = []
|
|
620
|
-
for each_helper in all_top_level_functions:
|
|
621
|
-
helper_window_dumps = _helper_match_window_dumps(each_helper)
|
|
622
|
-
if helper_window_dumps is None:
|
|
623
|
-
continue
|
|
624
|
-
for each_enclosing in all_top_level_functions:
|
|
625
|
-
if each_enclosing is each_helper:
|
|
626
|
-
continue
|
|
627
|
-
if not _function_inlines_window(
|
|
628
|
-
each_helper, each_enclosing, helper_window_dumps
|
|
629
|
-
):
|
|
630
|
-
continue
|
|
631
|
-
helper_span = _function_definition_span(each_helper)
|
|
632
|
-
enclosing_span = _function_definition_span(each_enclosing)
|
|
633
|
-
in_scope_lines = frozenset(helper_span) | frozenset(enclosing_span)
|
|
634
|
-
span_suffix = SAME_FILE_INLINE_DUPLICATE_SPAN_SUFFIX_TEMPLATE.format(
|
|
635
|
-
helper_start=helper_span.start,
|
|
636
|
-
helper_length=len(helper_span),
|
|
637
|
-
enclosing_start=enclosing_span.start,
|
|
638
|
-
enclosing_length=len(enclosing_span),
|
|
639
|
-
)
|
|
640
|
-
message = (
|
|
641
|
-
f"Function {each_helper.name!r} duplicates an inline block in "
|
|
642
|
-
f"{each_enclosing.name!r} — {SAME_FILE_INLINE_DUPLICATE_GUIDANCE} "
|
|
643
|
-
f"{span_suffix}"
|
|
644
|
-
)
|
|
645
|
-
all_violations_in_walk_order.append((in_scope_lines, message))
|
|
646
|
-
break
|
|
647
|
-
if len(all_violations_in_walk_order) >= MAX_DUPLICATE_BODY_ISSUES:
|
|
648
|
-
break
|
|
649
600
|
return _scope_violations_to_changed_lines(
|
|
650
|
-
|
|
601
|
+
_inline_duplicate_violations(all_top_level_functions, MAX_DUPLICATE_BODY_ISSUES),
|
|
651
602
|
all_changed_lines,
|
|
652
603
|
defer_scope_to_caller,
|
|
653
604
|
)
|
|
654
605
|
|
|
655
606
|
|
|
607
|
+
def _block_dumps_in_function(
|
|
608
|
+
function_node: _ModuleScopeFunction,
|
|
609
|
+
dump_by_statement: dict[ast.stmt, str],
|
|
610
|
+
) -> list[list[str]]:
|
|
611
|
+
"""Return the per-statement dumps of each reachable block in a function.
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
function_node: The module-scope function to read.
|
|
615
|
+
dump_by_statement: The file's statement-to-dump memo, mutated in place.
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
One dump list per reachable block, in block-collection order.
|
|
619
|
+
"""
|
|
620
|
+
return [
|
|
621
|
+
[
|
|
622
|
+
_memoized_statement_dump(each_statement, dump_by_statement)
|
|
623
|
+
for each_statement in each_block
|
|
624
|
+
]
|
|
625
|
+
for each_block in _statement_blocks_in_function(function_node)
|
|
626
|
+
]
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _function_scan_profile(
|
|
630
|
+
function_node: _ModuleScopeFunction,
|
|
631
|
+
dump_by_statement: dict[ast.stmt, str],
|
|
632
|
+
) -> _FunctionScanProfile:
|
|
633
|
+
"""Take the three readings the inline scan needs from one function.
|
|
634
|
+
|
|
635
|
+
The sorted body dumps carry the docstring-stripped statement multiset that
|
|
636
|
+
tests whether two functions are structural twins. Sorting makes that
|
|
637
|
+
comparison order-independent, so two peer helpers that read different inputs
|
|
638
|
+
but share the same statement shapes compare equal and are left to the
|
|
639
|
+
cross-file check.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
function_node: The module-scope function to read.
|
|
643
|
+
dump_by_statement: The file's statement-to-dump memo, mutated in place.
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
The function's reachable statement count, sorted body dumps, and
|
|
647
|
+
per-block statement dumps.
|
|
648
|
+
"""
|
|
649
|
+
return _FunctionScanProfile(
|
|
650
|
+
reachable_statement_count=_total_reachable_statement_count(function_node),
|
|
651
|
+
all_sorted_body_dumps=sorted(
|
|
652
|
+
_memoized_statement_dump(each_statement, dump_by_statement)
|
|
653
|
+
for each_statement in _body_statements_without_docstring(function_node)
|
|
654
|
+
),
|
|
655
|
+
all_block_dumps=_block_dumps_in_function(function_node, dump_by_statement),
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _block_dumps_contain_window(
|
|
660
|
+
all_block_dumps: list[str],
|
|
661
|
+
all_window_dumps: list[str],
|
|
662
|
+
) -> bool:
|
|
663
|
+
"""Return whether one block carries the window as a contiguous run.
|
|
664
|
+
|
|
665
|
+
A block shorter than the window holds no run and answers False.
|
|
666
|
+
|
|
667
|
+
Args:
|
|
668
|
+
all_block_dumps: One reachable block's per-statement dumps.
|
|
669
|
+
all_window_dumps: The helper's window dumps to seek.
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
True when the window appears in the block as a contiguous run.
|
|
673
|
+
"""
|
|
674
|
+
window_length = len(all_window_dumps)
|
|
675
|
+
last_start_index = len(all_block_dumps) - window_length
|
|
676
|
+
for each_start_index in range(last_start_index + 1):
|
|
677
|
+
window_end = each_start_index + window_length
|
|
678
|
+
if all_block_dumps[each_start_index:window_end] == all_window_dumps:
|
|
679
|
+
return True
|
|
680
|
+
return False
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _profile_inlines_window(
|
|
684
|
+
helper_profile: _FunctionScanProfile,
|
|
685
|
+
enclosing_profile: _FunctionScanProfile,
|
|
686
|
+
all_helper_window_dumps: list[str],
|
|
687
|
+
) -> bool:
|
|
688
|
+
"""Return whether a function inlines a helper window inside a larger body.
|
|
689
|
+
|
|
690
|
+
A run inside one of the enclosing function's reachable blocks whose dumps
|
|
691
|
+
match the helper's, in order, is the inlined copy. Two guards hold the report
|
|
692
|
+
to a genuine inlining: the enclosing function carries more reachable
|
|
693
|
+
statements than the window, and the two functions are not structural twins,
|
|
694
|
+
so peer helpers sharing a statement shape are left to the cross-file check.
|
|
695
|
+
|
|
696
|
+
Args:
|
|
697
|
+
helper_profile: The candidate helper's readings, for the twin guard.
|
|
698
|
+
enclosing_profile: The candidate enclosing function's readings.
|
|
699
|
+
all_helper_window_dumps: The helper's substantive-block statement dumps.
|
|
700
|
+
|
|
701
|
+
Returns:
|
|
702
|
+
True when a block carries the window inside a larger, non-twin body.
|
|
703
|
+
"""
|
|
704
|
+
if enclosing_profile.reachable_statement_count <= len(all_helper_window_dumps):
|
|
705
|
+
return False
|
|
706
|
+
if helper_profile.all_sorted_body_dumps == enclosing_profile.all_sorted_body_dumps:
|
|
707
|
+
return False
|
|
708
|
+
return any(
|
|
709
|
+
_block_dumps_contain_window(each_block_dumps, all_helper_window_dumps)
|
|
710
|
+
for each_block_dumps in enclosing_profile.all_block_dumps
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _first_enclosing_inliner(
|
|
715
|
+
helper_node: _ModuleScopeFunction,
|
|
716
|
+
all_helper_window_dumps: list[str],
|
|
717
|
+
profile_by_function: dict[_ModuleScopeFunction, _FunctionScanProfile],
|
|
718
|
+
) -> _ModuleScopeFunction | None:
|
|
719
|
+
"""Return the first other module-scope function that inlines this window.
|
|
720
|
+
|
|
721
|
+
Args:
|
|
722
|
+
helper_node: The candidate helper whose window is sought.
|
|
723
|
+
all_helper_window_dumps: The helper's substantive-block statement dumps.
|
|
724
|
+
profile_by_function: Each function's readings, keyed in source order.
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
The first function in source order that carries the window, or None.
|
|
728
|
+
"""
|
|
729
|
+
helper_profile = profile_by_function[helper_node]
|
|
730
|
+
for each_enclosing, each_profile in profile_by_function.items():
|
|
731
|
+
if each_enclosing is helper_node:
|
|
732
|
+
continue
|
|
733
|
+
if _profile_inlines_window(
|
|
734
|
+
helper_profile, each_profile, all_helper_window_dumps
|
|
735
|
+
):
|
|
736
|
+
return each_enclosing
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _inline_duplicate_violation(
|
|
741
|
+
helper_node: _ModuleScopeFunction,
|
|
742
|
+
enclosing_node: _ModuleScopeFunction,
|
|
743
|
+
) -> tuple[frozenset[int], str]:
|
|
744
|
+
"""Return the scoped lines and report text for one inlined-helper finding.
|
|
745
|
+
|
|
746
|
+
Args:
|
|
747
|
+
helper_node: The helper whose body appears inline elsewhere.
|
|
748
|
+
enclosing_node: The function carrying the inlined copy.
|
|
749
|
+
|
|
750
|
+
Returns:
|
|
751
|
+
The union of both function spans, and the message naming both.
|
|
752
|
+
"""
|
|
753
|
+
helper_span = _function_definition_span(helper_node)
|
|
754
|
+
enclosing_span = _function_definition_span(enclosing_node)
|
|
755
|
+
span_suffix = SAME_FILE_INLINE_DUPLICATE_SPAN_SUFFIX_TEMPLATE.format(
|
|
756
|
+
helper_start=helper_span.start,
|
|
757
|
+
helper_length=len(helper_span),
|
|
758
|
+
enclosing_start=enclosing_span.start,
|
|
759
|
+
enclosing_length=len(enclosing_span),
|
|
760
|
+
)
|
|
761
|
+
message = (
|
|
762
|
+
f"Function {helper_node.name!r} duplicates an inline block in "
|
|
763
|
+
f"{enclosing_node.name!r} — {SAME_FILE_INLINE_DUPLICATE_GUIDANCE} "
|
|
764
|
+
f"{span_suffix}"
|
|
765
|
+
)
|
|
766
|
+
return (frozenset(helper_span) | frozenset(enclosing_span), message)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _violations_for_qualifying_helpers(
|
|
770
|
+
window_by_function: dict[_ModuleScopeFunction, list[str]],
|
|
771
|
+
profile_by_function: dict[_ModuleScopeFunction, _FunctionScanProfile],
|
|
772
|
+
maximum_issue_count: int,
|
|
773
|
+
) -> list[tuple[frozenset[int], str]]:
|
|
774
|
+
"""Walk the helpers in source order and report the first inliner of each.
|
|
775
|
+
|
|
776
|
+
Args:
|
|
777
|
+
window_by_function: Each qualifying function's helper match window.
|
|
778
|
+
profile_by_function: Each function's readings, keyed in source order.
|
|
779
|
+
maximum_issue_count: The report ceiling the walk stops at.
|
|
780
|
+
|
|
781
|
+
Returns:
|
|
782
|
+
The in-scope lines and message of each report, in walk order.
|
|
783
|
+
"""
|
|
784
|
+
all_violations_in_walk_order: list[tuple[frozenset[int], str]] = []
|
|
785
|
+
for each_helper, each_window_dumps in window_by_function.items():
|
|
786
|
+
each_enclosing = _first_enclosing_inliner(
|
|
787
|
+
each_helper, each_window_dumps, profile_by_function
|
|
788
|
+
)
|
|
789
|
+
if each_enclosing is not None:
|
|
790
|
+
all_violations_in_walk_order.append(
|
|
791
|
+
_inline_duplicate_violation(each_helper, each_enclosing)
|
|
792
|
+
)
|
|
793
|
+
if len(all_violations_in_walk_order) >= maximum_issue_count:
|
|
794
|
+
break
|
|
795
|
+
return all_violations_in_walk_order
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _inline_duplicate_violations(
|
|
799
|
+
all_top_level_functions: list[_ModuleScopeFunction],
|
|
800
|
+
maximum_issue_count: int,
|
|
801
|
+
) -> list[tuple[frozenset[int], str]]:
|
|
802
|
+
"""Report every module-scope helper whose body is inlined in another function.
|
|
803
|
+
|
|
804
|
+
The dump memo and the per-function readings live for this one call and are
|
|
805
|
+
dropped when it returns, so no reading outlives the parse tree it describes.
|
|
806
|
+
|
|
807
|
+
Args:
|
|
808
|
+
all_top_level_functions: The module-scope functions in source order.
|
|
809
|
+
maximum_issue_count: The report ceiling the walk stops at.
|
|
810
|
+
|
|
811
|
+
Returns:
|
|
812
|
+
The in-scope lines and message of each report, in walk order.
|
|
813
|
+
"""
|
|
814
|
+
dump_by_statement: dict[ast.stmt, str] = {}
|
|
815
|
+
window_by_function = {
|
|
816
|
+
each_function: each_window
|
|
817
|
+
for each_function in all_top_level_functions
|
|
818
|
+
if (
|
|
819
|
+
each_window := _helper_match_window_dumps(each_function, dump_by_statement)
|
|
820
|
+
)
|
|
821
|
+
is not None
|
|
822
|
+
}
|
|
823
|
+
if not window_by_function:
|
|
824
|
+
return []
|
|
825
|
+
profile_by_function = {
|
|
826
|
+
each_function: _function_scan_profile(each_function, dump_by_statement)
|
|
827
|
+
for each_function in all_top_level_functions
|
|
828
|
+
}
|
|
829
|
+
return _violations_for_qualifying_helpers(
|
|
830
|
+
window_by_function, profile_by_function, maximum_issue_count
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
|
|
656
834
|
def _skill_scripts_root(file_path: str) -> Path | None:
|
|
657
835
|
"""Return the ``skills/<name>/scripts`` root the written file sits under.
|
|
658
836
|
|