claude-dev-env 1.44.0 → 1.46.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/CLAUDE.md +9 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +426 -85
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +82 -9
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +630 -21
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +15 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +57 -0
- package/agents/clean-coder.md +7 -1
- package/agents/code-quality-agent.md +8 -5
- package/hooks/blocking/code_rules_enforcer.py +1562 -37
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +19 -0
- package/hooks/blocking/open_questions_in_plans_blocker.py +249 -0
- package/hooks/blocking/test_code_rules_enforcer.py +1389 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +292 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +46 -8
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +189 -0
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +210 -0
- package/hooks/blocking/test_code_rules_enforcer_tests_isolate_home_temp.py +1512 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +9 -5
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +30 -0
- package/hooks/blocking/test_open_questions_in_plans_blocker.py +790 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/banned_identifiers_constants.py +19 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +129 -2
- package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +35 -0
- package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +125 -0
- package/package.json +1 -1
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +34 -13
- package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -2
- package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -4
- package/skills/_shared/pr-loop/scripts/test__path_resolver.py +57 -0
- package/skills/_shared/pr-loop/scripts/test_init_loop_state.py +48 -0
- package/skills/_shared/pr-loop/scripts/test_teardown_worktrees.py +59 -0
- package/skills/bugteam/PROMPTS.md +48 -12
- package/skills/bugteam/reference/team-setup.md +4 -2
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +487 -76
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +22 -1
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +602 -12
- package/skills/pr-converge/SKILL.md +5 -0
- package/skills/pr-converge/reference/per-tick.md +14 -5
- package/skills/pr-converge/reference/state-schema.md +7 -3
- package/skills/pr-converge/scripts/check_convergence.py +27 -1
- package/skills/pr-converge/scripts/test_check_convergence.py +28 -0
|
@@ -6,7 +6,7 @@ import importlib.util
|
|
|
6
6
|
import re
|
|
7
7
|
import subprocess
|
|
8
8
|
import sys
|
|
9
|
-
from collections.abc import Callable
|
|
9
|
+
from collections.abc import Callable, Iterator
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
ValidateContentCallable = Callable[..., list[str]]
|
|
@@ -15,12 +15,22 @@ from bugteam_scripts_constants.bugteam_code_rules_gate_constants import (
|
|
|
15
15
|
ALL_CODE_FILE_EXTENSIONS,
|
|
16
16
|
ALL_COLUMN_MAGIC_FALSE_VALUES,
|
|
17
17
|
ALL_GIT_DIFF_CACHED_ARGS,
|
|
18
|
-
ALL_JS_FILE_EXTENSIONS,
|
|
19
18
|
BUGTEAM_CODE_RULES_GATE_PREFIX,
|
|
20
19
|
EXIT_CODE_ENFORCER_MISSING,
|
|
20
|
+
FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX,
|
|
21
|
+
FUNCTION_LENGTH_SPAN_GROUP_INDEX,
|
|
22
|
+
FUNCTION_LENGTH_VIOLATION_PATTERN,
|
|
23
|
+
BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX,
|
|
24
|
+
BANNED_NOUN_SPAN_GROUP_INDEX,
|
|
25
|
+
BANNED_NOUN_VIOLATION_PATTERN,
|
|
21
26
|
HUNK_HEADER_RAW_PATTERN,
|
|
27
|
+
ISOLATION_DEFINITION_LINE_GROUP_INDEX,
|
|
28
|
+
ISOLATION_SPAN_GROUP_INDEX,
|
|
29
|
+
ISOLATION_VIOLATION_PATTERN,
|
|
30
|
+
MAX_VIOLATIONS_PER_CHECK,
|
|
22
31
|
MAXIMUM_COLUMN_TUPLE_ELEMENT_COUNT,
|
|
23
32
|
MAXIMUM_ISSUES_TO_REPORT,
|
|
33
|
+
PYTHON_FILE_EXTENSION,
|
|
24
34
|
VIOLATION_LINE_RAW_PATTERN,
|
|
25
35
|
)
|
|
26
36
|
|
|
@@ -416,6 +426,68 @@ def is_code_path(file_path: Path) -> bool:
|
|
|
416
426
|
return suffix in ALL_CODE_FILE_EXTENSIONS
|
|
417
427
|
|
|
418
428
|
|
|
429
|
+
def _path_is_eligible_for_validation(
|
|
430
|
+
resolved_path: Path,
|
|
431
|
+
repository_root: Path,
|
|
432
|
+
read_staged_content_flag: bool,
|
|
433
|
+
) -> bool:
|
|
434
|
+
"""Decide whether *resolved_path* should be validated by the gate.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
resolved_path: A resolved candidate path already confirmed to live
|
|
438
|
+
under *repository_root*.
|
|
439
|
+
repository_root: The repository root used to compute the relative path.
|
|
440
|
+
read_staged_content_flag: When True, require staged-index presence so
|
|
441
|
+
files staged for add or modify are validated and staged deletions
|
|
442
|
+
are skipped; when False, require working-tree presence.
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
True when the path carries a code extension and exists in the source
|
|
446
|
+
the gate will read; False otherwise.
|
|
447
|
+
"""
|
|
448
|
+
if not is_code_path(resolved_path):
|
|
449
|
+
return False
|
|
450
|
+
if read_staged_content_flag:
|
|
451
|
+
relative_posix = str(
|
|
452
|
+
resolved_path.relative_to(repository_root.resolve())
|
|
453
|
+
).replace("\\", "/")
|
|
454
|
+
return staged_blob_exists(repository_root.resolve(), relative_posix)
|
|
455
|
+
return resolved_path.is_file()
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _resolve_eligible_code_path(
|
|
459
|
+
candidate_path: Path,
|
|
460
|
+
repository_root: Path,
|
|
461
|
+
read_staged_content_flag: bool = False,
|
|
462
|
+
) -> Path | None:
|
|
463
|
+
"""Resolve *candidate_path* and return it only when the gate should scan it.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
candidate_path: One file path from the gate's candidate set.
|
|
467
|
+
repository_root: The repository root the resolved path must fall under.
|
|
468
|
+
read_staged_content_flag: When True, eligibility requires staged-index
|
|
469
|
+
presence; when False, it requires working-tree presence.
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
The resolved path when it lives under *repository_root*, carries a code
|
|
473
|
+
extension, and is present in the source the gate will read; otherwise
|
|
474
|
+
None.
|
|
475
|
+
"""
|
|
476
|
+
try:
|
|
477
|
+
resolved = candidate_path.resolve()
|
|
478
|
+
except OSError:
|
|
479
|
+
return None
|
|
480
|
+
try:
|
|
481
|
+
resolved.relative_to(repository_root.resolve())
|
|
482
|
+
except ValueError:
|
|
483
|
+
return None
|
|
484
|
+
if not _path_is_eligible_for_validation(
|
|
485
|
+
resolved, repository_root, read_staged_content_flag
|
|
486
|
+
):
|
|
487
|
+
return None
|
|
488
|
+
return resolved
|
|
489
|
+
|
|
490
|
+
|
|
419
491
|
def check_database_column_string_magic(content: str, file_path: str) -> list[str]:
|
|
420
492
|
"""Flag string literals that look like database/HTTP column or key names inside function bodies.
|
|
421
493
|
|
|
@@ -472,64 +544,129 @@ def check_database_column_string_magic(content: str, file_path: str) -> list[str
|
|
|
472
544
|
return issues
|
|
473
545
|
|
|
474
546
|
|
|
547
|
+
def _iter_calls_excluding_nested_functions(node: ast.AST) -> Iterator[ast.Call]:
|
|
548
|
+
for each_child in ast.iter_child_nodes(node):
|
|
549
|
+
if isinstance(each_child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
550
|
+
continue
|
|
551
|
+
if isinstance(each_child, ast.Call):
|
|
552
|
+
yield each_child
|
|
553
|
+
continue
|
|
554
|
+
yield from _iter_calls_excluding_nested_functions(each_child)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _module_level_optional_kwargs_by_name(tree: ast.Module) -> dict[str, set[str]]:
|
|
558
|
+
function_signatures: dict[str, set[str]] = {}
|
|
559
|
+
for each_node in ast.iter_child_nodes(tree):
|
|
560
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
561
|
+
optional_kwargs: set[str] = set()
|
|
562
|
+
for each_kwonly, each_default in zip(
|
|
563
|
+
each_node.args.kwonlyargs, each_node.args.kw_defaults
|
|
564
|
+
):
|
|
565
|
+
if each_default is not None:
|
|
566
|
+
optional_kwargs.add(each_kwonly.arg)
|
|
567
|
+
positional_defaults = each_node.args.defaults
|
|
568
|
+
positional_args_with_defaults = (
|
|
569
|
+
each_node.args.args[-len(positional_defaults):]
|
|
570
|
+
if positional_defaults
|
|
571
|
+
else []
|
|
572
|
+
)
|
|
573
|
+
for each_positional_arg in positional_args_with_defaults:
|
|
574
|
+
optional_kwargs.add(each_positional_arg.arg)
|
|
575
|
+
function_signatures[each_node.name] = optional_kwargs
|
|
576
|
+
return function_signatures
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _class_method_node_ids(tree: ast.Module) -> set[int]:
|
|
580
|
+
class_method_node_ids: set[int] = set()
|
|
581
|
+
for each_class_def in ast.walk(tree):
|
|
582
|
+
if not isinstance(each_class_def, ast.ClassDef):
|
|
583
|
+
continue
|
|
584
|
+
for each_class_body_node in each_class_def.body:
|
|
585
|
+
if isinstance(
|
|
586
|
+
each_class_body_node, (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
587
|
+
):
|
|
588
|
+
class_method_node_ids.add(id(each_class_body_node))
|
|
589
|
+
return class_method_node_ids
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _wrapper_dropped_kwarg_findings(
|
|
593
|
+
wrapper_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
594
|
+
kwargs_by_function_name: dict[str, set[str]],
|
|
595
|
+
) -> Iterator[str]:
|
|
596
|
+
wrapper_kwargs = kwargs_by_function_name.get(wrapper_node.name, set())
|
|
597
|
+
for each_call in _iter_calls_excluding_nested_functions(wrapper_node):
|
|
598
|
+
if isinstance(each_call.func, ast.Name):
|
|
599
|
+
delegate_name = each_call.func.id
|
|
600
|
+
elif isinstance(each_call.func, ast.Attribute):
|
|
601
|
+
delegate_name = each_call.func.attr
|
|
602
|
+
else:
|
|
603
|
+
continue
|
|
604
|
+
delegate_kwargs = kwargs_by_function_name.get(delegate_name)
|
|
605
|
+
if delegate_kwargs is None:
|
|
606
|
+
continue
|
|
607
|
+
missing = delegate_kwargs - wrapper_kwargs
|
|
608
|
+
if missing:
|
|
609
|
+
yield (
|
|
610
|
+
f"Line {wrapper_node.lineno}: Wrapper {wrapper_node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
475
614
|
def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
|
|
476
|
-
"""Flag public
|
|
615
|
+
"""Flag calls inside public functions that drop a same-file delegate's optional kwargs.
|
|
477
616
|
|
|
478
617
|
Walks the AST. For every public function (name does not start with '_'),
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
618
|
+
inspects every ast.Call inside its body and emits one finding per call
|
|
619
|
+
whose target name matches a same-file function that exposes optional
|
|
620
|
+
kwargs the enclosing public function does not also accept. Emission is
|
|
621
|
+
capped at MAX_VIOLATIONS_PER_CHECK findings per call to run_gate.
|
|
622
|
+
|
|
623
|
+
Limitations:
|
|
624
|
+
- Only module-level FunctionDef nodes contribute signatures, and ClassDef
|
|
625
|
+
methods are skipped both as signature sources and as wrapper candidates:
|
|
626
|
+
a class method's signature is unrelated to a free-function delegate's
|
|
627
|
+
keyword surface, so treating it as a wrapper produces false positives.
|
|
628
|
+
- ast.Attribute calls match by attribute name only; the receiver type is
|
|
629
|
+
not checked, so `self.fetch(...)` and `other.fetch(...)` both match a
|
|
630
|
+
module-level `fetch` definition.
|
|
631
|
+
- Nested call expressions inside another call's arguments are not treated as
|
|
632
|
+
separate call sites; only the enclosing Call is inspected. This avoids
|
|
633
|
+
false positives where a callee nested as an argument is confused with a
|
|
634
|
+
top-level delegate invocation (for example `delegate(helper(x))`).
|
|
482
635
|
|
|
483
636
|
Args:
|
|
484
|
-
content:
|
|
485
|
-
file_path:
|
|
637
|
+
content: File content as a single string for AST parsing.
|
|
638
|
+
file_path: Repository-relative POSIX path of the file (used to
|
|
639
|
+
skip non-Python code extensions early).
|
|
486
640
|
|
|
487
641
|
Returns:
|
|
488
|
-
List of violation
|
|
642
|
+
List of violation strings, one per dropped optional kwarg. Returns
|
|
643
|
+
an empty list when the file is not Python or has a syntax error.
|
|
489
644
|
"""
|
|
490
|
-
|
|
645
|
+
non_python_code_extensions = ALL_CODE_FILE_EXTENSIONS - {PYTHON_FILE_EXTENSION}
|
|
646
|
+
lowercase_file_path = file_path.lower()
|
|
647
|
+
if any(
|
|
648
|
+
lowercase_file_path.endswith(each_extension)
|
|
649
|
+
for each_extension in non_python_code_extensions
|
|
650
|
+
):
|
|
491
651
|
return []
|
|
492
652
|
try:
|
|
493
653
|
tree = ast.parse(content)
|
|
494
654
|
except SyntaxError:
|
|
495
655
|
return []
|
|
496
|
-
function_signatures
|
|
497
|
-
|
|
498
|
-
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
499
|
-
optional_kwargs: set[str] = set()
|
|
500
|
-
for each_kwonly, each_default in zip(each_node.args.kwonlyargs, each_node.args.kw_defaults):
|
|
501
|
-
if each_default is not None:
|
|
502
|
-
optional_kwargs.add(each_kwonly.arg)
|
|
503
|
-
function_signatures[each_node.name] = optional_kwargs
|
|
656
|
+
function_signatures = _module_level_optional_kwargs_by_name(tree)
|
|
657
|
+
class_method_node_ids = _class_method_node_ids(tree)
|
|
504
658
|
issues: list[str] = []
|
|
505
659
|
for each_node in ast.walk(tree):
|
|
506
660
|
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
507
661
|
continue
|
|
662
|
+
if id(each_node) in class_method_node_ids:
|
|
663
|
+
continue
|
|
508
664
|
if each_node.name.startswith("_"):
|
|
509
665
|
continue
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
if
|
|
513
|
-
|
|
514
|
-
if not isinstance(each_call.func, ast.Attribute):
|
|
515
|
-
continue
|
|
516
|
-
delegate_name = each_call.func.attr
|
|
517
|
-
delegate_kwargs = function_signatures.get(delegate_name)
|
|
518
|
-
if delegate_kwargs is None:
|
|
519
|
-
continue
|
|
520
|
-
missing = delegate_kwargs - wrapper_kwargs
|
|
521
|
-
if missing:
|
|
522
|
-
issues.append(
|
|
523
|
-
f"Line {each_node.lineno}: Wrapper {each_node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
|
|
524
|
-
)
|
|
525
|
-
if len(issues) >= MAXIMUM_ISSUES_TO_REPORT:
|
|
526
|
-
print(
|
|
527
|
-
f"{BUGTEAM_CODE_RULES_GATE_PREFIX}check_wrapper_plumb_through "
|
|
528
|
-
f"cap reached at {MAXIMUM_ISSUES_TO_REPORT} issues for {file_path}; "
|
|
529
|
-
"additional matches were dropped.",
|
|
530
|
-
file=sys.stderr,
|
|
531
|
-
)
|
|
532
|
-
return issues
|
|
666
|
+
for each_finding in _wrapper_dropped_kwarg_findings(each_node, function_signatures):
|
|
667
|
+
issues.append(each_finding)
|
|
668
|
+
if len(issues) >= MAX_VIOLATIONS_PER_CHECK:
|
|
669
|
+
return issues
|
|
533
670
|
return issues
|
|
534
671
|
|
|
535
672
|
|
|
@@ -639,8 +776,8 @@ def whole_file_line_set(file_path: Path) -> set[int]:
|
|
|
639
776
|
"no lines changed" and silently downgrades blocking violations.
|
|
640
777
|
"""
|
|
641
778
|
try:
|
|
642
|
-
total_lines = len(file_path.read_text().splitlines())
|
|
643
|
-
except OSError as read_error:
|
|
779
|
+
total_lines = len(file_path.read_text(encoding="utf-8").splitlines())
|
|
780
|
+
except (OSError, UnicodeDecodeError) as read_error:
|
|
644
781
|
print(
|
|
645
782
|
f"{BUGTEAM_CODE_RULES_GATE_PREFIX}whole_file_line_set could not read "
|
|
646
783
|
f"{file_path}: {type(read_error).__name__}: {read_error}",
|
|
@@ -703,24 +840,146 @@ def extract_violation_line_number(violation_text: str) -> int | None:
|
|
|
703
840
|
return int(match_result.group(1))
|
|
704
841
|
|
|
705
842
|
|
|
843
|
+
def function_length_span_range(violation_text: str) -> range | None:
|
|
844
|
+
"""Return the declared line range of a function-length violation, or None.
|
|
845
|
+
|
|
846
|
+
The enforcer's function-length message carries the definition line and
|
|
847
|
+
the function's line span: ``Function 'NAME' (defined at line X) is Y
|
|
848
|
+
lines - ...``. The function occupies lines ``X`` through ``X + Y - 1``
|
|
849
|
+
inclusive.
|
|
850
|
+
|
|
851
|
+
Args:
|
|
852
|
+
violation_text: A single violation string emitted by the enforcer.
|
|
853
|
+
|
|
854
|
+
Returns:
|
|
855
|
+
A ``range`` covering the function's declared line span, or None when
|
|
856
|
+
the text is not a function-length violation.
|
|
857
|
+
"""
|
|
858
|
+
span_match = FUNCTION_LENGTH_VIOLATION_PATTERN.search(violation_text)
|
|
859
|
+
if span_match is None:
|
|
860
|
+
return None
|
|
861
|
+
definition_line = int(span_match.group(FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX))
|
|
862
|
+
line_span = int(span_match.group(FUNCTION_LENGTH_SPAN_GROUP_INDEX))
|
|
863
|
+
return range(definition_line, definition_line + line_span)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def isolation_span_range(violation_text: str) -> range | None:
|
|
867
|
+
"""Return the enclosing test-function line range of an isolation violation.
|
|
868
|
+
|
|
869
|
+
The enforcer's HOME/TMP isolation message carries the enclosing test
|
|
870
|
+
function's definition line and span: ``Line N: Test 'NAME' (defined at
|
|
871
|
+
line X, spanning Y lines) probes ...``. The function occupies lines ``X``
|
|
872
|
+
through ``X + Y - 1`` inclusive, so a signature-line change that
|
|
873
|
+
un-isolates an unchanged-body probe is scoped by the same span the
|
|
874
|
+
enforcer uses rather than by the ``Line N:`` probe line alone.
|
|
875
|
+
|
|
876
|
+
Args:
|
|
877
|
+
violation_text: A single violation string emitted by the enforcer.
|
|
878
|
+
|
|
879
|
+
Returns:
|
|
880
|
+
A ``range`` covering the enclosing test function's declared line span,
|
|
881
|
+
or None when the text is not an isolation violation.
|
|
882
|
+
"""
|
|
883
|
+
span_match = ISOLATION_VIOLATION_PATTERN.search(violation_text)
|
|
884
|
+
if span_match is None:
|
|
885
|
+
return None
|
|
886
|
+
definition_line = int(span_match.group(ISOLATION_DEFINITION_LINE_GROUP_INDEX))
|
|
887
|
+
line_span = int(span_match.group(ISOLATION_SPAN_GROUP_INDEX))
|
|
888
|
+
return range(definition_line, definition_line + line_span)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def banned_noun_span_range(violation_text: str) -> range | None:
|
|
892
|
+
"""Return the one-line binding span of a banned-noun violation, or None.
|
|
893
|
+
|
|
894
|
+
The enforcer's banned-noun message carries the binding line and a one-line
|
|
895
|
+
span: ``Line N: Identifier 'NAME' ... (binding span at line X, spanning 1
|
|
896
|
+
lines)``. A banned-noun binding is a point fact about one identifier, so the
|
|
897
|
+
span is always the binding line alone (``X`` through ``X``) — never the
|
|
898
|
+
enclosing function span. Scoping to the binding line keeps a pre-existing
|
|
899
|
+
parameter or local-name binding out of scope when an unrelated line of its
|
|
900
|
+
enclosing function is edited.
|
|
901
|
+
|
|
902
|
+
Args:
|
|
903
|
+
violation_text: A single violation string emitted by the enforcer.
|
|
904
|
+
|
|
905
|
+
Returns:
|
|
906
|
+
A ``range`` covering the binding's one-line span, or None when the text
|
|
907
|
+
is not a banned-noun violation.
|
|
908
|
+
"""
|
|
909
|
+
span_match = BANNED_NOUN_VIOLATION_PATTERN.search(violation_text)
|
|
910
|
+
if span_match is None:
|
|
911
|
+
return None
|
|
912
|
+
definition_line = int(span_match.group(BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX))
|
|
913
|
+
line_span = int(span_match.group(BANNED_NOUN_SPAN_GROUP_INDEX))
|
|
914
|
+
return range(definition_line, definition_line + line_span)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _all_span_range_extractors() -> tuple[Callable[[str], range | None], ...]:
|
|
918
|
+
return (
|
|
919
|
+
function_length_span_range,
|
|
920
|
+
isolation_span_range,
|
|
921
|
+
banned_noun_span_range,
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def enclosing_span_range(violation_text: str) -> range | None:
|
|
926
|
+
"""Return the enclosing-unit line range of a span-tagged violation, or None.
|
|
927
|
+
|
|
928
|
+
Every diff-scoped enforcer check tags its message with an enclosing-unit
|
|
929
|
+
span fragment. This dispatcher tries each span extractor from
|
|
930
|
+
``_all_span_range_extractors`` so the gate reconstructs every scoped
|
|
931
|
+
check's span through one shared mechanism — adding a new scoped check means
|
|
932
|
+
adding one extractor to that registry rather than threading a new branch
|
|
933
|
+
through ``split_violations_by_scope``.
|
|
934
|
+
|
|
935
|
+
Args:
|
|
936
|
+
violation_text: A single violation string emitted by the enforcer.
|
|
937
|
+
|
|
938
|
+
Returns:
|
|
939
|
+
The first non-None span range any extractor recovers, or None when the
|
|
940
|
+
text carries no enclosing-unit span fragment.
|
|
941
|
+
"""
|
|
942
|
+
for each_extractor in _all_span_range_extractors():
|
|
943
|
+
span_range = each_extractor(violation_text)
|
|
944
|
+
if span_range is not None:
|
|
945
|
+
return span_range
|
|
946
|
+
return None
|
|
947
|
+
|
|
948
|
+
|
|
706
949
|
def split_violations_by_scope(
|
|
707
950
|
all_issues: list[str],
|
|
708
951
|
all_added_line_numbers: set[int] | None,
|
|
709
952
|
) -> tuple[list[str], list[str]]:
|
|
710
|
-
"""
|
|
953
|
+
"""Partition issues into blocking vs advisory based on touched lines.
|
|
711
954
|
|
|
712
955
|
Args:
|
|
713
|
-
all_issues:
|
|
714
|
-
all_added_line_numbers:
|
|
956
|
+
all_issues: Violation strings emitted by the enforcer.
|
|
957
|
+
all_added_line_numbers: Lines added in the current diff, or None
|
|
958
|
+
to treat every violation as blocking.
|
|
715
959
|
|
|
716
960
|
Returns:
|
|
717
|
-
Tuple
|
|
961
|
+
Tuple ``(blocking, advisory)``. When *all_added_line_numbers* is
|
|
962
|
+
None, every issue is blocking. Every diff-scoped violation
|
|
963
|
+
(function-length, HOME/TMP isolation, banned-noun) carries an
|
|
964
|
+
enclosing-unit span fragment that ``enclosing_span_range`` reconstructs
|
|
965
|
+
through one shared extractor registry; such a violation is blocking
|
|
966
|
+
when its declared span intersects the added lines (the unit grew or its
|
|
967
|
+
signature changed in this diff) and advisory otherwise (a pre-existing
|
|
968
|
+
untouched unit). Every other issue is blocking when its ``Line N:``
|
|
969
|
+
prefix names an added line and advisory otherwise.
|
|
718
970
|
"""
|
|
719
971
|
if all_added_line_numbers is None:
|
|
720
972
|
return list(all_issues), []
|
|
721
973
|
blocking: list[str] = []
|
|
722
974
|
advisory: list[str] = []
|
|
723
975
|
for each_issue in all_issues:
|
|
976
|
+
span_range = enclosing_span_range(each_issue)
|
|
977
|
+
if span_range is not None:
|
|
978
|
+
if any(each_line in all_added_line_numbers for each_line in span_range):
|
|
979
|
+
blocking.append(each_issue)
|
|
980
|
+
else:
|
|
981
|
+
advisory.append(each_issue)
|
|
982
|
+
continue
|
|
724
983
|
violation_line = extract_violation_line_number(each_issue)
|
|
725
984
|
if violation_line is None:
|
|
726
985
|
blocking.append(each_issue)
|
|
@@ -753,11 +1012,142 @@ def print_violation_section(
|
|
|
753
1012
|
print(f" {each_issue}", file=sys.stderr)
|
|
754
1013
|
|
|
755
1014
|
|
|
1015
|
+
def read_prior_committed_content(
|
|
1016
|
+
repository_root: Path, relative_path_posix: str
|
|
1017
|
+
) -> str:
|
|
1018
|
+
"""Return the HEAD-committed content for *relative_path_posix*.
|
|
1019
|
+
|
|
1020
|
+
Args:
|
|
1021
|
+
repository_root: The repository root for running git commands.
|
|
1022
|
+
relative_path_posix: POSIX-style relative path to read.
|
|
1023
|
+
|
|
1024
|
+
Returns:
|
|
1025
|
+
The committed file content at HEAD, or an empty string when the
|
|
1026
|
+
path is not tracked or ``git show`` returns non-zero.
|
|
1027
|
+
"""
|
|
1028
|
+
git_show_process = subprocess.run(
|
|
1029
|
+
["git", "show", f"HEAD:{relative_path_posix}"],
|
|
1030
|
+
cwd=str(repository_root),
|
|
1031
|
+
capture_output=True,
|
|
1032
|
+
text=True,
|
|
1033
|
+
encoding="utf-8",
|
|
1034
|
+
errors="replace",
|
|
1035
|
+
check=False,
|
|
1036
|
+
)
|
|
1037
|
+
if git_show_process.returncode != 0:
|
|
1038
|
+
return ""
|
|
1039
|
+
return git_show_process.stdout
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def read_staged_content(
|
|
1043
|
+
repository_root: Path, relative_path_posix: str
|
|
1044
|
+
) -> str | None:
|
|
1045
|
+
"""Return the staged-blob content for *relative_path_posix*.
|
|
1046
|
+
|
|
1047
|
+
Args:
|
|
1048
|
+
repository_root: The repository root for running git commands.
|
|
1049
|
+
relative_path_posix: POSIX-style relative path to read.
|
|
1050
|
+
|
|
1051
|
+
Returns:
|
|
1052
|
+
The staged blob content, or None when the path is not staged, when
|
|
1053
|
+
``git show`` returns non-zero, or when the staged bytes are not
|
|
1054
|
+
decodable Unicode (the caller skips and fails closed).
|
|
1055
|
+
"""
|
|
1056
|
+
git_show_process = subprocess.run(
|
|
1057
|
+
["git", "show", f":{relative_path_posix}"],
|
|
1058
|
+
cwd=str(repository_root),
|
|
1059
|
+
capture_output=True,
|
|
1060
|
+
check=False,
|
|
1061
|
+
)
|
|
1062
|
+
if git_show_process.returncode != 0:
|
|
1063
|
+
return None
|
|
1064
|
+
try:
|
|
1065
|
+
return git_show_process.stdout.decode(encoding="utf-8")
|
|
1066
|
+
except UnicodeDecodeError:
|
|
1067
|
+
return None
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def staged_blob_exists(
|
|
1071
|
+
repository_root: Path, relative_path_posix: str
|
|
1072
|
+
) -> bool:
|
|
1073
|
+
"""Report whether *relative_path_posix* is present in the staged index.
|
|
1074
|
+
|
|
1075
|
+
Args:
|
|
1076
|
+
repository_root: The repository root for running git commands.
|
|
1077
|
+
relative_path_posix: POSIX-style relative path to probe.
|
|
1078
|
+
|
|
1079
|
+
Returns:
|
|
1080
|
+
True when the path is staged for add or modify (its blob exists in the
|
|
1081
|
+
index); False when it is absent, such as a staged deletion.
|
|
1082
|
+
"""
|
|
1083
|
+
git_cat_file_process = subprocess.run(
|
|
1084
|
+
["git", "cat-file", "-e", f":{relative_path_posix}"],
|
|
1085
|
+
cwd=str(repository_root),
|
|
1086
|
+
capture_output=True,
|
|
1087
|
+
check=False,
|
|
1088
|
+
)
|
|
1089
|
+
return git_cat_file_process.returncode == 0
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _scoped_violations_for_file(
|
|
1093
|
+
validate_content: ValidateContentCallable,
|
|
1094
|
+
resolved_path: Path,
|
|
1095
|
+
repository_root: Path,
|
|
1096
|
+
all_added_lines_for_file: set[int] | None,
|
|
1097
|
+
read_staged_content_flag: bool = False,
|
|
1098
|
+
) -> tuple[list[str], list[str]] | None:
|
|
1099
|
+
"""Validate one resolved file and partition its violations by diff scope.
|
|
1100
|
+
|
|
1101
|
+
Args:
|
|
1102
|
+
validate_content: The validator function from code_rules_enforcer.
|
|
1103
|
+
resolved_path: The resolved code file to validate.
|
|
1104
|
+
repository_root: The repository root for relative path resolution.
|
|
1105
|
+
all_added_lines_for_file: Lines added in the current diff for this file,
|
|
1106
|
+
or None to treat every violation as blocking.
|
|
1107
|
+
read_staged_content_flag: When True, source the content from the staged
|
|
1108
|
+
blob so it matches the staged diff that scoped the added lines.
|
|
1109
|
+
|
|
1110
|
+
Returns:
|
|
1111
|
+
``(blocking, advisory)`` for the file, or None when the file could not
|
|
1112
|
+
be read (the caller logs the skip and counts it).
|
|
1113
|
+
"""
|
|
1114
|
+
relative_posix = str(
|
|
1115
|
+
resolved_path.relative_to(repository_root.resolve())
|
|
1116
|
+
).replace("\\", "/")
|
|
1117
|
+
if read_staged_content_flag:
|
|
1118
|
+
staged_content = read_staged_content(repository_root.resolve(), relative_posix)
|
|
1119
|
+
if staged_content is None:
|
|
1120
|
+
print(f"{BUGTEAM_CODE_RULES_GATE_PREFIX}skip unreadable {resolved_path}", file=sys.stderr)
|
|
1121
|
+
return None
|
|
1122
|
+
content = staged_content
|
|
1123
|
+
else:
|
|
1124
|
+
try:
|
|
1125
|
+
content = resolved_path.read_text(encoding="utf-8")
|
|
1126
|
+
except (OSError, UnicodeDecodeError):
|
|
1127
|
+
print(f"{BUGTEAM_CODE_RULES_GATE_PREFIX}skip unreadable {resolved_path}", file=sys.stderr)
|
|
1128
|
+
return None
|
|
1129
|
+
prior_content = read_prior_committed_content(
|
|
1130
|
+
repository_root.resolve(), relative_posix
|
|
1131
|
+
)
|
|
1132
|
+
issues = validate_content(
|
|
1133
|
+
content,
|
|
1134
|
+
relative_posix,
|
|
1135
|
+
old_content=prior_content,
|
|
1136
|
+
defer_scope_to_caller=True,
|
|
1137
|
+
)
|
|
1138
|
+
issues.extend(check_database_column_string_magic(content, relative_posix))
|
|
1139
|
+
issues.extend(check_wrapper_plumb_through(content, relative_posix))
|
|
1140
|
+
if not issues:
|
|
1141
|
+
return [], []
|
|
1142
|
+
return split_violations_by_scope(issues, all_added_lines_for_file)
|
|
1143
|
+
|
|
1144
|
+
|
|
756
1145
|
def run_gate(
|
|
757
1146
|
validate_content: ValidateContentCallable,
|
|
758
1147
|
all_file_paths: list[Path],
|
|
759
1148
|
repository_root: Path,
|
|
760
1149
|
all_added_lines_map: dict[Path, set[int]] | None,
|
|
1150
|
+
read_staged_content_flag: bool = False,
|
|
761
1151
|
) -> int:
|
|
762
1152
|
"""Run the CODE_RULES gate on a set of file paths.
|
|
763
1153
|
|
|
@@ -770,6 +1160,8 @@ def run_gate(
|
|
|
770
1160
|
repository_root: The repository root for relative path resolution.
|
|
771
1161
|
all_added_lines_map: Optional map of resolved path to added line numbers.
|
|
772
1162
|
When provided, violations on added lines are blocking; others are advisory.
|
|
1163
|
+
read_staged_content_flag: When True, validate each file's staged blob
|
|
1164
|
+
so the content source matches the staged diff.
|
|
773
1165
|
|
|
774
1166
|
Returns:
|
|
775
1167
|
Zero when every targeted file was validated and no blocking
|
|
@@ -781,51 +1173,69 @@ def run_gate(
|
|
|
781
1173
|
advisory_by_file: dict[Path, list[str]] = {}
|
|
782
1174
|
skipped_unreadable_count = 0
|
|
783
1175
|
for each_file_path in sorted(set(all_file_paths)):
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
try:
|
|
789
|
-
resolved.relative_to(repository_root.resolve())
|
|
790
|
-
except ValueError:
|
|
791
|
-
continue
|
|
792
|
-
if not is_code_path(resolved):
|
|
793
|
-
continue
|
|
794
|
-
if not resolved.is_file():
|
|
1176
|
+
resolved = _resolve_eligible_code_path(
|
|
1177
|
+
each_file_path, repository_root, read_staged_content_flag
|
|
1178
|
+
)
|
|
1179
|
+
if resolved is None:
|
|
795
1180
|
continue
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
1181
|
+
all_added_lines_for_file = (
|
|
1182
|
+
None if all_added_lines_map is None else all_added_lines_map.get(resolved)
|
|
1183
|
+
)
|
|
1184
|
+
scoped_violations = _scoped_violations_for_file(
|
|
1185
|
+
validate_content, resolved, repository_root,
|
|
1186
|
+
all_added_lines_for_file, read_staged_content_flag,
|
|
1187
|
+
)
|
|
1188
|
+
if scoped_violations is None:
|
|
800
1189
|
skipped_unreadable_count += 1
|
|
801
1190
|
continue
|
|
802
|
-
|
|
803
|
-
issues = validate_content(content, str(relative).replace("\\", "/"), old_content=content)
|
|
804
|
-
issues.extend(check_database_column_string_magic(content, str(relative).replace("\\", "/")))
|
|
805
|
-
issues.extend(check_wrapper_plumb_through(content, str(relative).replace("\\", "/")))
|
|
806
|
-
if not issues:
|
|
807
|
-
continue
|
|
808
|
-
added_for_file = None if all_added_lines_map is None else all_added_lines_map.get(resolved)
|
|
809
|
-
blocking, advisory = split_violations_by_scope(issues, added_for_file)
|
|
1191
|
+
blocking, advisory = scoped_violations
|
|
810
1192
|
if blocking:
|
|
811
1193
|
blocking_by_file[resolved] = blocking
|
|
812
1194
|
if advisory:
|
|
813
1195
|
advisory_by_file[resolved] = advisory
|
|
1196
|
+
return _report_partitioned_violations(
|
|
1197
|
+
blocking_by_file,
|
|
1198
|
+
advisory_by_file,
|
|
1199
|
+
repository_root,
|
|
1200
|
+
all_added_lines_map is None,
|
|
1201
|
+
skipped_unreadable_count,
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _report_partitioned_violations(
|
|
1206
|
+
blocking_by_file: dict[Path, list[str]],
|
|
1207
|
+
advisory_by_file: dict[Path, list[str]],
|
|
1208
|
+
repository_root: Path,
|
|
1209
|
+
is_whole_file_scope: bool,
|
|
1210
|
+
skipped_unreadable_count: int,
|
|
1211
|
+
) -> int:
|
|
1212
|
+
"""Print the blocking and advisory sections and return the gate exit code.
|
|
1213
|
+
|
|
1214
|
+
Args:
|
|
1215
|
+
blocking_by_file: Blocking violations grouped by resolved file path.
|
|
1216
|
+
advisory_by_file: Advisory violations grouped by resolved file path.
|
|
1217
|
+
repository_root: Repository root used to compute relative paths.
|
|
1218
|
+
is_whole_file_scope: True when no per-file added-line map was supplied,
|
|
1219
|
+
which selects the whole-file header wording.
|
|
1220
|
+
skipped_unreadable_count: Count of files that could not be read; a
|
|
1221
|
+
non-zero count forces a non-zero exit because the gate cannot
|
|
1222
|
+
vouch for those files.
|
|
1223
|
+
|
|
1224
|
+
Returns:
|
|
1225
|
+
Zero when no blocking violations were found and no file was skipped;
|
|
1226
|
+
non-zero otherwise.
|
|
1227
|
+
"""
|
|
814
1228
|
blocking_count = sum(len(each_list) for each_list in blocking_by_file.values())
|
|
815
1229
|
advisory_count = sum(len(each_list) for each_list in advisory_by_file.values())
|
|
816
1230
|
if blocking_count:
|
|
817
|
-
if
|
|
1231
|
+
if is_whole_file_scope:
|
|
818
1232
|
header = f"{BUGTEAM_CODE_RULES_GATE_PREFIX}{blocking_count} violation(s) reported."
|
|
819
1233
|
else:
|
|
820
1234
|
header = (
|
|
821
1235
|
f"{BUGTEAM_CODE_RULES_GATE_PREFIX}{blocking_count} violation(s) "
|
|
822
1236
|
"introduced on changed lines:"
|
|
823
1237
|
)
|
|
824
|
-
print_violation_section(
|
|
825
|
-
header,
|
|
826
|
-
blocking_by_file,
|
|
827
|
-
repository_root,
|
|
828
|
-
)
|
|
1238
|
+
print_violation_section(header, blocking_by_file, repository_root)
|
|
829
1239
|
if advisory_count:
|
|
830
1240
|
if blocking_count:
|
|
831
1241
|
print("", file=sys.stderr)
|
|
@@ -942,6 +1352,7 @@ def main(all_arguments: list[str]) -> int:
|
|
|
942
1352
|
staged_file_paths,
|
|
943
1353
|
repository_root,
|
|
944
1354
|
all_added_lines_map=staged_added_lines,
|
|
1355
|
+
read_staged_content_flag=True,
|
|
945
1356
|
)
|
|
946
1357
|
all_diff_paths = paths_from_git_diff(repository_root, arguments.base)
|
|
947
1358
|
all_diff_paths = filter_paths_under_prefixes(
|