claude-dev-env 1.79.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.
Files changed (76) hide show
  1. package/_shared/pr-loop/scripts/CLAUDE.md +3 -1
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
  3. package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +2 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
  8. package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
  9. package/_shared/pr-loop/scripts/tests/CLAUDE.md +8 -0
  10. package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
  12. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +242 -0
  13. package/_shared/pr-loop/scripts/tests/test_copilot_quota_constants.py +63 -0
  14. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
  15. package/audit-rubrics/CLAUDE.md +3 -2
  16. package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
  17. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
  18. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
  19. package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
  20. package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
  21. package/audit-rubrics/prompts/CLAUDE.md +2 -1
  22. package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
  23. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
  24. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
  25. package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
  26. package/docs/CODE_RULES.md +2 -2
  27. package/hooks/blocking/CLAUDE.md +2 -0
  28. package/hooks/blocking/code_rules_annotations_length.py +59 -11
  29. package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
  30. package/hooks/blocking/code_rules_command_dispatch.py +140 -0
  31. package/hooks/blocking/code_rules_docstrings.py +93 -0
  32. package/hooks/blocking/code_rules_enforcer.py +58 -4
  33. package/hooks/blocking/code_rules_imports_logging.py +136 -1
  34. package/hooks/blocking/code_rules_js_conventions.py +246 -0
  35. package/hooks/blocking/code_rules_naming_collection.py +5 -0
  36. package/hooks/blocking/code_rules_test_assertions.py +3 -0
  37. package/hooks/blocking/code_rules_unused_imports.py +7 -66
  38. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
  39. package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
  40. package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
  41. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
  42. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
  43. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
  44. package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
  45. package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +72 -0
  46. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +112 -18
  47. package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
  48. package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -0
  49. package/hooks/hooks_constants/CLAUDE.md +2 -0
  50. package/hooks/hooks_constants/blocking_check_limits.py +10 -0
  51. package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
  52. package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
  53. package/hooks/hooks_constants/js_conventions_constants.py +54 -0
  54. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +93 -0
  55. package/hooks/hooks_constants/unused_module_import_constants.py +0 -1
  56. package/package.json +1 -1
  57. package/rules/docstring-prose-matches-implementation.md +3 -1
  58. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
  59. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
  60. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
  61. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
  62. package/skills/autoconverge/SKILL.md +28 -7
  63. package/skills/autoconverge/reference/convergence.md +3 -3
  64. package/skills/autoconverge/reference/stop-conditions.md +1 -1
  65. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
  66. package/skills/autoconverge/workflow/converge.contract.test.mjs +58 -122
  67. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +289 -5
  68. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +50 -46
  69. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +6 -6
  70. package/skills/autoconverge/workflow/converge.mjs +235 -247
  71. package/skills/autoconverge/workflow/converge.run-input.test.mjs +11 -0
  72. package/skills/autoconverge/workflow/converge_multi.mjs +23 -7
  73. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +28 -2
  74. package/skills/pr-converge/SKILL.md +28 -2
  75. package/skills/pr-converge/reference/convergence-gates.md +13 -1
  76. package/skills/pr-converge/reference/state-schema.md +11 -0
@@ -23,7 +23,7 @@ Compact reference for agents. ⚡ marks rules enforced by `code_rules_enforcer.p
23
23
 
24
24
  `code_rules_enforcer.py` blocks each of these at Write/Edit and explains the specific violation when it fires; exact patterns and exemption lists live in the hook:
25
25
 
26
- no new comments · imports at top · logging format args (`log_*("...", arg)`) · no `%s`/`%d` printf tokens in a `str.format`-logger message (`log_*` imported from `automation_logging`; `str.format` drops the args — use `{}`) · no magic values in production bodies (0, 1, -1 exempt) · UPPER_SNAKE constants only in `config/` (exempt: `config/*`, `/migrations/`, workflow registries `/workflow/` + `_tab.py` + `/states.py` + `/modules.py`, test files) · no hardcoded user home paths · guarded `sys.path.insert` · no unused module-level imports · banned identifiers (`ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `tmp`, `elem`, `val`) · banned function prefixes (`handle_`, `process_`, `manage_`, `do_`) · no type escape hatches (`Any` import, `cast()`, inline `Any`, a parameter typed bare `object` whose body reads `param.attribute`) outside boundary files · no bare/broad `except` · no `Any` in signatures or class attributes · no stub bodies (`pass`/`...`/`raise NotImplementedError`) outside abstract/Protocol · TypedDict `_encode_*`/`_decode_*` companions in the same module · no test-mode branching in production (use dependency injection) · no thin wrapper modules · Google-style docstrings on public functions with `Args:` matching the signature · boolean names prefixed `is_`/`has_`/`should_`/`can_`/`was_`/`did_` (assignments AND bool-typed parameters) · must-check returns (`find_and_click`, `write_outcome`) assigned and checked · known pytest fixture parameters in test files annotated with their single documented type (`tmp_path: Path`, `monkeypatch: pytest.MonkeyPatch`, `capsys`, `caplog`, `request`, …) · known pytest fixture parameters a test function declares but never references (drop the unused parameter — pytest still pays its setup cost)
26
+ no new comments · imports at top · logging format args (`log_*("...", arg)`) · no `%s`/`%d` printf tokens in a `str.format`-logger message (`log_*` imported from `automation_logging`; `str.format` drops the args — use `{}`) · no magic values in production bodies (0, 1, -1 exempt) · UPPER_SNAKE constants only in `config/` (exempt: `config/*`, `/migrations/`, workflow registries `/workflow/` + `_tab.py` + `/states.py` + `/modules.py`, test files) · no hardcoded user home paths · guarded `sys.path.insert` · no unused module-level imports · banned identifiers (`ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `tmp`, `elem`, `val`) · banned function prefixes (`handle_`, `process_`, `manage_`, `do_`) · no type escape hatches (`Any` import, `cast()`, inline `Any`, a parameter typed bare `object` whose body reads `param.attribute`) outside boundary files · no bare/broad `except` · no `Any` in signatures or class attributes · no stub bodies (`pass`/`...`/`raise NotImplementedError`) outside abstract/Protocol · TypedDict `_encode_*`/`_decode_*` companions in the same module · no test-mode branching in production (use dependency injection) · no thin wrapper modules · Google-style docstrings on public functions with `Args:` matching the signature · boolean names prefixed `is_`/`has_`/`should_`/`can_`/`was_`/`did_` (assignments AND bool-typed parameters) · must-check returns (`find_and_click`, `write_outcome`) assigned and checked · known pytest fixture parameters in test files annotated with their single documented type (`tmp_path: Path`, `monkeypatch: pytest.MonkeyPatch`, `capsys`, `caplog`, `request`, …) · known pytest fixture parameters a test function declares but never references (drop the unused parameter — pytest still pays its setup cost) · JavaScript/TypeScript boolean declarations (`const`/`let`/`var` bound to a boolean literal or negation) and `@param {boolean}` JSDoc names prefixed `is`/`has`/`should`/`can`/`was`/`did` (camelCase forms) · banned identifiers as `.mjs`/`.js` declaration names (`result`, `data`, `ctx`, `msg`, …), scoped to changed lines · in test files, banned identifiers and unused module-level imports fire on changed lines, and pytest-collectable `test_*` functions need a return annotation · a `hooks/blocking/` command classifier anchors its multi-word command regex to the command start (`^`/`\A`) or tokenizes the first word (`shlex.split`), never matching a command as a bare substring
27
27
 
28
28
  Test files are exempt from most checks. The one annotation the test-file exemption does NOT cover is a known pytest builtin fixture parameter: `tmp_path`, `monkeypatch`, `capsys`, `capfd`, `caplog`, `request`, and `tmp_path_factory` each have a single documented injected type, so the gate requires that annotation (`tmp_path: Path`) even inside a test file. The same set of fixtures is also subject to a use check: a pytest-collected test function that declares one of these parameters and never references it in its body fails the gate, because pytest materializes the fixture's setup (the temp directory, the monkeypatch context, the output capture) on every run whether or not the body reads the value — drop the unused parameter. A parameter counts as referenced when its name is read, augmented-assigned, or deleted anywhere in the body, including inside a nested function or comprehension. Only pytest-collectable functions are inspected — those at module top level or defined directly in a class body; a function nested inside another function's body is a local helper pytest never collects, so its fixture-named parameter is exempt. A `@pytest.fixture`-decorated function is exempt from the use check, since injecting one fixture into another purely to order its setup is intentional. Ordinary test parameters stay exempt from both checks. See also the file-global constants use-count rule: [`rules/file-global-constants.md`](../rules/file-global-constants.md).
29
29
 
@@ -94,4 +94,4 @@ If you already have the data, don't fetch it again.
94
94
 
95
95
  ## 11. ENFORCEMENT SURFACES
96
96
 
97
- ⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation; the `/code` skill prepends strict mode for a session: no `Any`/`cast()`, immutable TypedDicts with `_encode_*`/`_decode_*` + `require_*` validation, per-module `_test_hooks.py` DI, 100% statement + branch coverage, zero mocks). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–P) cover cross-file architectural concerns. Rules with documented-but-pending hook coverage live in `~/.claude/rules/*.md` and `skills/code/SKILL.md`; each names its own promotion path. The docstring-prose standard (free-form enumerations match the body) lives in `packages/claude-dev-env/rules/docstring-prose-matches-implementation.md`, enforced via Category O6 audit.
97
+ ⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation; the `/code` skill prepends strict mode for a session: no `Any`/`cast()`, immutable TypedDicts with `_encode_*`/`_decode_*` + `require_*` validation, per-module `_test_hooks.py` DI, 100% statement + branch coverage, zero mocks). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–Q) cover cross-file architectural concerns. Rules with documented-but-pending hook coverage live in `~/.claude/rules/*.md` and `skills/code/SKILL.md`; each names its own promotion path. The docstring-prose standard (free-form enumerations match the body) lives in `packages/claude-dev-env/rules/docstring-prose-matches-implementation.md`, enforced via Category O6 audit.
@@ -23,6 +23,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
23
23
  | `code_rules_annotations_length.py` | Parameter/return annotations, function length, pytest fixture annotation requirements |
24
24
  | `code_rules_banned_identifiers.py` | Banned short names (`ctx`, `cfg`, `msg`, etc.), banned prefixes (`handle_`, `process_`, etc.) |
25
25
  | `code_rules_boolean_mustcheck.py` | Boolean naming (`is_`/`has_`/… prefixes) and must-check return values |
26
+ | `code_rules_command_dispatch.py` | A `hooks/blocking/` command classifier matching a multi-word command regex without a start anchor or first-word tokenization |
26
27
  | `code_rules_comments.py` | No new inline comments; no deletion of existing ones |
27
28
  | `code_rules_constants_config.py` | Constants must live in `config/`; file-global constant use-count |
28
29
  | `code_rules_dead_argparse_argument.py` | Argparse arguments with no references in the same file |
@@ -33,6 +34,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
33
34
  | `code_rules_docstrings.py` | Google-style docstrings; `Args:` section matches signature; fallback-branch coverage |
34
35
  | `code_rules_duplicate_body.py` | A function body copied from a sibling module, or a helper body inlined as a block inside a larger function in the same file |
35
36
  | `code_rules_imports_logging.py` | Imports at top of file; logging format-arg style; printf tokens in `str.format`-logger messages |
37
+ | `code_rules_js_conventions.py` | Boolean-prefix naming and banned identifiers for JavaScript/TypeScript declarations and `@param {boolean}` JSDoc, scoped to changed lines |
36
38
  | `code_rules_magic_values.py` | No magic numbers or strings in production code bodies |
37
39
  | `code_rules_mock_completeness.py` | Mock calls that skip required arguments |
38
40
  | `code_rules_naming_collection.py` | Collection names must use `all_*` prefix |
@@ -384,24 +384,72 @@ def check_unused_known_pytest_fixture_parameters(
384
384
  return issues
385
385
 
386
386
 
387
- def check_return_annotations(content: str, file_path: str) -> list[str]:
388
- if is_test_file(file_path):
389
- return []
387
+ def check_return_annotations(
388
+ content: str,
389
+ file_path: str,
390
+ all_changed_lines: set[int] | None = None,
391
+ defer_scope_to_caller: bool = False,
392
+ ) -> list[str]:
393
+ """Flag functions missing a return type annotation, scoped to changed lines.
394
+
395
+ In a production file every function definition needs a return annotation. In
396
+ a test file the requirement narrows to pytest-collectable test functions —
397
+ ``test_*`` at module top level or in a class body — which typically declare
398
+ ``-> None``; an ordinary helper in a test file stays exempt. Findings scope
399
+ to *all_changed_lines* so a pre-existing unannotated function on an untouched
400
+ line never blocks while a newly written one does.
401
+
402
+ Args:
403
+ content: The source text to inspect — the reconstructed full file on an
404
+ Edit so the parse succeeds.
405
+ file_path: The path the source will be written to, used for exemptions.
406
+ all_changed_lines: Post-edit line numbers the current edit touched, or
407
+ None to treat the whole file as in scope. When provided, a violation
408
+ blocks only when the function's definition line is among the changed
409
+ lines.
410
+ defer_scope_to_caller: When True, return every violation so the
411
+ commit/push gate scopes by added line through its ``Line N:``
412
+ partitioning.
413
+
414
+ Returns:
415
+ One issue per function missing a return type annotation, scoped to the
416
+ changed lines unless *defer_scope_to_caller* is True or *all_changed_lines*
417
+ is None.
418
+ """
390
419
  if is_workflow_registry_file(file_path) or is_migration_file(file_path):
391
420
  return []
392
421
  try:
393
422
  tree = ast.parse(content)
394
423
  except SyntaxError:
395
424
  return []
396
- issues: list[str] = []
397
- for each_node in ast.walk(tree):
398
- if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
425
+ if is_test_file(file_path):
426
+ candidate_functions: list[ast.FunctionDef | ast.AsyncFunctionDef] = [
427
+ each_node
428
+ for each_node in _collect_pytest_collectable_functions(tree)
429
+ if _is_pytest_test_function(each_node)
430
+ ]
431
+ else:
432
+ candidate_functions = [
433
+ each_node
434
+ for each_node in ast.walk(tree)
435
+ if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef))
436
+ ]
437
+ all_violations_in_source_order: list[tuple[range, str]] = []
438
+ for each_node in candidate_functions:
439
+ if each_node.returns is not None:
399
440
  continue
400
- if each_node.returns is None:
401
- issues.append(
402
- f"Line {each_node.lineno}: function {each_node.name!r} missing return type annotation (CODE_RULES §6)"
403
- )
404
- return issues
441
+ message = (
442
+ f"Line {each_node.lineno}: function {each_node.name!r} missing "
443
+ "return type annotation (CODE_RULES §6)"
444
+ )
445
+ all_violations_in_source_order.append(
446
+ (range(each_node.lineno, each_node.lineno + 1), message)
447
+ )
448
+ return _scope_violations_to_changed_lines(
449
+ all_violations_in_source_order,
450
+ all_changed_lines,
451
+ defer_scope_to_caller,
452
+ )
405
453
 
406
454
 
407
455
  def check_function_length(
@@ -118,9 +118,39 @@ def _collect_banned_names_from_node(node: ast.AST) -> list[ast.Name]:
118
118
  return []
119
119
 
120
120
 
121
- def check_banned_identifiers(content: str, file_path: str) -> list[str]:
122
- """Flag assignments to identifiers banned by the project Naming rules."""
123
- if is_test_file(file_path) or is_hook_infrastructure(file_path):
121
+ def check_banned_identifiers(
122
+ content: str,
123
+ file_path: str,
124
+ all_changed_lines: set[int] | None = None,
125
+ defer_scope_to_caller: bool = False,
126
+ ) -> list[str]:
127
+ """Flag assignments to identifiers banned by the project Naming rules.
128
+
129
+ Test files are scoped rather than exempt: a banned identifier a test binds
130
+ is flagged only when its binding line is among the changed lines, so a
131
+ pre-existing ``result`` on an untouched line never blocks an edit while a
132
+ newly written one does. Production files scope the same way, and a new-file
133
+ or full-file write treats every line as changed. Hook infrastructure stays
134
+ exempt.
135
+
136
+ Args:
137
+ content: The source text to inspect — the reconstructed full file on an
138
+ Edit so the parse succeeds and the diff scoping is meaningful.
139
+ file_path: The path the source will be written to, used for exemptions.
140
+ all_changed_lines: Post-edit line numbers the current edit touched, or
141
+ None to treat the whole file as in scope. When provided, a violation
142
+ blocks only when its binding line is among the changed lines.
143
+ defer_scope_to_caller: When True, return every violation so the
144
+ commit/push gate scopes by added line through its ``Line N:``
145
+ partitioning.
146
+
147
+ Returns:
148
+ One issue per banned binding, scoped to the changed lines unless
149
+ *defer_scope_to_caller* is True or *all_changed_lines* is None. The
150
+ terminal result is capped at the module limit; the deferred result is
151
+ uncapped so the gate can scope by added line and apply its own ceiling.
152
+ """
153
+ if is_hook_infrastructure(file_path):
124
154
  return []
125
155
 
126
156
  try:
@@ -135,15 +165,24 @@ def check_banned_identifiers(content: str, file_path: str) -> list[str]:
135
165
 
136
166
  banned_name_nodes.sort(key=lambda each_name: (each_name.lineno, each_name.col_offset))
137
167
 
138
- issues: list[str] = []
168
+ all_violations_in_source_order: list[tuple[range, str]] = []
139
169
  for each_name in banned_name_nodes:
140
- issues.append(
141
- f"Line {each_name.lineno}: Banned identifier '{each_name.id}' - {BANNED_IDENTIFIER_MESSAGE_SUFFIX}"
170
+ message = (
171
+ f"Line {each_name.lineno}: Banned identifier '{each_name.id}' - "
172
+ f"{BANNED_IDENTIFIER_MESSAGE_SUFFIX}"
173
+ )
174
+ all_violations_in_source_order.append(
175
+ (range(each_name.lineno, each_name.lineno + 1), message)
142
176
  )
143
- if len(issues) >= MAX_BANNED_IDENTIFIER_ISSUES:
144
- break
145
177
 
146
- return issues
178
+ scoped_issues = _scope_violations_to_changed_lines(
179
+ all_violations_in_source_order,
180
+ all_changed_lines,
181
+ defer_scope_to_caller,
182
+ )
183
+ if defer_scope_to_caller:
184
+ return scoped_issues
185
+ return scoped_issues[:MAX_BANNED_IDENTIFIER_ISSUES]
147
186
 
148
187
 
149
188
  def _identifier_word_parts(identifier: str) -> list[str]:
@@ -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,
@@ -102,11 +106,16 @@ from code_rules_imports_logging import ( # noqa: E402
102
106
  check_import_block_sorted,
103
107
  check_imports_at_top,
104
108
  check_js_resume_task_enumeration_coverage,
109
+ check_js_returns_object_schemaless_branch,
105
110
  check_library_print,
106
111
  check_logging_fstrings,
107
112
  check_logging_printf_tokens,
108
113
  check_windows_api_none,
109
114
  )
115
+ from code_rules_js_conventions import ( # noqa: E402
116
+ check_js_banned_identifiers,
117
+ check_js_boolean_naming,
118
+ )
110
119
  from code_rules_magic_values import ( # noqa: E402
111
120
  check_fstring_structural_literals,
112
121
  check_magic_values,
@@ -283,7 +292,14 @@ def validate_content(
283
292
  )
284
293
  )
285
294
  all_issues.extend(check_type_escape_hatches(effective_content, file_path))
286
- 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
+ )
287
303
  all_issues.extend(
288
304
  check_banned_noun_word_boundary(
289
305
  effective_content,
@@ -367,6 +383,9 @@ def validate_content(
367
383
  all_issues.extend(
368
384
  check_docstring_names_undefined_constant(effective_content, file_path)
369
385
  )
386
+ all_issues.extend(
387
+ check_docstring_names_absent_type_checking_gate(effective_content, file_path)
388
+ )
370
389
  all_issues.extend(
371
390
  check_docstring_args_single_line_scope_vs_span(effective_content, file_path)
372
391
  )
@@ -427,7 +446,14 @@ def validate_content(
427
446
  all_issues.extend(
428
447
  check_unused_known_pytest_fixture_parameters(content, file_path)
429
448
  )
430
- 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
+ )
431
457
  all_issues.extend(
432
458
  check_function_length(
433
459
  effective_content,
@@ -465,9 +491,28 @@ def validate_content(
465
491
  if not is_test_file(file_path):
466
492
  all_issues.extend(check_comment_changes(old_content, content, file_path))
467
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
+ )
468
510
  all_issues.extend(
469
511
  check_js_resume_task_enumeration_coverage(content, file_path)
470
512
  )
513
+ all_issues.extend(
514
+ check_js_returns_object_schemaless_branch(content, file_path)
515
+ )
471
516
 
472
517
  if extension in ALL_CODE_EXTENSIONS:
473
518
  advise_file_line_count(content, file_path)
@@ -575,8 +620,10 @@ def _hook_infrastructure_blocking_issues(
575
620
  runs the checks that must still guard them: the cross-file duplicate-body
576
621
  check and the same-file inline-duplicate-body check, each span-scoped to the
577
622
  lines an edit touched exactly as ``validate_content`` scopes it for production
578
- code; and the zero-payload alias check, whose docstring names hook modules as
579
- 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.
580
627
 
581
628
  Args:
582
629
  content: The fragment or whole-file body under validation.
@@ -609,6 +656,13 @@ def _hook_infrastructure_blocking_issues(
609
656
  )
610
657
  )
611
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
+ )
612
666
  return all_issues
613
667
 
614
668