claude-dev-env 1.67.2 → 1.69.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 (124) hide show
  1. package/_shared/CLAUDE.md +13 -0
  2. package/_shared/pr-loop/CLAUDE.md +24 -0
  3. package/_shared/pr-loop/scripts/CLAUDE.md +30 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +21 -0
  5. package/_shared/pr-loop/scripts/tests/CLAUDE.md +32 -0
  6. package/agents/CLAUDE.md +29 -0
  7. package/audit-rubrics/CLAUDE.md +41 -0
  8. package/audit-rubrics/category_rubrics/CLAUDE.md +36 -0
  9. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  10. package/audit-rubrics/prompts/CLAUDE.md +36 -0
  11. package/bin/CLAUDE.md +28 -0
  12. package/commands/CLAUDE.md +25 -0
  13. package/docs/CLAUDE.md +28 -0
  14. package/docs/references/CLAUDE.md +13 -0
  15. package/hooks/CLAUDE.md +31 -0
  16. package/hooks/advisory/CLAUDE.md +16 -0
  17. package/hooks/blocking/CLAUDE.md +107 -0
  18. package/hooks/blocking/code_rules_constants_config.py +7 -4
  19. package/hooks/blocking/code_rules_docstrings.py +253 -0
  20. package/hooks/blocking/code_rules_enforcer.py +6 -0
  21. package/hooks/blocking/config/CLAUDE.md +22 -0
  22. package/hooks/blocking/test_code_rules_enforcer_class_docstring_methods.py +262 -0
  23. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +36 -0
  24. package/hooks/blocking/test_code_rules_enforcer_docstring_fallback_branch.py +398 -0
  25. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +9 -0
  26. package/hooks/diagnostic/CLAUDE.md +43 -0
  27. package/hooks/diagnostic/migrations/CLAUDE.md +16 -0
  28. package/hooks/diagnostic/queries/CLAUDE.md +19 -0
  29. package/hooks/git-hooks/CLAUDE.md +28 -0
  30. package/hooks/git-hooks/git_hooks_constants/CLAUDE.md +21 -0
  31. package/hooks/hooks_constants/CLAUDE.md +60 -0
  32. package/hooks/hooks_constants/blocking_check_limits.py +18 -0
  33. package/hooks/lifecycle/CLAUDE.md +18 -0
  34. package/hooks/observability/CLAUDE.md +16 -0
  35. package/hooks/session/CLAUDE.md +21 -0
  36. package/hooks/validation/CLAUDE.md +19 -0
  37. package/hooks/validators/CLAUDE.md +49 -0
  38. package/hooks/workflow/CLAUDE.md +22 -0
  39. package/package.json +1 -1
  40. package/rules/CLAUDE.md +46 -0
  41. package/rules/docstring-prose-matches-implementation.md +3 -2
  42. package/scripts/CLAUDE.md +34 -0
  43. package/scripts/dev_env_scripts_constants/CLAUDE.md +14 -0
  44. package/scripts/sync_to_cursor/CLAUDE.md +23 -0
  45. package/scripts/tests/CLAUDE.md +18 -0
  46. package/skills/CLAUDE.md +66 -0
  47. package/skills/_shared/CLAUDE.md +11 -0
  48. package/skills/_shared/pr-loop/CLAUDE.md +27 -0
  49. package/skills/_shared/pr-loop/prompts/CLAUDE.md +9 -0
  50. package/skills/_shared/pr-loop/scripts/CLAUDE.md +23 -0
  51. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +19 -0
  52. package/skills/anthropic-plan/CLAUDE.md +34 -0
  53. package/skills/anthropic-plan/SKILL.md +1 -1
  54. package/skills/anthropic-plan/scripts/CLAUDE.md +11 -0
  55. package/skills/anthropic-plan/scripts/anthropic_plan_scripts_constants/CLAUDE.md +16 -0
  56. package/skills/anthropic-plan/templates/CLAUDE.md +13 -0
  57. package/skills/anthropic-plan/workflow/CLAUDE.md +14 -0
  58. package/skills/auditing-claude-config/CLAUDE.md +20 -0
  59. package/skills/autoconverge/CLAUDE.md +30 -0
  60. package/skills/autoconverge/reference/CLAUDE.md +12 -0
  61. package/skills/autoconverge/workflow/CLAUDE.md +23 -0
  62. package/skills/autoconverge/workflow/autoconverge_report_constants/CLAUDE.md +16 -0
  63. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +135 -0
  64. package/skills/autoconverge/workflow/converge.mjs +119 -2
  65. package/skills/bdd-protocol/CLAUDE.md +26 -0
  66. package/skills/bdd-protocol/references/CLAUDE.md +10 -0
  67. package/skills/bg-agent/CLAUDE.md +17 -0
  68. package/skills/bugteam/CLAUDE.md +30 -0
  69. package/skills/bugteam/reference/CLAUDE.md +22 -0
  70. package/skills/bugteam/reference/obstacles/CLAUDE.md +24 -0
  71. package/skills/bugteam/scripts/CLAUDE.md +36 -0
  72. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +20 -0
  73. package/skills/caveman/CLAUDE.md +15 -0
  74. package/skills/code/CLAUDE.md +17 -0
  75. package/skills/copilot-review/CLAUDE.md +17 -0
  76. package/skills/deep-research/CLAUDE.md +17 -0
  77. package/skills/doc-gist/CLAUDE.md +25 -0
  78. package/skills/doc-gist/references/CLAUDE.md +9 -0
  79. package/skills/doc-gist/references/examples/CLAUDE.md +25 -0
  80. package/skills/doc-gist/scripts/CLAUDE.md +27 -0
  81. package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +10 -0
  82. package/skills/everything-search/CLAUDE.md +17 -0
  83. package/skills/findbugs/CLAUDE.md +20 -0
  84. package/skills/fixbugs/CLAUDE.md +19 -0
  85. package/skills/fresh-branch/CLAUDE.md +15 -0
  86. package/skills/gh-paginate/CLAUDE.md +18 -0
  87. package/skills/gotcha/CLAUDE.md +33 -0
  88. package/skills/implement/CLAUDE.md +27 -0
  89. package/skills/implement/scripts/CLAUDE.md +22 -0
  90. package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +22 -0
  91. package/skills/logifix/CLAUDE.md +36 -0
  92. package/skills/logifix/scripts/CLAUDE.md +16 -0
  93. package/skills/monitor-open-prs/CLAUDE.md +34 -0
  94. package/skills/monitor-open-prs/scripts/CLAUDE.md +17 -0
  95. package/skills/pr-consistency-audit/CLAUDE.md +34 -0
  96. package/skills/pr-consistency-audit/reference/CLAUDE.md +16 -0
  97. package/skills/pr-converge/CLAUDE.md +29 -0
  98. package/skills/pr-converge/pr_converge_skill_constants/CLAUDE.md +26 -0
  99. package/skills/pr-converge/reference/CLAUDE.md +27 -0
  100. package/skills/pr-converge/reference/obstacles/CLAUDE.md +23 -0
  101. package/skills/pr-converge/scripts/CLAUDE.md +36 -0
  102. package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +17 -0
  103. package/skills/pr-converge/workflows/CLAUDE.md +16 -0
  104. package/skills/pr-review-responder/CLAUDE.md +35 -0
  105. package/skills/pre-compact/CLAUDE.md +24 -0
  106. package/skills/qbug/CLAUDE.md +40 -0
  107. package/skills/rebase/CLAUDE.md +32 -0
  108. package/skills/recall/CLAUDE.md +30 -0
  109. package/skills/refine/CLAUDE.md +44 -0
  110. package/skills/refine/templates/CLAUDE.md +17 -0
  111. package/skills/remember/CLAUDE.md +31 -0
  112. package/skills/research-mode/CLAUDE.md +35 -0
  113. package/skills/session-log/CLAUDE.md +31 -0
  114. package/skills/session-tidy/CLAUDE.md +36 -0
  115. package/skills/skill-builder/CLAUDE.md +45 -0
  116. package/skills/skill-builder/references/CLAUDE.md +19 -0
  117. package/skills/skill-builder/templates/CLAUDE.md +14 -0
  118. package/skills/skill-builder/workflows/CLAUDE.md +17 -0
  119. package/skills/structure-prompt/CLAUDE.md +42 -0
  120. package/skills/structure-prompt/reference/CLAUDE.md +28 -0
  121. package/skills/task-build/CLAUDE.md +28 -0
  122. package/skills/update/CLAUDE.md +38 -0
  123. package/skills/verified-build/CLAUDE.md +33 -0
  124. package/system-prompts/CLAUDE.md +17 -0
@@ -20,11 +20,17 @@ from code_rules_shared import ( # noqa: E402
20
20
  )
21
21
 
22
22
  from hooks_constants.blocking_check_limits import ( # noqa: E402
23
+ ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES,
23
24
  ALL_DOCSTRING_EXEMPT_DECORATOR_NAMES,
24
25
  ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES,
26
+ ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES,
27
+ DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
25
28
  DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
29
+ MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES,
26
30
  MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
31
+ MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
27
32
  MAX_DOCSTRING_FORMAT_ISSUES,
33
+ MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
28
34
  )
29
35
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
30
36
  ALL_DOCSTRING_ARGS_SECTION_HEADERS,
@@ -306,3 +312,250 @@ def check_docstring_args_match_signature(content: str, file_path: str) -> list[s
306
312
  if len(issues) >= MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES:
307
313
  return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]
308
314
  return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]
315
+
316
+
317
+ def _callee_expression_name(expression: ast.expr) -> str:
318
+ if isinstance(expression, ast.Name):
319
+ return expression.id
320
+ if isinstance(expression, ast.Attribute):
321
+ receiver_name = _callee_expression_name(expression.value)
322
+ if not receiver_name:
323
+ return ast.unparse(expression)
324
+ return f"{receiver_name}.{expression.attr}"
325
+ return ""
326
+
327
+
328
+ def _call_callee_name(call_node: ast.Call) -> str:
329
+ return _callee_expression_name(call_node.func)
330
+
331
+
332
+ def _branch_routes_directly_to_call(branch_node: ast.If) -> str:
333
+ """Return the callee name an early-return guard routes to, or empty string.
334
+
335
+ A guard counts when its block contains exactly one call expression and then
336
+ returns. A second call expression disqualifies the block; non-call
337
+ statements such as an assignment or a loop are skipped and do not
338
+ disqualify it. The await wrapper around an async call is unwrapped first.
339
+ """
340
+ routed_callee = ""
341
+ saw_return = False
342
+ for each_statement in branch_node.body:
343
+ candidate_expression: ast.expr | None = None
344
+ if isinstance(each_statement, ast.Expr):
345
+ candidate_expression = each_statement.value
346
+ elif isinstance(each_statement, ast.Return):
347
+ saw_return = True
348
+ continue
349
+ if candidate_expression is None:
350
+ continue
351
+ if isinstance(candidate_expression, ast.Await):
352
+ candidate_expression = candidate_expression.value
353
+ if not isinstance(candidate_expression, ast.Call):
354
+ return ""
355
+ if routed_callee:
356
+ return ""
357
+ routed_callee = _call_callee_name(candidate_expression)
358
+ if not saw_return:
359
+ return ""
360
+ return routed_callee
361
+
362
+
363
+ def _shared_fallback_route_count(
364
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
365
+ ) -> tuple[str, int]:
366
+ route_count_by_callee: dict[str, int] = {}
367
+ for each_statement in function_node.body:
368
+ if not isinstance(each_statement, ast.If):
369
+ continue
370
+ routed_callee = _branch_routes_directly_to_call(each_statement)
371
+ if not routed_callee:
372
+ continue
373
+ route_count_by_callee[routed_callee] = (
374
+ route_count_by_callee.get(routed_callee, 0) + 1
375
+ )
376
+ if not route_count_by_callee:
377
+ return "", 0
378
+ busiest_callee = max(route_count_by_callee, key=lambda name: route_count_by_callee[name])
379
+ return busiest_callee, route_count_by_callee[busiest_callee]
380
+
381
+
382
+ def _summary_contains_phrase_at_word_boundary(summary_text: str, phrase: str) -> bool:
383
+ search_start = 0
384
+ while True:
385
+ match_index = summary_text.find(phrase, search_start)
386
+ if match_index == -1:
387
+ return False
388
+ preceding_is_boundary = (
389
+ match_index == 0 or not summary_text[match_index - 1].isalnum()
390
+ )
391
+ following_index = match_index + len(phrase)
392
+ following_is_boundary = (
393
+ following_index >= len(summary_text)
394
+ or not summary_text[following_index].isalnum()
395
+ )
396
+ if preceding_is_boundary and following_is_boundary:
397
+ return True
398
+ search_start = match_index + 1
399
+
400
+
401
+ def _summary_joins_multiple_conditions(summary_text: str) -> bool:
402
+ return any(
403
+ joining_phrase in summary_text
404
+ for joining_phrase in ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES
405
+ )
406
+
407
+
408
+ def _docstring_summary_scopes_a_single_condition(docstring_text: str) -> bool:
409
+ summary_text = docstring_text.split("\n\n", 1)[0].lower()
410
+ has_scope_phrase = any(
411
+ _summary_contains_phrase_at_word_boundary(summary_text, each_phrase)
412
+ for each_phrase in ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES
413
+ )
414
+ if not has_scope_phrase:
415
+ return False
416
+ return not _summary_joins_multiple_conditions(summary_text)
417
+
418
+
419
+ def check_docstring_fallback_branch_coverage(content: str, file_path: str) -> list[str]:
420
+ """Flag a fallback docstring that scopes a branch the body reaches twice.
421
+
422
+ The drift this catches: a function whose summary describes a fallback
423
+ action under a single condition (``only when``, ``falls back to ... when``)
424
+ while the body routes to that same fallback call from two or more distinct
425
+ early-return guards. The second guard fires under a condition the prose
426
+ never names, so the enumeration the reader trusts is incomplete. This is
427
+ the deterministic slice of Category O6 (docstring prose vs implementation
428
+ drift): a structural branch-count-versus-prose-condition mismatch.
429
+
430
+ Args:
431
+ content: The source text to inspect.
432
+ file_path: The path the source will be written to, used for exemptions.
433
+
434
+ Returns:
435
+ One issue per function whose fallback prose omits a second route to the
436
+ same call, capped at the module limit.
437
+ """
438
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
439
+ return []
440
+ try:
441
+ parsed_tree = ast.parse(content)
442
+ except SyntaxError:
443
+ return []
444
+ issues: list[str] = []
445
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
446
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
447
+ continue
448
+ if _function_has_exempt_decorator(each_node):
449
+ continue
450
+ docstring_text = _function_docstring_text(each_node)
451
+ if not docstring_text:
452
+ continue
453
+ if not _docstring_summary_scopes_a_single_condition(docstring_text):
454
+ continue
455
+ fallback_callee, route_count = _shared_fallback_route_count(each_node)
456
+ if route_count < DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT:
457
+ continue
458
+ issues.append(
459
+ f"Line {each_node.lineno}: {each_node.name}() docstring scopes a fallback to "
460
+ f"one condition, but the body routes to {fallback_callee}() from {route_count} "
461
+ "distinct branches — enumerate every condition that reaches the fallback "
462
+ "(Category O6 docstring-vs-implementation drift)"
463
+ )
464
+ if len(issues) >= MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES:
465
+ break
466
+ return issues[:MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES]
467
+
468
+
469
+ def _class_docstring_summary_is_single_line(docstring_text: str) -> bool:
470
+ stripped_text = docstring_text.strip()
471
+ if not stripped_text:
472
+ return False
473
+ summary_line, separator, _remainder = stripped_text.partition("\n")
474
+ if separator and stripped_text[len(summary_line):].strip():
475
+ return False
476
+ return bool(summary_line.strip())
477
+
478
+
479
+ def _public_method_names(class_node: ast.ClassDef) -> list[str]:
480
+ deduplicated_names: dict[str, None] = {}
481
+ for each_statement in class_node.body:
482
+ if not isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
483
+ continue
484
+ if _function_is_private_or_dunder(each_statement.name):
485
+ continue
486
+ deduplicated_names[each_statement.name] = None
487
+ return list(deduplicated_names)
488
+
489
+
490
+ def _name_tokens(method_name: str) -> list[str]:
491
+ return [each_token for each_token in method_name.split("_") if each_token]
492
+
493
+
494
+ def _docstring_mentions_method(docstring_text: str, method_name: str) -> bool:
495
+ lowered_docstring = docstring_text.lower()
496
+ if method_name.lower() in lowered_docstring:
497
+ return True
498
+ return all(
499
+ each_token.lower() in lowered_docstring for each_token in _name_tokens(method_name)
500
+ )
501
+
502
+
503
+ def _unmentioned_public_methods(class_node: ast.ClassDef, docstring_text: str) -> list[str]:
504
+ return [
505
+ each_name
506
+ for each_name in _public_method_names(class_node)
507
+ if not _docstring_mentions_method(docstring_text, each_name)
508
+ ]
509
+
510
+
511
+ def check_class_docstring_names_public_methods(
512
+ content: str, file_path: str
513
+ ) -> list[str]:
514
+ """Flag a one-line class docstring that omits two or more public methods.
515
+
516
+ A class whose docstring is a single summary line names one responsibility,
517
+ so a reader trusts that line to describe the whole class. When the class
518
+ later gains a second public entry point — the drift pattern where a
519
+ coffee-break reporter grows a regular-pace method — the terse summary keeps
520
+ describing only the original feature. Each public method whose name (or all
521
+ of its underscore-separated tokens) appears nowhere in the summary counts as
522
+ omitted; a class with two or more omitted public methods is reported so the
523
+ summary is widened to name the broader surface. Classes with a multi-line
524
+ docstring body are left to the audit lane, since their prose can carry the
525
+ enumeration without naming each method by name.
526
+
527
+ Args:
528
+ content: The source text to inspect.
529
+ file_path: The path the source will be written to, used for exemptions.
530
+
531
+ Returns:
532
+ One issue per class whose single-line docstring omits two or more of its
533
+ public methods, capped at the module limit.
534
+ """
535
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
536
+ return []
537
+ try:
538
+ parsed_tree = ast.parse(content)
539
+ except SyntaxError:
540
+ return []
541
+ issues: list[str] = []
542
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
543
+ if not isinstance(each_node, ast.ClassDef):
544
+ continue
545
+ class_docstring = ast.get_docstring(each_node) or ""
546
+ if not _class_docstring_summary_is_single_line(class_docstring):
547
+ continue
548
+ public_names = _public_method_names(each_node)
549
+ if len(public_names) < MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH:
550
+ continue
551
+ unmentioned_names = _unmentioned_public_methods(each_node, class_docstring)
552
+ if len(unmentioned_names) < MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH:
553
+ continue
554
+ issues.append(
555
+ f"Line {each_node.lineno}: {each_node.name} one-line docstring omits "
556
+ f"public method(s) {', '.join(unmentioned_names)} — widen the summary "
557
+ "so it names the class's full public surface"
558
+ )
559
+ if len(issues) >= MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES:
560
+ break
561
+ return issues[:MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES]
@@ -65,7 +65,9 @@ from code_rules_dead_module_constant import ( # noqa: E402
65
65
  check_dead_module_constants,
66
66
  )
67
67
  from code_rules_docstrings import ( # noqa: E402
68
+ check_class_docstring_names_public_methods,
68
69
  check_docstring_args_match_signature,
70
+ check_docstring_fallback_branch_coverage,
69
71
  check_docstring_format,
70
72
  )
71
73
  from code_rules_duplicate_body import ( # noqa: E402
@@ -248,6 +250,10 @@ def validate_content(
248
250
  all_issues.extend(check_boundary_types(effective_content, file_path))
249
251
  all_issues.extend(check_docstring_format(effective_content, file_path))
250
252
  all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
253
+ all_issues.extend(check_docstring_fallback_branch_coverage(effective_content, file_path))
254
+ all_issues.extend(
255
+ check_class_docstring_names_public_methods(effective_content, file_path)
256
+ )
251
257
  all_issues.extend(
252
258
  check_boolean_naming(
253
259
  effective_content,
@@ -0,0 +1,22 @@
1
+ # hooks/blocking/config
2
+
3
+ A Python package that holds shared constants for the verified-commit gate family. Three modules in `blocking/` import from here:
4
+
5
+ - `verification_verdict_store.py`
6
+ - `verified_commit_gate.py`
7
+ - `verifier_verdict_minter.py`
8
+
9
+ ## Key files
10
+
11
+ | File | Contents |
12
+ |---|---|
13
+ | `__init__.py` | Declares this as a regular package (not a namespace package) so it resolves first on `sys.path` |
14
+ | `verified_commit_constants.py` | All tunables for the gate: directory names, regex patterns for detecting verdict paths and obfuscation attempts, timeout values, git subcommand sets, bypass marker, and corrective messages |
15
+
16
+ ## Key constants in `verified_commit_constants.py`
17
+
18
+ - `VERIFICATION_BYPASS_MARKER` — the `# verify-skip` comment that exempts a single commit/push from the gate
19
+ - `MINTING_AGENT_TYPE` — `"code-verifier"`, the agent type whose SubagentStop hook mints verdicts
20
+ - `VERDICT_DIRECTORY_NAME` — `"verification"`, the directory under `~/.claude/` that holds verdict JSON files
21
+ - `DOCS_ONLY_EXTENSIONS` — extensions (`.md`, `.txt`, images) whose changes are mechanically exempt from the gate
22
+ - `CORRECTIVE_MESSAGE` / `VERDICT_DIRECTORY_GUARD_MESSAGE` — user-facing block messages
@@ -0,0 +1,262 @@
1
+ """Tests for check_class_docstring_names_public_methods — class prose breadth.
2
+
3
+ A class whose docstring is a single summary line names one responsibility. When
4
+ the class exposes a second public entry point the summary never names, the prose
5
+ under-describes the class — the same drift the os_update_workflow break reporter
6
+ hit when it grew a regular-pace method beside its coffee-break method.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.util
12
+ from pathlib import Path
13
+ from types import ModuleType
14
+
15
+
16
+ def _load_enforcer_module() -> ModuleType:
17
+ module_path = Path(__file__).parent / "code_rules_enforcer.py"
18
+ spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
19
+ assert spec is not None
20
+ assert spec.loader is not None
21
+ module = importlib.util.module_from_spec(spec)
22
+ spec.loader.exec_module(module)
23
+ return module
24
+
25
+
26
+ code_rules_enforcer = _load_enforcer_module()
27
+
28
+
29
+ def check_class_docstring_names_public_methods(content: str, file_path: str) -> list[str]:
30
+ return code_rules_enforcer.check_class_docstring_names_public_methods(content, file_path)
31
+
32
+
33
+ def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
34
+ return code_rules_enforcer.validate_content(content, file_path, old_content)
35
+
36
+
37
+ PRODUCTION_FILE_PATH = "/project/src/break_reporter.py"
38
+ TEST_FILE_PATH = "/project/src/test_break_reporter.py"
39
+ HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/example.py"
40
+
41
+
42
+ def _narrow_class_with_widened_surface() -> str:
43
+ return (
44
+ "class ConsoleBreakReporter:\n"
45
+ ' """Run a coffee break with operator visibility: announce, then count down."""\n'
46
+ "\n"
47
+ " async def pause_then_resume(self, seconds: float) -> None:\n"
48
+ " await self._sleep(seconds)\n"
49
+ "\n"
50
+ " async def stretch_then_resume(self, seconds: float) -> None:\n"
51
+ " await self._sleep(seconds)\n"
52
+ )
53
+
54
+
55
+ def test_should_flag_single_line_docstring_omitting_two_public_methods() -> None:
56
+ issues = check_class_docstring_names_public_methods(
57
+ _narrow_class_with_widened_surface(), PRODUCTION_FILE_PATH
58
+ )
59
+ assert any("pause_then_resume" in each for each in issues), (
60
+ f"Expected omitted-method flag, got: {issues!r}"
61
+ )
62
+ assert any("stretch_then_resume" in each for each in issues)
63
+ assert len(issues) == 1
64
+
65
+
66
+ def test_should_not_flag_when_summary_names_every_public_method() -> None:
67
+ source = (
68
+ "class ConsoleBreakReporter:\n"
69
+ ' """Announce a pause then resume, or stretch then resume, with a countdown."""\n'
70
+ "\n"
71
+ " async def pause_then_resume(self, seconds: float) -> None:\n"
72
+ " await self._sleep(seconds)\n"
73
+ "\n"
74
+ " async def stretch_then_resume(self, seconds: float) -> None:\n"
75
+ " await self._sleep(seconds)\n"
76
+ )
77
+ issues = check_class_docstring_names_public_methods(source, PRODUCTION_FILE_PATH)
78
+ assert issues == [], f"Summary naming every method must not flag, got: {issues!r}"
79
+
80
+
81
+ def test_should_not_flag_when_only_one_public_method_is_omitted() -> None:
82
+ source = (
83
+ "class ConsoleBreakReporter:\n"
84
+ ' """Pause then resume the submission run with an operator countdown."""\n'
85
+ "\n"
86
+ " async def pause_then_resume(self, seconds: float) -> None:\n"
87
+ " await self._sleep(seconds)\n"
88
+ "\n"
89
+ " async def stretch_then_resume(self, seconds: float) -> None:\n"
90
+ " await self._sleep(seconds)\n"
91
+ )
92
+ issues = check_class_docstring_names_public_methods(source, PRODUCTION_FILE_PATH)
93
+ assert issues == [], f"A single omitted method must not flag, got: {issues!r}"
94
+
95
+
96
+ def test_should_not_flag_multi_line_docstring_body() -> None:
97
+ source = (
98
+ "class ConsoleBreakReporter:\n"
99
+ ' """Run a coffee break with operator visibility.\n'
100
+ "\n"
101
+ " Also paces the regular between-theme waits through the same seam.\n"
102
+ ' """\n'
103
+ "\n"
104
+ " async def pause_then_resume(self, seconds: float) -> None:\n"
105
+ " await self._sleep(seconds)\n"
106
+ "\n"
107
+ " async def stretch_then_resume(self, seconds: float) -> None:\n"
108
+ " await self._sleep(seconds)\n"
109
+ )
110
+ issues = check_class_docstring_names_public_methods(source, PRODUCTION_FILE_PATH)
111
+ assert issues == [], f"Multi-line docstrings go to the audit lane, got: {issues!r}"
112
+
113
+
114
+ def test_should_not_flag_class_with_single_public_method() -> None:
115
+ source = (
116
+ "class ConsoleBreakReporter:\n"
117
+ ' """Run a coffee break with operator visibility."""\n'
118
+ "\n"
119
+ " async def pause_then_resume(self, seconds: float) -> None:\n"
120
+ " await self._sleep(seconds)\n"
121
+ )
122
+ issues = check_class_docstring_names_public_methods(source, PRODUCTION_FILE_PATH)
123
+ assert issues == [], f"A one-method class must not flag, got: {issues!r}"
124
+
125
+
126
+ def test_should_skip_private_methods_when_counting_surface() -> None:
127
+ source = (
128
+ "class ConsoleBreakReporter:\n"
129
+ ' """Run a coffee break with operator visibility."""\n'
130
+ "\n"
131
+ " async def pause_then_resume(self, seconds: float) -> None:\n"
132
+ " await self._sleep(seconds)\n"
133
+ "\n"
134
+ " async def _sleep(self, seconds: float) -> None:\n"
135
+ " await self._clock.sleep(seconds)\n"
136
+ "\n"
137
+ " def __init__(self) -> None:\n"
138
+ " self._clock = None\n"
139
+ )
140
+ issues = check_class_docstring_names_public_methods(source, PRODUCTION_FILE_PATH)
141
+ assert issues == [], f"Private and dunder methods are not public surface, got: {issues!r}"
142
+
143
+
144
+ def test_should_skip_class_without_docstring() -> None:
145
+ source = (
146
+ "class ConsoleBreakReporter:\n"
147
+ " async def pause_then_resume(self, seconds: float) -> None:\n"
148
+ " await self._sleep(seconds)\n"
149
+ "\n"
150
+ " async def stretch_then_resume(self, seconds: float) -> None:\n"
151
+ " await self._sleep(seconds)\n"
152
+ )
153
+ issues = check_class_docstring_names_public_methods(source, PRODUCTION_FILE_PATH)
154
+ assert issues == [], f"No-docstring classes are out of scope, got: {issues!r}"
155
+
156
+
157
+ def test_should_skip_test_file() -> None:
158
+ issues = check_class_docstring_names_public_methods(
159
+ _narrow_class_with_widened_surface(), TEST_FILE_PATH
160
+ )
161
+ assert issues == [], f"Test files exempt, got: {issues!r}"
162
+
163
+
164
+ def test_should_skip_hook_infrastructure() -> None:
165
+ issues = check_class_docstring_names_public_methods(
166
+ _narrow_class_with_widened_surface(), HOOK_INFRASTRUCTURE_PATH
167
+ )
168
+ assert issues == [], f"Hook infrastructure exempt, got: {issues!r}"
169
+
170
+
171
+ def test_should_handle_syntax_error_gracefully() -> None:
172
+ issues = check_class_docstring_names_public_methods("class Broken(\n", PRODUCTION_FILE_PATH)
173
+ assert issues == [], f"Syntax error must yield no issues, got: {issues!r}"
174
+
175
+
176
+ def _real_break_reporter_drift() -> str:
177
+ return (
178
+ "class ConsoleBreakReporter:\n"
179
+ ' """Run a coffee-break with operator visibility: announce, then count down."""\n'
180
+ "\n"
181
+ " async def announce_and_pause(self, nominal_break_seconds: float) -> None:\n"
182
+ " await self._announce(nominal_break_seconds)\n"
183
+ "\n"
184
+ " async def announce_and_pause_exact(self, break_seconds: float) -> None:\n"
185
+ " await self._announce(break_seconds)\n"
186
+ )
187
+
188
+
189
+ def test_should_flag_real_break_reporter_widened_surface() -> None:
190
+ issues = check_class_docstring_names_public_methods(
191
+ _real_break_reporter_drift(), PRODUCTION_FILE_PATH
192
+ )
193
+ assert any("announce_and_pause_exact" in each for each in issues), (
194
+ f"Expected the regular-pace method to flag, got: {issues!r}"
195
+ )
196
+ assert any("announce_and_pause" in each for each in issues)
197
+ assert len(issues) == 1
198
+
199
+
200
+ def _overload_class_omitting_the_only_method() -> str:
201
+ return (
202
+ "from typing import overload\n"
203
+ "\n"
204
+ "class PayloadTransformer:\n"
205
+ ' """Hold a fixed payload value for later use."""\n'
206
+ "\n"
207
+ " @overload\n"
208
+ " def transform(self, payload: str) -> dict[str, str]: ...\n"
209
+ "\n"
210
+ " @overload\n"
211
+ " def transform(self, payload: bytes) -> dict[str, str]: ...\n"
212
+ "\n"
213
+ " def transform(self, payload: str | bytes) -> dict[str, str]:\n"
214
+ " return self._normalize(payload)\n"
215
+ )
216
+
217
+
218
+ def test_should_not_flag_single_overloaded_method_below_breadth_threshold() -> None:
219
+ issues = check_class_docstring_names_public_methods(
220
+ _overload_class_omitting_the_only_method(), PRODUCTION_FILE_PATH
221
+ )
222
+ assert issues == [], f"One overloaded public method must not flag, got: {issues!r}"
223
+
224
+
225
+ def test_should_not_repeat_an_overloaded_name_in_the_issue_message() -> None:
226
+ source = (
227
+ "from typing import overload\n"
228
+ "\n"
229
+ "class PayloadTransformer:\n"
230
+ ' """Hold a fixed payload value for later use."""\n'
231
+ "\n"
232
+ " @overload\n"
233
+ " def transform(self, payload: str) -> dict[str, str]: ...\n"
234
+ "\n"
235
+ " @overload\n"
236
+ " def transform(self, payload: bytes) -> dict[str, str]: ...\n"
237
+ "\n"
238
+ " def transform(self, payload: str | bytes) -> dict[str, str]:\n"
239
+ " return self._normalize(payload)\n"
240
+ "\n"
241
+ " def reset(self) -> None:\n"
242
+ " self._payload = None\n"
243
+ )
244
+ issues = check_class_docstring_names_public_methods(source, PRODUCTION_FILE_PATH)
245
+ assert len(issues) == 1, f"Expected a single drift issue, got: {issues!r}"
246
+ only_issue = issues[0]
247
+ assert only_issue.count("transform") == 1, (
248
+ f"Overloaded name must appear once in the message, got: {only_issue!r}"
249
+ )
250
+ assert "reset" in only_issue
251
+
252
+
253
+ def test_validate_content_surfaces_class_docstring_breadth_drift() -> None:
254
+ issues = validate_content(
255
+ _narrow_class_with_widened_surface(), PRODUCTION_FILE_PATH, old_content=""
256
+ )
257
+ matching_issues = [
258
+ each for each in issues if "pause_then_resume" in each and "public method" in each
259
+ ]
260
+ assert matching_issues, (
261
+ f"Expected validate_content to surface the class-breadth drift, got: {issues!r}"
262
+ )
@@ -56,6 +56,12 @@ def _check(source: str, file_path: str) -> list[str]:
56
56
  return code_rules_enforcer.check_dead_module_constants(source, file_path)
57
57
 
58
58
 
59
+ def _check_edit(fragment: str, full_file_content: str, file_path: str) -> list[str]:
60
+ return code_rules_enforcer.check_dead_module_constants(
61
+ fragment, file_path, full_file_content
62
+ )
63
+
64
+
59
65
  def _build_constants_package(
60
66
  workflow_directory: Path,
61
67
  constants_body: str,
@@ -94,6 +100,36 @@ def test_flags_constant_imported_by_no_module_in_the_tree(neutral_root: Path) ->
94
100
  ), f"Imported constants must not be flagged, got: {issues}"
95
101
 
96
102
 
103
+ def test_flags_a_dead_constant_added_by_an_edit_to_an_existing_module(
104
+ neutral_root: Path,
105
+ ) -> None:
106
+ consumer_body = (
107
+ "from report_constants.render_report_constants import (\n"
108
+ " MEDIUM_CODE,\n"
109
+ " MEDIUM_TERMINAL,\n"
110
+ ")\n"
111
+ "\n"
112
+ "def panel_class(medium: str) -> str:\n"
113
+ " if medium == MEDIUM_TERMINAL:\n"
114
+ " return 'terminal'\n"
115
+ " return 'code-panel' if medium == MEDIUM_CODE else 'text-panel'\n"
116
+ )
117
+ prior_body = 'MEDIUM_TERMINAL = "terminal"\nMEDIUM_CODE = "code"\n'
118
+ constants_path = _build_constants_package(
119
+ neutral_root / "workflow", prior_body, consumer_body
120
+ )
121
+ edit_fragment = 'MEDIUM_CODE = "code"\nMEDIUM_TEXT = "text"\n'
122
+ post_edit_body = prior_body + 'MEDIUM_TEXT = "text"\n'
123
+ issues = _check_edit(edit_fragment, post_edit_body, str(constants_path))
124
+ assert any("MEDIUM_TEXT" in each_issue for each_issue in issues), (
125
+ f"An Edit that inserts a dead constant must be flagged, got: {issues}"
126
+ )
127
+ assert not any(
128
+ "MEDIUM_TERMINAL" in each_issue or "MEDIUM_CODE" in each_issue
129
+ for each_issue in issues
130
+ ), f"Imported constants must not be flagged on an edit, got: {issues}"
131
+
132
+
97
133
  def test_does_not_flag_constant_imported_one_directory_up(neutral_root: Path) -> None:
98
134
  consumer_uses_text = (
99
135
  "from report_constants.render_report_constants import (\n"