claude-dev-env 1.44.0 → 1.45.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/tests/test_code_rules_gate.py +625 -21
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +15 -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/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_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 +597 -12
package/CLAUDE.md
CHANGED
|
@@ -7,6 +7,15 @@ The user delegates execution to you and expects zero manual steps unless strictl
|
|
|
7
7
|
|
|
8
8
|
ALWAYS call the AskUserQuestion tool if you have a question for the user. Provide content-appropriate default options, with a flag for the recommended one.
|
|
9
9
|
|
|
10
|
+
## Timeless Documentation (all `.md` files)
|
|
11
|
+
|
|
12
|
+
Every Markdown file I write or edit describes the system's **current** state only. The test: a reader a year out, with zero prior context, finds every sentence true and complete without knowing what came before. State what **is**, not what changed — git history records change; docs record the contract.
|
|
13
|
+
|
|
14
|
+
- No historical/transitional language (`previously`, `now uses`, `instead of`, `migrated from`, `used to`, `no longer`, `as of`, `originally`, version-transition narration).
|
|
15
|
+
- No references to the conversation that produced the doc (`as discussed`, `Option A`, `after Round 3`).
|
|
16
|
+
|
|
17
|
+
Full banned-pattern set + enforcement: `~/.claude/rules/no-historical-clutter.md` (hook `state-description-blocker`) and `~/.claude/rules/self-contained-docs.md`.
|
|
18
|
+
|
|
10
19
|
## GOTCHAS
|
|
11
20
|
When making code changes, make sure you are working in the proper worktree path for the task at hand.
|
|
12
21
|
When writing to an existing file, you must either EDIT the file, or remove it and THEN re-write it if it's truly a full re-write.
|
|
@@ -19,8 +19,17 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
|
|
|
19
19
|
ALL_TEST_FILENAME_SUFFIXES,
|
|
20
20
|
EXPECTED_NON_RENAME_COLUMN_COUNT,
|
|
21
21
|
EXPECTED_RENAME_COLUMN_COUNT,
|
|
22
|
+
BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX,
|
|
23
|
+
BANNED_NOUN_SPAN_GROUP_INDEX,
|
|
24
|
+
BANNED_NOUN_VIOLATION_PATTERN,
|
|
25
|
+
FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX,
|
|
26
|
+
FUNCTION_LENGTH_SPAN_GROUP_INDEX,
|
|
27
|
+
FUNCTION_LENGTH_VIOLATION_PATTERN,
|
|
22
28
|
GIT_NAME_STATUS_ADDED_PREFIX,
|
|
23
29
|
GIT_NAME_STATUS_RENAMED_PREFIX,
|
|
30
|
+
ISOLATION_DEFINITION_LINE_GROUP_INDEX,
|
|
31
|
+
ISOLATION_SPAN_GROUP_INDEX,
|
|
32
|
+
ISOLATION_VIOLATION_PATTERN,
|
|
24
33
|
MAX_VIOLATIONS_PER_CHECK,
|
|
25
34
|
PYTHON_FILE_EXTENSION,
|
|
26
35
|
TEST_CONFTEST_FILENAME,
|
|
@@ -29,7 +38,7 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
|
|
|
29
38
|
)
|
|
30
39
|
|
|
31
40
|
|
|
32
|
-
ValidateContentCallable = Callable[
|
|
41
|
+
ValidateContentCallable = Callable[..., list[str]]
|
|
33
42
|
|
|
34
43
|
|
|
35
44
|
def hunk_header_pattern() -> re.Pattern[str]:
|
|
@@ -500,6 +509,63 @@ def _iter_calls_excluding_nested_functions(node: ast.AST) -> Iterator[ast.Call]:
|
|
|
500
509
|
yield from _iter_calls_excluding_nested_functions(each_child)
|
|
501
510
|
|
|
502
511
|
|
|
512
|
+
def _module_level_optional_kwargs_by_name(tree: ast.Module) -> dict[str, set[str]]:
|
|
513
|
+
function_signatures: dict[str, set[str]] = {}
|
|
514
|
+
for each_node in ast.iter_child_nodes(tree):
|
|
515
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
516
|
+
optional_kwargs: set[str] = set()
|
|
517
|
+
for each_kwonly, each_default in zip(
|
|
518
|
+
each_node.args.kwonlyargs, each_node.args.kw_defaults
|
|
519
|
+
):
|
|
520
|
+
if each_default is not None:
|
|
521
|
+
optional_kwargs.add(each_kwonly.arg)
|
|
522
|
+
positional_defaults = each_node.args.defaults
|
|
523
|
+
positional_args_with_defaults = (
|
|
524
|
+
each_node.args.args[-len(positional_defaults):]
|
|
525
|
+
if positional_defaults
|
|
526
|
+
else []
|
|
527
|
+
)
|
|
528
|
+
for each_positional_arg in positional_args_with_defaults:
|
|
529
|
+
optional_kwargs.add(each_positional_arg.arg)
|
|
530
|
+
function_signatures[each_node.name] = optional_kwargs
|
|
531
|
+
return function_signatures
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _class_method_node_ids(tree: ast.Module) -> set[int]:
|
|
535
|
+
class_method_node_ids: set[int] = set()
|
|
536
|
+
for each_class_def in ast.walk(tree):
|
|
537
|
+
if not isinstance(each_class_def, ast.ClassDef):
|
|
538
|
+
continue
|
|
539
|
+
for each_class_body_node in each_class_def.body:
|
|
540
|
+
if isinstance(
|
|
541
|
+
each_class_body_node, (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
542
|
+
):
|
|
543
|
+
class_method_node_ids.add(id(each_class_body_node))
|
|
544
|
+
return class_method_node_ids
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _wrapper_dropped_kwarg_findings(
|
|
548
|
+
wrapper_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
549
|
+
kwargs_by_function_name: dict[str, set[str]],
|
|
550
|
+
) -> Iterator[str]:
|
|
551
|
+
wrapper_kwargs = kwargs_by_function_name.get(wrapper_node.name, set())
|
|
552
|
+
for each_call in _iter_calls_excluding_nested_functions(wrapper_node):
|
|
553
|
+
if isinstance(each_call.func, ast.Name):
|
|
554
|
+
delegate_name = each_call.func.id
|
|
555
|
+
elif isinstance(each_call.func, ast.Attribute):
|
|
556
|
+
delegate_name = each_call.func.attr
|
|
557
|
+
else:
|
|
558
|
+
continue
|
|
559
|
+
delegate_kwargs = kwargs_by_function_name.get(delegate_name)
|
|
560
|
+
if delegate_kwargs is None:
|
|
561
|
+
continue
|
|
562
|
+
missing = delegate_kwargs - wrapper_kwargs
|
|
563
|
+
if missing:
|
|
564
|
+
yield (
|
|
565
|
+
f"Line {wrapper_node.lineno}: Wrapper {wrapper_node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
503
569
|
def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
|
|
504
570
|
"""Flag calls inside public functions that drop a same-file delegate's optional kwargs.
|
|
505
571
|
|
|
@@ -510,14 +576,10 @@ def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
|
|
|
510
576
|
capped at MAX_VIOLATIONS_PER_CHECK findings per call to run_gate.
|
|
511
577
|
|
|
512
578
|
Limitations:
|
|
513
|
-
- Only module-level FunctionDef nodes contribute signatures
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
candidates. A class method that calls a module-level delegate has a
|
|
518
|
-
signature unrelated to that delegate's keyword-argument surface, so
|
|
519
|
-
treating it as a wrapper produces false positives that flag every
|
|
520
|
-
class method calling a free-function delegate with optional kwargs.
|
|
579
|
+
- Only module-level FunctionDef nodes contribute signatures, and ClassDef
|
|
580
|
+
methods are skipped both as signature sources and as wrapper candidates:
|
|
581
|
+
a class method's signature is unrelated to a free-function delegate's
|
|
582
|
+
keyword surface, so treating it as a wrapper produces false positives.
|
|
521
583
|
- ast.Attribute calls match by attribute name only; the receiver type is
|
|
522
584
|
not checked, so `self.fetch(...)` and `other.fetch(...)` both match a
|
|
523
585
|
module-level `fetch` definition.
|
|
@@ -546,33 +608,8 @@ def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
|
|
|
546
608
|
tree = ast.parse(content)
|
|
547
609
|
except SyntaxError:
|
|
548
610
|
return []
|
|
549
|
-
function_signatures
|
|
550
|
-
|
|
551
|
-
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
552
|
-
optional_kwargs: set[str] = set()
|
|
553
|
-
for each_kwonly, each_default in zip(
|
|
554
|
-
each_node.args.kwonlyargs, each_node.args.kw_defaults
|
|
555
|
-
):
|
|
556
|
-
if each_default is not None:
|
|
557
|
-
optional_kwargs.add(each_kwonly.arg)
|
|
558
|
-
positional_defaults = each_node.args.defaults
|
|
559
|
-
positional_args_with_defaults = (
|
|
560
|
-
each_node.args.args[-len(positional_defaults):]
|
|
561
|
-
if positional_defaults
|
|
562
|
-
else []
|
|
563
|
-
)
|
|
564
|
-
for each_positional_arg in positional_args_with_defaults:
|
|
565
|
-
optional_kwargs.add(each_positional_arg.arg)
|
|
566
|
-
function_signatures[each_node.name] = optional_kwargs
|
|
567
|
-
class_method_node_ids: set[int] = set()
|
|
568
|
-
for each_class_def in ast.walk(tree):
|
|
569
|
-
if not isinstance(each_class_def, ast.ClassDef):
|
|
570
|
-
continue
|
|
571
|
-
for each_class_body_node in each_class_def.body:
|
|
572
|
-
if isinstance(
|
|
573
|
-
each_class_body_node, (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
574
|
-
):
|
|
575
|
-
class_method_node_ids.add(id(each_class_body_node))
|
|
611
|
+
function_signatures = _module_level_optional_kwargs_by_name(tree)
|
|
612
|
+
class_method_node_ids = _class_method_node_ids(tree)
|
|
576
613
|
issues: list[str] = []
|
|
577
614
|
for each_node in ast.walk(tree):
|
|
578
615
|
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
@@ -581,24 +618,10 @@ def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
|
|
|
581
618
|
continue
|
|
582
619
|
if each_node.name.startswith("_"):
|
|
583
620
|
continue
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
if
|
|
587
|
-
|
|
588
|
-
elif isinstance(each_call.func, ast.Attribute):
|
|
589
|
-
delegate_name = each_call.func.attr
|
|
590
|
-
else:
|
|
591
|
-
continue
|
|
592
|
-
delegate_kwargs = function_signatures.get(delegate_name)
|
|
593
|
-
if delegate_kwargs is None:
|
|
594
|
-
continue
|
|
595
|
-
missing = delegate_kwargs - wrapper_kwargs
|
|
596
|
-
if missing:
|
|
597
|
-
issues.append(
|
|
598
|
-
f"Line {each_node.lineno}: Wrapper {each_node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
|
|
599
|
-
)
|
|
600
|
-
if len(issues) >= MAX_VIOLATIONS_PER_CHECK:
|
|
601
|
-
return issues
|
|
621
|
+
for each_finding in _wrapper_dropped_kwarg_findings(each_node, function_signatures):
|
|
622
|
+
issues.append(each_finding)
|
|
623
|
+
if len(issues) >= MAX_VIOLATIONS_PER_CHECK:
|
|
624
|
+
return issues
|
|
602
625
|
return issues
|
|
603
626
|
|
|
604
627
|
|
|
@@ -909,6 +932,112 @@ def extract_violation_line_number(violation_text: str) -> int | None:
|
|
|
909
932
|
return int(match_result.group(1))
|
|
910
933
|
|
|
911
934
|
|
|
935
|
+
def function_length_span_range(violation_text: str) -> range | None:
|
|
936
|
+
"""Return the declared line range of a function-length violation, or None.
|
|
937
|
+
|
|
938
|
+
The enforcer's function-length message carries the definition line and
|
|
939
|
+
the function's line span: ``Function 'NAME' (defined at line X) is Y
|
|
940
|
+
lines - ...``. The function occupies lines ``X`` through ``X + Y - 1``
|
|
941
|
+
inclusive.
|
|
942
|
+
|
|
943
|
+
Args:
|
|
944
|
+
violation_text: A single violation string emitted by the enforcer.
|
|
945
|
+
|
|
946
|
+
Returns:
|
|
947
|
+
A ``range`` covering the function's declared line span, or None when
|
|
948
|
+
the text is not a function-length violation.
|
|
949
|
+
"""
|
|
950
|
+
span_match = FUNCTION_LENGTH_VIOLATION_PATTERN.search(violation_text)
|
|
951
|
+
if span_match is None:
|
|
952
|
+
return None
|
|
953
|
+
definition_line = int(span_match.group(FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX))
|
|
954
|
+
line_span = int(span_match.group(FUNCTION_LENGTH_SPAN_GROUP_INDEX))
|
|
955
|
+
return range(definition_line, definition_line + line_span)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def isolation_span_range(violation_text: str) -> range | None:
|
|
959
|
+
"""Return the enclosing test-function line range of an isolation violation.
|
|
960
|
+
|
|
961
|
+
The enforcer's HOME/TMP isolation message carries the enclosing test
|
|
962
|
+
function's definition line and span: ``Line N: Test 'NAME' (defined at
|
|
963
|
+
line X, spanning Y lines) probes ...``. The function occupies lines ``X``
|
|
964
|
+
through ``X + Y - 1`` inclusive, so a signature-line change that
|
|
965
|
+
un-isolates an unchanged-body probe is scoped by the same span the
|
|
966
|
+
enforcer uses rather than by the ``Line N:`` probe line alone.
|
|
967
|
+
|
|
968
|
+
Args:
|
|
969
|
+
violation_text: A single violation string emitted by the enforcer.
|
|
970
|
+
|
|
971
|
+
Returns:
|
|
972
|
+
A ``range`` covering the enclosing test function's declared line span,
|
|
973
|
+
or None when the text is not an isolation violation.
|
|
974
|
+
"""
|
|
975
|
+
span_match = ISOLATION_VIOLATION_PATTERN.search(violation_text)
|
|
976
|
+
if span_match is None:
|
|
977
|
+
return None
|
|
978
|
+
definition_line = int(span_match.group(ISOLATION_DEFINITION_LINE_GROUP_INDEX))
|
|
979
|
+
line_span = int(span_match.group(ISOLATION_SPAN_GROUP_INDEX))
|
|
980
|
+
return range(definition_line, definition_line + line_span)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def banned_noun_span_range(violation_text: str) -> range | None:
|
|
984
|
+
"""Return the one-line binding span of a banned-noun violation, or None.
|
|
985
|
+
|
|
986
|
+
The enforcer's banned-noun message carries the binding line and a one-line
|
|
987
|
+
span: ``Line N: Identifier 'NAME' ... (binding span at line X, spanning 1
|
|
988
|
+
lines)``. A banned-noun binding is a point fact about one identifier, so the
|
|
989
|
+
span is always the binding line alone (``X`` through ``X``) — never the
|
|
990
|
+
enclosing function span. Scoping to the binding line keeps a pre-existing
|
|
991
|
+
parameter or local-name binding out of scope when an unrelated line of its
|
|
992
|
+
enclosing function is edited.
|
|
993
|
+
|
|
994
|
+
Args:
|
|
995
|
+
violation_text: A single violation string emitted by the enforcer.
|
|
996
|
+
|
|
997
|
+
Returns:
|
|
998
|
+
A ``range`` covering the binding's one-line span, or None when the text
|
|
999
|
+
is not a banned-noun violation.
|
|
1000
|
+
"""
|
|
1001
|
+
span_match = BANNED_NOUN_VIOLATION_PATTERN.search(violation_text)
|
|
1002
|
+
if span_match is None:
|
|
1003
|
+
return None
|
|
1004
|
+
definition_line = int(span_match.group(BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX))
|
|
1005
|
+
line_span = int(span_match.group(BANNED_NOUN_SPAN_GROUP_INDEX))
|
|
1006
|
+
return range(definition_line, definition_line + line_span)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _all_span_range_extractors() -> tuple[Callable[[str], range | None], ...]:
|
|
1010
|
+
return (
|
|
1011
|
+
function_length_span_range,
|
|
1012
|
+
isolation_span_range,
|
|
1013
|
+
banned_noun_span_range,
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def enclosing_span_range(violation_text: str) -> range | None:
|
|
1018
|
+
"""Return the enclosing-unit line range of a span-tagged violation, or None.
|
|
1019
|
+
|
|
1020
|
+
Every diff-scoped enforcer check tags its message with an enclosing-unit
|
|
1021
|
+
span fragment. This dispatcher tries each span extractor from
|
|
1022
|
+
``_all_span_range_extractors`` so the gate reconstructs every scoped
|
|
1023
|
+
check's span through one shared mechanism — adding a new scoped check means
|
|
1024
|
+
adding one extractor to that registry rather than threading a new branch
|
|
1025
|
+
through ``split_violations_by_scope``.
|
|
1026
|
+
|
|
1027
|
+
Args:
|
|
1028
|
+
violation_text: A single violation string emitted by the enforcer.
|
|
1029
|
+
|
|
1030
|
+
Returns:
|
|
1031
|
+
The first non-None span range any extractor recovers, or None when the
|
|
1032
|
+
text carries no enclosing-unit span fragment.
|
|
1033
|
+
"""
|
|
1034
|
+
for each_extractor in _all_span_range_extractors():
|
|
1035
|
+
span_range = each_extractor(violation_text)
|
|
1036
|
+
if span_range is not None:
|
|
1037
|
+
return span_range
|
|
1038
|
+
return None
|
|
1039
|
+
|
|
1040
|
+
|
|
912
1041
|
def split_violations_by_scope(
|
|
913
1042
|
all_issues: list[str],
|
|
914
1043
|
all_added_line_numbers: set[int] | None,
|
|
@@ -922,15 +1051,27 @@ def split_violations_by_scope(
|
|
|
922
1051
|
|
|
923
1052
|
Returns:
|
|
924
1053
|
Tuple ``(blocking, advisory)``. When *all_added_line_numbers* is
|
|
925
|
-
None, every issue is blocking
|
|
926
|
-
|
|
927
|
-
|
|
1054
|
+
None, every issue is blocking. Every diff-scoped violation
|
|
1055
|
+
(function-length, HOME/TMP isolation, banned-noun) carries an
|
|
1056
|
+
enclosing-unit span fragment that ``enclosing_span_range`` reconstructs
|
|
1057
|
+
through one shared extractor registry; such a violation is blocking
|
|
1058
|
+
when its declared span intersects the added lines (the unit grew or its
|
|
1059
|
+
signature changed in this diff) and advisory otherwise (a pre-existing
|
|
1060
|
+
untouched unit). Every other issue is blocking when its ``Line N:``
|
|
1061
|
+
prefix names an added line and advisory otherwise.
|
|
928
1062
|
"""
|
|
929
1063
|
if all_added_line_numbers is None:
|
|
930
1064
|
return list(all_issues), []
|
|
931
1065
|
blocking: list[str] = []
|
|
932
1066
|
advisory: list[str] = []
|
|
933
1067
|
for each_issue in all_issues:
|
|
1068
|
+
span_range = enclosing_span_range(each_issue)
|
|
1069
|
+
if span_range is not None:
|
|
1070
|
+
if any(each_line in all_added_line_numbers for each_line in span_range):
|
|
1071
|
+
blocking.append(each_issue)
|
|
1072
|
+
else:
|
|
1073
|
+
advisory.append(each_issue)
|
|
1074
|
+
continue
|
|
934
1075
|
violation_line = extract_violation_line_number(each_issue)
|
|
935
1076
|
if violation_line is None:
|
|
936
1077
|
blocking.append(each_issue)
|
|
@@ -991,11 +1132,143 @@ def read_prior_committed_content(
|
|
|
991
1132
|
return show_result.stdout
|
|
992
1133
|
|
|
993
1134
|
|
|
1135
|
+
def read_staged_content(
|
|
1136
|
+
repository_root: Path, relative_path_posix: str
|
|
1137
|
+
) -> str | None:
|
|
1138
|
+
"""Return the staged-blob content for *relative_path_posix*.
|
|
1139
|
+
|
|
1140
|
+
Args:
|
|
1141
|
+
repository_root: Repository root used as the ``git -C`` target.
|
|
1142
|
+
relative_path_posix: Repository-relative POSIX path to read.
|
|
1143
|
+
|
|
1144
|
+
Returns:
|
|
1145
|
+
The staged blob content, or None when the path is not staged, when
|
|
1146
|
+
``git show`` returns non-zero, or when the staged bytes are not
|
|
1147
|
+
decodable Unicode (the caller skips and fails closed).
|
|
1148
|
+
"""
|
|
1149
|
+
git_show_process = subprocess.run(
|
|
1150
|
+
["git", "show", f":{relative_path_posix}"],
|
|
1151
|
+
cwd=str(repository_root),
|
|
1152
|
+
capture_output=True,
|
|
1153
|
+
check=False,
|
|
1154
|
+
)
|
|
1155
|
+
if git_show_process.returncode != 0:
|
|
1156
|
+
return None
|
|
1157
|
+
try:
|
|
1158
|
+
return git_show_process.stdout.decode(encoding="utf-8")
|
|
1159
|
+
except UnicodeDecodeError:
|
|
1160
|
+
return None
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def staged_blob_exists(
|
|
1164
|
+
repository_root: Path, relative_path_posix: str
|
|
1165
|
+
) -> bool:
|
|
1166
|
+
"""Report whether *relative_path_posix* is present in the staged index.
|
|
1167
|
+
|
|
1168
|
+
Args:
|
|
1169
|
+
repository_root: Repository root used as the ``git -C`` target.
|
|
1170
|
+
relative_path_posix: Repository-relative POSIX path to probe.
|
|
1171
|
+
|
|
1172
|
+
Returns:
|
|
1173
|
+
True when the path is staged for add or modify (its blob exists in the
|
|
1174
|
+
index); False when it is absent, such as a staged deletion.
|
|
1175
|
+
"""
|
|
1176
|
+
git_cat_file_process = subprocess.run(
|
|
1177
|
+
["git", "cat-file", "-e", f":{relative_path_posix}"],
|
|
1178
|
+
cwd=str(repository_root),
|
|
1179
|
+
capture_output=True,
|
|
1180
|
+
check=False,
|
|
1181
|
+
)
|
|
1182
|
+
return git_cat_file_process.returncode == 0
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def _path_is_eligible_for_validation(
|
|
1186
|
+
resolved_path: Path,
|
|
1187
|
+
repository_root: Path,
|
|
1188
|
+
read_staged_content_flag: bool,
|
|
1189
|
+
) -> bool:
|
|
1190
|
+
"""Decide whether *resolved_path* should be validated by the gate.
|
|
1191
|
+
|
|
1192
|
+
Args:
|
|
1193
|
+
resolved_path: A resolved candidate path already confirmed to live
|
|
1194
|
+
under *repository_root*.
|
|
1195
|
+
repository_root: Repository root used to compute the relative path.
|
|
1196
|
+
read_staged_content_flag: When True, require staged-index presence so
|
|
1197
|
+
files staged for add or modify are validated and staged deletions
|
|
1198
|
+
are skipped; when False, require working-tree presence.
|
|
1199
|
+
|
|
1200
|
+
Returns:
|
|
1201
|
+
True when the path carries a code extension and exists in the source
|
|
1202
|
+
the gate will read; False otherwise.
|
|
1203
|
+
"""
|
|
1204
|
+
if not is_code_path(resolved_path):
|
|
1205
|
+
return False
|
|
1206
|
+
if read_staged_content_flag:
|
|
1207
|
+
relative_posix = str(
|
|
1208
|
+
resolved_path.relative_to(repository_root.resolve())
|
|
1209
|
+
).replace("\\", "/")
|
|
1210
|
+
return staged_blob_exists(repository_root.resolve(), relative_posix)
|
|
1211
|
+
return resolved_path.is_file()
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _scoped_violations_for_file(
|
|
1215
|
+
validate_content: ValidateContentCallable,
|
|
1216
|
+
resolved_path: Path,
|
|
1217
|
+
repository_root: Path,
|
|
1218
|
+
all_added_lines_for_file: set[int] | None,
|
|
1219
|
+
read_staged_content_flag: bool = False,
|
|
1220
|
+
) -> tuple[list[str], list[str]] | None:
|
|
1221
|
+
"""Validate one resolved file and partition its violations by diff scope.
|
|
1222
|
+
|
|
1223
|
+
Args:
|
|
1224
|
+
validate_content: The enforcer ``validate_content`` callable.
|
|
1225
|
+
resolved_path: The resolved code file to validate.
|
|
1226
|
+
repository_root: Repository root used to resolve the relative path.
|
|
1227
|
+
all_added_lines_for_file: Lines added in the current diff for this file,
|
|
1228
|
+
or None to treat every violation as blocking.
|
|
1229
|
+
read_staged_content_flag: When True, source the content from the staged
|
|
1230
|
+
blob so it matches the staged diff that scoped the added lines.
|
|
1231
|
+
|
|
1232
|
+
Returns:
|
|
1233
|
+
``(blocking, advisory)`` for the file, or None when the file is
|
|
1234
|
+
unreadable (the caller logs and skips it).
|
|
1235
|
+
"""
|
|
1236
|
+
relative_posix = str(
|
|
1237
|
+
resolved_path.relative_to(repository_root.resolve())
|
|
1238
|
+
).replace("\\", "/")
|
|
1239
|
+
if read_staged_content_flag:
|
|
1240
|
+
staged_content = read_staged_content(repository_root.resolve(), relative_posix)
|
|
1241
|
+
if staged_content is None:
|
|
1242
|
+
print(f"code_rules_gate: skip unreadable {resolved_path}", file=sys.stderr)
|
|
1243
|
+
return None
|
|
1244
|
+
content = staged_content
|
|
1245
|
+
else:
|
|
1246
|
+
try:
|
|
1247
|
+
content = resolved_path.read_text(encoding="utf-8")
|
|
1248
|
+
except (OSError, UnicodeDecodeError):
|
|
1249
|
+
print(f"code_rules_gate: skip unreadable {resolved_path}", file=sys.stderr)
|
|
1250
|
+
return None
|
|
1251
|
+
prior_content = read_prior_committed_content(
|
|
1252
|
+
repository_root.resolve(), relative_posix
|
|
1253
|
+
)
|
|
1254
|
+
issues = validate_content(
|
|
1255
|
+
content,
|
|
1256
|
+
relative_posix,
|
|
1257
|
+
prior_content,
|
|
1258
|
+
defer_scope_to_caller=True,
|
|
1259
|
+
)
|
|
1260
|
+
issues.extend(check_wrapper_plumb_through(content, relative_posix))
|
|
1261
|
+
if not issues:
|
|
1262
|
+
return [], []
|
|
1263
|
+
return split_violations_by_scope(issues, all_added_lines_for_file)
|
|
1264
|
+
|
|
1265
|
+
|
|
994
1266
|
def run_gate(
|
|
995
1267
|
validate_content: ValidateContentCallable,
|
|
996
1268
|
all_file_paths: list[Path],
|
|
997
1269
|
repository_root: Path,
|
|
998
1270
|
all_added_lines_by_path: dict[Path, set[int]] | None = None,
|
|
1271
|
+
read_staged_content_flag: bool = False,
|
|
999
1272
|
) -> int:
|
|
1000
1273
|
"""Run the gate over *all_file_paths* and emit a partitioned report.
|
|
1001
1274
|
|
|
@@ -1005,13 +1278,59 @@ def run_gate(
|
|
|
1005
1278
|
repository_root: Repository root used to resolve relative paths.
|
|
1006
1279
|
all_added_lines_by_path: Optional per-file added-line maps used to
|
|
1007
1280
|
partition issues into blocking vs advisory.
|
|
1281
|
+
read_staged_content_flag: When True, validate each file's staged blob
|
|
1282
|
+
so the content source matches the staged diff.
|
|
1283
|
+
|
|
1284
|
+
Returns:
|
|
1285
|
+
Zero when every targeted file was validated and no blocking violation
|
|
1286
|
+
was found. Non-zero when any blocking violation was reported OR when
|
|
1287
|
+
one or more files could not be read (a skipped file means the gate
|
|
1288
|
+
could not vouch for it).
|
|
1289
|
+
"""
|
|
1290
|
+
blocking_by_file, advisory_by_file, skipped_unreadable_count = (
|
|
1291
|
+
_collect_partitioned_violations(
|
|
1292
|
+
validate_content,
|
|
1293
|
+
all_file_paths,
|
|
1294
|
+
repository_root,
|
|
1295
|
+
all_added_lines_by_path,
|
|
1296
|
+
read_staged_content_flag,
|
|
1297
|
+
)
|
|
1298
|
+
)
|
|
1299
|
+
return _report_partitioned_violations(
|
|
1300
|
+
blocking_by_file,
|
|
1301
|
+
advisory_by_file,
|
|
1302
|
+
repository_root,
|
|
1303
|
+
all_added_lines_by_path is None,
|
|
1304
|
+
skipped_unreadable_count,
|
|
1305
|
+
)
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def _collect_partitioned_violations(
|
|
1309
|
+
validate_content: ValidateContentCallable,
|
|
1310
|
+
all_file_paths: list[Path],
|
|
1311
|
+
repository_root: Path,
|
|
1312
|
+
all_added_lines_by_path: dict[Path, set[int]] | None,
|
|
1313
|
+
read_staged_content_flag: bool = False,
|
|
1314
|
+
) -> tuple[dict[Path, list[str]], dict[Path, list[str]], int]:
|
|
1315
|
+
"""Validate every targeted file and partition results by diff scope.
|
|
1316
|
+
|
|
1317
|
+
Args:
|
|
1318
|
+
validate_content: The enforcer ``validate_content`` callable.
|
|
1319
|
+
all_file_paths: File paths to inspect.
|
|
1320
|
+
repository_root: Repository root used to resolve relative paths.
|
|
1321
|
+
all_added_lines_by_path: Optional per-file added-line maps used to
|
|
1322
|
+
partition issues into blocking vs advisory.
|
|
1323
|
+
read_staged_content_flag: When True, validate each file's staged blob
|
|
1324
|
+
so the content source matches the staged diff.
|
|
1008
1325
|
|
|
1009
1326
|
Returns:
|
|
1010
|
-
``
|
|
1011
|
-
|
|
1327
|
+
``(blocking_by_file, advisory_by_file, skipped_unreadable_count)`` where
|
|
1328
|
+
the skipped count increments for every changed file that could not be
|
|
1329
|
+
read, so the caller can fail closed on unvalidated files.
|
|
1012
1330
|
"""
|
|
1013
1331
|
blocking_by_file: dict[Path, list[str]] = {}
|
|
1014
1332
|
advisory_by_file: dict[Path, list[str]] = {}
|
|
1333
|
+
skipped_unreadable_count = 0
|
|
1015
1334
|
for each_path in sorted(set(all_file_paths)):
|
|
1016
1335
|
try:
|
|
1017
1336
|
resolved = each_path.resolve()
|
|
@@ -1021,49 +1340,64 @@ def run_gate(
|
|
|
1021
1340
|
resolved.relative_to(repository_root.resolve())
|
|
1022
1341
|
except ValueError:
|
|
1023
1342
|
continue
|
|
1024
|
-
if not
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
continue
|
|
1028
|
-
try:
|
|
1029
|
-
content = resolved.read_text(encoding="utf-8")
|
|
1030
|
-
except (OSError, UnicodeDecodeError):
|
|
1031
|
-
print(f"code_rules_gate: skip unreadable {resolved}", file=sys.stderr)
|
|
1032
|
-
continue
|
|
1033
|
-
relative = resolved.relative_to(repository_root.resolve())
|
|
1034
|
-
relative_posix = str(relative).replace("\\", "/")
|
|
1035
|
-
prior_content = read_prior_committed_content(
|
|
1036
|
-
repository_root.resolve(), relative_posix
|
|
1037
|
-
)
|
|
1038
|
-
issues = validate_content(content, relative_posix, prior_content)
|
|
1039
|
-
issues.extend(check_wrapper_plumb_through(content, relative_posix))
|
|
1040
|
-
if not issues:
|
|
1343
|
+
if not _path_is_eligible_for_validation(
|
|
1344
|
+
resolved, repository_root, read_staged_content_flag
|
|
1345
|
+
):
|
|
1041
1346
|
continue
|
|
1042
|
-
|
|
1347
|
+
all_added_lines_for_file = (
|
|
1043
1348
|
None
|
|
1044
1349
|
if all_added_lines_by_path is None
|
|
1045
1350
|
else all_added_lines_by_path.get(resolved)
|
|
1046
1351
|
)
|
|
1047
|
-
|
|
1352
|
+
scoped_violations = _scoped_violations_for_file(
|
|
1353
|
+
validate_content, resolved, repository_root,
|
|
1354
|
+
all_added_lines_for_file, read_staged_content_flag,
|
|
1355
|
+
)
|
|
1356
|
+
if scoped_violations is None:
|
|
1357
|
+
skipped_unreadable_count += 1
|
|
1358
|
+
continue
|
|
1359
|
+
blocking, advisory = scoped_violations
|
|
1048
1360
|
if blocking:
|
|
1049
1361
|
blocking_by_file[resolved] = blocking
|
|
1050
1362
|
if advisory:
|
|
1051
1363
|
advisory_by_file[resolved] = advisory
|
|
1364
|
+
return blocking_by_file, advisory_by_file, skipped_unreadable_count
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def _report_partitioned_violations(
|
|
1368
|
+
blocking_by_file: dict[Path, list[str]],
|
|
1369
|
+
advisory_by_file: dict[Path, list[str]],
|
|
1370
|
+
repository_root: Path,
|
|
1371
|
+
is_whole_file_scope: bool,
|
|
1372
|
+
skipped_unreadable_count: int,
|
|
1373
|
+
) -> int:
|
|
1374
|
+
"""Print the blocking and advisory sections and return the gate exit code.
|
|
1375
|
+
|
|
1376
|
+
Args:
|
|
1377
|
+
blocking_by_file: Blocking violations grouped by resolved file path.
|
|
1378
|
+
advisory_by_file: Advisory violations grouped by resolved file path.
|
|
1379
|
+
repository_root: Repository root used to compute relative paths.
|
|
1380
|
+
is_whole_file_scope: True when no per-file added-line map was supplied,
|
|
1381
|
+
which selects the whole-file header wording.
|
|
1382
|
+
skipped_unreadable_count: Count of changed files that could not be read;
|
|
1383
|
+
a non-zero count forces a non-zero exit because the gate cannot
|
|
1384
|
+
vouch for those files.
|
|
1385
|
+
|
|
1386
|
+
Returns:
|
|
1387
|
+
Zero when no blocking violation was found and no file was skipped;
|
|
1388
|
+
non-zero otherwise.
|
|
1389
|
+
"""
|
|
1052
1390
|
blocking_count = sum(len(each_list) for each_list in blocking_by_file.values())
|
|
1053
1391
|
advisory_count = sum(len(each_list) for each_list in advisory_by_file.values())
|
|
1054
1392
|
if blocking_count:
|
|
1055
|
-
if
|
|
1393
|
+
if is_whole_file_scope:
|
|
1056
1394
|
header = f"code_rules_gate: {blocking_count} violation(s) reported."
|
|
1057
1395
|
else:
|
|
1058
1396
|
header = (
|
|
1059
1397
|
f"code_rules_gate: {blocking_count} violation(s) "
|
|
1060
1398
|
"introduced on changed lines:"
|
|
1061
1399
|
)
|
|
1062
|
-
print_violation_section(
|
|
1063
|
-
header,
|
|
1064
|
-
blocking_by_file,
|
|
1065
|
-
repository_root,
|
|
1066
|
-
)
|
|
1400
|
+
print_violation_section(header, blocking_by_file, repository_root)
|
|
1067
1401
|
if advisory_count:
|
|
1068
1402
|
if blocking_count:
|
|
1069
1403
|
print("", file=sys.stderr)
|
|
@@ -1075,7 +1409,13 @@ def run_gate(
|
|
|
1075
1409
|
advisory_by_file,
|
|
1076
1410
|
repository_root,
|
|
1077
1411
|
)
|
|
1078
|
-
if
|
|
1412
|
+
if skipped_unreadable_count:
|
|
1413
|
+
print(
|
|
1414
|
+
f"code_rules_gate: {skipped_unreadable_count} file(s) "
|
|
1415
|
+
"skipped due to read errors; gate cannot vouch for those files.",
|
|
1416
|
+
file=sys.stderr,
|
|
1417
|
+
)
|
|
1418
|
+
if blocking_count or skipped_unreadable_count:
|
|
1079
1419
|
return 1
|
|
1080
1420
|
return 0
|
|
1081
1421
|
|
|
@@ -1176,6 +1516,7 @@ def main(all_arguments: list[str]) -> int:
|
|
|
1176
1516
|
staged_file_paths,
|
|
1177
1517
|
repository_root,
|
|
1178
1518
|
all_added_lines_by_path=staged_added_lines,
|
|
1519
|
+
read_staged_content_flag=True,
|
|
1179
1520
|
)
|
|
1180
1521
|
file_paths = paths_from_git_diff(repository_root, arguments.base)
|
|
1181
1522
|
file_paths = filter_paths_under_prefixes(
|
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
"""Constants for code_rules_gate.py per CODE_RULES centralized-config rule."""
|
|
2
2
|
|
|
3
|
+
import re
|
|
4
|
+
|
|
3
5
|
MAX_VIOLATIONS_PER_CHECK: int = 3
|
|
4
6
|
EXPECTED_TUPLE_PAIR_LENGTH: int = 2
|
|
5
7
|
|
|
8
|
+
FUNCTION_LENGTH_VIOLATION_PATTERN: re.Pattern[str] = re.compile(
|
|
9
|
+
r"\(defined at line (\d+)\) is (\d+) lines"
|
|
10
|
+
)
|
|
11
|
+
FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX: int = 1
|
|
12
|
+
FUNCTION_LENGTH_SPAN_GROUP_INDEX: int = 2
|
|
13
|
+
|
|
14
|
+
ISOLATION_VIOLATION_PATTERN: re.Pattern[str] = re.compile(
|
|
15
|
+
r"\(defined at line (\d+), spanning (\d+) lines\)"
|
|
16
|
+
)
|
|
17
|
+
ISOLATION_DEFINITION_LINE_GROUP_INDEX: int = 1
|
|
18
|
+
ISOLATION_SPAN_GROUP_INDEX: int = 2
|
|
19
|
+
|
|
20
|
+
BANNED_NOUN_VIOLATION_PATTERN: re.Pattern[str] = re.compile(
|
|
21
|
+
r"\(binding span at line (\d+), spanning (\d+) lines\)"
|
|
22
|
+
)
|
|
23
|
+
BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX: int = 1
|
|
24
|
+
BANNED_NOUN_SPAN_GROUP_INDEX: int = 2
|
|
25
|
+
|
|
6
26
|
ALL_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
|
|
7
27
|
{".py", ".js", ".ts", ".tsx", ".jsx"}
|
|
8
28
|
)
|