claude-dev-env 1.80.0 → 1.82.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.
Files changed (64) hide show
  1. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
  6. package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
  7. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -0
  8. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
  9. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
  10. package/audit-rubrics/CLAUDE.md +3 -2
  11. package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
  12. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
  13. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
  14. package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
  15. package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
  16. package/audit-rubrics/prompts/CLAUDE.md +2 -1
  17. package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
  18. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
  19. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
  20. package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
  21. package/docs/CODE_RULES.md +2 -2
  22. package/hooks/blocking/CLAUDE.md +3 -0
  23. package/hooks/blocking/code_rules_annotations_length.py +59 -11
  24. package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
  25. package/hooks/blocking/code_rules_command_dispatch.py +140 -0
  26. package/hooks/blocking/code_rules_docstrings.py +93 -0
  27. package/hooks/blocking/code_rules_enforcer.py +54 -4
  28. package/hooks/blocking/code_rules_js_conventions.py +246 -0
  29. package/hooks/blocking/code_rules_naming_collection.py +5 -0
  30. package/hooks/blocking/code_rules_test_assertions.py +3 -0
  31. package/hooks/blocking/code_rules_unused_imports.py +0 -3
  32. package/hooks/blocking/conventional_pr_title_gate.py +444 -0
  33. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
  34. package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
  35. package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
  36. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
  37. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
  38. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
  39. package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
  40. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +16 -3
  41. package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
  42. package/hooks/blocking/test_conventional_pr_title_gate.py +640 -0
  43. package/hooks/hooks.json +5 -0
  44. package/hooks/hooks_constants/CLAUDE.md +3 -0
  45. package/hooks/hooks_constants/blocking_check_limits.py +9 -0
  46. package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
  47. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +58 -0
  48. package/hooks/hooks_constants/js_conventions_constants.py +54 -0
  49. package/package.json +1 -1
  50. package/rules/docstring-prose-matches-implementation.md +2 -1
  51. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
  52. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
  53. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
  54. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
  55. package/skills/autoconverge/SKILL.md +92 -7
  56. package/skills/autoconverge/reference/convergence.md +3 -3
  57. package/skills/autoconverge/reference/gotchas.md +5 -5
  58. package/skills/autoconverge/reference/stop-conditions.md +1 -1
  59. package/skills/autoconverge/workflow/converge.contract.test.mjs +161 -29
  60. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +342 -1
  61. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +2 -2
  62. package/skills/autoconverge/workflow/converge.mjs +178 -32
  63. package/skills/autoconverge/workflow/converge_multi.mjs +6 -2
  64. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +26 -0
@@ -0,0 +1,140 @@
1
+ """Meta-gate flagging an unanchored multi-word command pattern in a blocker.
2
+
3
+ A hook that classifies a shell command reads the command text and matches it
4
+ against a regex. When that regex names a multi-word command such as
5
+ ``gh\\s+pr\\s+(create|edit)`` and is matched with a bare ``re.search`` — no
6
+ start anchor and no first-word tokenization — the pattern matches the command
7
+ as a substring anywhere in the string. A benign command like
8
+ ``echo gh pr create --title x`` then trips the gate. This check flags that shape
9
+ at write time on files under ``hooks/blocking/`` so a new blocker anchors its
10
+ command match to the start of the command or tokenizes the first word.
11
+ """
12
+
13
+ import ast
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ _blocking_directory = str(Path(__file__).resolve().parent)
18
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
19
+ if _blocking_directory not in sys.path:
20
+ sys.path.insert(0, _blocking_directory)
21
+ if _hooks_directory not in sys.path:
22
+ sys.path.insert(0, _hooks_directory)
23
+
24
+ from code_rules_shared import ( # noqa: E402
25
+ _scope_violations_to_changed_lines,
26
+ is_test_file,
27
+ )
28
+
29
+ from hooks_constants.command_dispatch_constants import ( # noqa: E402
30
+ ALL_REGEX_START_ANCHOR_TOKENS,
31
+ COMMAND_DISPATCH_LITERAL_PATTERN,
32
+ COMMAND_DISPATCH_MESSAGE_SUFFIX,
33
+ COMMAND_DISPATCH_PATH_MARKER,
34
+ COMMAND_KEY_ACCESS_PATTERN,
35
+ FIRST_TOKEN_TOKENIZATION_PATTERN,
36
+ MAX_COMMAND_DISPATCH_ISSUES,
37
+ )
38
+
39
+
40
+ def _is_under_hooks_blocking(file_path: str) -> bool:
41
+ """Return whether the path sits under the ``hooks/blocking`` directory."""
42
+ return COMMAND_DISPATCH_PATH_MARKER in file_path.replace("\\", "/")
43
+
44
+
45
+ def _command_literal_is_anchored(literal_value: str, match_start: int) -> bool:
46
+ """Return whether a start anchor precedes the command word in the pattern.
47
+
48
+ Args:
49
+ literal_value: The regex string literal the command word appears in.
50
+ match_start: The index where the command word match begins.
51
+
52
+ Returns:
53
+ True when a ``^`` or ``\\A`` anchor appears before the command word, so
54
+ the pattern binds the command to the start of the string.
55
+ """
56
+ prefix = literal_value[:match_start]
57
+ return any(each_anchor in prefix for each_anchor in ALL_REGEX_START_ANCHOR_TOKENS)
58
+
59
+
60
+ def _unanchored_command_literals(
61
+ parsed_tree: ast.AST,
62
+ ) -> list[tuple[range, str]]:
63
+ """Return one span-tagged violation per unanchored command-regex literal.
64
+
65
+ Args:
66
+ parsed_tree: The parsed module to scan for string-constant nodes.
67
+
68
+ Returns:
69
+ ``(line_range, message)`` pairs in source order.
70
+ """
71
+ all_violations: list[tuple[range, str]] = []
72
+ for each_node in ast.walk(parsed_tree):
73
+ if not isinstance(each_node, ast.Constant) or not isinstance(
74
+ each_node.value, str
75
+ ):
76
+ continue
77
+ literal_match = COMMAND_DISPATCH_LITERAL_PATTERN.search(each_node.value)
78
+ if literal_match is None:
79
+ continue
80
+ if _command_literal_is_anchored(each_node.value, literal_match.start()):
81
+ continue
82
+ end_line = each_node.end_lineno or each_node.lineno
83
+ message = (
84
+ f"Line {each_node.lineno}: command pattern {each_node.value!r} "
85
+ f"{COMMAND_DISPATCH_MESSAGE_SUFFIX}"
86
+ )
87
+ all_violations.append((range(each_node.lineno, end_line + 1), message))
88
+ return all_violations
89
+
90
+
91
+ def check_unanchored_command_dispatch(
92
+ content: str,
93
+ file_path: str,
94
+ all_changed_lines: set[int] | None = None,
95
+ defer_scope_to_caller: bool = False,
96
+ ) -> list[str]:
97
+ """Flag an unanchored multi-word command regex in a hooks/blocking file.
98
+
99
+ The check fires only on a file under ``hooks/blocking/`` that reads a
100
+ ``command`` key (a shell-command classifier) and does not tokenize the
101
+ command's first word (no ``shlex.split`` or ``.split(`` nearby). Under those
102
+ conditions, a regex string literal naming a known command word followed by
103
+ ``\\s+`` without a leading ``^``/``\\A`` anchor is flagged. Findings scope to
104
+ *all_changed_lines* so a pre-existing pattern on an untouched line does not
105
+ block an edit while a newly written one does.
106
+
107
+ Args:
108
+ content: The source text to inspect — the reconstructed full file on an
109
+ Edit so the parse succeeds.
110
+ file_path: The path the source will be written to, used for scoping to
111
+ ``hooks/blocking`` and skipping test files.
112
+ all_changed_lines: Post-edit line numbers the current edit touched, or
113
+ None to treat the whole file as in scope.
114
+ defer_scope_to_caller: When True, return every violation so a downstream
115
+ scoper classifies by added line.
116
+
117
+ Returns:
118
+ One issue per unanchored command-regex literal, scoped to the changed
119
+ lines unless *defer_scope_to_caller* is True or *all_changed_lines* is
120
+ None, capped at the module limit.
121
+ """
122
+ if is_test_file(file_path) or not _is_under_hooks_blocking(file_path):
123
+ return []
124
+ if COMMAND_KEY_ACCESS_PATTERN.search(content) is None:
125
+ return []
126
+ if FIRST_TOKEN_TOKENIZATION_PATTERN.search(content) is not None:
127
+ return []
128
+ try:
129
+ parsed_tree = ast.parse(content)
130
+ except SyntaxError:
131
+ return []
132
+ all_violations_in_source_order = _unanchored_command_literals(parsed_tree)
133
+ scoped_issues = _scope_violations_to_changed_lines(
134
+ all_violations_in_source_order,
135
+ all_changed_lines,
136
+ defer_scope_to_caller,
137
+ )
138
+ if defer_scope_to_caller:
139
+ return scoped_issues
140
+ return scoped_issues[:MAX_COMMAND_DISPATCH_ISSUES]
@@ -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(check_banned_identifiers(content, file_path))
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(check_return_annotations(content, file_path))
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; and the zero-payload alias check, whose docstring names hook modules as
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: