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.
- package/_shared/CLAUDE.md +13 -0
- package/_shared/pr-loop/CLAUDE.md +24 -0
- package/_shared/pr-loop/scripts/CLAUDE.md +30 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +21 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +32 -0
- package/agents/CLAUDE.md +29 -0
- package/audit-rubrics/CLAUDE.md +41 -0
- package/audit-rubrics/category_rubrics/CLAUDE.md +36 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/audit-rubrics/prompts/CLAUDE.md +36 -0
- package/bin/CLAUDE.md +28 -0
- package/commands/CLAUDE.md +25 -0
- package/docs/CLAUDE.md +28 -0
- package/docs/references/CLAUDE.md +13 -0
- package/hooks/CLAUDE.md +31 -0
- package/hooks/advisory/CLAUDE.md +16 -0
- package/hooks/blocking/CLAUDE.md +107 -0
- package/hooks/blocking/code_rules_constants_config.py +7 -4
- package/hooks/blocking/code_rules_docstrings.py +253 -0
- package/hooks/blocking/code_rules_enforcer.py +6 -0
- package/hooks/blocking/config/CLAUDE.md +22 -0
- package/hooks/blocking/test_code_rules_enforcer_class_docstring_methods.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +36 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_fallback_branch.py +398 -0
- package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +9 -0
- package/hooks/diagnostic/CLAUDE.md +43 -0
- package/hooks/diagnostic/migrations/CLAUDE.md +16 -0
- package/hooks/diagnostic/queries/CLAUDE.md +19 -0
- package/hooks/git-hooks/CLAUDE.md +28 -0
- package/hooks/git-hooks/git_hooks_constants/CLAUDE.md +21 -0
- package/hooks/hooks_constants/CLAUDE.md +60 -0
- package/hooks/hooks_constants/blocking_check_limits.py +18 -0
- package/hooks/lifecycle/CLAUDE.md +18 -0
- package/hooks/observability/CLAUDE.md +16 -0
- package/hooks/session/CLAUDE.md +21 -0
- package/hooks/validation/CLAUDE.md +19 -0
- package/hooks/validators/CLAUDE.md +49 -0
- package/hooks/workflow/CLAUDE.md +22 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +46 -0
- package/rules/docstring-prose-matches-implementation.md +3 -2
- package/scripts/CLAUDE.md +34 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +14 -0
- package/scripts/sync_to_cursor/CLAUDE.md +23 -0
- package/scripts/tests/CLAUDE.md +18 -0
- package/skills/CLAUDE.md +66 -0
- package/skills/_shared/CLAUDE.md +11 -0
- package/skills/_shared/pr-loop/CLAUDE.md +27 -0
- package/skills/_shared/pr-loop/prompts/CLAUDE.md +9 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +23 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +19 -0
- package/skills/anthropic-plan/CLAUDE.md +34 -0
- package/skills/anthropic-plan/SKILL.md +1 -1
- package/skills/anthropic-plan/scripts/CLAUDE.md +11 -0
- package/skills/anthropic-plan/scripts/anthropic_plan_scripts_constants/CLAUDE.md +16 -0
- package/skills/anthropic-plan/templates/CLAUDE.md +13 -0
- package/skills/anthropic-plan/workflow/CLAUDE.md +14 -0
- package/skills/auditing-claude-config/CLAUDE.md +20 -0
- package/skills/autoconverge/CLAUDE.md +30 -0
- package/skills/autoconverge/reference/CLAUDE.md +12 -0
- package/skills/autoconverge/workflow/CLAUDE.md +23 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/CLAUDE.md +16 -0
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +135 -0
- package/skills/autoconverge/workflow/converge.mjs +119 -2
- package/skills/bdd-protocol/CLAUDE.md +26 -0
- package/skills/bdd-protocol/references/CLAUDE.md +10 -0
- package/skills/bg-agent/CLAUDE.md +17 -0
- package/skills/bugteam/CLAUDE.md +30 -0
- package/skills/bugteam/reference/CLAUDE.md +22 -0
- package/skills/bugteam/reference/obstacles/CLAUDE.md +24 -0
- package/skills/bugteam/scripts/CLAUDE.md +36 -0
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +20 -0
- package/skills/caveman/CLAUDE.md +15 -0
- package/skills/code/CLAUDE.md +17 -0
- package/skills/copilot-review/CLAUDE.md +17 -0
- package/skills/deep-research/CLAUDE.md +17 -0
- package/skills/doc-gist/CLAUDE.md +25 -0
- package/skills/doc-gist/references/CLAUDE.md +9 -0
- package/skills/doc-gist/references/examples/CLAUDE.md +25 -0
- package/skills/doc-gist/scripts/CLAUDE.md +27 -0
- package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +10 -0
- package/skills/everything-search/CLAUDE.md +17 -0
- package/skills/findbugs/CLAUDE.md +20 -0
- package/skills/fixbugs/CLAUDE.md +19 -0
- package/skills/fresh-branch/CLAUDE.md +15 -0
- package/skills/gh-paginate/CLAUDE.md +18 -0
- package/skills/gotcha/CLAUDE.md +33 -0
- package/skills/implement/CLAUDE.md +27 -0
- package/skills/implement/scripts/CLAUDE.md +22 -0
- package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +22 -0
- package/skills/logifix/CLAUDE.md +36 -0
- package/skills/logifix/scripts/CLAUDE.md +16 -0
- package/skills/monitor-open-prs/CLAUDE.md +34 -0
- package/skills/monitor-open-prs/scripts/CLAUDE.md +17 -0
- package/skills/pr-consistency-audit/CLAUDE.md +34 -0
- package/skills/pr-consistency-audit/reference/CLAUDE.md +16 -0
- package/skills/pr-converge/CLAUDE.md +29 -0
- package/skills/pr-converge/pr_converge_skill_constants/CLAUDE.md +26 -0
- package/skills/pr-converge/reference/CLAUDE.md +27 -0
- package/skills/pr-converge/reference/obstacles/CLAUDE.md +23 -0
- package/skills/pr-converge/scripts/CLAUDE.md +36 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +17 -0
- package/skills/pr-converge/workflows/CLAUDE.md +16 -0
- package/skills/pr-review-responder/CLAUDE.md +35 -0
- package/skills/pre-compact/CLAUDE.md +24 -0
- package/skills/qbug/CLAUDE.md +40 -0
- package/skills/rebase/CLAUDE.md +32 -0
- package/skills/recall/CLAUDE.md +30 -0
- package/skills/refine/CLAUDE.md +44 -0
- package/skills/refine/templates/CLAUDE.md +17 -0
- package/skills/remember/CLAUDE.md +31 -0
- package/skills/research-mode/CLAUDE.md +35 -0
- package/skills/session-log/CLAUDE.md +31 -0
- package/skills/session-tidy/CLAUDE.md +36 -0
- package/skills/skill-builder/CLAUDE.md +45 -0
- package/skills/skill-builder/references/CLAUDE.md +19 -0
- package/skills/skill-builder/templates/CLAUDE.md +14 -0
- package/skills/skill-builder/workflows/CLAUDE.md +17 -0
- package/skills/structure-prompt/CLAUDE.md +42 -0
- package/skills/structure-prompt/reference/CLAUDE.md +28 -0
- package/skills/task-build/CLAUDE.md +28 -0
- package/skills/update/CLAUDE.md +38 -0
- package/skills/verified-build/CLAUDE.md +33 -0
- 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"
|