claude-dev-env 1.82.0 → 1.83.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 (67) hide show
  1. package/CLAUDE.md +16 -13
  2. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  3. package/_shared/pr-loop/scripts/README.md +1 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviewer_availability_constants.py +12 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +0 -2
  7. package/_shared/pr-loop/scripts/reviewer_availability.py +182 -0
  8. package/_shared/pr-loop/scripts/terminology_sweep.py +9 -33
  9. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  10. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +159 -0
  11. package/_shared/pr-loop/scripts/tests/test_reviewer_availability_constants.py +36 -0
  12. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +14 -4
  13. package/hooks/blocking/CLAUDE.md +2 -0
  14. package/hooks/blocking/code_rules_constants_config.py +159 -1
  15. package/hooks/blocking/code_rules_docstrings.py +312 -9
  16. package/hooks/blocking/code_rules_enforcer.py +29 -0
  17. package/hooks/blocking/code_rules_imports_logging.py +867 -1
  18. package/hooks/blocking/code_rules_naming_collection.py +141 -0
  19. package/hooks/blocking/code_rules_string_magic.py +68 -0
  20. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  21. package/hooks/blocking/reviewer_spawn_gate.py +182 -0
  22. package/hooks/blocking/stale_comment_reference_blocker.py +267 -0
  23. package/hooks/blocking/state_description_blocker.py +96 -5
  24. package/hooks/blocking/test_code_rules_config_duplicate_path_anchor.py +132 -0
  25. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +2 -0
  26. package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +385 -0
  27. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +67 -0
  28. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +40 -0
  29. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +213 -0
  30. package/hooks/blocking/test_code_rules_enforcer_referenced_underscore_loop.py +169 -0
  31. package/hooks/blocking/test_code_rules_js_bare_flag_return_directive.py +266 -0
  32. package/hooks/blocking/test_code_rules_js_sibling_return_object_key_drift.py +490 -0
  33. package/hooks/blocking/test_code_rules_logging_adjacent_literals.py +171 -0
  34. package/hooks/blocking/test_pre_tool_use_dispatcher.py +9 -3
  35. package/hooks/blocking/test_reviewer_spawn_gate.py +230 -0
  36. package/hooks/blocking/test_stale_comment_reference_blocker.py +236 -0
  37. package/hooks/blocking/test_state_description_blocker.py +135 -0
  38. package/hooks/hooks.json +5 -0
  39. package/hooks/hooks_constants/CLAUDE.md +3 -1
  40. package/hooks/hooks_constants/blocking_check_limits.py +43 -0
  41. package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -0
  42. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  43. package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +41 -0
  44. package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +76 -0
  45. package/hooks/hooks_constants/state_description_blocker_constants.py +8 -0
  46. package/package.json +1 -1
  47. package/rules/CLAUDE.md +4 -1
  48. package/rules/claude-md-orphan-file.md +5 -0
  49. package/rules/docstring-prose-matches-implementation.md +10 -1
  50. package/rules/env-var-table-code-drift.md +5 -0
  51. package/rules/es-exe-file-search.md +17 -0
  52. package/rules/no-historical-clutter.md +12 -1
  53. package/rules/orphan-css-class.md +5 -0
  54. package/rules/package-inventory-stale-entry.md +10 -0
  55. package/rules/paired-test-coverage.md +5 -0
  56. package/rules/plain-illustrative-docstrings.md +5 -0
  57. package/rules/verify-before-asking.md +7 -0
  58. package/rules/verify-runtime-state.md +40 -0
  59. package/rules/windows-filesystem-safe.md +8 -0
  60. package/rules/workers-done-before-complete.md +33 -0
  61. package/rules/workflow-substitution-slots.md +5 -0
  62. package/skills/CLAUDE.md +1 -1
  63. package/skills/autoconverge/SKILL.md +10 -4
  64. package/skills/autoconverge/workflow/converge.contract.test.mjs +69 -0
  65. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +54 -18
  66. package/skills/autoconverge/workflow/converge.mjs +97 -33
  67. package/skills/everything-search/SKILL.md +5 -0
@@ -271,6 +271,147 @@ def check_loop_variable_naming(content: str, file_path: str) -> list[str]:
271
271
  return issues
272
272
 
273
273
 
274
+ def _comprehension_rebinds_name(comprehension_node: ast.expr, target_name: str) -> bool:
275
+ """True when any generator of the comprehension binds its own target_name-named variable."""
276
+ generators = getattr(comprehension_node, "generators", [])
277
+ for each_generator in generators:
278
+ for each_name_node in _walk_assignment_targets(each_generator.target):
279
+ if each_name_node.id == target_name:
280
+ return True
281
+ return False
282
+
283
+
284
+ def _function_binds_name(
285
+ function_node: ast.Lambda | ast.FunctionDef | ast.AsyncFunctionDef, target_name: str
286
+ ) -> bool:
287
+ """True when a lambda or nested def has a parameter named target_name."""
288
+ argument_specification = function_node.args
289
+ all_argument_nodes = [
290
+ *argument_specification.posonlyargs,
291
+ *argument_specification.args,
292
+ *argument_specification.kwonlyargs,
293
+ ]
294
+ if argument_specification.vararg is not None:
295
+ all_argument_nodes.append(argument_specification.vararg)
296
+ if argument_specification.kwarg is not None:
297
+ all_argument_nodes.append(argument_specification.kwarg)
298
+ return any(each_argument.arg == target_name for each_argument in all_argument_nodes)
299
+
300
+
301
+ def _function_enclosing_scope_expressions(
302
+ function_node: ast.Lambda | ast.FunctionDef | ast.AsyncFunctionDef,
303
+ ) -> list[ast.expr]:
304
+ """Return the sub-expressions a lambda or def evaluates in its enclosing scope.
305
+
306
+ Parameter defaults and decorators run before the parameters bind, so a Load of
307
+ the outer name there still reads the outer name; the body never does once a
308
+ parameter shadows it.
309
+ """
310
+ argument_specification = function_node.args
311
+ enclosing_expressions: list[ast.expr] = [*argument_specification.defaults]
312
+ enclosing_expressions.extend(
313
+ each_default
314
+ for each_default in argument_specification.kw_defaults
315
+ if each_default is not None
316
+ )
317
+ if isinstance(function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
318
+ enclosing_expressions.extend(function_node.decorator_list)
319
+ return enclosing_expressions
320
+
321
+
322
+ def _loop_rebinds_name(loop_node: ast.For | ast.AsyncFor, target_name: str) -> bool:
323
+ """True when the loop's own target rebinds target_name."""
324
+ return any(
325
+ each_name_node.id == target_name
326
+ for each_name_node in _walk_assignment_targets(loop_node.target)
327
+ )
328
+
329
+
330
+ def _node_reads_name(node: ast.AST, target_name: str) -> bool:
331
+ """True when node contains a genuine Load of target_name, honoring scope shadowing.
332
+
333
+ A comprehension, a lambda, a nested def, or a nested loop that rebinds
334
+ target_name owns that name inside its body, so a Load there reads the inner
335
+ variable, not the outer loop variable. Only the parts each construct evaluates
336
+ in the enclosing scope can still read the outer name: a comprehension's or
337
+ nested loop's iterable, and a function's parameter defaults and decorators.
338
+ """
339
+ comprehension_node_types = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
340
+ if isinstance(node, comprehension_node_types) and _comprehension_rebinds_name(
341
+ node, target_name
342
+ ):
343
+ outermost_iterable = node.generators[0].iter
344
+ return _node_reads_name(outermost_iterable, target_name)
345
+ if isinstance(node, (ast.Lambda, ast.FunctionDef, ast.AsyncFunctionDef)) and _function_binds_name(
346
+ node, target_name
347
+ ):
348
+ return any(
349
+ _node_reads_name(each_expression, target_name)
350
+ for each_expression in _function_enclosing_scope_expressions(node)
351
+ )
352
+ if isinstance(node, (ast.For, ast.AsyncFor)) and _loop_rebinds_name(node, target_name):
353
+ return _node_reads_name(node.iter, target_name)
354
+ if isinstance(node, ast.Name) and node.id == target_name and isinstance(node.ctx, ast.Load):
355
+ return True
356
+ for each_child_node in ast.iter_child_nodes(node):
357
+ if _node_reads_name(each_child_node, target_name):
358
+ return True
359
+ return False
360
+
361
+
362
+ def _loop_body_references_name(loop_node: ast.For | ast.AsyncFor, target_name: str) -> bool:
363
+ for each_statement in [*loop_node.body, *loop_node.orelse]:
364
+ if _node_reads_name(each_statement, target_name):
365
+ return True
366
+ return False
367
+
368
+
369
+ def check_referenced_underscore_loop_variable(content: str, file_path: str) -> list[str]:
370
+ """Flag a leading-underscore loop variable the loop body reads.
371
+
372
+ A leading underscore marks a name as a throwaway the reader can ignore, so
373
+ ``for _foreign_module_name in names:`` promises the body never touches it.
374
+ When the body then reads ``del sys.modules[_foreign_module_name]``, the
375
+ marker lies: the value carries meaning and earns a real name. This check
376
+ fires in test files too — a conftest that reuses an underscore-marked loop
377
+ variable is the exact shape this closes — because the misleading marker is a
378
+ naming smell everywhere, not a convention the test-file exemption waives.
379
+ Workflow-registry and migration files are exempt. A bare ``_`` target and a
380
+ genuinely unread ``_unused`` throwaway both pass.
381
+
382
+ Args:
383
+ content: The source text to inspect.
384
+ file_path: The path the source will be written to, used for exemptions.
385
+
386
+ Returns:
387
+ One issue per leading-underscore loop variable that its loop body reads.
388
+ """
389
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
390
+ return []
391
+ try:
392
+ tree = ast.parse(content)
393
+ except SyntaxError:
394
+ return []
395
+ issues: list[str] = []
396
+ for each_node in ast.walk(tree):
397
+ if not isinstance(each_node, (ast.For, ast.AsyncFor)):
398
+ continue
399
+ for each_name_node in _collect_target_names(each_node.target):
400
+ target_name = each_name_node.id
401
+ if not target_name.startswith("_"):
402
+ continue
403
+ if target_name.strip("_") == "":
404
+ continue
405
+ if not _loop_body_references_name(each_node, target_name):
406
+ continue
407
+ issues.append(
408
+ f"Line {each_name_node.lineno}: loop variable {target_name!r} is read"
409
+ f" in its body - drop the leading underscore throwaway marker and"
410
+ f" give it a real name (CODE_RULES §5)"
411
+ )
412
+ return issues
413
+
414
+
274
415
  def _name_carries_token(name: str, token: str) -> bool:
275
416
  """True when name carries token as a whole underscore-delimited word."""
276
417
  return re.search(POLARITY_TOKEN_BOUNDARY_PATTERN % re.escape(token), name) is not None
@@ -123,6 +123,74 @@ def check_string_literal_magic(content: str, file_path: str) -> list[str]:
123
123
  return issues
124
124
 
125
125
 
126
+ def check_join_separator_string_magic(content: str, file_path: str) -> list[str]:
127
+ """Flag a bare string-literal separator passed to ``str.join`` in a body.
128
+
129
+ A call like ``", ".join(all_paths)`` hard-codes the delimiter that stitches
130
+ a list into one string. That delimiter is a magic value: change the wording
131
+ once and every reader has to grep the whole file for the stray literal. A
132
+ named constant in ``config/`` gives the delimiter one home. The empty
133
+ separator ``"".join(...)`` is left alone because it carries no delimiter —
134
+ it is the idiomatic way to concatenate parts. Config files, test files,
135
+ workflow-registry files, and migration files are exempt.
136
+
137
+ Args:
138
+ content: The source text to inspect.
139
+ file_path: The path the source will be written to, used for exemptions.
140
+
141
+ Returns:
142
+ One issue per non-empty string-literal ``.join`` separator found in a
143
+ function body.
144
+ """
145
+ if is_test_file(file_path):
146
+ return []
147
+ if is_config_file(file_path):
148
+ return []
149
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
150
+ return []
151
+ try:
152
+ tree = ast.parse(content)
153
+ except SyntaxError:
154
+ return []
155
+ issues: list[str] = []
156
+ flagged_node_ids: set[int] = set()
157
+ for each_function_node in ast.walk(tree):
158
+ if not isinstance(each_function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
159
+ continue
160
+ for each_body_statement in each_function_node.body:
161
+ for each_descendant in _walk_skipping_nested_function_defs(each_body_statement):
162
+ if not isinstance(each_descendant, ast.Call):
163
+ continue
164
+ separator_literal = _join_call_string_separator(each_descendant)
165
+ if separator_literal is None:
166
+ continue
167
+ if id(each_descendant) in flagged_node_ids:
168
+ continue
169
+ flagged_node_ids.add(id(each_descendant))
170
+ issues.append(
171
+ f"Line {each_descendant.lineno}: string separator"
172
+ f" {separator_literal!r} passed to .join(...)"
173
+ f" - extract to a named constant in config/"
174
+ )
175
+ return issues
176
+
177
+
178
+ def _join_call_string_separator(call_node: ast.Call) -> str | None:
179
+ called = call_node.func
180
+ if not isinstance(called, ast.Attribute):
181
+ return None
182
+ if called.attr != "join":
183
+ return None
184
+ separator_node = called.value
185
+ if not isinstance(separator_node, ast.Constant):
186
+ return None
187
+ if not isinstance(separator_node.value, str):
188
+ return None
189
+ if separator_node.value == "":
190
+ return None
191
+ return separator_node.value
192
+
193
+
126
194
  def check_inline_literal_collections(content: str, file_path: str) -> list[str]:
127
195
  if is_test_file(file_path):
128
196
  return []
@@ -8,9 +8,9 @@ decision when any hook denied (carrying every denying reason) or exits zero to
8
8
  allow.
9
9
 
10
10
  The per-hook coverage matrix:
11
- - Write -> Group A (11 hooks) + Group B (7 hooks) = 18 hooks
12
- - Edit -> Group A (11 hooks) + Group B (7 hooks) = 18 hooks
13
- - MultiEdit -> Group B only (7 hooks)
11
+ - Write -> Group A (11 hooks) + Group B (8 hooks) = 19 hooks
12
+ - Edit -> Group A (11 hooks) + Group B (8 hooks) + the Edit-only hook = 20 hooks
13
+ - MultiEdit -> Group B only (8 hooks)
14
14
  """
15
15
 
16
16
  from __future__ import annotations
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: block a reviewer-spawn trigger when that reviewer is down.
3
+
4
+ autoconverge already skips a reviewer step when its own pre-check reports
5
+ that reviewer unavailable. That skip only protects the trigger commands the
6
+ workflow generates and inspects itself. This hook is the backstop: it reads
7
+ the Bash command a tool call is about to run and blocks it when the matched
8
+ reviewer reports unavailable.
9
+
10
+ The gate only inspects a command carrying the literal sentinel marker
11
+ `CLAUDE_REVIEWER_GATE=autoconverge`. autoconverge prepends this marker to its
12
+ own trigger commands. A command without the marker is never inspected, so a
13
+ manual or unrelated `gh` call always passes through untouched.
14
+
15
+ A Copilot trigger is a command containing both `requested_reviewers` and
16
+ `copilot-pull-request-reviewer[bot]`. A Bugbot trigger is a command
17
+ containing `post_fix_reply.py` and a `--body "bugbot run"` argument.
18
+
19
+ For the matched reviewer, the gate runs
20
+ `reviewer_availability.py --reviewer copilot|bugbot` and denies the call only
21
+ when that script exits with the documented reviewer-down code (3). Any other
22
+ exit code is an unexpected script failure, not a down report, so the call is
23
+ allowed (fail open). The deny reason names the reviewer and carries
24
+ the script's output. The script path resolves relative to this hook's
25
+ install location by default, or from the
26
+ `REVIEWER_SPAWN_GATE_AVAILABILITY_SCRIPT_PATH` environment variable when set.
27
+ A missing script, a timeout, or a failure to launch it allows the call
28
+ (fail open), so a broken availability check never blocks a legitimate run.
29
+ """
30
+
31
+ import json
32
+ import os
33
+ import subprocess
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
38
+ if _hooks_dir not in sys.path:
39
+ sys.path.insert(0, _hooks_dir)
40
+
41
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
42
+ from hooks_constants.reviewer_spawn_gate_constants import ( # noqa: E402
43
+ ALL_COPILOT_TRIGGER_MARKERS,
44
+ AVAILABILITY_DOWN_EXIT_CODE,
45
+ AVAILABILITY_REVIEWER_FLAG,
46
+ AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME,
47
+ AVAILABILITY_SCRIPT_RELATIVE_PATH,
48
+ AVAILABILITY_SCRIPT_TIMEOUT_SECONDS,
49
+ BASH_TOOL_NAME,
50
+ BUGBOT_REVIEWER_LABEL,
51
+ BUGBOT_REVIEWER_TOKEN,
52
+ BUGBOT_RUN_BODY_PATTERN,
53
+ BUGBOT_TRIGGER_SCRIPT_MARKER,
54
+ COPILOT_REVIEWER_LABEL,
55
+ COPILOT_REVIEWER_TOKEN,
56
+ DENY_REASON_TEMPLATE,
57
+ GATE_SENTINEL_MARKER,
58
+ )
59
+
60
+
61
+ def _matches_copilot_trigger(command: str) -> bool:
62
+ return all(each_marker in command for each_marker in ALL_COPILOT_TRIGGER_MARKERS)
63
+
64
+
65
+ def _matches_bugbot_trigger(command: str) -> bool:
66
+ return BUGBOT_TRIGGER_SCRIPT_MARKER in command and bool(
67
+ BUGBOT_RUN_BODY_PATTERN.search(command)
68
+ )
69
+
70
+
71
+ def _reviewer_trigger(command: str) -> tuple[str, str] | None:
72
+ """Return the reviewer token and label a scoped command triggers.
73
+
74
+ Args:
75
+ command: The Bash command text carrying the gate's sentinel marker.
76
+
77
+ Returns:
78
+ The matched reviewer's CLI token paired with its display label, or
79
+ None when the command matches neither the Copilot review-request
80
+ shape nor the Bugbot rerun-comment shape.
81
+ """
82
+ if _matches_copilot_trigger(command):
83
+ return COPILOT_REVIEWER_TOKEN, COPILOT_REVIEWER_LABEL
84
+ if _matches_bugbot_trigger(command):
85
+ return BUGBOT_REVIEWER_TOKEN, BUGBOT_REVIEWER_LABEL
86
+ return None
87
+
88
+
89
+ def _resolve_availability_script_path() -> Path:
90
+ override_path = os.environ.get(AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, "")
91
+ if override_path:
92
+ return Path(override_path)
93
+ plugin_root = Path(__file__).resolve().parents[2]
94
+ return plugin_root / AVAILABILITY_SCRIPT_RELATIVE_PATH
95
+
96
+
97
+ def _probe_reviewer_availability(reviewer_token: str) -> tuple[bool, str]:
98
+ """Run the shared availability script for a reviewer and report the outcome.
99
+
100
+ Args:
101
+ reviewer_token: The reviewer's CLI token (copilot or bugbot) passed to
102
+ the availability script's --reviewer flag.
103
+
104
+ Returns:
105
+ A pair of (is_available, detail_text). is_available is False only
106
+ when the script exits with the documented reviewer-down code;
107
+ detail_text carries the script's combined stdout and stderr for the
108
+ deny message. A missing script, a timeout, an OS-level failure to
109
+ launch the script, or any exit code other than the documented down
110
+ code reports available (fail open), so an availability-check crash
111
+ never blocks a legitimate run.
112
+ """
113
+ script_path = _resolve_availability_script_path()
114
+ if not script_path.is_file():
115
+ return True, f"availability script not found at {script_path}"
116
+ try:
117
+ completed_process = subprocess.run(
118
+ [sys.executable, str(script_path), AVAILABILITY_REVIEWER_FLAG, reviewer_token],
119
+ capture_output=True,
120
+ text=True,
121
+ encoding="utf-8",
122
+ errors="replace",
123
+ timeout=AVAILABILITY_SCRIPT_TIMEOUT_SECONDS,
124
+ check=False,
125
+ )
126
+ except (OSError, subprocess.TimeoutExpired) as availability_error:
127
+ return True, f"availability script failed to run: {availability_error}"
128
+ availability_detail_text = (completed_process.stdout + completed_process.stderr).strip()
129
+ is_reviewer_down = completed_process.returncode == AVAILABILITY_DOWN_EXIT_CODE
130
+ return not is_reviewer_down, availability_detail_text
131
+
132
+
133
+ def main() -> None:
134
+ try:
135
+ hook_input = json.load(sys.stdin)
136
+ except json.JSONDecodeError:
137
+ sys.exit(0)
138
+ if not isinstance(hook_input, dict):
139
+ sys.exit(0)
140
+
141
+ tool_name = hook_input.get("tool_name", "")
142
+ if tool_name != BASH_TOOL_NAME:
143
+ sys.exit(0)
144
+
145
+ tool_input = hook_input.get("tool_input", {})
146
+ command = tool_input.get("command", "") if isinstance(tool_input, dict) else ""
147
+ if not command or GATE_SENTINEL_MARKER not in command:
148
+ sys.exit(0)
149
+
150
+ reviewer_trigger = _reviewer_trigger(command)
151
+ if reviewer_trigger is None:
152
+ sys.exit(0)
153
+ reviewer_token, reviewer_label = reviewer_trigger
154
+
155
+ is_available, availability_detail = _probe_reviewer_availability(reviewer_token)
156
+ if is_available:
157
+ sys.exit(0)
158
+
159
+ deny_reason = DENY_REASON_TEMPLATE.format(
160
+ reviewer_label=reviewer_label, availability_detail=availability_detail
161
+ )
162
+ deny_payload = {
163
+ "hookSpecificOutput": {
164
+ "hookEventName": "PreToolUse",
165
+ "permissionDecision": "deny",
166
+ "permissionDecisionReason": deny_reason,
167
+ }
168
+ }
169
+ log_hook_block(
170
+ calling_hook_name="reviewer_spawn_gate.py",
171
+ hook_event="PreToolUse",
172
+ block_reason=deny_reason,
173
+ tool_name=tool_name,
174
+ offending_input_preview=command,
175
+ )
176
+ print(json.dumps(deny_payload))
177
+ sys.stdout.flush()
178
+ sys.exit(0)
179
+
180
+
181
+ if __name__ == "__main__":
182
+ main()
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: blocks an Edit that rewrites a Python code line while keeping a contradicting comment above it.
3
+
4
+ Say a comment reads ``# Mock asyncio`` right above a line that patches
5
+ ``asyncio.sleep``. An Edit that rewrites just that line to patch something
6
+ else, but leaves the comment untouched, orphans the comment: it still names
7
+ ``asyncio``, but the code below no longer does. The same gap opens when the
8
+ Edit deletes the line outright instead of rewriting it. The hook locates
9
+ each occurrence of the edit's ``old_string`` in the file and reads the line
10
+ directly above it, denying the edit when that line is a standalone ``#``
11
+ comment naming an identifier ``old_string`` carries and ``new_string`` drops.
12
+ """
13
+
14
+ import json
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import TextIO
19
+
20
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
21
+ if _hooks_dir not in sys.path:
22
+ sys.path.insert(0, _hooks_dir)
23
+
24
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
25
+ from hooks_constants.pre_tool_use_dispatcher_constants import EDIT_TOOL_NAME # noqa: E402
26
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
27
+ from hooks_constants.stale_comment_reference_blocker_constants import ( # noqa: E402
28
+ ALL_COMMENT_STOPWORDS,
29
+ COMMENT_IDENTIFIER_PATTERN,
30
+ COMMENT_LINE_PREFIX,
31
+ PYTHON_FILE_SUFFIX,
32
+ STALE_COMMENT_ADDITIONAL_CONTEXT,
33
+ STALE_COMMENT_DENY_TEMPLATE,
34
+ STALE_COMMENT_SYSTEM_MESSAGE,
35
+ )
36
+
37
+
38
+ def _first_orphaned_identifier(
39
+ overlying_comment: str,
40
+ all_old_block_lines: list[str],
41
+ all_new_block_lines: list[str],
42
+ ) -> str | None:
43
+ """Return the first comment identifier the edit removes from the block.
44
+
45
+ Args:
46
+ overlying_comment: The stripped standalone comment text above the block.
47
+ all_old_block_lines: The replaced lines as they read before the edit.
48
+ all_new_block_lines: The replacement lines as they read after the edit.
49
+
50
+ Returns:
51
+ The first identifier the comment names that matches the old block and
52
+ not the new block, or None when the comment stays consistent.
53
+ """
54
+ words_in_comment = overlying_comment.lstrip(COMMENT_LINE_PREFIX).strip()
55
+ original_block_text = "\n".join(all_old_block_lines)
56
+ revised_block_text = "\n".join(all_new_block_lines)
57
+ for each_identifier in COMMENT_IDENTIFIER_PATTERN.findall(words_in_comment):
58
+ if each_identifier.lower() in ALL_COMMENT_STOPWORDS:
59
+ continue
60
+ bounded_pattern = re.compile(
61
+ "(?<![A-Za-z0-9_])" + re.escape(each_identifier) + "(?![A-Za-z0-9_])"
62
+ )
63
+ if bounded_pattern.search(original_block_text) and not bounded_pattern.search(
64
+ revised_block_text
65
+ ):
66
+ return each_identifier
67
+ return None
68
+
69
+
70
+ def _occurrence_start_offsets(
71
+ old_content: str,
72
+ old_string: str,
73
+ is_replace_all: bool,
74
+ ) -> list[int]:
75
+ """List the character offsets where old_string starts in old_content.
76
+
77
+ Walks old_content left to right the same way str.replace does, so the
78
+ offsets line up with the occurrences the edit actually rewrites.
79
+
80
+ Args:
81
+ old_content: The file text before the edit.
82
+ old_string: The text the edit replaces.
83
+ is_replace_all: Whether every occurrence is collected, matching the
84
+ Edit tool's replace_all flag, or only the first one.
85
+
86
+ Returns:
87
+ The offset of each matching occurrence, in file order.
88
+ """
89
+ all_offsets: list[int] = []
90
+ search_from = 0
91
+ while True:
92
+ found_at = old_content.find(old_string, search_from)
93
+ if found_at == -1:
94
+ return all_offsets
95
+ all_offsets.append(found_at)
96
+ if not is_replace_all:
97
+ return all_offsets
98
+ search_from = found_at + len(old_string)
99
+
100
+
101
+ def _preceding_line_text(old_content: str, occurrence_start: int) -> str | None:
102
+ """Return the stripped line directly above the line an occurrence sits in.
103
+
104
+ Args:
105
+ old_content: The file text before the edit.
106
+ occurrence_start: The character offset where the occurrence begins.
107
+
108
+ Returns:
109
+ The stripped text of the line above the occurrence's line, or None
110
+ when that line is the first line in the file.
111
+ """
112
+ containing_line_start = old_content.rfind("\n", 0, occurrence_start) + 1
113
+ if containing_line_start == 0:
114
+ return None
115
+ preceding_line_end = containing_line_start - 1
116
+ preceding_line_start = old_content.rfind("\n", 0, preceding_line_end) + 1
117
+ return old_content[preceding_line_start:preceding_line_end].strip()
118
+
119
+
120
+ def _find_stale_comment_reference(
121
+ old_content: str,
122
+ old_string: str,
123
+ new_string: str,
124
+ is_replace_all: bool,
125
+ file_path: str,
126
+ ) -> str | None:
127
+ """Check each occurrence old_string rewrites for an orphaned comment above it.
128
+
129
+ Args:
130
+ old_content: The file text before the edit.
131
+ old_string: The text the edit replaces.
132
+ new_string: The text the edit substitutes in, empty for a straight
133
+ deletion.
134
+ is_replace_all: Whether every occurrence of old_string is checked,
135
+ matching the Edit tool's replace_all flag.
136
+ file_path: The target path, named in the deny reason.
137
+
138
+ Returns:
139
+ The deny-reason text for the first occurrence whose kept comment
140
+ names an identifier old_string carries and new_string drops, or None
141
+ when every kept comment stays consistent with the rewritten line.
142
+ """
143
+ all_old_block_lines = old_string.splitlines()
144
+ all_new_block_lines = new_string.splitlines()
145
+ for each_occurrence_start in _occurrence_start_offsets(old_content, old_string, is_replace_all):
146
+ preceding_line = _preceding_line_text(old_content, each_occurrence_start)
147
+ if preceding_line is None or not preceding_line.startswith(COMMENT_LINE_PREFIX):
148
+ continue
149
+ maybe_identifier = _first_orphaned_identifier(
150
+ preceding_line, all_old_block_lines, all_new_block_lines
151
+ )
152
+ if maybe_identifier is not None:
153
+ return STALE_COMMENT_DENY_TEMPLATE.format(
154
+ file_path=file_path,
155
+ contradicted_comment=preceding_line,
156
+ orphaned_name=maybe_identifier,
157
+ )
158
+ return None
159
+
160
+
161
+ def evaluate(payload_by_key: dict[str, object]) -> str | None:
162
+ """Decide whether an Edit payload orphans a comment above a changed line.
163
+
164
+ Reads the target file from disk and checks the line directly above each
165
+ occurrence the edit rewrites for a kept standalone comment whose named
166
+ identifier the edit removes from the line below it. Non-Edit tools,
167
+ non-Python targets, unreadable files, and an old_string absent from the
168
+ file all pass.
169
+
170
+ Args:
171
+ payload_by_key: The PreToolUse payload with tool_name and tool_input.
172
+
173
+ Returns:
174
+ The deny-reason text when the edit is denied, or None when allowed.
175
+ """
176
+ raw_tool_name = payload_by_key.get("tool_name", "")
177
+ tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
178
+ if tool_name != EDIT_TOOL_NAME:
179
+ return None
180
+
181
+ raw_tool_input = payload_by_key.get("tool_input", {})
182
+ tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
183
+
184
+ file_path = tool_input.get("file_path", "")
185
+ if not isinstance(file_path, str) or not file_path.endswith(PYTHON_FILE_SUFFIX):
186
+ return None
187
+
188
+ raw_old_string = tool_input.get("old_string", "")
189
+ raw_new_string = tool_input.get("new_string", "")
190
+ old_string = raw_old_string if isinstance(raw_old_string, str) else ""
191
+ new_string = raw_new_string if isinstance(raw_new_string, str) else ""
192
+ if not old_string:
193
+ return None
194
+
195
+ try:
196
+ old_content = Path(file_path).read_text(encoding="utf-8")
197
+ except (OSError, UnicodeDecodeError):
198
+ return None
199
+ if old_string not in old_content:
200
+ return None
201
+
202
+ is_replace_all = tool_input.get("replace_all") is True
203
+ return _find_stale_comment_reference(
204
+ old_content, old_string, new_string, is_replace_all, file_path
205
+ )
206
+
207
+
208
+ def build_deny_payload(deny_reason: str) -> dict[str, object]:
209
+ """Build the full deny payload the hook writes for a deny-reason string.
210
+
211
+ Logs the block, then returns the permission decision with the corrective
212
+ guidance in additionalContext, the user-facing systemMessage, and output
213
+ suppression, matching the deny shape the sibling blockers write.
214
+
215
+ Args:
216
+ deny_reason: The permissionDecisionReason text for the denial.
217
+
218
+ Returns:
219
+ The deny payload dictionary the hook serializes to stdout.
220
+ """
221
+ log_hook_block(
222
+ calling_hook_name="stale_comment_reference_blocker.py",
223
+ hook_event="PreToolUse",
224
+ block_reason=deny_reason,
225
+ )
226
+ return {
227
+ "hookSpecificOutput": {
228
+ "hookEventName": "PreToolUse",
229
+ "permissionDecision": "deny",
230
+ "permissionDecisionReason": deny_reason,
231
+ "additionalContext": STALE_COMMENT_ADDITIONAL_CONTEXT,
232
+ },
233
+ "systemMessage": STALE_COMMENT_SYSTEM_MESSAGE,
234
+ "suppressOutput": True,
235
+ }
236
+
237
+
238
+ def main() -> None:
239
+ """Run the gate over the stdin payload dictionary and emit any denial.
240
+ """
241
+ payload_dictionary = read_hook_input_dictionary_from_stdin()
242
+ if payload_dictionary is None:
243
+ sys.exit(0)
244
+
245
+ deny_reason = evaluate(payload_dictionary)
246
+ if deny_reason is None:
247
+ sys.exit(0)
248
+
249
+ _emit_deny_line(build_deny_payload(deny_reason), sys.stdout)
250
+ sys.exit(0)
251
+
252
+
253
+ def _emit_deny_line(
254
+ all_deny_payload_fields: dict[str, object], destination_stream: TextIO
255
+ ) -> None:
256
+ """Write the deny payload JSON as one line to the given stream.
257
+
258
+ Args:
259
+ all_deny_payload_fields: The deny payload to serialize.
260
+ destination_stream: The stream the JSON line is written to.
261
+ """
262
+ destination_stream.write(json.dumps(all_deny_payload_fields) + "\n")
263
+ destination_stream.flush()
264
+
265
+
266
+ if __name__ == "__main__":
267
+ main()