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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import inspect
|
|
3
4
|
import subprocess
|
|
4
5
|
import sys
|
|
5
6
|
import unittest.mock
|
|
@@ -32,6 +33,11 @@ def initialize_git_repository(repository_root: Path) -> None:
|
|
|
32
33
|
run_git_in_repository(repository_root, "config", "user.email", "test@example.com")
|
|
33
34
|
run_git_in_repository(repository_root, "config", "user.name", "Test")
|
|
34
35
|
run_git_in_repository(repository_root, "config", "commit.gpgsign", "false")
|
|
36
|
+
disabled_hooks_directory = repository_root / "disabled-git-hooks"
|
|
37
|
+
disabled_hooks_directory.mkdir()
|
|
38
|
+
run_git_in_repository(
|
|
39
|
+
repository_root, "config", "core.hooksPath", str(disabled_hooks_directory)
|
|
40
|
+
)
|
|
35
41
|
|
|
36
42
|
|
|
37
43
|
def commit_all_files(repository_root: Path, commit_message: str) -> None:
|
|
@@ -177,10 +183,10 @@ def test_main_staged_mode_blocks_when_staged_lines_introduce_violations(
|
|
|
177
183
|
temporary_git_repository: Path,
|
|
178
184
|
monkeypatch: pytest.MonkeyPatch,
|
|
179
185
|
) -> None:
|
|
180
|
-
write_file(temporary_git_repository / "module.py", "
|
|
186
|
+
write_file(temporary_git_repository / "module.py", "first_count = 1\n")
|
|
181
187
|
commit_all_files(temporary_git_repository, "initial")
|
|
182
188
|
staged_content_with_banned_identifier = (
|
|
183
|
-
"
|
|
189
|
+
"first_count = 1\n"
|
|
184
190
|
"def compute_total(operand):\n"
|
|
185
191
|
" result = operand + 1\n"
|
|
186
192
|
" return result\n"
|
|
@@ -201,10 +207,10 @@ def test_main_staged_mode_passes_when_no_staged_violations(
|
|
|
201
207
|
temporary_git_repository: Path,
|
|
202
208
|
monkeypatch: pytest.MonkeyPatch,
|
|
203
209
|
) -> None:
|
|
204
|
-
write_file(temporary_git_repository / "module.py", "
|
|
210
|
+
write_file(temporary_git_repository / "module.py", "first_count = 1\n")
|
|
205
211
|
commit_all_files(temporary_git_repository, "initial")
|
|
206
212
|
write_file(
|
|
207
|
-
temporary_git_repository / "module.py", "
|
|
213
|
+
temporary_git_repository / "module.py", "first_count = 1\nsecond_count = 2\n"
|
|
208
214
|
)
|
|
209
215
|
stage_file(temporary_git_repository, "module.py")
|
|
210
216
|
|
|
@@ -218,7 +224,7 @@ def test_main_staged_mode_exits_zero_when_nothing_staged(
|
|
|
218
224
|
temporary_git_repository: Path,
|
|
219
225
|
monkeypatch: pytest.MonkeyPatch,
|
|
220
226
|
) -> None:
|
|
221
|
-
write_file(temporary_git_repository / "module.py", "
|
|
227
|
+
write_file(temporary_git_repository / "module.py", "first_count = 1\n")
|
|
222
228
|
commit_all_files(temporary_git_repository, "initial")
|
|
223
229
|
|
|
224
230
|
monkeypatch.chdir(temporary_git_repository)
|
|
@@ -304,6 +310,27 @@ def test_whole_file_line_set_raises_system_exit_on_oserror(
|
|
|
304
310
|
assert "denied" in captured.err or "PermissionError" in captured.err
|
|
305
311
|
|
|
306
312
|
|
|
313
|
+
def test_whole_file_line_set_raises_system_exit_on_non_utf8_file(
|
|
314
|
+
tmp_path: Path,
|
|
315
|
+
capsys: pytest.CaptureFixture[str],
|
|
316
|
+
) -> None:
|
|
317
|
+
"""A genuine non-UTF-8 file must fail closed as SystemExit, never crash.
|
|
318
|
+
|
|
319
|
+
``read_text(encoding="utf-8")`` on undecodable bytes raises
|
|
320
|
+
UnicodeDecodeError, a ValueError subclass that ``OSError`` does not catch.
|
|
321
|
+
The fail-closed contract that holds for read failures holds equally here:
|
|
322
|
+
returning an empty set would route every violation to the advisory bucket,
|
|
323
|
+
so an undecodable file must propagate as SystemExit rather than escape as
|
|
324
|
+
an unhandled UnicodeDecodeError.
|
|
325
|
+
"""
|
|
326
|
+
non_utf8_path = tmp_path / "garbled.py"
|
|
327
|
+
non_utf8_path.write_bytes(b"\xff\xfe\x00bad")
|
|
328
|
+
with pytest.raises(SystemExit):
|
|
329
|
+
gate_module.whole_file_line_set(non_utf8_path)
|
|
330
|
+
captured = capsys.readouterr()
|
|
331
|
+
assert str(non_utf8_path) in captured.err
|
|
332
|
+
|
|
333
|
+
|
|
307
334
|
def test_check_database_column_string_magic_signals_cap_exit(
|
|
308
335
|
capsys: pytest.CaptureFixture[str],
|
|
309
336
|
) -> None:
|
|
@@ -329,10 +356,10 @@ def test_check_database_column_string_magic_signals_cap_exit(
|
|
|
329
356
|
assert "cap reached" in captured.err.lower()
|
|
330
357
|
|
|
331
358
|
|
|
332
|
-
def
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
359
|
+
def test_check_wrapper_plumb_through_caps_findings_at_max_per_check() -> None:
|
|
360
|
+
"""check_wrapper_plumb_through stops emitting at MAX_VIOLATIONS_PER_CHECK
|
|
361
|
+
findings — the cap bounds the blocking payload silently, matching the
|
|
362
|
+
_shared gate copy."""
|
|
336
363
|
delegate_definition = (
|
|
337
364
|
"def delegate(*, optional_one=1, optional_two=2, optional_three=3,"
|
|
338
365
|
" optional_four=4): return 0\n"
|
|
@@ -346,9 +373,61 @@ def test_check_wrapper_plumb_through_signals_cap_exit(
|
|
|
346
373
|
source_with_many_wrappers,
|
|
347
374
|
"production/wrappers.py",
|
|
348
375
|
)
|
|
349
|
-
assert len(issues) ==
|
|
350
|
-
|
|
351
|
-
|
|
376
|
+
assert len(issues) == gate_module.MAX_VIOLATIONS_PER_CHECK
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _bugteam_banned_noun_parameter_issues() -> list[str]:
|
|
380
|
+
validate_content = gate_module.load_validate_content()
|
|
381
|
+
source = (
|
|
382
|
+
"def aggregate(canned_results: int) -> int:\n"
|
|
383
|
+
" doubled = canned_results * 2\n"
|
|
384
|
+
" return doubled\n"
|
|
385
|
+
)
|
|
386
|
+
issues = validate_content(source, "src/module.py", "")
|
|
387
|
+
return [each_issue for each_issue in issues if "banned noun" in each_issue]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_bugteam_split_violations_blocks_banned_noun_when_binding_line_is_added() -> None:
|
|
391
|
+
"""The bugteam gate reconstructs a banned-noun binding's one-line span the
|
|
392
|
+
same way the _shared gate does: the violation is blocking when its own
|
|
393
|
+
binding line is among the added lines."""
|
|
394
|
+
banned_noun_issues = _bugteam_banned_noun_parameter_issues()
|
|
395
|
+
assert banned_noun_issues, "expected a banned-noun parameter issue"
|
|
396
|
+
parameter_binding_line = 1
|
|
397
|
+
blocking, advisory = gate_module.split_violations_by_scope(
|
|
398
|
+
banned_noun_issues,
|
|
399
|
+
{parameter_binding_line},
|
|
400
|
+
)
|
|
401
|
+
assert blocking == banned_noun_issues
|
|
402
|
+
assert advisory == []
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_bugteam_split_violations_advises_banned_noun_when_binding_line_untouched() -> None:
|
|
406
|
+
"""A banned-noun binding whose own line is untouched is advisory at the
|
|
407
|
+
bugteam gate, so editing an unrelated body line does not pull a pre-existing
|
|
408
|
+
binding into scope."""
|
|
409
|
+
banned_noun_issues = _bugteam_banned_noun_parameter_issues()
|
|
410
|
+
assert banned_noun_issues, "expected a banned-noun parameter issue"
|
|
411
|
+
unrelated_body_line = 2
|
|
412
|
+
blocking, advisory = gate_module.split_violations_by_scope(
|
|
413
|
+
banned_noun_issues,
|
|
414
|
+
{unrelated_body_line},
|
|
415
|
+
)
|
|
416
|
+
assert advisory == banned_noun_issues
|
|
417
|
+
assert blocking == []
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_bugteam_banned_noun_span_range_covers_only_the_binding_line() -> None:
|
|
421
|
+
"""The reconstructed span is the binding line alone — one line, never the
|
|
422
|
+
enclosing function span. A parameter declared on a ``def`` line yields a
|
|
423
|
+
range covering only that line, so an unrelated body edit cannot pull the
|
|
424
|
+
pre-existing binding into scope."""
|
|
425
|
+
banned_noun_issues = _bugteam_banned_noun_parameter_issues()
|
|
426
|
+
assert banned_noun_issues, "expected a banned-noun parameter issue"
|
|
427
|
+
parameter_binding_line = 1
|
|
428
|
+
span = gate_module.banned_noun_span_range(banned_noun_issues[0])
|
|
429
|
+
assert span == range(parameter_binding_line, parameter_binding_line + 1)
|
|
430
|
+
assert len(span) == 1
|
|
352
431
|
|
|
353
432
|
|
|
354
433
|
def test_run_gate_exits_nonzero_when_a_file_is_unreadable(
|
|
@@ -378,6 +457,31 @@ def test_run_gate_exits_nonzero_when_a_file_is_unreadable(
|
|
|
378
457
|
assert "skip unreadable" in captured.err
|
|
379
458
|
|
|
380
459
|
|
|
460
|
+
def test_run_gate_skips_non_utf8_file_without_crashing(
|
|
461
|
+
tmp_path: Path,
|
|
462
|
+
capsys: pytest.CaptureFixture[str],
|
|
463
|
+
) -> None:
|
|
464
|
+
"""A non-UTF-8 code file is skipped, not crashed on, and forces a non-zero exit."""
|
|
465
|
+
non_utf8_file = tmp_path / "garbled.py"
|
|
466
|
+
non_utf8_file.write_bytes(b"\xff\xfe\x00bad bytes")
|
|
467
|
+
|
|
468
|
+
def fake_validate(_content: str, _path: str, **_kwargs: object) -> list[str]:
|
|
469
|
+
return []
|
|
470
|
+
|
|
471
|
+
exit_code = gate_module.run_gate(
|
|
472
|
+
fake_validate,
|
|
473
|
+
[non_utf8_file],
|
|
474
|
+
tmp_path,
|
|
475
|
+
all_added_lines_map=None,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
captured = capsys.readouterr()
|
|
479
|
+
assert exit_code != 0, (
|
|
480
|
+
"A file skipped for non-UTF-8 content must produce a non-zero gate exit"
|
|
481
|
+
)
|
|
482
|
+
assert "skip unreadable" in captured.err
|
|
483
|
+
|
|
484
|
+
|
|
381
485
|
def test_added_lines_for_staged_file_returns_parsed_result_when_diff_is_non_empty_even_if_parse_returns_empty(
|
|
382
486
|
temporary_git_repository: Path,
|
|
383
487
|
monkeypatch: pytest.MonkeyPatch,
|
|
@@ -398,3 +502,489 @@ def test_added_lines_for_staged_file_returns_parsed_result_when_diff_is_non_empt
|
|
|
398
502
|
)
|
|
399
503
|
|
|
400
504
|
assert added_line_numbers == set()
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _build_function_module(
|
|
508
|
+
function_name: str, body_line_count: int, leading_lines: int
|
|
509
|
+
) -> str:
|
|
510
|
+
preamble = "".join("anchor_name\n" for _ in range(leading_lines))
|
|
511
|
+
body = "\n".join(" keep_alive_name" for _ in range(body_line_count))
|
|
512
|
+
return f"{preamble}def {function_name}() -> None:\n{body}\n"
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def test_split_violations_blocks_function_length_when_span_intersects_added_lines() -> None:
|
|
516
|
+
"""A function-length issue whose declared span overlaps the diff's added
|
|
517
|
+
lines is blocking — the body grew, which is the regression intent."""
|
|
518
|
+
validate_content = gate_module.load_validate_content()
|
|
519
|
+
long_function = _build_function_module(
|
|
520
|
+
"oversized", body_line_count=70, leading_lines=3
|
|
521
|
+
)
|
|
522
|
+
issues = validate_content(long_function, "src/long_module.py", "")
|
|
523
|
+
function_length_issues = [
|
|
524
|
+
each_issue for each_issue in issues if "blocking threshold" in each_issue
|
|
525
|
+
]
|
|
526
|
+
assert function_length_issues, f"expected a function-length issue, got {issues!r}"
|
|
527
|
+
span_def_line = 4
|
|
528
|
+
inside_span_line = span_def_line + 10
|
|
529
|
+
blocking, advisory = gate_module.split_violations_by_scope(
|
|
530
|
+
function_length_issues,
|
|
531
|
+
all_added_line_numbers={inside_span_line},
|
|
532
|
+
)
|
|
533
|
+
assert blocking == function_length_issues
|
|
534
|
+
assert advisory == []
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def test_split_violations_advises_function_length_when_span_misses_added_lines() -> None:
|
|
538
|
+
"""A function-length issue for an untouched pre-existing function — whose
|
|
539
|
+
declared span does not overlap any added line — is advisory, not blocking.
|
|
540
|
+
Prevents the over-block regression where every pre-existing long function
|
|
541
|
+
in a touched file was forced into the blocking payload."""
|
|
542
|
+
validate_content = gate_module.load_validate_content()
|
|
543
|
+
long_function = _build_function_module(
|
|
544
|
+
"oversized", body_line_count=70, leading_lines=3
|
|
545
|
+
)
|
|
546
|
+
issues = validate_content(long_function, "src/long_module.py", "")
|
|
547
|
+
function_length_issues = [
|
|
548
|
+
each_issue for each_issue in issues if "blocking threshold" in each_issue
|
|
549
|
+
]
|
|
550
|
+
assert function_length_issues, f"expected a function-length issue, got {issues!r}"
|
|
551
|
+
line_far_outside_span = 5000
|
|
552
|
+
blocking, advisory = gate_module.split_violations_by_scope(
|
|
553
|
+
function_length_issues,
|
|
554
|
+
all_added_line_numbers={line_far_outside_span},
|
|
555
|
+
)
|
|
556
|
+
assert advisory == function_length_issues
|
|
557
|
+
assert blocking == []
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _isolation_issues_for_home_probe_test() -> list[str]:
|
|
561
|
+
validate_content = gate_module.load_validate_content()
|
|
562
|
+
header = "from pathlib import Path\n"
|
|
563
|
+
test_body = (
|
|
564
|
+
"def test_reads_home() -> None:\n"
|
|
565
|
+
" target_path = Path.home()\n"
|
|
566
|
+
" assert target_path\n"
|
|
567
|
+
)
|
|
568
|
+
issues = validate_content(header + test_body, "src/test_module.py", "")
|
|
569
|
+
return [each_issue for each_issue in issues if "probes" in each_issue]
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def test_split_violations_blocks_isolation_when_function_span_intersects_added_lines() -> None:
|
|
573
|
+
"""An isolation issue whose enclosing test-function span overlaps the diff's
|
|
574
|
+
added lines is blocking — a signature-line change that un-isolates an
|
|
575
|
+
unchanged-body probe must block, matching the enforcer's terminal path."""
|
|
576
|
+
isolation_issues = _isolation_issues_for_home_probe_test()
|
|
577
|
+
assert isolation_issues, "expected an isolation issue from the HOME probe test"
|
|
578
|
+
signature_line = 2
|
|
579
|
+
blocking, advisory = gate_module.split_violations_by_scope(
|
|
580
|
+
isolation_issues,
|
|
581
|
+
all_added_line_numbers={signature_line},
|
|
582
|
+
)
|
|
583
|
+
assert blocking == isolation_issues
|
|
584
|
+
assert advisory == []
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def test_split_violations_advises_isolation_when_function_span_misses_added_lines() -> None:
|
|
588
|
+
"""An isolation issue for an untouched pre-existing probe — whose enclosing
|
|
589
|
+
test-function span does not overlap any added line — is advisory, not
|
|
590
|
+
blocking, mirroring the function-length scope contract."""
|
|
591
|
+
isolation_issues = _isolation_issues_for_home_probe_test()
|
|
592
|
+
assert isolation_issues, "expected an isolation issue from the HOME probe test"
|
|
593
|
+
line_far_outside_span = 5000
|
|
594
|
+
blocking, advisory = gate_module.split_violations_by_scope(
|
|
595
|
+
isolation_issues,
|
|
596
|
+
all_added_line_numbers={line_far_outside_span},
|
|
597
|
+
)
|
|
598
|
+
assert advisory == isolation_issues
|
|
599
|
+
assert blocking == []
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _oversized_function_text(function_name: str) -> str:
|
|
603
|
+
body = "\n".join(" keep_alive_name" for _ in range(70))
|
|
604
|
+
return f"def {function_name}() -> None:\n{body}\n"
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _short_function_text(function_name: str) -> str:
|
|
608
|
+
return f"def {function_name}() -> None:\n keep_alive_name\n"
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def test_main_blocks_sixth_long_function_on_added_lines_past_document_order(
|
|
612
|
+
temporary_git_repository: Path,
|
|
613
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
614
|
+
) -> None:
|
|
615
|
+
"""bugbot-2: with five pre-existing untouched long functions ahead of it in
|
|
616
|
+
document order, growing the sixth function past the threshold on staged
|
|
617
|
+
lines must still block at the bugteam gate. The gate scopes by added lines,
|
|
618
|
+
so the in-scope sixth violation blocks regardless of how many untouched
|
|
619
|
+
ones precede it."""
|
|
620
|
+
leading_long_functions = "".join(
|
|
621
|
+
_oversized_function_text(f"leading_long_{each_index}")
|
|
622
|
+
for each_index in range(5)
|
|
623
|
+
)
|
|
624
|
+
baseline = leading_long_functions + _short_function_text("target_function")
|
|
625
|
+
write_file(temporary_git_repository / "module.py", baseline)
|
|
626
|
+
commit_all_files(temporary_git_repository, "five long functions plus a short sixth")
|
|
627
|
+
|
|
628
|
+
grown = leading_long_functions + _oversized_function_text("target_function")
|
|
629
|
+
write_file(temporary_git_repository / "module.py", grown)
|
|
630
|
+
stage_file(temporary_git_repository, "module.py")
|
|
631
|
+
|
|
632
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
633
|
+
exit_code = gate_module.main(["--staged"])
|
|
634
|
+
|
|
635
|
+
assert exit_code == 1, (
|
|
636
|
+
"the sixth long function — the only one on staged lines — must block "
|
|
637
|
+
"even though five untouched long functions precede it in document order"
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _home_probe_test_text(test_name: str) -> str:
|
|
642
|
+
return (
|
|
643
|
+
f"def {test_name}() -> None:\n"
|
|
644
|
+
" target_path = Path.home()\n"
|
|
645
|
+
" assert target_path\n"
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _clean_test_text(test_name: str) -> str:
|
|
650
|
+
return f"def {test_name}() -> None:\n assert 1 + 1 == 2\n"
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def test_main_blocks_sixth_isolation_probe_on_added_lines_past_document_order(
|
|
654
|
+
temporary_git_repository: Path,
|
|
655
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
656
|
+
) -> None:
|
|
657
|
+
"""bugbot-2 mirror: with five pre-existing untouched HOME probes ahead of it
|
|
658
|
+
in document order, adding a HOME probe to the sixth test on staged lines
|
|
659
|
+
must still block at the bugteam gate. The gate scopes by added lines, so the
|
|
660
|
+
in-scope sixth probe blocks regardless of how many untouched ones precede
|
|
661
|
+
it."""
|
|
662
|
+
header = "from pathlib import Path\n"
|
|
663
|
+
leading_probe_tests = "".join(
|
|
664
|
+
_home_probe_test_text(f"test_leading_probe_{each_index}")
|
|
665
|
+
for each_index in range(5)
|
|
666
|
+
)
|
|
667
|
+
baseline = header + leading_probe_tests + _clean_test_text("test_target_probe")
|
|
668
|
+
write_file(temporary_git_repository / "test_module.py", baseline)
|
|
669
|
+
commit_all_files(temporary_git_repository, "five probe tests plus a clean sixth")
|
|
670
|
+
|
|
671
|
+
grown = header + leading_probe_tests + _home_probe_test_text("test_target_probe")
|
|
672
|
+
write_file(temporary_git_repository / "test_module.py", grown)
|
|
673
|
+
stage_file(temporary_git_repository, "test_module.py")
|
|
674
|
+
|
|
675
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
676
|
+
exit_code = gate_module.main(["--staged"])
|
|
677
|
+
|
|
678
|
+
assert exit_code == 1, (
|
|
679
|
+
"the sixth HOME probe — the only one on staged lines — must block even "
|
|
680
|
+
"though five untouched probes precede it in document order"
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _banned_noun_function_text(index: int) -> str:
|
|
685
|
+
return (
|
|
686
|
+
f"def leading_{index}(canned_results: int) -> int:\n"
|
|
687
|
+
f" return canned_results\n"
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def test_main_blocks_banned_noun_on_added_lines_past_document_order(
|
|
692
|
+
temporary_git_repository: Path,
|
|
693
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
694
|
+
) -> None:
|
|
695
|
+
"""loop7-P1: with three pre-existing untouched banned-noun identifiers ahead
|
|
696
|
+
of it in document order, introducing a fourth banned-noun on a staged line
|
|
697
|
+
must still block at the bugteam gate. The gate scopes by added lines, so the
|
|
698
|
+
in-scope identifier blocks regardless of how many untouched ones precede
|
|
699
|
+
it."""
|
|
700
|
+
leading_count = 3
|
|
701
|
+
leading_functions = "".join(
|
|
702
|
+
_banned_noun_function_text(each_index) for each_index in range(leading_count)
|
|
703
|
+
)
|
|
704
|
+
baseline = leading_functions + "def placeholder() -> int:\n return 0\n"
|
|
705
|
+
write_file(temporary_git_repository / "module.py", baseline)
|
|
706
|
+
commit_all_files(temporary_git_repository, "three banned nouns plus a clean function")
|
|
707
|
+
|
|
708
|
+
grown = leading_functions + "def aggregate(holiday_result: int) -> int:\n return holiday_result\n"
|
|
709
|
+
write_file(temporary_git_repository / "module.py", grown)
|
|
710
|
+
stage_file(temporary_git_repository, "module.py")
|
|
711
|
+
|
|
712
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
713
|
+
exit_code = gate_module.main(["--staged"])
|
|
714
|
+
|
|
715
|
+
assert exit_code == 1, (
|
|
716
|
+
"the fourth banned-noun identifier — the only one on staged lines — must "
|
|
717
|
+
"block even though three untouched ones precede it in document order"
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def test_report_partitioned_violations_returns_zero_when_clean(tmp_path: Path) -> None:
|
|
722
|
+
"""No blocking violations and no skipped files yields a zero exit code."""
|
|
723
|
+
exit_code = gate_module._report_partitioned_violations(
|
|
724
|
+
blocking_by_file={},
|
|
725
|
+
advisory_by_file={tmp_path / "a.py": ["Line 1: advisory only"]},
|
|
726
|
+
repository_root=tmp_path,
|
|
727
|
+
is_whole_file_scope=False,
|
|
728
|
+
skipped_unreadable_count=0,
|
|
729
|
+
)
|
|
730
|
+
assert exit_code == 0
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def test_report_partitioned_violations_returns_one_on_blocking(tmp_path: Path) -> None:
|
|
734
|
+
"""A blocking violation yields a non-zero exit code."""
|
|
735
|
+
exit_code = gate_module._report_partitioned_violations(
|
|
736
|
+
blocking_by_file={tmp_path / "a.py": ["Line 1: blocking violation"]},
|
|
737
|
+
advisory_by_file={},
|
|
738
|
+
repository_root=tmp_path,
|
|
739
|
+
is_whole_file_scope=False,
|
|
740
|
+
skipped_unreadable_count=0,
|
|
741
|
+
)
|
|
742
|
+
assert exit_code == 1
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def test_report_partitioned_violations_returns_one_when_file_skipped(tmp_path: Path) -> None:
|
|
746
|
+
"""A skipped unreadable file forces a non-zero exit even with no blocking
|
|
747
|
+
violations, because the gate cannot vouch for the file it could not read."""
|
|
748
|
+
exit_code = gate_module._report_partitioned_violations(
|
|
749
|
+
blocking_by_file={},
|
|
750
|
+
advisory_by_file={},
|
|
751
|
+
repository_root=tmp_path,
|
|
752
|
+
is_whole_file_scope=False,
|
|
753
|
+
skipped_unreadable_count=1,
|
|
754
|
+
)
|
|
755
|
+
assert exit_code == 1
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def test_check_wrapper_plumb_through_skips_class_methods_calling_module_delegate() -> None:
|
|
759
|
+
"""A class method calling a module-level delegate is not a wrapper; its
|
|
760
|
+
signature is unrelated to the delegate's keyword surface, so it must not be
|
|
761
|
+
flagged — matching the _shared gate copy."""
|
|
762
|
+
source = (
|
|
763
|
+
"def fetch(target, *, retries=3):\n"
|
|
764
|
+
" return target\n"
|
|
765
|
+
"\n"
|
|
766
|
+
"class MyService:\n"
|
|
767
|
+
" def public_method(self, target):\n"
|
|
768
|
+
" return fetch(target)\n"
|
|
769
|
+
)
|
|
770
|
+
issues = gate_module.check_wrapper_plumb_through(source, "module.py")
|
|
771
|
+
assert issues == [], (
|
|
772
|
+
f"class methods must not be treated as module-level wrappers; got {issues!r}"
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def test_check_wrapper_plumb_through_flags_name_call_dropping_kwarg() -> None:
|
|
777
|
+
"""A bare-name call (``delegate(value)``) to a same-file delegate that
|
|
778
|
+
exposes an optional kwarg the public wrapper omits must be flagged — the
|
|
779
|
+
bugteam copy must handle ``ast.Name`` targets, not only ``ast.Attribute``."""
|
|
780
|
+
source = (
|
|
781
|
+
"def delegate(value, *, retries=3):\n"
|
|
782
|
+
" return value\n"
|
|
783
|
+
"\n"
|
|
784
|
+
"def public_wrapper(value):\n"
|
|
785
|
+
" return delegate(value)\n"
|
|
786
|
+
)
|
|
787
|
+
issues = gate_module.check_wrapper_plumb_through(source, "module.py")
|
|
788
|
+
assert any("retries" in each_issue for each_issue in issues), (
|
|
789
|
+
f"a bare-name delegate call dropping an optional kwarg must flag; got {issues!r}"
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def test_check_wrapper_plumb_through_ignores_calls_nested_inside_delegate_arguments() -> None:
|
|
794
|
+
"""A callee nested as an argument (``delegate(helper(x))``) is not a
|
|
795
|
+
separate call site; only the enclosing call is inspected, matching the
|
|
796
|
+
_shared gate copy."""
|
|
797
|
+
source = (
|
|
798
|
+
"def delegate(value, *, retries=3):\n"
|
|
799
|
+
" return value\n"
|
|
800
|
+
"\n"
|
|
801
|
+
"def helper(value):\n"
|
|
802
|
+
" return value\n"
|
|
803
|
+
"\n"
|
|
804
|
+
"def public_caller(value):\n"
|
|
805
|
+
" return delegate(helper(value))\n"
|
|
806
|
+
)
|
|
807
|
+
issues = gate_module.check_wrapper_plumb_through(source, "module.py")
|
|
808
|
+
assert all("helper" not in each_issue for each_issue in issues), (
|
|
809
|
+
f"nested-argument callee must not be a separate call site; got {issues!r}"
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def test_main_staged_mode_blocks_newly_staged_inline_comment(
|
|
814
|
+
temporary_git_repository: Path,
|
|
815
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
816
|
+
) -> None:
|
|
817
|
+
"""A NEWLY STAGED inline comment must be detected as a new comment in
|
|
818
|
+
``--staged`` mode. This proves the gate passes the HEAD-committed content as
|
|
819
|
+
``old_content`` to the comparison validators (the prior base), not the
|
|
820
|
+
current working-tree content. When the current file content is reused as
|
|
821
|
+
``old_content``, ``check_comment_changes`` sees identical old/new text and
|
|
822
|
+
misses the staged comment entirely, so the gate exits 0 — the regression."""
|
|
823
|
+
write_file(
|
|
824
|
+
temporary_git_repository / "module.py",
|
|
825
|
+
'def describe_state() -> str:\n return "ready"\n',
|
|
826
|
+
)
|
|
827
|
+
commit_all_files(temporary_git_repository, "initial without comment")
|
|
828
|
+
staged_content_with_new_comment = (
|
|
829
|
+
"def describe_state() -> str:\n"
|
|
830
|
+
' label = "ready" # newly staged inline comment\n'
|
|
831
|
+
" return label\n"
|
|
832
|
+
)
|
|
833
|
+
write_file(
|
|
834
|
+
temporary_git_repository / "module.py",
|
|
835
|
+
staged_content_with_new_comment,
|
|
836
|
+
)
|
|
837
|
+
stage_file(temporary_git_repository, "module.py")
|
|
838
|
+
|
|
839
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
840
|
+
exit_code = gate_module.main(["--staged"])
|
|
841
|
+
|
|
842
|
+
assert exit_code == 1, (
|
|
843
|
+
"a newly staged inline comment must block; if it does not, the gate is "
|
|
844
|
+
"passing the current file content as old_content instead of the "
|
|
845
|
+
"HEAD-committed prior base, so check_comment_changes sees identical "
|
|
846
|
+
"old/new text and misses the staged comment"
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def test_check_wrapper_plumb_through_stays_under_function_length_threshold() -> None:
|
|
851
|
+
"""check_wrapper_plumb_through must stay under the enforcer's function-length
|
|
852
|
+
blocking threshold so its signature-index, class-method-id, and per-wrapper
|
|
853
|
+
finding logic live in extracted helpers, matching the _shared gate copy."""
|
|
854
|
+
declared_line_count = len(
|
|
855
|
+
inspect.getsource(gate_module.check_wrapper_plumb_through).splitlines()
|
|
856
|
+
)
|
|
857
|
+
blocking_threshold = 60
|
|
858
|
+
assert declared_line_count < blocking_threshold, (
|
|
859
|
+
f"check_wrapper_plumb_through is {declared_line_count} lines; extract "
|
|
860
|
+
"helpers to keep it under the function-length blocking threshold"
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def test_read_prior_committed_content_returns_head_content_for_tracked_path(
|
|
865
|
+
temporary_git_repository: Path,
|
|
866
|
+
) -> None:
|
|
867
|
+
"""A tracked path returns its HEAD-committed content, not the working copy."""
|
|
868
|
+
committed_text = "alpha = 1\nbeta = 2\n"
|
|
869
|
+
write_file(temporary_git_repository / "tracked.py", committed_text)
|
|
870
|
+
commit_all_files(temporary_git_repository, "commit tracked file")
|
|
871
|
+
write_file(
|
|
872
|
+
temporary_git_repository / "tracked.py",
|
|
873
|
+
committed_text + "gamma = 3\n",
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
prior_content = gate_module.read_prior_committed_content(
|
|
877
|
+
temporary_git_repository.resolve(), "tracked.py"
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
assert prior_content == committed_text
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def test_read_prior_committed_content_returns_empty_for_untracked_path(
|
|
884
|
+
temporary_git_repository: Path,
|
|
885
|
+
) -> None:
|
|
886
|
+
"""An untracked path yields an empty string because git show returns non-zero."""
|
|
887
|
+
write_file(temporary_git_repository / "anchor.py", "anchor = 1\n")
|
|
888
|
+
commit_all_files(temporary_git_repository, "anchor commit")
|
|
889
|
+
|
|
890
|
+
prior_content = gate_module.read_prior_committed_content(
|
|
891
|
+
temporary_git_repository.resolve(), "never_committed.py"
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
assert prior_content == ""
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def test_main_staged_mode_validates_staged_blob_not_working_tree(
|
|
898
|
+
temporary_git_repository: Path,
|
|
899
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
900
|
+
) -> None:
|
|
901
|
+
"""Staged mode validates the staged blob, not the working tree.
|
|
902
|
+
|
|
903
|
+
A blocking violation lives in the staged blob, but the working tree has
|
|
904
|
+
been edited afterward to remove it. The gate must still block because it
|
|
905
|
+
scopes added lines from the staged index and must read its content from
|
|
906
|
+
the same staged source rather than the diverged working tree.
|
|
907
|
+
"""
|
|
908
|
+
write_file(temporary_git_repository / "module.py", "first_count = 1\n")
|
|
909
|
+
commit_all_files(temporary_git_repository, "initial")
|
|
910
|
+
staged_content_with_banned_identifier = (
|
|
911
|
+
"first_count = 1\n"
|
|
912
|
+
"def compute_total(operand):\n"
|
|
913
|
+
" result = operand + 1\n"
|
|
914
|
+
" return result\n"
|
|
915
|
+
)
|
|
916
|
+
write_file(
|
|
917
|
+
temporary_git_repository / "module.py",
|
|
918
|
+
staged_content_with_banned_identifier,
|
|
919
|
+
)
|
|
920
|
+
stage_file(temporary_git_repository, "module.py")
|
|
921
|
+
clean_working_tree_content = (
|
|
922
|
+
"first_count = 1\n"
|
|
923
|
+
"def compute_total(operand: int) -> int:\n"
|
|
924
|
+
" return operand + 1\n"
|
|
925
|
+
)
|
|
926
|
+
write_file(
|
|
927
|
+
temporary_git_repository / "module.py",
|
|
928
|
+
clean_working_tree_content,
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
932
|
+
exit_code = gate_module.main(["--staged"])
|
|
933
|
+
|
|
934
|
+
assert exit_code == 1, (
|
|
935
|
+
"the staged blob carries a blocking violation; the gate must block "
|
|
936
|
+
"even though the working tree was edited clean afterward"
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def test_main_staged_mode_blocks_when_staged_file_absent_from_working_tree(
|
|
941
|
+
temporary_git_repository: Path,
|
|
942
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
943
|
+
) -> None:
|
|
944
|
+
"""A staged blocking violation must block even when the working tree file
|
|
945
|
+
is gone. Staging a violating file and then deleting it from the working
|
|
946
|
+
tree leaves the violation only in the staged blob; the gate must validate
|
|
947
|
+
that blob rather than skip the path for failing a working-tree existence
|
|
948
|
+
check."""
|
|
949
|
+
write_file(temporary_git_repository / "baseline.py", "first_count = 1\n")
|
|
950
|
+
commit_all_files(temporary_git_repository, "initial")
|
|
951
|
+
staged_content_with_banned_identifier = (
|
|
952
|
+
"def compute_total(operand):\n"
|
|
953
|
+
" result = operand + 1\n"
|
|
954
|
+
" return result\n"
|
|
955
|
+
)
|
|
956
|
+
write_file(
|
|
957
|
+
temporary_git_repository / "module.py",
|
|
958
|
+
staged_content_with_banned_identifier,
|
|
959
|
+
)
|
|
960
|
+
stage_file(temporary_git_repository, "module.py")
|
|
961
|
+
(temporary_git_repository / "module.py").unlink()
|
|
962
|
+
|
|
963
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
964
|
+
exit_code = gate_module.main(["--staged"])
|
|
965
|
+
|
|
966
|
+
assert exit_code == 1, (
|
|
967
|
+
"the staged blob carries a blocking violation; the gate must block "
|
|
968
|
+
"even though the file was deleted from the working tree after staging"
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def test_main_staged_mode_passes_on_staged_deletion_of_clean_file(
|
|
973
|
+
temporary_git_repository: Path,
|
|
974
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
975
|
+
) -> None:
|
|
976
|
+
"""A staged deletion is not in the index, so its staged blob cannot be read.
|
|
977
|
+
The gate must skip such a path cleanly rather than counting it as an
|
|
978
|
+
unreadable file and failing closed. With no other staged violation, the
|
|
979
|
+
gate must exit zero."""
|
|
980
|
+
write_file(temporary_git_repository / "removable.py", "first_count = 1\n")
|
|
981
|
+
commit_all_files(temporary_git_repository, "initial")
|
|
982
|
+
run_git_in_repository(temporary_git_repository, "rm", "--", "removable.py")
|
|
983
|
+
|
|
984
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
985
|
+
exit_code = gate_module.main(["--staged"])
|
|
986
|
+
|
|
987
|
+
assert exit_code == 0, (
|
|
988
|
+
"a staged deletion has no staged blob; the gate must skip it cleanly "
|
|
989
|
+
"rather than fail closed as if the file were unreadable"
|
|
990
|
+
)
|
|
@@ -125,6 +125,11 @@ no longer applies.
|
|
|
125
125
|
- [ ] **Step 4: BUGBOT — fetch, decide, fix, reply, resolve**
|
|
126
126
|
See: [`reference/per-tick.md` § Step 2 BUGBOT + Step 3](reference/per-tick.md)
|
|
127
127
|
|
|
128
|
+
- [ ] **Opt-out gate (runs first, every BUGBOT entry).**
|
|
129
|
+
`python "$HOME/.claude/_shared/pr-loop/scripts/reviews_disabled.py" --reviewer bugbot`
|
|
130
|
+
- [ ] Exit 0 (`CLAUDE_REVIEWS_DISABLED` lists `bugbot`) → set `bugbot_down = true`, `phase = BUGTEAM`, advance to Step 5 (bypass). Cursor Bugbot is skipped for the entire run.
|
|
131
|
+
- [ ] Exit 1 → continue below.
|
|
132
|
+
|
|
128
133
|
Fetch bugbot reviews + inline comments on `current_head`.
|
|
129
134
|
|
|
130
135
|
- [ ] **dirty** (findings on `current_head`) →
|