claude-dev-env 1.75.0 → 1.77.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 (47) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate.py +60 -5
  2. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/inline_duplicate_body_span_constants.py +22 -0
  4. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +147 -3
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
  6. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
  7. package/docs/CODE_RULES.md +1 -1
  8. package/hooks/blocking/CLAUDE.md +2 -2
  9. package/hooks/blocking/claude_md_orphan_file_blocker.py +170 -20
  10. package/hooks/blocking/code_rules_docstrings.py +378 -0
  11. package/hooks/blocking/code_rules_duplicate_body.py +378 -26
  12. package/hooks/blocking/code_rules_enforcer.py +48 -5
  13. package/hooks/blocking/code_rules_imports_logging.py +679 -1
  14. package/hooks/blocking/code_rules_shared.py +8 -5
  15. package/hooks/blocking/code_rules_test_assertions.py +6 -7
  16. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +484 -0
  17. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  18. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +174 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
  21. package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +157 -0
  22. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +466 -0
  23. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +11 -9
  24. package/hooks/blocking/test_code_rules_js_resume_task_enumeration.py +758 -0
  25. package/hooks/blocking/test_code_rules_logging_printf_tokens.py +134 -0
  26. package/hooks/blocking/test_verification_verdict_store.py +66 -1
  27. package/hooks/blocking/test_verifier_verdict_minter.py +64 -5
  28. package/hooks/blocking/verification_verdict_store.py +19 -5
  29. package/hooks/blocking/verifier_verdict_minter.py +18 -15
  30. package/hooks/hooks_constants/blocking_check_limits.py +56 -0
  31. package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +52 -24
  32. package/hooks/hooks_constants/code_rules_enforcer_constants.py +76 -1
  33. package/hooks/hooks_constants/duplicate_function_body_constants.py +21 -5
  34. package/package.json +1 -1
  35. package/rules/CLAUDE.md +1 -0
  36. package/rules/claude-md-orphan-file.md +7 -8
  37. package/rules/docstring-prose-matches-implementation.md +5 -1
  38. package/rules/package-inventory-stale-entry.md +16 -0
  39. package/rules/plain-illustrative-docstrings.md +56 -0
  40. package/skills/anthropic-plan/CLAUDE.md +1 -1
  41. package/skills/anthropic-plan/SKILL.md +15 -2
  42. package/skills/autoconverge/workflow/converge.contract.test.mjs +12 -19
  43. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +71 -0
  44. package/skills/autoconverge/workflow/converge.mjs +86 -110
  45. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +58 -4
  46. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +9 -0
  47. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +42 -0
@@ -1,7 +1,9 @@
1
- """Imports-at-top, logging f-string, win32gui None, E2E spec naming, file-length advisory, and library-print checks."""
1
+ """Imports-at-top, import-block-sorted, logging f-string, win32gui None, E2E spec naming, JS resume-task enumeration coverage, file-length advisory, and library-print checks."""
2
2
 
3
3
  import ast
4
+ import json
4
5
  import re
6
+ import subprocess
5
7
  import sys
6
8
  from pathlib import Path
7
9
 
@@ -16,6 +18,7 @@ from code_rules_path_utils import ( # noqa: E402
16
18
  is_config_file,
17
19
  )
18
20
  from code_rules_shared import ( # noqa: E402
21
+ _scope_violations_to_changed_lines,
19
22
  get_file_extension,
20
23
  is_hook_infrastructure,
21
24
  is_spec_file,
@@ -23,18 +26,48 @@ from code_rules_shared import ( # noqa: E402
23
26
  )
24
27
 
25
28
  from hooks_constants.blocking_check_limits import ( # noqa: E402
29
+ ALL_FORMAT_LOGGER_FUNCTION_NAMES,
30
+ ALL_IMPORT_BLOCK_SORT_RUFF_COMMAND_PREFIX,
31
+ ALL_RUFF_STANDALONE_CONFIG_FILENAMES,
32
+ IMPORT_BLOCK_SORT_RUFF_TIMEOUT_SECONDS,
33
+ IMPORT_BLOCK_SORT_RULE_CODE,
26
34
  MAX_E2E_TEST_NAMING_ISSUES,
35
+ MAX_IMPORT_BLOCK_SORT_ISSUES,
36
+ MAX_JS_RESUME_TASK_ENUMERATION_ISSUES,
27
37
  MAX_LOGGING_FSTRING_ISSUES,
38
+ MAX_LOGGING_PRINTF_TOKEN_ISSUES,
28
39
  MAX_WINDOWS_API_NONE_ISSUES,
40
+ MINIMUM_RESUME_TASK_ENUMERATION_ITEMS,
41
+ RUFF_PYPROJECT_CONFIG_FILENAME,
42
+ RUFF_PYPROJECT_TOOL_TABLE_MARKER,
43
+ RUFF_STDIN_ENCODING,
29
44
  )
30
45
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
31
46
  ADVISORY_LINE_THRESHOLD_HARD,
32
47
  ADVISORY_LINE_THRESHOLD_SOFT,
33
48
  ALL_CLI_FILE_PATH_MARKERS,
34
49
  ALL_IMPORT_STATEMENT_PREFIXES,
50
+ ALL_JAVASCRIPT_EXTENSIONS,
51
+ ALL_JAVASCRIPT_REGEX_PRECEDING_CHARACTERS,
52
+ ALL_JAVASCRIPT_REGEX_PRECEDING_KEYWORDS,
53
+ ALL_JAVASCRIPT_STRING_DELIMITERS,
35
54
  ALL_PYTHON_EXTENSIONS,
55
+ ENUMERATION_LEADING_CONJUNCTION_PATTERN,
56
+ ENUMERATION_LIST_ITEM_SEPARATOR_PATTERN,
57
+ ENUMERATION_TASK_ITEM_PATTERN,
58
+ HYPHENATED_TASK_ITEM_PATTERN,
59
+ JAVASCRIPT_BLOCK_COMMENT_CLOSER,
60
+ JAVASCRIPT_BLOCK_COMMENT_OPENER,
61
+ JAVASCRIPT_LINE_COMMENT_OPENER,
62
+ JAVASCRIPT_REGEX_DELIMITER,
63
+ JAVASCRIPT_STRING_ESCAPE_CHARACTER,
36
64
  LOGGING_FSTRING_PATTERN,
65
+ LOGGING_PRINTF_TOKEN_PATTERN,
66
+ MINIMUM_FORMAT_LOGGER_ARGUMENT_COUNT,
37
67
  NOT_INSIDE_TYPE_CHECKING_BLOCK,
68
+ RESUME_TASK_ENUMERATION_PATTERN,
69
+ SPAWN_AGENT_WITH_JSDOC_PATTERN,
70
+ TASK_DISPATCH_NAME_PATTERN,
38
71
  TRIPLE_DOUBLE_QUOTE_DELIMITER,
39
72
  TRIPLE_QUOTE_PARITY_DIVISOR,
40
73
  TRIPLE_SINGLE_QUOTE_DELIMITER,
@@ -129,6 +162,232 @@ def check_imports_at_top(content: str) -> list[str]:
129
162
  return issues
130
163
 
131
164
 
165
+ def check_import_block_sorted(
166
+ content: str,
167
+ file_path: str,
168
+ all_changed_lines: set[int] | None = None,
169
+ defer_scope_to_caller: bool = False,
170
+ ) -> list[str]:
171
+ """Flag a Python import block left un-sorted per isort rules (ruff I001).
172
+
173
+ ruff/isort treats a contiguous run of ``import`` and ``from`` statements as
174
+ one sortable unit. An edit that re-touches such a block pulls the whole block
175
+ into the gate's scope, so a pre-existing mis-ordering in an untouched line of
176
+ that block becomes a quality-gate failure (``check.ps1`` runs ruff over
177
+ ``hooks/blocking``). This catches that drift at Write/Edit time by delegating
178
+ to ruff itself, the same sorter the quality gate uses, so the two surfaces
179
+ never disagree.
180
+
181
+ The check resolves *file_path* to an absolute path and runs ruff against
182
+ *content* on stdin under that path, so ruff walks parents to find the repo's
183
+ ``[tool.ruff]`` config. Without a discoverable config ruff falls back to
184
+ default isort first-party grouping, which orders sibling imports differently
185
+ and produces false positives, so the check fails open when no ruff config is
186
+ found above the file. Non-Python files and test files are exempt. When ruff
187
+ is not installed, times out, or returns output the parser cannot read, the
188
+ check also fails open and reports nothing — a missing or slow tool never
189
+ blocks a write.
190
+
191
+ An I001 finding covers the whole sortable block, so each finding carries a
192
+ span from the block's anchor line (``location.row``) through its last line
193
+ (``end_location.row``) scoped through the shared
194
+ ``_scope_violations_to_changed_lines`` helper. On a terminal diff-scoped Edit
195
+ a finding is returned when any line in that block span is among
196
+ ``all_changed_lines``, so an edit touching any line of an unsorted block
197
+ blocks while an edit far from the block does not. On a new-file or full-file
198
+ write (``all_changed_lines`` None) every finding is in scope;
199
+ ``defer_scope_to_caller`` returns every finding so the commit/push gate
200
+ scopes by added line through its own ``Line N:`` partitioning.
201
+
202
+ Args:
203
+ content: The full file content the Write or Edit would leave on disk.
204
+ file_path: The destination path, used both to gate by extension and to
205
+ give ruff the filename it needs for config discovery.
206
+ all_changed_lines: Post-edit line numbers the current edit touched, or
207
+ None to treat the whole file as in scope. When provided, a finding
208
+ blocks only when its block-anchor line is among the changed lines.
209
+ defer_scope_to_caller: When True, return every finding so the commit/push
210
+ gate's ``Line N:`` partitioning scopes by added line.
211
+
212
+ Returns:
213
+ One issue string per detected I001 finding, capped at
214
+ ``MAX_IMPORT_BLOCK_SORT_ISSUES``; an empty list when the block sorts
215
+ cleanly, every finding falls outside the changed lines, or the check
216
+ fails open.
217
+ """
218
+ if get_file_extension(file_path) not in ALL_PYTHON_EXTENSIONS:
219
+ return []
220
+ if is_test_file(file_path):
221
+ return []
222
+
223
+ absolute_file_path = Path(file_path).resolve()
224
+ if not _ruff_config_discoverable(absolute_file_path):
225
+ return []
226
+
227
+ ruff_findings = _run_ruff_import_sort_check(content, str(absolute_file_path))
228
+ if ruff_findings is None:
229
+ return []
230
+
231
+ span_end_line_offset = 1
232
+ all_violations_in_finding_order: list[tuple[range, str]] = []
233
+ for each_finding in ruff_findings:
234
+ if each_finding.get("code") != IMPORT_BLOCK_SORT_RULE_CODE:
235
+ continue
236
+ line_number = _finding_line_number(each_finding.get("location"))
237
+ block_end_line_number = _finding_block_end_line_number(
238
+ each_finding.get("end_location"), line_number
239
+ )
240
+ span_range = range(line_number, block_end_line_number + span_end_line_offset)
241
+ message = (
242
+ f"Line {line_number}: Import block is un-sorted (ruff I001) - "
243
+ f"run 'ruff check --fix' to sort the block before writing"
244
+ )
245
+ all_violations_in_finding_order.append((span_range, message))
246
+ if len(all_violations_in_finding_order) >= MAX_IMPORT_BLOCK_SORT_ISSUES:
247
+ break
248
+
249
+ return _scope_violations_to_changed_lines(
250
+ all_violations_in_finding_order,
251
+ all_changed_lines,
252
+ defer_scope_to_caller,
253
+ )
254
+
255
+
256
+ def _run_ruff_import_sort_check(content: str, file_path: str) -> list[dict[str, object]] | None:
257
+ """Run ruff's I001 check over *content* and return its parsed JSON findings.
258
+
259
+ Args:
260
+ content: The file content fed to ruff on stdin.
261
+ file_path: The filename ruff is told the stdin content belongs to, so it
262
+ discovers the surrounding ``[tool.ruff]`` config.
263
+
264
+ Returns:
265
+ The list of ruff finding dictionaries, or ``None`` when ruff is absent,
266
+ times out, or emits output that is not the expected JSON list.
267
+ """
268
+ ruff_command = [
269
+ *ALL_IMPORT_BLOCK_SORT_RUFF_COMMAND_PREFIX,
270
+ "--stdin-filename",
271
+ file_path,
272
+ "-",
273
+ ]
274
+ try:
275
+ completed_process = subprocess.run(
276
+ ruff_command,
277
+ input=content.encode(RUFF_STDIN_ENCODING),
278
+ capture_output=True,
279
+ timeout=IMPORT_BLOCK_SORT_RUFF_TIMEOUT_SECONDS,
280
+ check=False,
281
+ )
282
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
283
+ return None
284
+
285
+ try:
286
+ ruff_stdout = completed_process.stdout.decode(RUFF_STDIN_ENCODING)
287
+ except UnicodeDecodeError:
288
+ return None
289
+
290
+ try:
291
+ parsed_findings = json.loads(ruff_stdout)
292
+ except json.JSONDecodeError:
293
+ return None
294
+
295
+ if not isinstance(parsed_findings, list):
296
+ return None
297
+ return [each_finding for each_finding in parsed_findings if isinstance(each_finding, dict)]
298
+
299
+
300
+ def _ruff_config_discoverable(absolute_file_path: Path) -> bool:
301
+ """Return whether a ruff config sits above *absolute_file_path*.
302
+
303
+ Walks the file's parent directories looking for a standalone ``ruff.toml`` /
304
+ ``.ruff.toml`` or a ``pyproject.toml`` that declares a ``[tool.ruff`` table.
305
+ ruff uses this same nearest-ancestor walk to resolve its settings; mirroring
306
+ it lets the check confirm ruff will see the project's isort configuration
307
+ rather than its built-in defaults before trusting an I001 finding.
308
+
309
+ Args:
310
+ absolute_file_path: The resolved path of the file under validation.
311
+
312
+ Returns:
313
+ True when a ruff config is found in the file's directory or any ancestor;
314
+ False when the walk reaches the filesystem root without one.
315
+ """
316
+ for each_directory in absolute_file_path.parents:
317
+ for each_standalone_name in ALL_RUFF_STANDALONE_CONFIG_FILENAMES:
318
+ if (each_directory / each_standalone_name).is_file():
319
+ return True
320
+ pyproject_path = each_directory / RUFF_PYPROJECT_CONFIG_FILENAME
321
+ if _pyproject_declares_ruff_table(pyproject_path):
322
+ return True
323
+ return False
324
+
325
+
326
+ def _pyproject_declares_ruff_table(pyproject_path: Path) -> bool:
327
+ """Return whether *pyproject_path* exists and declares a ``[tool.ruff`` table.
328
+
329
+ Args:
330
+ pyproject_path: The candidate ``pyproject.toml`` path to inspect.
331
+
332
+ Returns:
333
+ True when the file exists and its text contains the ``[tool.ruff`` table
334
+ marker; False when the file is absent or unreadable.
335
+ """
336
+ if not pyproject_path.is_file():
337
+ return False
338
+ try:
339
+ pyproject_text = pyproject_path.read_text(encoding=RUFF_STDIN_ENCODING)
340
+ except OSError:
341
+ return False
342
+ return RUFF_PYPROJECT_TOOL_TABLE_MARKER in pyproject_text
343
+
344
+
345
+ def _finding_line_number(location: object) -> int:
346
+ """Return the source line a ruff finding's location anchors to.
347
+
348
+ Args:
349
+ location: The ``location`` value from one ruff JSON finding, expected to
350
+ be a mapping carrying an integer ``row``; any other shape yields a
351
+ zero line number.
352
+
353
+ Returns:
354
+ The ``row`` value as an integer, or a zero line number when the location
355
+ is absent or carries no readable row.
356
+ """
357
+ if not isinstance(location, dict):
358
+ return 0
359
+ row = location.get("row")
360
+ if not isinstance(row, int):
361
+ return 0
362
+ return row
363
+
364
+
365
+ def _finding_block_end_line_number(end_location: object, anchor_line_number: int) -> int:
366
+ """Return the last source line a ruff finding's block spans.
367
+
368
+ ruff anchors an I001 finding at its block's first line and records the
369
+ block's last line in ``end_location.row``, so the two together bound the
370
+ whole sortable run. The end row falls back to the anchor line when the
371
+ ``end_location`` is absent or carries no readable row, or when it would land
372
+ above the anchor — keeping the span to at least the anchor line itself.
373
+
374
+ Args:
375
+ end_location: The ``end_location`` value from one ruff JSON finding,
376
+ expected to be a mapping carrying an integer ``row``.
377
+ anchor_line_number: The finding's block-anchor line, used as the floor
378
+ for the returned end line.
379
+
380
+ Returns:
381
+ The block's last line as an integer, never below *anchor_line_number*.
382
+ """
383
+ if not isinstance(end_location, dict):
384
+ return anchor_line_number
385
+ row = end_location.get("row")
386
+ if not isinstance(row, int) or row < anchor_line_number:
387
+ return anchor_line_number
388
+ return row
389
+
390
+
132
391
  def _update_triple_quote_state_for_line(
133
392
  line_text: str, current_delimiter: str | None
134
393
  ) -> str | None:
@@ -183,6 +442,121 @@ def check_logging_fstrings(content: str) -> list[str]:
183
442
  return issues
184
443
 
185
444
 
445
+ def _format_logger_names_imported(tree: ast.Module) -> set[str]:
446
+ """Return the local names bound to automation_logging log helpers.
447
+
448
+ Scans every ``from ... import ...`` statement for an import whose module
449
+ path contains ``automation_logging`` and collects the local binding of each
450
+ imported ``log_*`` helper (the alias when ``as`` is present, otherwise the
451
+ imported name). Only these names identify a str.format-logger call; a
452
+ ``log_*`` helper from any other module is not collected, because a
453
+ ``%``-style logger formats its tokens correctly.
454
+
455
+ Args:
456
+ tree: The parsed module to scan for logger imports.
457
+
458
+ Returns:
459
+ The set of local names bound to automation_logging log helpers.
460
+ """
461
+ bound_names: set[str] = set()
462
+ for each_node in ast.walk(tree):
463
+ if not isinstance(each_node, ast.ImportFrom):
464
+ continue
465
+ if each_node.module is None or "automation_logging" not in each_node.module:
466
+ continue
467
+ for each_alias in each_node.names:
468
+ if each_alias.name in ALL_FORMAT_LOGGER_FUNCTION_NAMES:
469
+ bound_names.add(each_alias.asname or each_alias.name)
470
+ return bound_names
471
+
472
+
473
+ def _printf_token_log_call_line(
474
+ node: ast.AST, all_format_logger_names: set[str]
475
+ ) -> int | None:
476
+ """Return the line of a format-logger call carrying a printf token, else None.
477
+
478
+ Args:
479
+ node: The AST node to inspect.
480
+ all_format_logger_names: Local names bound to automation_logging log
481
+ helpers.
482
+
483
+ Returns:
484
+ The 1-based line number when ``node`` is a bare-name call to a format
485
+ logger that has at least one format argument after the message and
486
+ whose first string-literal argument carries a printf token; otherwise
487
+ None. A call with only the message and no format arguments never runs
488
+ ``.format(*args)``, so its token prints intact and is not flagged.
489
+ """
490
+ if not isinstance(node, ast.Call):
491
+ return None
492
+ function_reference = node.func
493
+ if (
494
+ not isinstance(function_reference, ast.Name)
495
+ or function_reference.id not in all_format_logger_names
496
+ ):
497
+ return None
498
+ if len(node.args) < MINIMUM_FORMAT_LOGGER_ARGUMENT_COUNT:
499
+ return None
500
+ message_argument = node.args[0]
501
+ if not isinstance(message_argument, ast.Constant) or not isinstance(
502
+ message_argument.value, str
503
+ ):
504
+ return None
505
+ if not LOGGING_PRINTF_TOKEN_PATTERN.search(message_argument.value):
506
+ return None
507
+ return node.lineno
508
+
509
+
510
+ def check_logging_printf_tokens(content: str, file_path: str) -> list[str]:
511
+ """Flag printf tokens in a str.format-logger (automation_logging) message.
512
+
513
+ The ``shared_utils.automation_logging`` helpers (``log_info``, ``log_error``,
514
+ ...) format with ``str.format`` (``{}`` placeholders) only when format
515
+ arguments follow the message (``message.format(*args) if args else
516
+ message``), so a printf-style token such as ``%s`` in the message literal is
517
+ never substituted: the format arguments are dropped and the literal token
518
+ prints. The check fires only in a file that imports one of those helpers
519
+ from an ``automation_logging`` module, and only for a bare-name call to such
520
+ a helper that passes at least one format argument after the message and
521
+ whose first argument is a string literal carrying a token. A call with only
522
+ the message and no format arguments never runs ``.format``, so its token
523
+ prints intact and is left alone. An attribute call (``logger.info``) or a
524
+ ``log_*`` helper from any other module formats ``%``-tokens correctly and is
525
+ left alone. Test files are exempt so a test may exercise the malformed shape.
526
+
527
+ Args:
528
+ content: The Python source under validation.
529
+ file_path: The destination path, used to skip test files and non-Python.
530
+
531
+ Returns:
532
+ One issue line per offending call, capped at the configured maximum.
533
+ """
534
+ if is_test_file(file_path):
535
+ return []
536
+ if get_file_extension(file_path) not in ALL_PYTHON_EXTENSIONS:
537
+ return []
538
+ try:
539
+ tree = ast.parse(content)
540
+ except SyntaxError:
541
+ return []
542
+ all_format_logger_names = _format_logger_names_imported(tree)
543
+ if not all_format_logger_names:
544
+ return []
545
+ issues: list[str] = []
546
+ for each_node in ast.walk(tree):
547
+ offending_line = _printf_token_log_call_line(each_node, all_format_logger_names)
548
+ if offending_line is None:
549
+ continue
550
+ issues.append(
551
+ f"Line {offending_line}: printf token in a str.format logger "
552
+ "message - the automation_logging helpers format with str.format; "
553
+ "use {} placeholders (the %-arguments are silently dropped)"
554
+ )
555
+ if len(issues) >= MAX_LOGGING_PRINTF_TOKEN_ISSUES:
556
+ break
557
+ return issues
558
+
559
+
186
560
  def advise_file_line_count(content: str, file_path: str) -> None:
187
561
  """Emit non-blocking stderr advisories when a file crosses size smell thresholds.
188
562
 
@@ -241,6 +615,310 @@ def check_e2e_test_naming(content: str, file_path: str) -> list[str]:
241
615
  return issues
242
616
 
243
617
 
618
+ class _JavaScriptRegionScanner:
619
+ """Walks JavaScript source one character at a time, tracking string,
620
+ comment, and regex regions so a caller can tell code from non-code.
621
+
622
+ The scanner holds the region the current character sits in. ``consume``
623
+ advances the state for one character and returns whether that character is
624
+ structural code (``True``) or part of a string, comment, or regex literal
625
+ (``False``).
626
+ """
627
+
628
+ def __init__(self) -> None:
629
+ self._active_string_delimiter: str | None = None
630
+ self._is_inside_line_comment = False
631
+ self._is_inside_block_comment = False
632
+ self._is_inside_regex_literal = False
633
+ self._previous_code_character = "\n"
634
+ self._previous_code_word = ""
635
+ self._is_building_code_word = False
636
+
637
+ def consume(self, character: str, source: str, index: int) -> bool:
638
+ if self._active_string_delimiter is not None:
639
+ return self._consume_inside_string(character, source, index)
640
+ if self._is_inside_line_comment:
641
+ return self._consume_inside_line_comment(character)
642
+ if self._is_inside_block_comment:
643
+ return self._consume_inside_block_comment(source, index)
644
+ if self._is_inside_regex_literal:
645
+ return self._consume_inside_regex(character, source, index)
646
+ return self._consume_inside_code(character, source, index)
647
+
648
+ def _consume_inside_string(self, character: str, source: str, index: int) -> bool:
649
+ if character == self._active_string_delimiter and not _is_escaped(source, index):
650
+ self._active_string_delimiter = None
651
+ return False
652
+
653
+ def _consume_inside_line_comment(self, character: str) -> bool:
654
+ if character == "\n":
655
+ self._is_inside_line_comment = False
656
+ return False
657
+
658
+ def _consume_inside_block_comment(self, source: str, index: int) -> bool:
659
+ if source[index - 1 : index + 1] == JAVASCRIPT_BLOCK_COMMENT_CLOSER:
660
+ self._is_inside_block_comment = False
661
+ return False
662
+
663
+ def _consume_inside_regex(self, character: str, source: str, index: int) -> bool:
664
+ if character == JAVASCRIPT_REGEX_DELIMITER and not _is_escaped(source, index):
665
+ self._is_inside_regex_literal = False
666
+ return False
667
+
668
+ def _consume_inside_code(self, character: str, source: str, index: int) -> bool:
669
+ if character in ALL_JAVASCRIPT_STRING_DELIMITERS:
670
+ self._active_string_delimiter = character
671
+ return False
672
+ if source.startswith(JAVASCRIPT_LINE_COMMENT_OPENER, index):
673
+ self._is_inside_line_comment = True
674
+ return False
675
+ if source.startswith(JAVASCRIPT_BLOCK_COMMENT_OPENER, index):
676
+ self._is_inside_block_comment = True
677
+ return False
678
+ if character == JAVASCRIPT_REGEX_DELIMITER and self._is_regex_start():
679
+ self._is_inside_regex_literal = True
680
+ return False
681
+ self._track_preceding_word(character)
682
+ if not character.isspace():
683
+ self._previous_code_character = character
684
+ return True
685
+
686
+ def _track_preceding_word(self, character: str) -> None:
687
+ if character.isalnum() or character == "_":
688
+ if not self._is_building_code_word:
689
+ self._previous_code_word = ""
690
+ self._is_building_code_word = True
691
+ self._previous_code_word += character
692
+ return
693
+ if not character.isspace():
694
+ self._previous_code_word = ""
695
+ self._is_building_code_word = False
696
+
697
+ def _is_regex_start(self) -> bool:
698
+ if self._previous_code_character in ALL_JAVASCRIPT_REGEX_PRECEDING_CHARACTERS:
699
+ return True
700
+ return self._previous_code_word in ALL_JAVASCRIPT_REGEX_PRECEDING_KEYWORDS
701
+
702
+
703
+ def _is_escaped(source: str, index: int) -> bool:
704
+ backslash_run_length = 0
705
+ scan_index = index - 1
706
+ while scan_index >= 0 and source[scan_index] == JAVASCRIPT_STRING_ESCAPE_CHARACTER:
707
+ backslash_run_length += 1
708
+ scan_index -= 1
709
+ return backslash_run_length % 2 == 1
710
+
711
+
712
+ def _code_position_flags(source: str) -> list[bool]:
713
+ """Return one flag per character: ``True`` for structural code, ``False`` for
714
+ a character inside a string literal, comment, or regex literal.
715
+
716
+ A backslash escapes the next character inside a string literal, so a delimiter
717
+ preceded by an odd run of backslashes stays inside the literal and a delimiter
718
+ preceded by an even run (including zero) closes it. A ``/`` opens a regex
719
+ literal only when it sits at an expression position: either the previous
720
+ structural character is a punctuation/operator that precedes an expression, or
721
+ the preceding identifier token is an expression-introducing keyword (``return``,
722
+ ``typeof``, ``case``, ``in``, ``of``, ``do``, ``else``, ``void``, ``delete``,
723
+ ``instanceof``, ``new``, ``yield``, ``await``, ``throw``). After a value it is
724
+ the division operator and is left intact.
725
+ """
726
+ region = _JavaScriptRegionScanner()
727
+ return [
728
+ region.consume(each_character, source, each_index)
729
+ for each_index, each_character in enumerate(source)
730
+ ]
731
+
732
+
733
+ def _blank_non_code_regions(body_text: str) -> str:
734
+ """Replace every non-code region in JavaScript source with spaces.
735
+
736
+ A non-code region is a string literal (single-, double-, or backtick-quoted),
737
+ a ``//`` line comment, a ``/* */`` block comment, or a ``/.../`` regex
738
+ literal. Each is blanked to spaces, newlines preserved, so the returned text
739
+ has the same length and line structure as the input while carrying only
740
+ structural code characters.
741
+
742
+ Blanking comments and regex literals keeps a stray brace inside either from
743
+ skewing the brace-depth scan that bounds the resume body, and blanking string
744
+ literals keeps a brace inside a prompt string from skewing it the same way.
745
+ """
746
+ code_flags = _code_position_flags(body_text)
747
+ rendered_characters = [
748
+ each_character if (code_flags[each_index] or each_character == "\n") else " "
749
+ for each_index, each_character in enumerate(body_text)
750
+ ]
751
+ return "".join(rendered_characters)
752
+
753
+
754
+ def _dispatched_task_names(resume_body: str) -> set[str]:
755
+ """Return the task names the resume body dispatches on in code position.
756
+
757
+ A real dispatch branch (``task === 'fix-verify'``) has its ``task`` keyword in
758
+ structural code; a ``task === '<name>'`` mention inside an agent-prompt string,
759
+ a comment, or a regex literal is prose and its keyword sits in a non-code
760
+ region. Only a match whose ``task`` keyword is at a code position counts, so a
761
+ prose mention is never read as a dispatch branch.
762
+ """
763
+ code_flags = _code_position_flags(resume_body)
764
+ dispatched_tasks: set[str] = set()
765
+ for each_match in TASK_DISPATCH_NAME_PATTERN.finditer(resume_body):
766
+ if code_flags[each_match.start()]:
767
+ dispatched_tasks.add(each_match.group("task"))
768
+ return dispatched_tasks
769
+
770
+
771
+ def _balanced_brace_body(content: str, from_index: int) -> str | None:
772
+ """Return the brace-balanced span of ``content`` starting at the first ``{``.
773
+
774
+ Braces inside string literals, comments, and regex literals do not count
775
+ toward the depth: the scan walks a blanked copy where those regions are
776
+ spaces, so only structural braces move the depth. The returned span is a slice
777
+ of the original ``content`` so the caller reads real source for the dispatch
778
+ scan.
779
+ """
780
+ blanked_content = _blank_non_code_regions(content)
781
+ opening_index = blanked_content.find("{", from_index)
782
+ if opening_index == -1:
783
+ return None
784
+ depth = 0
785
+ for each_position in range(opening_index, len(blanked_content)):
786
+ character = blanked_content[each_position]
787
+ if character == "{":
788
+ depth += 1
789
+ elif character == "}":
790
+ depth -= 1
791
+ if depth == 0:
792
+ return content[opening_index : each_position + 1]
793
+ return content[opening_index:]
794
+
795
+
796
+ def _resume_function_body(content: str, role: str) -> str | None:
797
+ header_pattern = re.compile(
798
+ r"(?:async\s+)?function\s+resume" + re.escape(role) + r"Agent\s*\("
799
+ )
800
+ header_match = header_pattern.search(_blank_non_code_regions(content))
801
+ if header_match is None:
802
+ return None
803
+ bounded_content = content[: _next_top_level_function_index(content, header_match.end())]
804
+ return _balanced_brace_body(bounded_content, header_match.end())
805
+
806
+
807
+ def _next_top_level_function_index(content: str, from_index: int) -> int:
808
+ blanked_content = _blank_non_code_regions(content)
809
+ next_function_match = re.compile(
810
+ r"^(?:async\s+)?function\s+\w+", re.MULTILINE
811
+ ).search(blanked_content, from_index)
812
+ if next_function_match is None:
813
+ return len(content)
814
+ return next_function_match.start()
815
+
816
+
817
+ def _task_list_from_enumeration_text(
818
+ enumeration_text: str, all_dispatched_tasks: frozenset[str]
819
+ ) -> set[str] | None:
820
+ enumerated_items = ENUMERATION_LIST_ITEM_SEPARATOR_PATTERN.split(enumeration_text)
821
+ task_items = {_strip_leading_conjunction(each_item) for each_item in enumerated_items}
822
+ if not all(ENUMERATION_TASK_ITEM_PATTERN.match(each_item) for each_item in task_items):
823
+ return None
824
+ if not any(HYPHENATED_TASK_ITEM_PATTERN.match(each_item) for each_item in task_items):
825
+ return None
826
+ if len(task_items) < MINIMUM_RESUME_TASK_ENUMERATION_ITEMS and not (task_items & all_dispatched_tasks):
827
+ return None
828
+ return task_items
829
+
830
+
831
+ def _strip_leading_conjunction(enumeration_item: str) -> str:
832
+ stripped_item = enumeration_item.strip()
833
+ return ENUMERATION_LEADING_CONJUNCTION_PATTERN.sub("", stripped_item).strip()
834
+
835
+
836
+ def _enumeration_task_names(
837
+ jsdoc_text: str, all_dispatched_tasks: frozenset[str]
838
+ ) -> set[str] | None:
839
+ for each_enumeration_match in RESUME_TASK_ENUMERATION_PATTERN.finditer(jsdoc_text):
840
+ enumeration_text = each_enumeration_match.group("enumeration").replace("*", " ")
841
+ task_items = _task_list_from_enumeration_text(enumeration_text, all_dispatched_tasks)
842
+ if task_items is not None:
843
+ return task_items
844
+ return None
845
+
846
+
847
+ def check_js_resume_task_enumeration_coverage(
848
+ content: str, file_path: str
849
+ ) -> list[str]:
850
+ """Flag a spawn JSDoc whose resume enumeration omits a dispatched task.
851
+
852
+ The drift this catches: a ``spawn<Role>Agent`` function carries a JSDoc that
853
+ enumerates the resume tasks of its sibling ``resume<Role>Agent`` in a
854
+ parenthetical ``resume (repair-verify, hardening-verify)`` list, while the
855
+ ``resume<Role>Agent`` body dispatches on a ``task === '<name>'`` branch the
856
+ enumeration never names. A reader who trusts the spawn JSDoc to list every
857
+ step that runs on the session misses the omitted task. This is the JS/.mjs
858
+ slice of Category O6 docstring-prose-vs-implementation drift; the Python
859
+ enforcer's AST docstring checks never inspect JavaScript source.
860
+
861
+ A spawn JSDoc binds to its sibling resume function by the shared ``<Role>``
862
+ token (``spawnVerifierAgent`` pairs with ``resumeVerifierAgent``). The check
863
+ reads the sibling resume body's dispatched task set first, then scans each
864
+ standalone-word ``resume (...)`` parenthetical in the JSDoc (a longer word
865
+ ending in ``resume``, such as ``presume (...)``, does not introduce an
866
+ enumeration) and binds to the first that reads as a task list: every comma- or
867
+ ``and``-delimited item (including the Oxford-comma ``a, b, and c`` form, where
868
+ the trailing ``and`` is stripped from the final item) is a single
869
+ dispatch-shaped task token (no embedded spaces, so a descriptive phrase such as
870
+ ``re-establishing the session`` is not a task list, and a later real task list
871
+ still binds when an earlier descriptive parenthetical precedes it) and at least
872
+ one item is hyphenated, proving the prose is a resume-task enumeration
873
+ describing the sibling rather than incidental prose. A multi-item parenthetical
874
+ that clears those gates is a task list. A single-item parenthetical clears them
875
+ only when its lone token is itself one of the sibling's dispatched tasks, so a
876
+ descriptive single hyphenated word such as ``re-entry`` or ``fast-forward`` —
877
+ which names no dispatched task — stays descriptive prose rather than a
878
+ one-item task list. This keeps the enumerated token set a superset of the
879
+ sibling's dispatched task set. The resume body is read up to the next
880
+ top-level ``function`` declaration, and only a ``task === '<name>'`` whose
881
+ ``task`` keyword sits in structural code counts as a dispatch branch: a
882
+ ``task === '<name>'`` mention inside a string literal (any quote flavor), a
883
+ comment, or a regex literal is prose and is skipped. Each
884
+ ``task === '<name>'`` branch name the resume body dispatches on that the
885
+ enumeration omits is flagged.
886
+
887
+ Args:
888
+ content: The source text to inspect.
889
+ file_path: The path the source will be written to, used for exemptions.
890
+
891
+ Returns:
892
+ One issue per dispatched task name the spawn enumeration omits, capped at
893
+ the module limit.
894
+ """
895
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
896
+ return []
897
+ if get_file_extension(file_path) not in ALL_JAVASCRIPT_EXTENSIONS:
898
+ return []
899
+ issues: list[str] = []
900
+ for each_spawn_match in SPAWN_AGENT_WITH_JSDOC_PATTERN.finditer(content):
901
+ role = each_spawn_match.group("role")
902
+ resume_body = _resume_function_body(content, role)
903
+ if resume_body is None:
904
+ continue
905
+ dispatched_tasks = _dispatched_task_names(resume_body)
906
+ enumerated_tasks = _enumeration_task_names(
907
+ each_spawn_match.group("jsdoc"), frozenset(dispatched_tasks)
908
+ )
909
+ if not enumerated_tasks:
910
+ continue
911
+ for each_task in sorted(dispatched_tasks - enumerated_tasks):
912
+ issues.append(
913
+ f"spawn{role}Agent() JSDoc resume enumeration omits the "
914
+ f"'{each_task}' task that resume{role}Agent dispatches on — add it "
915
+ "to the parenthetical (Category O6 docstring-vs-implementation drift)"
916
+ )
917
+ if len(issues) >= MAX_JS_RESUME_TASK_ENUMERATION_ISSUES:
918
+ return issues[:MAX_JS_RESUME_TASK_ENUMERATION_ISSUES]
919
+ return issues[:MAX_JS_RESUME_TASK_ENUMERATION_ISSUES]
920
+
921
+
244
922
  def _is_cli_entry_point(file_path: str) -> bool:
245
923
  path_lower = file_path.lower().replace("\\", "/")
246
924
  return any(marker.replace("\\", "/") in path_lower for marker in ALL_CLI_FILE_PATH_MARKERS)