claude-dev-env 2.32.0 → 2.33.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 (96) hide show
  1. package/.agents/agents/clean-coder.md +8 -7
  2. package/.agents/agents/code-quality-agent.md +4 -2
  3. package/.agents/agents/pr-description-writer.md +8 -3
  4. package/.agents/agents/reference/pr-description-illustrative-voice.md +6 -1
  5. package/.agents/agents/test_agent_frontmatter.py +0 -9
  6. package/.agents/agents/test_pr_description_writer_style.py +24 -1
  7. package/.agents/skills/e-simplify/SKILL.md +1 -15
  8. package/.agents/skills/grok-spawn/reference/worker-briefs.md +4 -0
  9. package/.agents/skills/pr-refinement/SKILL.md +2 -2
  10. package/.agents/skills/pr-shared-extraction/SKILL.md +2 -2
  11. package/.agents/skills/pr-title-description/SKILL.md +6 -2
  12. package/.agents/skills/review-tier/SKILL.md +1 -1
  13. package/.agents/skills/review-tier/reference/tier-policy.json +2 -2
  14. package/.agents/skills/review-tier/scripts/review_tier.py +31 -4
  15. package/.agents/skills/review-tier/scripts/test_generation.py +7 -19
  16. package/.agents/skills/review-tier/scripts/test_review_tier.py +11 -11
  17. package/AGENTS.md +83 -0
  18. package/_shared/pr-loop/code-rules-gate.md +5 -2
  19. package/_shared/pr-loop/scripts/code_rules_gate.py +7 -3
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +13 -1
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +7 -0
  22. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -1
  23. package/audit-rubrics/audit-categories.json +2 -2
  24. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -1
  25. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +5 -3
  26. package/audit-rubrics/category_rubrics/category-l-behavior-equivalence.md +6 -3
  27. package/audit-rubrics/prompts/category-e-dead-code.md +6 -6
  28. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +25 -19
  29. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +28 -25
  30. package/docs/CODE_RULES.md +7 -8
  31. package/hooks/blocking/code_rules_comments.py +118 -59
  32. package/hooks/blocking/code_rules_enforcer.py +19 -7
  33. package/hooks/blocking/code_rules_type_escape.py +16 -23
  34. package/hooks/blocking/javascript_comment_scanner.py +99 -0
  35. package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +24 -4
  36. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
  37. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +190 -3
  38. package/hooks/blocking/test_code_rules_enforcer_javascript_comments.py +44 -0
  39. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +0 -1
  40. package/hooks/git-hooks/pull_request_handoff.py +1 -1
  41. package/hooks/git-hooks/test_post_commit.py +8 -0
  42. package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +3 -4
  43. package/hooks/hooks_constants/test_unused_module_import_constants.py +9 -1
  44. package/hooks/hooks_constants/unused_module_import_constants.py +1 -1
  45. package/hooks/validators/comment_checks.py +2 -2
  46. package/hooks/validators/fast_save_validators.py +0 -9
  47. package/hooks/validators/git_checks.py +0 -84
  48. package/hooks/validators/run_all_validators.py +2 -9
  49. package/hooks/validators/test_fast_save_validators.py +2 -8
  50. package/hooks/validators/test_git_checks.py +0 -249
  51. package/hooks/validators/test_todo_checks.py +1 -0
  52. package/hooks/validators/todo_checks.py +1 -1
  53. package/package.json +1 -1
  54. package/rules/git-workflow.md +0 -8
  55. package/scripts/check.ps1 +35 -1
  56. package/scripts/codex_compat_materializer.py +1 -0
  57. package/scripts/tests/test_rules.py +111 -0
  58. package/system-prompts/software-engineer.xml +6 -19
  59. package/.agents/skills-archived/split-pr/SKILL.md +0 -85
  60. package/.agents/skills-archived/split-pr/reference/path-layers.md +0 -16
  61. package/.agents/skills-archived/split-pr/reference/proposal-format.md +0 -15
  62. package/.agents/skills-archived/split-pr/reference/split-further-loop.md +0 -10
  63. package/.agents/skills-archived/split-pr/reference/splitting-principles.md +0 -26
  64. package/.agents/skills-archived/split-pr/scripts/analyze_pr.py +0 -279
  65. package/.agents/skills-archived/split-pr/scripts/categorize_files.py +0 -106
  66. package/.agents/skills-archived/split-pr/scripts/config/__init__.py +0 -1
  67. package/.agents/skills-archived/split-pr/scripts/config/dependency_constants.py +0 -14
  68. package/.agents/skills-archived/split-pr/scripts/config/git_operations_constants.py +0 -36
  69. package/.agents/skills-archived/split-pr/scripts/config/packing_constants.py +0 -61
  70. package/.agents/skills-archived/split-pr/scripts/config/plan_constants.py +0 -49
  71. package/.agents/skills-archived/split-pr/scripts/config/split_pr_constants.py +0 -110
  72. package/.agents/skills-archived/split-pr/scripts/execute_split_slices.py +0 -82
  73. package/.agents/skills-archived/split-pr/scripts/pack_files_into_slices.py +0 -212
  74. package/.agents/skills-archived/split-pr/scripts/split_pr_dependency_graph.py +0 -70
  75. package/.agents/skills-archived/split-pr/scripts/split_pr_git_operations.py +0 -184
  76. package/.agents/skills-archived/split-pr/scripts/split_pr_layer_order.py +0 -58
  77. package/.agents/skills-archived/split-pr/scripts/split_pr_paginate.py +0 -119
  78. package/.agents/skills-archived/split-pr/scripts/split_pr_process_runner.py +0 -52
  79. package/.agents/skills-archived/split-pr/scripts/split_pr_script_types.py +0 -126
  80. package/.agents/skills-archived/split-pr/scripts/split_pr_title.py +0 -41
  81. package/.agents/skills-archived/split-pr/scripts/test_analyze_pr.py +0 -228
  82. package/.agents/skills-archived/split-pr/scripts/test_categorize_files.py +0 -55
  83. package/.agents/skills-archived/split-pr/scripts/test_categorize_files_packing.py +0 -59
  84. package/.agents/skills-archived/split-pr/scripts/test_execute_split_slices.py +0 -99
  85. package/.agents/skills-archived/split-pr/scripts/test_split_pr_dependency_graph.py +0 -47
  86. package/.agents/skills-archived/split-pr/scripts/test_split_pr_git_operations.py +0 -125
  87. package/.agents/skills-archived/split-pr/scripts/test_split_pr_layer_order.py +0 -36
  88. package/.agents/skills-archived/split-pr/scripts/test_split_pr_paginate.py +0 -65
  89. package/.agents/skills-archived/split-pr/scripts/test_split_pr_script_types.py +0 -73
  90. package/.agents/skills-archived/split-pr/scripts/test_split_pr_title.py +0 -28
  91. package/.agents/skills-archived/split-pr/scripts/test_verify_dependency_graph.py +0 -46
  92. package/.agents/skills-archived/split-pr/scripts/test_verify_plan.py +0 -56
  93. package/.agents/skills-archived/split-pr/scripts/test_verify_plan_contract.py +0 -50
  94. package/.agents/skills-archived/split-pr/scripts/test_verify_plan_path_normalization.py +0 -45
  95. package/.agents/skills-archived/split-pr/scripts/verify_dependency_graph.py +0 -111
  96. package/.agents/skills-archived/split-pr/scripts/verify_plan.py +0 -139
@@ -1,10 +1,14 @@
1
1
  """Comment-presence and comment-change checks for Python and JavaScript sources."""
2
2
 
3
+ import difflib
4
+ import importlib
3
5
  import io
4
6
  import sys
5
7
  import tokenize
8
+ from collections import Counter
6
9
  from collections.abc import Iterator
7
10
  from pathlib import Path
11
+ from typing import cast
8
12
 
9
13
  _blocking_directory = str(Path(__file__).resolve().parent)
10
14
  _hooks_directory = str(Path(__file__).resolve().parent.parent)
@@ -20,7 +24,6 @@ from code_rules_shared import ( # noqa: E402
20
24
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
21
25
  ALL_FREE_FORM_EXEMPT_COMMENT_BODIES,
22
26
  ALL_JAVASCRIPT_EXEMPT_COMMENT_PREFIXES,
23
- ALL_JAVASCRIPT_EXEMPT_INLINE_COMMENT_PREFIXES,
24
27
  ALL_JAVASCRIPT_EXTENSIONS,
25
28
  ALL_PYTHON_EXTENSIONS,
26
29
  ALL_PYTHON_TOKENIZE_FAILURE_EXCEPTIONS,
@@ -29,6 +32,10 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
29
32
  CHAINED_INLINE_COMMENT_PATTERN,
30
33
  MAX_COMMENT_ISSUES,
31
34
  )
35
+ _javascript_comment_scanner = importlib.import_module("javascript_comment_scanner")
36
+ extract_javascript_comment_occurrences = (
37
+ _javascript_comment_scanner.extract_javascript_comment_occurrences
38
+ )
32
39
 
33
40
 
34
41
  def check_comments_python(content: str) -> list[str]:
@@ -93,7 +100,9 @@ def check_comments_javascript(content: str) -> list[str]:
93
100
  return issues
94
101
 
95
102
 
96
- def extract_comment_texts(content: str, file_path: str) -> tuple[set[str], set[str]]:
103
+ def extract_comment_texts(
104
+ content: str, file_path: str, include_directive_comments: bool = False
105
+ ) -> tuple[set[str], set[str]]:
97
106
  """Extract normalized comment text strings from content for comparison.
98
107
 
99
108
  Returns:
@@ -108,46 +117,58 @@ def extract_comment_texts(content: str, file_path: str) -> tuple[set[str], set[s
108
117
  return inline_comments, standalone_comments
109
118
 
110
119
  if extension in ALL_PYTHON_EXTENSIONS:
111
- inline_comments, standalone_comments, _ = _extract_python_comment_sets(content)
120
+ inline_comments, standalone_comments, _ = _extract_python_comment_sets(
121
+ content, include_directive_comments
122
+ )
112
123
  return inline_comments, standalone_comments
113
124
 
114
- lines = content.split("\n")
115
-
116
125
  if extension in ALL_JAVASCRIPT_EXTENSIONS:
117
- is_in_multiline = False
118
- for each_line in lines:
119
- stripped = each_line.strip()
120
- if not stripped:
121
- continue
122
- if is_in_multiline:
123
- if "*/" in stripped:
124
- is_in_multiline = False
125
- continue
126
- if stripped.startswith("/*"):
127
- is_in_multiline = "*/" not in stripped
128
- if not stripped.startswith("/**"):
129
- standalone_comments.add(stripped)
130
- continue
131
- if stripped.startswith("//"):
132
- if not stripped.startswith(ALL_JAVASCRIPT_EXEMPT_COMMENT_PREFIXES):
133
- standalone_comments.add(stripped)
134
- elif "//" in each_line:
135
- before_slash = each_line[:each_line.index("//")]
136
- if before_slash.strip():
137
- comment_start = stripped.index("//")
138
- comment_text = stripped[comment_start + 2 :].strip()
139
- if not comment_text.startswith(ALL_JAVASCRIPT_EXEMPT_INLINE_COMMENT_PREFIXES):
140
- inline_comments.add(stripped[comment_start:])
126
+ all_occurrences: list[tuple[str, int, bool]] = cast(
127
+ list[tuple[str, int, bool]],
128
+ extract_javascript_comment_occurrences(content, include_directive_comments),
129
+ )
130
+ inline_comments = {
131
+ each_text
132
+ for each_text, _each_line_number, is_inline in all_occurrences
133
+ if is_inline
134
+ }
135
+ standalone_comments = {
136
+ each_text
137
+ for each_text, _each_line_number, is_inline in all_occurrences
138
+ if not is_inline
139
+ }
141
140
 
142
141
  return inline_comments, standalone_comments
143
142
 
144
143
 
144
+ def _python_comment_occurrences(
145
+ content: str,
146
+ ) -> tuple[list[tuple[str, int, bool]], bool]:
147
+ """Return Python comments with exact lines and a tokenize status."""
148
+ source_lines = content.split("\n")
149
+ try:
150
+ all_tokens = list(_python_tokens(content))
151
+ except ALL_PYTHON_TOKENIZE_FAILURE_EXCEPTIONS:
152
+ return [], False
153
+ all_occurrences: list[tuple[str, int, bool]] = []
154
+ for each_token in all_tokens:
155
+ if each_token.type != tokenize.COMMENT:
156
+ continue
157
+ if each_token.string.startswith("#!") and each_token.start == (1, 0):
158
+ continue
159
+ source_line = source_lines[each_token.start[0] - 1]
160
+ is_inline = bool(source_line[: each_token.start[1]].strip())
161
+ all_occurrences.append(
162
+ (each_token.string.strip(), each_token.start[0], is_inline)
163
+ )
164
+ return all_occurrences, True
165
+
166
+
145
167
  def check_comment_changes(old_content: str, new_content: str, file_path: str) -> list[str]:
146
168
  """Check for comment additions or removals between old and new content.
147
169
 
148
- Inline comments (after code on same line): BLOCK when added.
149
- Standalone comment lines: NUDGE (print advisory) when added.
150
- Existing comments being removed: NUDGE (print advisory) — comment preservation is advisory.
170
+ Inline and standalone comments are blocking findings when added.
171
+ Existing comments can be removed when the touched code no longer needs them.
151
172
 
152
173
  When the file is Python and either *old_content* or *new_content* cannot
153
174
  be tokenized (common for mid-edit Edit fragments), the comparison is
@@ -161,38 +182,71 @@ def check_comment_changes(old_content: str, new_content: str, file_path: str) ->
161
182
 
162
183
  extension = get_file_extension(file_path)
163
184
  if extension in ALL_PYTHON_EXTENSIONS:
164
- old_inline, old_standalone, old_tokenize_ok = _extract_python_comment_sets(old_content)
165
- new_inline, new_standalone, new_tokenize_ok = _extract_python_comment_sets(new_content)
185
+ old_occurrences, old_tokenize_ok = _python_comment_occurrences(old_content)
186
+ new_occurrences, new_tokenize_ok = _python_comment_occurrences(new_content)
166
187
  if not (old_tokenize_ok and new_tokenize_ok):
167
188
  return issues
168
189
  else:
169
- old_inline, old_standalone = extract_comment_texts(old_content, file_path)
170
- new_inline, new_standalone = extract_comment_texts(new_content, file_path)
171
-
172
- added_inline = new_inline - old_inline
173
- if added_inline:
174
- sample = next(iter(added_inline))
175
- issues.append(f"Inline comment added: {sample[:60]} - refactor to self-documenting code")
176
-
177
- added_standalone = new_standalone - old_standalone
178
- if added_standalone:
179
- sample = next(iter(added_standalone))
180
- print(f"[CODE_RULES advisory] Standalone comment added: {sample[:60]} - prefer self-documenting code", file=sys.stderr)
181
-
182
- all_old = old_inline | old_standalone
183
- all_new = new_inline | new_standalone
184
- removed_comments = all_old - all_new
185
- if removed_comments:
186
- old_line_count = len([line for line in old_content.split("\n") if line.strip()])
187
- new_line_count = len([line for line in new_content.split("\n") if line.strip()])
188
- code_was_removed = new_line_count < old_line_count - len(removed_comments)
189
- if not code_was_removed:
190
- sample = next(iter(removed_comments))
191
- print(f"[CODE_RULES advisory] Existing comment removed: {sample[:60]} - comment preservation is advisory", file=sys.stderr)
190
+ old_occurrences = extract_javascript_comment_occurrences(old_content, True)
191
+ new_occurrences = extract_javascript_comment_occurrences(new_content, True)
192
+
193
+ old_occurrence_counts = Counter(
194
+ (each_text, is_inline)
195
+ for each_text, _line_number, is_inline in old_occurrences
196
+ )
197
+ seen_occurrence_counts: Counter[tuple[str, bool]] = Counter()
198
+ for each_text, each_line_number, each_is_inline in new_occurrences:
199
+ occurrence_key = (each_text, each_is_inline)
200
+ if seen_occurrence_counts[occurrence_key] >= old_occurrence_counts[occurrence_key]:
201
+ comment_kind = "Inline" if each_is_inline else "Standalone"
202
+ issues.append(
203
+ f"Line {each_line_number}: {comment_kind} comment added: "
204
+ f"{each_text[:60]} - refactor to self-documenting code"
205
+ )
206
+ seen_occurrence_counts[occurrence_key] += 1
207
+ if len(issues) >= MAX_COMMENT_ISSUES:
208
+ break
209
+ issues.extend(_retained_comment_issues(old_content, new_content, file_path))
192
210
 
193
211
  return issues
194
212
 
195
213
 
214
+ def _changed_line_numbers(old_content: str, new_content: str) -> set[int]:
215
+ matcher = difflib.SequenceMatcher(
216
+ None, old_content.splitlines(), new_content.splitlines(), autojunk=False
217
+ )
218
+ return {
219
+ each_line_number
220
+ for each_tag, _, _, each_new_start, each_new_end in matcher.get_opcodes()
221
+ if each_tag != "equal"
222
+ for each_line_number in range(each_new_start + 1, each_new_end + 1)
223
+ }
224
+
225
+
226
+ def _retained_comment_issues(
227
+ old_content: str, new_content: str, file_path: str
228
+ ) -> list[str]:
229
+ old_inline, old_standalone = extract_comment_texts(old_content, file_path, True)
230
+ new_inline, new_standalone = extract_comment_texts(new_content, file_path, True)
231
+ shared_inline = set(Counter(old_inline) & Counter(new_inline))
232
+ shared_standalone = old_standalone & new_standalone
233
+ if not shared_inline and not shared_standalone:
234
+ return []
235
+ changed_lines = _changed_line_numbers(old_content, new_content)
236
+ new_lines = new_content.splitlines()
237
+ issues: list[str] = []
238
+ for each_line_number in sorted(changed_lines):
239
+ inline_comment = next((each_comment for each_comment in shared_inline if each_comment in new_lines[each_line_number - 1]), None)
240
+ if inline_comment is not None:
241
+ issues.append(f"Line {each_line_number}: Inline comment retained on changed code: {inline_comment} - remove the comment")
242
+ preceding_line_number = each_line_number - 1
243
+ if preceding_line_number > 0 and new_lines[preceding_line_number - 1].strip() in shared_standalone:
244
+ issues.append(f"Line {each_line_number}: Standalone comment retained on changed code at line {preceding_line_number}: {new_lines[preceding_line_number - 1].strip()} - remove the comment")
245
+ if len(issues) >= MAX_COMMENT_ISSUES:
246
+ break
247
+ return issues[:MAX_COMMENT_ISSUES]
248
+
249
+
196
250
  def _python_tokens(source: str) -> Iterator[tokenize.TokenInfo]:
197
251
  """Yield Python tokens from *source* one at a time.
198
252
 
@@ -304,7 +358,9 @@ def _starts_with_bounded_token_anchored_directive(directive_body: str) -> bool:
304
358
  return False
305
359
 
306
360
 
307
- def _extract_python_comment_sets(content: str) -> tuple[set[str], set[str], bool]:
361
+ def _extract_python_comment_sets(
362
+ content: str, include_directive_comments: bool = False
363
+ ) -> tuple[set[str], set[str], bool]:
308
364
  """Return (inline_comments, standalone_comments, tokenize_succeeded).
309
365
 
310
366
  Streams *content* once via ``_python_tokens``. A tokenize failure
@@ -321,7 +377,10 @@ def _extract_python_comment_sets(content: str) -> tuple[set[str], set[str], bool
321
377
  for each_token in _python_tokens(content):
322
378
  if each_token.type != tokenize.COMMENT:
323
379
  continue
324
- if _is_exempt_python_comment(each_token):
380
+ is_shebang = each_token.string.startswith("#!") and each_token.start == (1, 0)
381
+ if is_shebang or (
382
+ not include_directive_comments and _is_exempt_python_comment(each_token)
383
+ ):
325
384
  continue
326
385
  line_number = each_token.start[0]
327
386
  column_offset = each_token.start[1]
@@ -196,6 +196,8 @@ from hooks_constants.subprocess_budget_completeness_content import ( # noqa: E4
196
196
  existing_file_content,
197
197
  )
198
198
 
199
+ __all__ = ("is_test_file",)
200
+
199
201
 
200
202
  def _multiedit_post_edit_view(
201
203
  file_path: str, all_tool_input: dict[str, object]
@@ -234,6 +236,7 @@ class _ValidationContext(NamedTuple):
234
236
  defer_scope_to_caller: bool
235
237
  sibling_directory: Path | None
236
238
  phase: str
239
+ include_comment_policy: bool = True
237
240
 
238
241
 
239
242
  def _validated_phase(phase: str) -> str:
@@ -325,7 +328,7 @@ def _python_comment_and_logging_issues(context: _ValidationContext) -> list[str]
325
328
  context.file_path,
326
329
  )
327
330
  all_issues: list[str] = []
328
- if not is_test_file(file_path):
331
+ if context.phase == FULL_GATE_PHASE and context.include_comment_policy:
329
332
  all_issues.extend(check_comment_changes(old_content, content, file_path))
330
333
  all_issues.extend(check_imports_at_top(content))
331
334
  all_issues.extend(check_logging_fstrings(content))
@@ -366,7 +369,15 @@ def _python_duplicate_body_and_banned_issues(context: _ValidationContext) -> lis
366
369
  all_issues = check_same_file_inline_duplicate_body(
367
370
  effective_content, file_path, changed, defer
368
371
  )
369
- all_issues.extend(check_type_escape_hatches(effective_content, file_path))
372
+ all_issues.extend(
373
+ check_type_escape_hatches(
374
+ effective_content,
375
+ file_path,
376
+ include_type_ignore_comments=(
377
+ context.phase == FULL_GATE_PHASE and context.include_comment_policy
378
+ ),
379
+ )
380
+ )
370
381
  all_issues.extend(
371
382
  check_banned_identifiers(effective_content, file_path, changed, defer)
372
383
  )
@@ -576,7 +587,7 @@ def _javascript_comment_and_naming_issues(context: _ValidationContext) -> list[s
576
587
  context.file_path,
577
588
  )
578
589
  all_issues: list[str] = []
579
- if not is_test_file(file_path):
590
+ if context.phase == FULL_GATE_PHASE and context.include_comment_policy:
580
591
  all_issues.extend(check_comment_changes(old_content, content, file_path))
581
592
  all_issues.extend(check_e2e_test_naming(content, file_path))
582
593
  all_issues.extend(
@@ -642,6 +653,7 @@ def validate_content_for_phase(
642
653
  sibling_directory: Path | None = None,
643
654
  *,
644
655
  phase: str,
656
+ include_comment_policy: bool = True,
645
657
  ) -> list[str]:
646
658
  """Run all applicable validators on content for one named validation phase."""
647
659
  validated_phase = _validated_phase(phase)
@@ -649,10 +661,7 @@ def validate_content_for_phase(
649
661
  effective_content, all_changed_lines = _effective_content_and_changed_lines(
650
662
  content, full_file_content, prior_full_file_content
651
663
  )
652
- context = _ValidationContext(
653
- content, old_content, effective_content, file_path, all_changed_lines,
654
- defer_scope_to_caller, sibling_directory, validated_phase,
655
- )
664
+ context = _ValidationContext(content, old_content, effective_content, file_path, all_changed_lines, defer_scope_to_caller, sibling_directory, validated_phase, include_comment_policy)
656
665
  all_issues: list[str] = []
657
666
  if extension in ALL_PYTHON_EXTENSIONS:
658
667
  all_issues = _python_extension_issues(context)
@@ -693,6 +702,8 @@ def validate_content_for_full_gate(
693
702
  prior_full_file_content: str = "",
694
703
  defer_scope_to_caller: bool = False,
695
704
  sibling_directory: Path | None = None,
705
+ *,
706
+ include_comment_policy: bool = True,
696
707
  ) -> list[str]:
697
708
  """Run the full-gate phase, including the six checks that read sibling files."""
698
709
  return validate_content_for_phase(
@@ -704,6 +715,7 @@ def validate_content_for_full_gate(
704
715
  defer_scope_to_caller,
705
716
  sibling_directory,
706
717
  phase=FULL_GATE_PHASE,
718
+ include_comment_policy=include_comment_policy,
707
719
  )
708
720
 
709
721
 
@@ -79,23 +79,13 @@ def _find_any_annotation_lines(source: str) -> list[int]:
79
79
  return offending_line_numbers
80
80
 
81
81
 
82
- def _find_unjustified_type_ignore_lines(source: str) -> list[int]:
83
- """Return line numbers of # type: ignore comments lacking a trailing reason."""
82
+ def _find_type_ignore_lines(source: str) -> list[int]:
83
+ """Return line numbers of all type-ignore comments."""
84
84
  ignore_pattern = re.compile(r"#\s*type:\s*ignore(?:\[[^\]]*\])?(.*)$")
85
- minimum_justification_characters = len("xxxxx")
86
85
  offending_line_numbers: list[int] = []
87
86
  for each_comment_token in _comment_tokens(source):
88
- matched = ignore_pattern.search(each_comment_token.string)
89
- if not matched:
90
- continue
91
- line_number = each_comment_token.start[0]
92
- trailing_text = matched.group(1).strip()
93
- if not trailing_text.startswith("#"):
94
- offending_line_numbers.append(line_number)
95
- continue
96
- justification_text = trailing_text.lstrip("#").strip()
97
- if len(justification_text) < minimum_justification_characters:
98
- offending_line_numbers.append(line_number)
87
+ if ignore_pattern.search(each_comment_token.string):
88
+ offending_line_numbers.append(each_comment_token.start[0])
99
89
  return offending_line_numbers
100
90
 
101
91
 
@@ -625,12 +615,20 @@ def _find_object_annotated_parameter_lines(source: str) -> list[tuple[int, str]]
625
615
  return offending_parameters
626
616
 
627
617
 
628
- def check_type_escape_hatches(content: str, file_path: str) -> list[str]:
629
- """Flag Any annotations, Any imports, cast() calls, object-typed dereferenced parameters, and unjustified # type: ignore."""
618
+ def check_type_escape_hatches(
619
+ content: str, file_path: str, include_type_ignore_comments: bool = True
620
+ ) -> list[str]:
621
+ """Flag type escape hatches and every type-ignore comment."""
622
+ issues: list[str] = []
623
+ type_ignore_issues: list[str] = []
624
+ if include_type_ignore_comments:
625
+ for each_ignore_line in _find_type_ignore_lines(content):
626
+ type_ignore_issues.append(
627
+ f"Line {each_ignore_line}: # type: ignore is not allowed - remove it and use a typed boundary or real type"
628
+ )
630
629
  if is_test_file(file_path) or is_hook_infrastructure(file_path):
631
- return []
630
+ return type_ignore_issues
632
631
 
633
- issues: list[str] = []
634
632
  is_any_exempt = _file_path_matches_any_exemption(file_path)
635
633
 
636
634
  if not is_any_exempt:
@@ -670,11 +668,6 @@ def check_type_escape_hatches(content: str, file_path: str) -> list[str]:
670
668
  )
671
669
  issues.extend(object_parameter_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
672
670
 
673
- type_ignore_issues: list[str] = []
674
- for each_ignore_line in _find_unjustified_type_ignore_lines(content):
675
- type_ignore_issues.append(
676
- f"Line {each_ignore_line}: Unjustified # type: ignore - add trailing '# reason' explaining why"
677
- )
678
671
  issues.extend(type_ignore_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
679
672
 
680
673
  return issues
@@ -0,0 +1,99 @@
1
+ """Extract JavaScript comments without matching comment-like strings."""
2
+
3
+ import io
4
+ import re
5
+ import tokenize
6
+ from typing import NamedTuple
7
+
8
+ from hooks_constants.code_rules_enforcer_constants import (
9
+ ALL_JAVASCRIPT_EXEMPT_COMMENT_PREFIXES,
10
+ ALL_JAVASCRIPT_EXEMPT_INLINE_COMMENT_PREFIXES,
11
+ ALL_PYTHON_TOKENIZE_FAILURE_EXCEPTIONS,
12
+ )
13
+
14
+
15
+ class JavaScriptCommentOccurrence(NamedTuple):
16
+ """One JavaScript comment with its source line and placement."""
17
+
18
+ text: str
19
+ line_number: int
20
+ is_inline: bool
21
+
22
+
23
+ def _mask_javascript_strings(content: str) -> str:
24
+ """Replace quoted JavaScript strings while preserving source positions."""
25
+ string_pattern = r'''("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)'''
26
+ return re.sub(
27
+ string_pattern,
28
+ lambda each_match: "".join(
29
+ "\n" if each_character == "\n" else " "
30
+ for each_character in each_match.group()
31
+ ),
32
+ content,
33
+ flags=re.DOTALL,
34
+ )
35
+
36
+
37
+ def _is_allowed_comment(
38
+ comment_text: str, is_inline: bool, include_directive_comments: bool
39
+ ) -> bool:
40
+ """Return whether a comment is excluded from the policy comparison."""
41
+ if comment_text.startswith("/**"):
42
+ return True
43
+ if include_directive_comments:
44
+ return False
45
+ prefixes = (
46
+ ALL_JAVASCRIPT_EXEMPT_INLINE_COMMENT_PREFIXES
47
+ if is_inline
48
+ else ALL_JAVASCRIPT_EXEMPT_COMMENT_PREFIXES
49
+ )
50
+ comment_body = (
51
+ comment_text.removeprefix("//").lstrip()
52
+ if comment_text.startswith("//")
53
+ else comment_text
54
+ )
55
+ return comment_body.startswith(prefixes)
56
+
57
+
58
+ def extract_javascript_comment_occurrences(
59
+ content: str, include_directive_comments: bool
60
+ ) -> list[JavaScriptCommentOccurrence]:
61
+ """Return real JavaScript comment occurrences with exact line numbers."""
62
+ masked_content = _mask_javascript_strings(content)
63
+ all_occurrences: list[JavaScriptCommentOccurrence] = []
64
+ for each_match in re.finditer(r"//[^\n]*|/\*[\s\S]*?\*/", masked_content):
65
+ comment_text = content[each_match.start() : each_match.end()].strip()
66
+ line_start = masked_content.rfind("\n", 0, each_match.start()) + 1
67
+ is_inline = bool(masked_content[line_start : each_match.start()].strip())
68
+ if _is_allowed_comment(comment_text, is_inline, include_directive_comments):
69
+ continue
70
+ line_number = content.count("\n", 0, each_match.start()) + 1
71
+ all_occurrences.append(
72
+ JavaScriptCommentOccurrence(comment_text, line_number, is_inline)
73
+ )
74
+ return all_occurrences
75
+
76
+
77
+ def extract_python_comment_occurrences(
78
+ content: str,
79
+ ) -> tuple[list[JavaScriptCommentOccurrence], bool]:
80
+ """Return Python comments with exact lines and a tokenize status."""
81
+ source_lines = content.split("\n")
82
+ try:
83
+ all_tokens = list(tokenize.generate_tokens(io.StringIO(content).readline))
84
+ except ALL_PYTHON_TOKENIZE_FAILURE_EXCEPTIONS:
85
+ return [], False
86
+ all_occurrences: list[JavaScriptCommentOccurrence] = []
87
+ for each_token in all_tokens:
88
+ if each_token.type != tokenize.COMMENT:
89
+ continue
90
+ if each_token.string.startswith("#!") and each_token.start == (1, 0):
91
+ continue
92
+ source_line = source_lines[each_token.start[0] - 1]
93
+ is_inline = bool(source_line[: each_token.start[1]].strip())
94
+ all_occurrences.append(
95
+ JavaScriptCommentOccurrence(
96
+ each_token.string.strip(), each_token.start[0], is_inline
97
+ )
98
+ )
99
+ return all_occurrences, True
@@ -56,6 +56,7 @@ def test_should_flag_bare_type_ignore() -> None:
56
56
  source = "x = 1 # type: ignore\n"
57
57
  issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
58
58
  assert any("type: ignore" in issue for issue in issues)
59
+ assert any("remove it and use a typed boundary or real type" in issue for issue in issues)
59
60
 
60
61
 
61
62
  def test_should_flag_coded_type_ignore_without_justification() -> None:
@@ -64,16 +65,35 @@ def test_should_flag_coded_type_ignore_without_justification() -> None:
64
65
  assert any("type: ignore" in issue for issue in issues)
65
66
 
66
67
 
67
- def test_should_allow_justified_type_ignore() -> None:
68
+ def test_should_flag_justified_type_ignore() -> None:
68
69
  source = "x = 1 # type: ignore[misc] # stubs missing in foo library\n"
69
70
  issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
70
- assert not any("type: ignore" in issue for issue in issues)
71
+ assert any("type: ignore" in issue for issue in issues)
72
+
73
+
74
+ def test_should_flag_justified_type_ignore_in_test_files() -> None:
75
+ source = "x = 1 # type: ignore[misc] # stubs missing in foo library\n"
76
+ issues = check_type_escape_hatches(source, TEST_FILE_PATH)
77
+ assert any("type: ignore" in issue for issue in issues)
71
78
 
72
79
 
73
- def test_should_skip_test_files() -> None:
80
+ def test_should_flag_type_ignore_in_test_files() -> None:
74
81
  source = "def foo(x: Any) -> Any:\n y: Any = 1 # type: ignore\n return y\n"
75
82
  issues = check_type_escape_hatches(source, TEST_FILE_PATH)
76
- assert issues == []
83
+ assert any("type: ignore" in issue for issue in issues)
84
+ assert not any("Any annotation" in issue for issue in issues)
85
+
86
+
87
+ def test_edit_lane_does_not_report_type_ignore_comment_policy() -> None:
88
+ source = "total = 1 # type: ignore[misc]\n"
89
+ issues = hook_module.validate_content_for_edit_lane(source, TEST_FILE_PATH)
90
+ assert not any("type: ignore" in issue for issue in issues)
91
+
92
+
93
+ def test_edit_lane_does_not_report_production_type_ignore_comment_policy() -> None:
94
+ source = "total = 1 # type: ignore[misc]\n"
95
+ issues = hook_module.validate_content_for_edit_lane(source, PRODUCTION_FILE_PATH)
96
+ assert not any("type: ignore" in issue for issue in issues)
77
97
 
78
98
 
79
99
  def test_should_flag_any_on_positional_only_parameter() -> None:
@@ -68,7 +68,6 @@ KNOWN_UNCAPPED_CHECKS_PENDING_REVIEW: frozenset[str] = frozenset(
68
68
  {
69
69
  "check_boolean_naming",
70
70
  "check_collection_prefix",
71
- "check_comment_changes",
72
71
  "check_constant_equality_tests",
73
72
  "check_constants_outside_config",
74
73
  "check_existence_check_tests",