claude-dev-env 1.80.0 → 1.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
- package/audit-rubrics/CLAUDE.md +3 -2
- package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
- package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
- package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
- package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
- package/audit-rubrics/prompts/CLAUDE.md +2 -1
- package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
- package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/code_rules_annotations_length.py +59 -11
- package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
- package/hooks/blocking/code_rules_command_dispatch.py +140 -0
- package/hooks/blocking/code_rules_docstrings.py +93 -0
- package/hooks/blocking/code_rules_enforcer.py +54 -4
- package/hooks/blocking/code_rules_js_conventions.py +246 -0
- package/hooks/blocking/code_rules_naming_collection.py +5 -0
- package/hooks/blocking/code_rules_test_assertions.py +3 -0
- package/hooks/blocking/code_rules_unused_imports.py +0 -3
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
- package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
- package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +16 -3
- package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/blocking_check_limits.py +9 -0
- package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
- package/hooks/hooks_constants/js_conventions_constants.py +54 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
- package/skills/autoconverge/SKILL.md +2 -2
- package/skills/autoconverge/reference/convergence.md +3 -3
- package/skills/autoconverge/reference/stop-conditions.md +1 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +2 -2
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +245 -1
- package/skills/autoconverge/workflow/converge.mjs +129 -23
|
@@ -22,6 +22,7 @@ from code_rules_shared import ( # noqa: E402
|
|
|
22
22
|
)
|
|
23
23
|
|
|
24
24
|
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
25
|
+
ALL_ABSENT_TYPE_CHECKING_GATE_DOCSTRING_PHRASES,
|
|
25
26
|
ALL_DATA_SCHEMA_CONSTANT_NAME_MARKERS,
|
|
26
27
|
ALL_DATA_SCHEMA_DOCSTRING_ACKNOWLEDGEMENT_PHRASES,
|
|
27
28
|
ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES,
|
|
@@ -74,6 +75,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
74
75
|
MAX_DOCSTRING_RUNON_SENTENCE_ISSUES,
|
|
75
76
|
MAX_DOCSTRING_STEP_DISPATCH_ISSUES,
|
|
76
77
|
MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES,
|
|
78
|
+
MAX_DOCSTRING_TYPE_CHECKING_GATE_ISSUES,
|
|
77
79
|
MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES,
|
|
78
80
|
MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES,
|
|
79
81
|
MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES,
|
|
@@ -90,6 +92,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
90
92
|
MODULE_DOCSTRING_DATA_SCHEMA_CONSTANT_SAMPLE_LIMIT,
|
|
91
93
|
PYTHON_MODULE_FILE_SUFFIX,
|
|
92
94
|
SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT,
|
|
95
|
+
TYPE_CHECKING_IDENTIFIER_MARKER,
|
|
93
96
|
WORD_BOUNDARY_REGEX,
|
|
94
97
|
ZIPFILE_ALLOW_ZIP64_KEYWORD,
|
|
95
98
|
ZIPFILE_ALLOW_ZIP64_POSITIONAL_INDEX,
|
|
@@ -959,6 +962,96 @@ def check_docstring_no_network_claim_with_metadata_access(
|
|
|
959
962
|
return issues[:MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES]
|
|
960
963
|
|
|
961
964
|
|
|
965
|
+
def _module_code_references_type_checking(parsed_tree: ast.Module) -> bool:
|
|
966
|
+
"""Return True when a code identifier in the module handles TYPE_CHECKING.
|
|
967
|
+
|
|
968
|
+
A module that names TYPE_CHECKING in code genuinely gates on it. That covers
|
|
969
|
+
a Name reference (any context — load, store, or delete), an import alias, an
|
|
970
|
+
attribute access, or a function or class definition whose name carries the
|
|
971
|
+
type_checking marker. Docstring text is an ast.Constant and never counts, so
|
|
972
|
+
the check can still flag a gate the prose names while the body performs none.
|
|
973
|
+
"""
|
|
974
|
+
for each_node in ast.walk(parsed_tree):
|
|
975
|
+
if isinstance(each_node, ast.Name) and (
|
|
976
|
+
TYPE_CHECKING_IDENTIFIER_MARKER in each_node.id.lower()
|
|
977
|
+
):
|
|
978
|
+
return True
|
|
979
|
+
if isinstance(each_node, ast.Attribute) and (
|
|
980
|
+
TYPE_CHECKING_IDENTIFIER_MARKER in each_node.attr.lower()
|
|
981
|
+
):
|
|
982
|
+
return True
|
|
983
|
+
if isinstance(each_node, ast.alias) and any(
|
|
984
|
+
each_alias_name and TYPE_CHECKING_IDENTIFIER_MARKER in each_alias_name.lower()
|
|
985
|
+
for each_alias_name in (each_node.name, each_node.asname)
|
|
986
|
+
):
|
|
987
|
+
return True
|
|
988
|
+
if isinstance(
|
|
989
|
+
each_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
990
|
+
) and TYPE_CHECKING_IDENTIFIER_MARKER in each_node.name.lower():
|
|
991
|
+
return True
|
|
992
|
+
return False
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _docstring_names_absent_type_checking_gate(docstring_text: str) -> str:
|
|
996
|
+
collapsed_docstring = " ".join(docstring_text.replace("`", " ").lower().split())
|
|
997
|
+
for each_phrase in ALL_ABSENT_TYPE_CHECKING_GATE_DOCSTRING_PHRASES:
|
|
998
|
+
if each_phrase in collapsed_docstring:
|
|
999
|
+
return each_phrase
|
|
1000
|
+
return ""
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def check_docstring_names_absent_type_checking_gate(
|
|
1004
|
+
content: str, file_path: str
|
|
1005
|
+
) -> list[str]:
|
|
1006
|
+
"""Flag a docstring naming a TYPE_CHECKING gate the module's code never runs.
|
|
1007
|
+
|
|
1008
|
+
Picture a hook whose docstring advertises a `TYPE_CHECKING` gate-detection
|
|
1009
|
+
step, or a module summary that names `type-checking-gate` helpers, while no
|
|
1010
|
+
identifier in the body handles TYPE_CHECKING. A reader trusts the prose and
|
|
1011
|
+
looks for the gate, but the code performs none, so the claim points at
|
|
1012
|
+
machinery the module does not hold.
|
|
1013
|
+
|
|
1014
|
+
This is the deterministic slice of Category O6 docstring-prose-vs-
|
|
1015
|
+
implementation drift for a TYPE_CHECKING gate claim. The check reads the
|
|
1016
|
+
module docstring and every function and class docstring. It fires only when
|
|
1017
|
+
a docstring names a `TYPE_CHECKING` gate or a `type-checking-gate` helper
|
|
1018
|
+
family while no code identifier in the module carries the `type_checking`
|
|
1019
|
+
marker, so a module that genuinely gates on TYPE_CHECKING is left alone.
|
|
1020
|
+
Hook infrastructure is in scope, since the import-scan hooks that carry this
|
|
1021
|
+
drift class are themselves hooks. Test files are exempt.
|
|
1022
|
+
|
|
1023
|
+
Args:
|
|
1024
|
+
content: The source text to inspect.
|
|
1025
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
1026
|
+
|
|
1027
|
+
Returns:
|
|
1028
|
+
One issue per docstring naming a TYPE_CHECKING gate the code never
|
|
1029
|
+
performs, capped at the module limit.
|
|
1030
|
+
"""
|
|
1031
|
+
if is_strict_test_file(file_path):
|
|
1032
|
+
return []
|
|
1033
|
+
try:
|
|
1034
|
+
parsed_tree = ast.parse(content)
|
|
1035
|
+
except SyntaxError:
|
|
1036
|
+
return []
|
|
1037
|
+
if _module_code_references_type_checking(parsed_tree):
|
|
1038
|
+
return []
|
|
1039
|
+
issues: list[str] = []
|
|
1040
|
+
for each_line_number, each_docstring in _documentable_nodes_with_docstrings(parsed_tree):
|
|
1041
|
+
matched_phrase = _docstring_names_absent_type_checking_gate(each_docstring)
|
|
1042
|
+
if not matched_phrase:
|
|
1043
|
+
continue
|
|
1044
|
+
issues.append(
|
|
1045
|
+
f"Line {each_line_number}: docstring names a '{matched_phrase}' the module's "
|
|
1046
|
+
"code never performs — no identifier in the body handles TYPE_CHECKING, so the "
|
|
1047
|
+
"gate-detection claim is stale; drop the TYPE_CHECKING gate wording or add the "
|
|
1048
|
+
"detection (Category O6 docstring-vs-implementation drift)"
|
|
1049
|
+
)
|
|
1050
|
+
if len(issues) >= MAX_DOCSTRING_TYPE_CHECKING_GATE_ISSUES:
|
|
1051
|
+
break
|
|
1052
|
+
return issues[:MAX_DOCSTRING_TYPE_CHECKING_GATE_ISSUES]
|
|
1053
|
+
|
|
1054
|
+
|
|
962
1055
|
def _module_docstring_claims_no_inline_literal(module_docstring: str) -> str:
|
|
963
1056
|
collapsed_docstring = " ".join(module_docstring.lower().split())
|
|
964
1057
|
for each_phrase in ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES:
|
|
@@ -44,6 +44,9 @@ from code_rules_boolean_mustcheck import ( # noqa: E402
|
|
|
44
44
|
check_boolean_naming,
|
|
45
45
|
check_ignored_must_check_return,
|
|
46
46
|
)
|
|
47
|
+
from code_rules_command_dispatch import ( # noqa: E402
|
|
48
|
+
check_unanchored_command_dispatch,
|
|
49
|
+
)
|
|
47
50
|
from code_rules_comments import ( # noqa: E402
|
|
48
51
|
check_comment_changes,
|
|
49
52
|
)
|
|
@@ -77,6 +80,7 @@ from code_rules_docstrings import ( # noqa: E402
|
|
|
77
80
|
check_docstring_field_runmode_outcome,
|
|
78
81
|
check_docstring_format,
|
|
79
82
|
check_docstring_length_constant_superlative_vs_exact_gate,
|
|
83
|
+
check_docstring_names_absent_type_checking_gate,
|
|
80
84
|
check_docstring_names_undefined_constant,
|
|
81
85
|
check_docstring_no_consumer_claim,
|
|
82
86
|
check_docstring_no_inline_literal_claim,
|
|
@@ -108,6 +112,10 @@ from code_rules_imports_logging import ( # noqa: E402
|
|
|
108
112
|
check_logging_printf_tokens,
|
|
109
113
|
check_windows_api_none,
|
|
110
114
|
)
|
|
115
|
+
from code_rules_js_conventions import ( # noqa: E402
|
|
116
|
+
check_js_banned_identifiers,
|
|
117
|
+
check_js_boolean_naming,
|
|
118
|
+
)
|
|
111
119
|
from code_rules_magic_values import ( # noqa: E402
|
|
112
120
|
check_fstring_structural_literals,
|
|
113
121
|
check_magic_values,
|
|
@@ -284,7 +292,14 @@ def validate_content(
|
|
|
284
292
|
)
|
|
285
293
|
)
|
|
286
294
|
all_issues.extend(check_type_escape_hatches(effective_content, file_path))
|
|
287
|
-
all_issues.extend(
|
|
295
|
+
all_issues.extend(
|
|
296
|
+
check_banned_identifiers(
|
|
297
|
+
effective_content,
|
|
298
|
+
file_path,
|
|
299
|
+
all_changed_lines,
|
|
300
|
+
defer_scope_to_caller,
|
|
301
|
+
)
|
|
302
|
+
)
|
|
288
303
|
all_issues.extend(
|
|
289
304
|
check_banned_noun_word_boundary(
|
|
290
305
|
effective_content,
|
|
@@ -368,6 +383,9 @@ def validate_content(
|
|
|
368
383
|
all_issues.extend(
|
|
369
384
|
check_docstring_names_undefined_constant(effective_content, file_path)
|
|
370
385
|
)
|
|
386
|
+
all_issues.extend(
|
|
387
|
+
check_docstring_names_absent_type_checking_gate(effective_content, file_path)
|
|
388
|
+
)
|
|
371
389
|
all_issues.extend(
|
|
372
390
|
check_docstring_args_single_line_scope_vs_span(effective_content, file_path)
|
|
373
391
|
)
|
|
@@ -428,7 +446,14 @@ def validate_content(
|
|
|
428
446
|
all_issues.extend(
|
|
429
447
|
check_unused_known_pytest_fixture_parameters(content, file_path)
|
|
430
448
|
)
|
|
431
|
-
all_issues.extend(
|
|
449
|
+
all_issues.extend(
|
|
450
|
+
check_return_annotations(
|
|
451
|
+
effective_content,
|
|
452
|
+
file_path,
|
|
453
|
+
all_changed_lines,
|
|
454
|
+
defer_scope_to_caller,
|
|
455
|
+
)
|
|
456
|
+
)
|
|
432
457
|
all_issues.extend(
|
|
433
458
|
check_function_length(
|
|
434
459
|
effective_content,
|
|
@@ -466,6 +491,22 @@ def validate_content(
|
|
|
466
491
|
if not is_test_file(file_path):
|
|
467
492
|
all_issues.extend(check_comment_changes(old_content, content, file_path))
|
|
468
493
|
all_issues.extend(check_e2e_test_naming(content, file_path))
|
|
494
|
+
all_issues.extend(
|
|
495
|
+
check_js_boolean_naming(
|
|
496
|
+
effective_content,
|
|
497
|
+
file_path,
|
|
498
|
+
all_changed_lines,
|
|
499
|
+
defer_scope_to_caller,
|
|
500
|
+
)
|
|
501
|
+
)
|
|
502
|
+
all_issues.extend(
|
|
503
|
+
check_js_banned_identifiers(
|
|
504
|
+
effective_content,
|
|
505
|
+
file_path,
|
|
506
|
+
all_changed_lines,
|
|
507
|
+
defer_scope_to_caller,
|
|
508
|
+
)
|
|
509
|
+
)
|
|
469
510
|
all_issues.extend(
|
|
470
511
|
check_js_resume_task_enumeration_coverage(content, file_path)
|
|
471
512
|
)
|
|
@@ -579,8 +620,10 @@ def _hook_infrastructure_blocking_issues(
|
|
|
579
620
|
runs the checks that must still guard them: the cross-file duplicate-body
|
|
580
621
|
check and the same-file inline-duplicate-body check, each span-scoped to the
|
|
581
622
|
lines an edit touched exactly as ``validate_content`` scopes it for production
|
|
582
|
-
code;
|
|
583
|
-
its motivating case, run over the whole post-edit file
|
|
623
|
+
code; the zero-payload alias check, whose docstring names hook modules as
|
|
624
|
+
its motivating case, run over the whole post-edit file; and the
|
|
625
|
+
unanchored command-dispatch check, which guards a ``hooks/blocking``
|
|
626
|
+
command classifier against matching a multi-word command as a substring.
|
|
584
627
|
|
|
585
628
|
Args:
|
|
586
629
|
content: The fragment or whole-file body under validation.
|
|
@@ -613,6 +656,13 @@ def _hook_infrastructure_blocking_issues(
|
|
|
613
656
|
)
|
|
614
657
|
)
|
|
615
658
|
all_issues.extend(check_zero_payload_function_alias(effective_content, file_path))
|
|
659
|
+
all_issues.extend(
|
|
660
|
+
check_unanchored_command_dispatch(
|
|
661
|
+
effective_content,
|
|
662
|
+
file_path,
|
|
663
|
+
all_changed_lines,
|
|
664
|
+
)
|
|
665
|
+
)
|
|
616
666
|
return all_issues
|
|
617
667
|
|
|
618
668
|
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Boolean-naming and banned-identifier checks for JavaScript and TypeScript source.
|
|
2
|
+
|
|
3
|
+
These mirror the Python ``check_boolean_naming`` and ``check_banned_identifiers``
|
|
4
|
+
rules for the ``.mjs`` / ``.js`` family so a JavaScript declaration receives the
|
|
5
|
+
same naming discipline the Python checks apply. Detection is line-regex based
|
|
6
|
+
over a copy of the source whose string, comment, and regex regions are blanked,
|
|
7
|
+
so a boolean assignment inside a prompt string or a comment never fires. The
|
|
8
|
+
``@param {boolean}`` scan reads the raw source instead, because JSDoc lives
|
|
9
|
+
inside a block comment the blanking step removes.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
16
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
17
|
+
if _blocking_directory not in sys.path:
|
|
18
|
+
sys.path.insert(0, _blocking_directory)
|
|
19
|
+
if _hooks_directory not in sys.path:
|
|
20
|
+
sys.path.insert(0, _hooks_directory)
|
|
21
|
+
|
|
22
|
+
from code_rules_imports_logging import ( # noqa: E402
|
|
23
|
+
_blank_non_code_regions,
|
|
24
|
+
)
|
|
25
|
+
from code_rules_shared import ( # noqa: E402
|
|
26
|
+
_scope_violations_to_changed_lines,
|
|
27
|
+
get_file_extension,
|
|
28
|
+
is_hook_infrastructure,
|
|
29
|
+
is_test_file,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from hooks_constants.banned_identifiers_constants import ( # noqa: E402
|
|
33
|
+
BANNED_IDENTIFIER_MESSAGE_SUFFIX,
|
|
34
|
+
)
|
|
35
|
+
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
36
|
+
ALL_JAVASCRIPT_EXTENSIONS,
|
|
37
|
+
)
|
|
38
|
+
from hooks_constants.js_conventions_constants import ( # noqa: E402
|
|
39
|
+
ALL_JAVASCRIPT_BANNED_IDENTIFIERS,
|
|
40
|
+
BOOLEAN_PREFIX_GUIDANCE,
|
|
41
|
+
JAVASCRIPT_BOOLEAN_DECLARATION_PATTERN,
|
|
42
|
+
JAVASCRIPT_BOOLEAN_JSDOC_PARAMETER_PATTERN,
|
|
43
|
+
JAVASCRIPT_BOOLEAN_PREFIX_PATTERN,
|
|
44
|
+
JAVASCRIPT_DECLARATION_NAME_PATTERN,
|
|
45
|
+
MAX_JAVASCRIPT_BANNED_IDENTIFIER_ISSUES,
|
|
46
|
+
MAX_JAVASCRIPT_BOOLEAN_NAMING_ISSUES,
|
|
47
|
+
SINGLE_CHARACTER_NAME_LENGTH,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_javascript_target(file_path: str) -> bool:
|
|
52
|
+
"""Return whether a check should run on this path.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
file_path: The destination path of the write or edit.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
True when the path carries a JavaScript extension and is neither a test
|
|
59
|
+
file nor hook infrastructure; False for every exempt path.
|
|
60
|
+
"""
|
|
61
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
62
|
+
return False
|
|
63
|
+
return get_file_extension(file_path) in ALL_JAVASCRIPT_EXTENSIONS
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _boolean_name_lacks_prefix(name: str) -> bool:
|
|
67
|
+
"""Return whether a boolean name needs a naming prefix it does not carry.
|
|
68
|
+
|
|
69
|
+
A single-character name, an all-uppercase constant name, and a name already
|
|
70
|
+
carrying an ``is``/``has``/``should``/``can``/``was``/``did`` prefix are all
|
|
71
|
+
accepted.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
name: The declared boolean identifier.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
True when the name should carry a boolean prefix and does not.
|
|
78
|
+
"""
|
|
79
|
+
if len(name) <= SINGLE_CHARACTER_NAME_LENGTH:
|
|
80
|
+
return False
|
|
81
|
+
if name.isupper():
|
|
82
|
+
return False
|
|
83
|
+
return JAVASCRIPT_BOOLEAN_PREFIX_PATTERN.match(name) is None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _boolean_declaration_violations(
|
|
87
|
+
blanked_content: str,
|
|
88
|
+
) -> list[tuple[range, str]]:
|
|
89
|
+
"""Return one span-tagged violation per unprefixed boolean declaration.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
blanked_content: The source with string, comment, and regex regions
|
|
93
|
+
replaced by spaces so only structural code is scanned.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
``(line_range, message)`` pairs in source order.
|
|
97
|
+
"""
|
|
98
|
+
all_violations: list[tuple[range, str]] = []
|
|
99
|
+
for each_line_number, each_line in enumerate(blanked_content.split("\n"), 1):
|
|
100
|
+
for each_match in JAVASCRIPT_BOOLEAN_DECLARATION_PATTERN.finditer(each_line):
|
|
101
|
+
name = each_match.group("name")
|
|
102
|
+
if not _boolean_name_lacks_prefix(name):
|
|
103
|
+
continue
|
|
104
|
+
message = (
|
|
105
|
+
f"Line {each_line_number}: Boolean {name} - {BOOLEAN_PREFIX_GUIDANCE}"
|
|
106
|
+
)
|
|
107
|
+
all_violations.append(
|
|
108
|
+
(range(each_line_number, each_line_number + 1), message)
|
|
109
|
+
)
|
|
110
|
+
return all_violations
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _boolean_jsdoc_parameter_violations(content: str) -> list[tuple[range, str]]:
|
|
114
|
+
"""Return one span-tagged violation per unprefixed ``@param {boolean}`` name.
|
|
115
|
+
|
|
116
|
+
The raw source is scanned rather than the blanked copy because JSDoc lives
|
|
117
|
+
inside a block comment the blanking step removes.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
content: The raw source text.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
``(line_range, message)`` pairs in source order.
|
|
124
|
+
"""
|
|
125
|
+
all_violations: list[tuple[range, str]] = []
|
|
126
|
+
for each_line_number, each_line in enumerate(content.split("\n"), 1):
|
|
127
|
+
for each_match in JAVASCRIPT_BOOLEAN_JSDOC_PARAMETER_PATTERN.finditer(
|
|
128
|
+
each_line
|
|
129
|
+
):
|
|
130
|
+
name = each_match.group("name")
|
|
131
|
+
if not _boolean_name_lacks_prefix(name):
|
|
132
|
+
continue
|
|
133
|
+
message = (
|
|
134
|
+
f"Line {each_line_number}: Boolean parameter {name} - "
|
|
135
|
+
f"{BOOLEAN_PREFIX_GUIDANCE}"
|
|
136
|
+
)
|
|
137
|
+
all_violations.append(
|
|
138
|
+
(range(each_line_number, each_line_number + 1), message)
|
|
139
|
+
)
|
|
140
|
+
return all_violations
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def check_js_boolean_naming(
|
|
144
|
+
content: str,
|
|
145
|
+
file_path: str,
|
|
146
|
+
all_changed_lines: set[int] | None = None,
|
|
147
|
+
defer_scope_to_caller: bool = False,
|
|
148
|
+
) -> list[str]:
|
|
149
|
+
"""Flag JavaScript boolean declarations and JSDoc params lacking a prefix.
|
|
150
|
+
|
|
151
|
+
A ``const``/``let``/``var`` declaration whose right-hand side is a boolean
|
|
152
|
+
literal, or a negation that is the entire right-hand side (``= !ready;`` or
|
|
153
|
+
``= !obj.prop`` or ``= !call(...)``, ending the statement with no ``?``,
|
|
154
|
+
``&&``, ``||``, or ``,`` combining the negated operand), and a
|
|
155
|
+
``@param {boolean}`` JSDoc entry, all name a boolean. A ``!``-headed side
|
|
156
|
+
that a ternary or logical operator continues (``!active ? "on" : "off"``,
|
|
157
|
+
``!err && getName()``) is an ordinary expression, not a boolean, and is left
|
|
158
|
+
alone. When the boolean name lacks an ``is``/``has``/``should``/``can``/
|
|
159
|
+
``was``/``did`` prefix (camelCase forms such as ``isReady``), the check flags
|
|
160
|
+
it, so a reader learns a value is a boolean from its name. Findings scope to
|
|
161
|
+
*all_changed_lines* so an edit blocks on the unprefixed boolean it just
|
|
162
|
+
introduced while a pre-existing one on an untouched line does not block.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
content: The source text to inspect.
|
|
166
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
167
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
168
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
169
|
+
blocks only when its line intersects the changed lines.
|
|
170
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
171
|
+
commit/push gate scopes by added line through its ``Line N:``
|
|
172
|
+
partitioning.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
One issue per unprefixed boolean declaration and JSDoc parameter, scoped
|
|
176
|
+
to the changed lines unless *defer_scope_to_caller* is True or
|
|
177
|
+
*all_changed_lines* is None, capped at the module limit.
|
|
178
|
+
"""
|
|
179
|
+
if not _is_javascript_target(file_path):
|
|
180
|
+
return []
|
|
181
|
+
blanked_content = _blank_non_code_regions(content)
|
|
182
|
+
all_violations_in_source_order = _boolean_declaration_violations(blanked_content)
|
|
183
|
+
all_violations_in_source_order.extend(_boolean_jsdoc_parameter_violations(content))
|
|
184
|
+
scoped_issues = _scope_violations_to_changed_lines(
|
|
185
|
+
all_violations_in_source_order,
|
|
186
|
+
all_changed_lines,
|
|
187
|
+
defer_scope_to_caller,
|
|
188
|
+
)
|
|
189
|
+
if defer_scope_to_caller:
|
|
190
|
+
return scoped_issues
|
|
191
|
+
return scoped_issues[:MAX_JAVASCRIPT_BOOLEAN_NAMING_ISSUES]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def check_js_banned_identifiers(
|
|
195
|
+
content: str,
|
|
196
|
+
file_path: str,
|
|
197
|
+
all_changed_lines: set[int] | None = None,
|
|
198
|
+
defer_scope_to_caller: bool = False,
|
|
199
|
+
) -> list[str]:
|
|
200
|
+
"""Flag JavaScript declarations bound to a banned identifier name.
|
|
201
|
+
|
|
202
|
+
A ``const``/``let``/``var`` declaration whose name is one of the banned
|
|
203
|
+
placeholder names (``result``, ``data``, ``response``, ``ctx``, and the rest)
|
|
204
|
+
is flagged so the author picks a domain-specific name. Findings scope to
|
|
205
|
+
*all_changed_lines* so a pre-existing ``result`` on an untouched line never
|
|
206
|
+
blocks while a newly written one does.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
content: The source text to inspect.
|
|
210
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
211
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
212
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
213
|
+
blocks only when its line intersects the changed lines.
|
|
214
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
215
|
+
commit/push gate scopes by added line through its ``Line N:``
|
|
216
|
+
partitioning.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
One issue per banned declaration name, scoped to the changed lines unless
|
|
220
|
+
*defer_scope_to_caller* is True or *all_changed_lines* is None, capped at
|
|
221
|
+
the module limit.
|
|
222
|
+
"""
|
|
223
|
+
if not _is_javascript_target(file_path):
|
|
224
|
+
return []
|
|
225
|
+
blanked_content = _blank_non_code_regions(content)
|
|
226
|
+
all_violations_in_source_order: list[tuple[range, str]] = []
|
|
227
|
+
for each_line_number, each_line in enumerate(blanked_content.split("\n"), 1):
|
|
228
|
+
for each_match in JAVASCRIPT_DECLARATION_NAME_PATTERN.finditer(each_line):
|
|
229
|
+
name = each_match.group("name")
|
|
230
|
+
if name not in ALL_JAVASCRIPT_BANNED_IDENTIFIERS:
|
|
231
|
+
continue
|
|
232
|
+
message = (
|
|
233
|
+
f"Line {each_line_number}: Banned identifier '{name}' - "
|
|
234
|
+
f"{BANNED_IDENTIFIER_MESSAGE_SUFFIX}"
|
|
235
|
+
)
|
|
236
|
+
all_violations_in_source_order.append(
|
|
237
|
+
(range(each_line_number, each_line_number + 1), message)
|
|
238
|
+
)
|
|
239
|
+
scoped_issues = _scope_violations_to_changed_lines(
|
|
240
|
+
all_violations_in_source_order,
|
|
241
|
+
all_changed_lines,
|
|
242
|
+
defer_scope_to_caller,
|
|
243
|
+
)
|
|
244
|
+
if defer_scope_to_caller:
|
|
245
|
+
return scoped_issues
|
|
246
|
+
return scoped_issues[:MAX_JAVASCRIPT_BANNED_IDENTIFIER_ISSUES]
|
|
@@ -33,6 +33,9 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
33
33
|
POLARITY_TOKEN_BOUNDARY_PATTERN,
|
|
34
34
|
UPPER_SNAKE_CONSTANT_PATTERN,
|
|
35
35
|
)
|
|
36
|
+
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
37
|
+
MAX_POLARITY_CONTRADICTION_ISSUES,
|
|
38
|
+
)
|
|
36
39
|
from hooks_constants.stuttering_check_config import ( # noqa: E402
|
|
37
40
|
MAX_STUTTERING_PREFIX_ISSUES,
|
|
38
41
|
STUTTERING_ALL_PREFIX_PATTERN,
|
|
@@ -336,4 +339,6 @@ def check_polarity_name_contradiction(content: str, file_path: str) -> list[str]
|
|
|
336
339
|
f" {called_name!r} (says {called_token!r}) - the name contradicts the value; rename the"
|
|
337
340
|
f" callee to a neutral form so each call site reads truthfully (CODE_RULES §5)"
|
|
338
341
|
)
|
|
342
|
+
if len(issues) >= MAX_POLARITY_CONTRADICTION_ISSUES:
|
|
343
|
+
break
|
|
339
344
|
return issues
|
|
@@ -51,6 +51,7 @@ from code_rules_shared import ( # noqa: E402
|
|
|
51
51
|
|
|
52
52
|
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
53
53
|
MAX_STALE_TEST_NAME_TARGET_ISSUES,
|
|
54
|
+
MAX_VACUOUS_CLEANUP_ASSERTION_ISSUES,
|
|
54
55
|
STALE_TEST_NAME_MINIMUM_SHARED_TOKEN_COUNT,
|
|
55
56
|
)
|
|
56
57
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
@@ -387,6 +388,8 @@ def check_vacuous_cleanup_assertion_tests(content: str, file_path: str) -> list[
|
|
|
387
388
|
f" failure (e.g. monkeypatch os.replace to raise after the temp exists)"
|
|
388
389
|
f" then assert the temp was removed"
|
|
389
390
|
)
|
|
391
|
+
if len(issues) >= MAX_VACUOUS_CLEANUP_ASSERTION_ISSUES:
|
|
392
|
+
break
|
|
390
393
|
|
|
391
394
|
return issues
|
|
392
395
|
|
|
@@ -19,7 +19,6 @@ from code_rules_scope_binding import ( # noqa: E402
|
|
|
19
19
|
from code_rules_shared import ( # noqa: E402
|
|
20
20
|
_build_parent_map,
|
|
21
21
|
is_migration_file,
|
|
22
|
-
is_test_file,
|
|
23
22
|
)
|
|
24
23
|
|
|
25
24
|
from hooks_constants.unused_module_import_constants import ( # noqa: E402
|
|
@@ -149,8 +148,6 @@ def check_unused_module_level_imports(
|
|
|
149
148
|
Edit applies). This prevents false-positive flags on imports added in the
|
|
150
149
|
same Edit as their consumers.
|
|
151
150
|
"""
|
|
152
|
-
if is_test_file(file_path):
|
|
153
|
-
return []
|
|
154
151
|
if is_migration_file(file_path):
|
|
155
152
|
return []
|
|
156
153
|
try:
|
|
@@ -39,6 +39,7 @@ from hooks_constants.duplicate_rmtree_helper_blocker_constants import ( # noqa:
|
|
|
39
39
|
PYTHON_FILE_EXTENSION,
|
|
40
40
|
TRIPLE_QUOTED_STRING_PATTERN,
|
|
41
41
|
)
|
|
42
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
42
43
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
|
|
43
44
|
|
|
44
45
|
|
|
@@ -146,6 +147,12 @@ def main() -> None:
|
|
|
146
147
|
"permissionDecisionReason": corrective_message,
|
|
147
148
|
}
|
|
148
149
|
}
|
|
150
|
+
log_hook_block(
|
|
151
|
+
calling_hook_name="duplicate_rmtree_helper_blocker.py",
|
|
152
|
+
hook_event="PreToolUse",
|
|
153
|
+
block_reason=corrective_message,
|
|
154
|
+
tool_name=tool_name,
|
|
155
|
+
)
|
|
149
156
|
print(json.dumps(deny_response))
|
|
150
157
|
sys.stdout.flush()
|
|
151
158
|
sys.exit(0)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Behavioral tests for the unanchored command-dispatch meta-gate."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
7
|
+
if _blocking_directory not in sys.path:
|
|
8
|
+
sys.path.insert(0, _blocking_directory)
|
|
9
|
+
|
|
10
|
+
from code_rules_command_dispatch import ( # noqa: E402
|
|
11
|
+
check_unanchored_command_dispatch,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
GATE_PATH = "packages/claude-dev-env/hooks/blocking/conventional_pr_title_gate.py"
|
|
15
|
+
|
|
16
|
+
UNANCHORED_SOURCE = (
|
|
17
|
+
"import re\n"
|
|
18
|
+
"def run(tool_input):\n"
|
|
19
|
+
' command_text = tool_input["command"]\n'
|
|
20
|
+
' if re.search(r"gh\\s+pr\\s+(create|edit)", command_text):\n'
|
|
21
|
+
" return True\n"
|
|
22
|
+
" return False\n"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_flags_unanchored_multi_word_command_pattern() -> None:
|
|
27
|
+
issues = check_unanchored_command_dispatch(UNANCHORED_SOURCE, GATE_PATH)
|
|
28
|
+
assert len(issues) == 1
|
|
29
|
+
assert "gh" in issues[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_accepts_anchored_command_pattern() -> None:
|
|
33
|
+
source = (
|
|
34
|
+
"import re\n"
|
|
35
|
+
"def run(tool_input):\n"
|
|
36
|
+
' command_text = tool_input["command"]\n'
|
|
37
|
+
' if re.search(r"^gh\\s+pr\\s+(create|edit)", command_text):\n'
|
|
38
|
+
" return True\n"
|
|
39
|
+
" return False\n"
|
|
40
|
+
)
|
|
41
|
+
assert check_unanchored_command_dispatch(source, GATE_PATH) == []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_accepts_pattern_when_first_word_is_tokenized() -> None:
|
|
45
|
+
source = (
|
|
46
|
+
"import re\n"
|
|
47
|
+
"import shlex\n"
|
|
48
|
+
"def run(tool_input):\n"
|
|
49
|
+
' command_text = tool_input["command"]\n'
|
|
50
|
+
" first_word = shlex.split(command_text)[0]\n"
|
|
51
|
+
' if first_word == "gh" and re.search(r"gh\\s+pr\\s+create", command_text):\n'
|
|
52
|
+
" return True\n"
|
|
53
|
+
" return False\n"
|
|
54
|
+
)
|
|
55
|
+
assert check_unanchored_command_dispatch(source, GATE_PATH) == []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_ignores_file_that_does_not_read_a_command_key() -> None:
|
|
59
|
+
source = (
|
|
60
|
+
"import re\n"
|
|
61
|
+
"def run(text):\n"
|
|
62
|
+
' if re.search(r"gh\\s+pr\\s+create", text):\n'
|
|
63
|
+
" return True\n"
|
|
64
|
+
" return False\n"
|
|
65
|
+
)
|
|
66
|
+
assert check_unanchored_command_dispatch(source, GATE_PATH) == []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_ignores_single_word_command_literal() -> None:
|
|
70
|
+
source = (
|
|
71
|
+
"import re\n"
|
|
72
|
+
"def run(tool_input):\n"
|
|
73
|
+
' command_text = tool_input["command"]\n'
|
|
74
|
+
' if re.search(r"gh", command_text):\n'
|
|
75
|
+
" return True\n"
|
|
76
|
+
" return False\n"
|
|
77
|
+
)
|
|
78
|
+
assert check_unanchored_command_dispatch(source, GATE_PATH) == []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_ignores_file_outside_hooks_blocking() -> None:
|
|
82
|
+
other_path = "packages/claude-dev-env/skills/thing/helper.py"
|
|
83
|
+
assert check_unanchored_command_dispatch(UNANCHORED_SOURCE, other_path) == []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_ignores_test_file() -> None:
|
|
87
|
+
test_path = "packages/claude-dev-env/hooks/blocking/test_conventional_pr_title_gate.py"
|
|
88
|
+
assert check_unanchored_command_dispatch(UNANCHORED_SOURCE, test_path) == []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_scoped_to_changed_lines() -> None:
|
|
92
|
+
unchanged = check_unanchored_command_dispatch(UNANCHORED_SOURCE, GATE_PATH, {1, 2, 3})
|
|
93
|
+
assert unchanged == []
|
|
94
|
+
on_pattern_line = check_unanchored_command_dispatch(UNANCHORED_SOURCE, GATE_PATH, {4})
|
|
95
|
+
assert len(on_pattern_line) == 1
|
|
@@ -89,10 +89,28 @@ def test_should_flag_async_function_without_return_annotation() -> None:
|
|
|
89
89
|
)
|
|
90
90
|
|
|
91
91
|
|
|
92
|
-
def
|
|
92
|
+
def test_should_skip_return_check_for_test_file_helper() -> None:
|
|
93
93
|
source = "def fetch(url: str):\n return url\n"
|
|
94
94
|
issues = code_rules_enforcer.check_return_annotations(source, TEST_FILE_PATH)
|
|
95
|
-
assert issues == [], f"
|
|
95
|
+
assert issues == [], f"Non-test helper in a test file must be exempt, got: {issues}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_should_flag_test_function_missing_return_annotation() -> None:
|
|
99
|
+
source = "def test_loads():\n assert True\n"
|
|
100
|
+
issues = code_rules_enforcer.check_return_annotations(source, TEST_FILE_PATH)
|
|
101
|
+
assert any("test_loads" in each_issue for each_issue in issues), (
|
|
102
|
+
f"Pytest test function must require a return annotation, got: {issues}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_should_scope_test_function_return_annotation_to_changed_lines() -> None:
|
|
107
|
+
source = "def helper():\n return 1\n\n\ndef test_uses_it():\n assert helper()\n"
|
|
108
|
+
unchanged = code_rules_enforcer.check_return_annotations(source, TEST_FILE_PATH, {2})
|
|
109
|
+
assert unchanged == []
|
|
110
|
+
on_definition = code_rules_enforcer.check_return_annotations(
|
|
111
|
+
source, TEST_FILE_PATH, {5}
|
|
112
|
+
)
|
|
113
|
+
assert any("test_uses_it" in each_issue for each_issue in on_definition)
|
|
96
114
|
|
|
97
115
|
|
|
98
116
|
def test_should_flag_unannotated_known_fixture_in_test_file() -> None:
|