claude-dev-env 8.10.0 → 8.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +85 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
- package/_shared/pr-loop/scripts/fix_pr_test_proof.py +233 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +47 -0
- package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +186 -0
- package/bin/codex-skill-load-block.mjs +93 -0
- package/bin/codex-skill-load-block.test.mjs +134 -0
- package/bin/install.agents-home.test.mjs +76 -1
- package/bin/install.mjs +55 -31
- package/bin/install.test.mjs +57 -0
- package/bin/install.transaction.test.mjs +29 -7
- package/bin/seed-codex-pstack-models.mjs +27 -0
- package/bin/seed-codex-pstack-models.test.mjs +72 -0
- package/docs/CODE_RULES.md +5 -1
- package/hooks/blocking/code_rules_enforcer.py +1 -3
- package/hooks/blocking/code_rules_magic_values.py +1 -3
- package/hooks/blocking/code_rules_paired_test.py +5 -6
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -3
- package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
- package/hooks/blocking/tests/test_repository_exemption.py +7 -0
- package/hooks/followup_ledger.py +8 -1
- package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
- package/hooks/hooks_constants/messages.py +0 -1
- package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
- package/hooks/hooks_constants/test_messages.py +1 -7
- package/hooks/lifecycle/nested_project_hooks.py +395 -0
- package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
- package/hooks/test_followup_ledger.py +18 -0
- package/package.json +2 -1
- package/pstack-codex-models.md +37 -0
- package/rules/bdd.md +1 -1
- package/rules/build-needs-a-user.md +53 -0
- package/rules/code-standards.md +1 -1
- package/rules/flag-non-breaking-findings.md +7 -0
- package/rules/paired-test-coverage.md +4 -2
- package/rules/testing.md +5 -1
- package/scripts/cde_lint.py +47 -1
- package/scripts/policy_lint/adapter_callers.py +124 -0
- package/scripts/policy_lint/adapter_pairing.py +4 -2
- package/scripts/policy_lint/adapter_support.py +4 -3
- package/scripts/policy_lint/adapters.py +15 -0
- package/scripts/policy_lint/check_catalog.py +24 -0
- package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
- package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
- package/scripts/policy_lint/config/constants.py +18 -0
- package/scripts/policy_lint/engine.py +9 -3
- package/scripts/policy_lint/model.py +6 -2
- package/scripts/policy_lint/registry.py +6 -0
- package/scripts/policy_lint/selection.py +2 -0
- package/scripts/repository_checks/tracked_private_terms.py +15 -1
- package/scripts/tests/test_cde_lint.py +50 -0
- package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
- package/scripts/tests/test_policy_lint_rules_callers.py +140 -0
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
- package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
- package/scripts/tests/test_policy_lint_selection.py +35 -0
- package/scripts/tests/test_tracked_private_terms.py +35 -0
- package/hooks/blocking/tdd_enforcer.py +0 -195
- package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
- package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
- package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
- package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
- package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
- package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
- package/hooks/blocking/test_tdd_enforcer.py +0 -1506
- package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
- package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
- package/scripts/codex_compat_watcher.py +0 -502
- package/scripts/tests/test_codex_compat_watcher.py +0 -599
|
@@ -385,11 +385,13 @@ def _first_nonzero(all_exit_codes: Iterable[int]) -> int:
|
|
|
385
385
|
return 0
|
|
386
386
|
|
|
387
387
|
|
|
388
|
-
def _add_baseline_worktree(
|
|
389
|
-
|
|
388
|
+
def _add_baseline_worktree(
|
|
389
|
+
repository_root: Path, baseline_worktree: Path, revision: str = GIT_HEAD_REVISION
|
|
390
|
+
) -> bool:
|
|
391
|
+
"""Attach a detached checkout of *revision* at *baseline_worktree*; True when git created it."""
|
|
390
392
|
worktree_added = _run_git(
|
|
391
393
|
repository_root,
|
|
392
|
-
(*ALL_GIT_WORKTREE_ADD_DETACH_ARGS, str(baseline_worktree),
|
|
394
|
+
(*ALL_GIT_WORKTREE_ADD_DETACH_ARGS, str(baseline_worktree), revision),
|
|
393
395
|
)
|
|
394
396
|
return worktree_added.returncode == 0
|
|
395
397
|
|
|
@@ -515,6 +517,86 @@ def run_grouped_tests_with_regression_gate(
|
|
|
515
517
|
return first_failing_exit_code
|
|
516
518
|
|
|
517
519
|
|
|
520
|
+
def _overlay_files_into_worktree(
|
|
521
|
+
all_overlay_paths: list[Path], repository_root: Path, baseline_worktree: Path
|
|
522
|
+
) -> None:
|
|
523
|
+
"""Copy each working-tree file onto the same relative path inside the worktree."""
|
|
524
|
+
for each_path in all_overlay_paths:
|
|
525
|
+
worktree_path = _path_under_baseline_worktree(
|
|
526
|
+
each_path, repository_root, baseline_worktree
|
|
527
|
+
)
|
|
528
|
+
worktree_path.parent.mkdir(parents=True, exist_ok=True)
|
|
529
|
+
worktree_path.write_bytes(each_path.read_bytes())
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _revision_outcomes(
|
|
533
|
+
all_tests_by_root: dict[Path, list[Path]],
|
|
534
|
+
repository_root: Path,
|
|
535
|
+
revision: str,
|
|
536
|
+
all_overlay_paths: list[Path],
|
|
537
|
+
junit_root: Path,
|
|
538
|
+
) -> dict[Path, GroupOutcome] | None:
|
|
539
|
+
"""Run every group in a worktree of *revision* with the overlay files copied in."""
|
|
540
|
+
with tempfile.TemporaryDirectory(
|
|
541
|
+
prefix=REGRESSION_BASELINE_WORKTREE_TEMP_DIRECTORY_PREFIX,
|
|
542
|
+
ignore_cleanup_errors=True,
|
|
543
|
+
) as baseline_parent_text:
|
|
544
|
+
baseline_worktree = (
|
|
545
|
+
Path(baseline_parent_text) / REGRESSION_BASELINE_WORKTREE_DIRECTORY_NAME
|
|
546
|
+
)
|
|
547
|
+
if not _add_baseline_worktree(repository_root, baseline_worktree, revision):
|
|
548
|
+
return None
|
|
549
|
+
try:
|
|
550
|
+
_overlay_files_into_worktree(
|
|
551
|
+
all_overlay_paths, repository_root, baseline_worktree
|
|
552
|
+
)
|
|
553
|
+
return _baseline_outcomes_for_failing_groups(
|
|
554
|
+
repository_root, all_tests_by_root, junit_root, baseline_worktree
|
|
555
|
+
)
|
|
556
|
+
finally:
|
|
557
|
+
_remove_baseline_worktree(repository_root, baseline_worktree)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def working_tree_and_revision_outcomes(
|
|
561
|
+
all_tests_by_root: dict[Path, list[Path]],
|
|
562
|
+
repository_root: Path,
|
|
563
|
+
revision: str,
|
|
564
|
+
all_overlay_paths: list[Path],
|
|
565
|
+
) -> tuple[dict[Path, GroupOutcome], dict[Path, GroupOutcome] | None]:
|
|
566
|
+
"""Run the test groups on the working tree, then on *revision* with overlays copied in.
|
|
567
|
+
|
|
568
|
+
::
|
|
569
|
+
|
|
570
|
+
working tree: pkg/test_calc.py passes -> exit 0
|
|
571
|
+
revision: base code + head pkg/test_calc.py -> fails, the test proves the fix
|
|
572
|
+
|
|
573
|
+
The revision run reuses the baseline worktree and its import isolation, so
|
|
574
|
+
a run that read working-tree code reports no failures.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
all_tests_by_root: Test files grouped by owning pytest-config root.
|
|
578
|
+
repository_root: The repository the test files belong to.
|
|
579
|
+
revision: The git revision the second run checks out.
|
|
580
|
+
all_overlay_paths: Working-tree files copied into the revision checkout
|
|
581
|
+
before it runs, such as the test files a change adds or edits.
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
The working-tree outcomes, and the revision outcomes keyed by the same
|
|
585
|
+
group roots, or None when git could not create the revision worktree.
|
|
586
|
+
"""
|
|
587
|
+
with tempfile.TemporaryDirectory(
|
|
588
|
+
prefix=REGRESSION_JUNIT_TEMP_DIRECTORY_PREFIX
|
|
589
|
+
) as junit_root_text:
|
|
590
|
+
junit_root = Path(junit_root_text)
|
|
591
|
+
working_tree_outcomes = _run_staged_groups(
|
|
592
|
+
all_tests_by_root, repository_root, junit_root
|
|
593
|
+
)
|
|
594
|
+
revision_outcomes = _revision_outcomes(
|
|
595
|
+
all_tests_by_root, repository_root, revision, all_overlay_paths, junit_root
|
|
596
|
+
)
|
|
597
|
+
return working_tree_outcomes, revision_outcomes
|
|
598
|
+
|
|
599
|
+
|
|
518
600
|
def run_staged_test_files(repository_root: Path) -> int:
|
|
519
601
|
"""Discover the staged test files and run them under the regression gate.
|
|
520
602
|
|
|
@@ -307,3 +307,37 @@ def test_run_staged_test_files_passes_when_config_less_packages_share_a_module_n
|
|
|
307
307
|
_stage_package_owning_a_bare_config(repository_root, "beta_package", "beta")
|
|
308
308
|
|
|
309
309
|
assert staged_test_regression.run_staged_test_files(repository_root) == 0
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_a_revision_run_executes_an_overlaid_test_against_the_older_code(
|
|
313
|
+
tmp_path: Path,
|
|
314
|
+
) -> None:
|
|
315
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
316
|
+
write_and_stage(repository_root, "pkg_a/calc.py", "VALUE = 'old'\n")
|
|
317
|
+
run_git(repository_root, "commit", "--no-verify", "-m", "old value")
|
|
318
|
+
base_revision = (
|
|
319
|
+
run_git(repository_root, "rev-parse", "HEAD").stdout.decode().strip()
|
|
320
|
+
)
|
|
321
|
+
write_and_stage(repository_root, "pkg_a/calc.py", "VALUE = 'new'\n")
|
|
322
|
+
test_path = write_and_stage(
|
|
323
|
+
repository_root,
|
|
324
|
+
"pkg_a/test_calc.py",
|
|
325
|
+
"from calc import VALUE\n\n\ndef test_value_is_new() -> None:\n"
|
|
326
|
+
" assert VALUE == 'new'\n",
|
|
327
|
+
)
|
|
328
|
+
run_git(repository_root, "commit", "--no-verify", "-m", "new value")
|
|
329
|
+
|
|
330
|
+
working_tree_outcomes, revision_outcomes = (
|
|
331
|
+
staged_test_regression.working_tree_and_revision_outcomes(
|
|
332
|
+
{repository_root.resolve(): [test_path]},
|
|
333
|
+
repository_root,
|
|
334
|
+
base_revision,
|
|
335
|
+
[test_path],
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
assert [each.exit_code for each in working_tree_outcomes.values()] == [0]
|
|
340
|
+
assert revision_outcomes is not None
|
|
341
|
+
assert [
|
|
342
|
+
len(each.failing_identities) for each in revision_outcomes.values()
|
|
343
|
+
] == [1]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Require a fix pull request to carry a test that fails on its base and passes on its head.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
title "docs: ..." -> pass, nothing to prove
|
|
6
|
+
title "fix: ..." changing only docs or .github -> pass, nothing to prove
|
|
7
|
+
title "fix: ..." with no changed Python test -> fail
|
|
8
|
+
title "fix: ..." with no changed Python or Node test -> fail
|
|
9
|
+
changed test passes on head, fails on base -> pass
|
|
10
|
+
changed test passes on both -> fail
|
|
11
|
+
any changed test fails on head -> fail
|
|
12
|
+
|
|
13
|
+
Python tests reuse the regression gate's detached worktree: the changed test
|
|
14
|
+
files are copied onto the base checkout, which runs them with import isolation.
|
|
15
|
+
Node tests (``*.test.mjs``, ``*.test.js``, ``*.test.cjs``) run through
|
|
16
|
+
``node --test`` on the head, then on a detached base checkout that holds a copy
|
|
17
|
+
of the changed test files. The fix is proven when at least one language's
|
|
18
|
+
changed tests fail on the base.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from code_rules_gate_parts import staged_test_regression, staged_test_running
|
|
30
|
+
from code_rules_gate_parts.git_file_sets import paths_from_git_diff
|
|
31
|
+
from code_rules_gate_parts.wrapper_plumb_check import is_test_path
|
|
32
|
+
from pr_loop_shared_constants.fix_pr_test_proof_constants import (
|
|
33
|
+
ALL_NODE_TEST_COMMAND,
|
|
34
|
+
ALL_NODE_TEST_SUFFIXES,
|
|
35
|
+
ALL_PRODUCTION_CODE_SUFFIXES,
|
|
36
|
+
CI_CONFIG_DIRECTORY_NAME,
|
|
37
|
+
FAILED_EXIT_CODE,
|
|
38
|
+
FIX_TITLE_PATTERN,
|
|
39
|
+
HEAD_FAILURE_MESSAGE,
|
|
40
|
+
NO_BASE_FAILURE_MESSAGE,
|
|
41
|
+
NO_CHANGED_TEST_MESSAGE,
|
|
42
|
+
NO_PRODUCTION_CHANGE_MESSAGE,
|
|
43
|
+
NODE_BASE_WORKTREE_DIRECTORY_NAME,
|
|
44
|
+
NODE_BASE_WORKTREE_TEMP_DIRECTORY_PREFIX,
|
|
45
|
+
NOT_A_FIX_MESSAGE,
|
|
46
|
+
PASSED_EXIT_CODE,
|
|
47
|
+
PROVEN_MESSAGE,
|
|
48
|
+
WORKTREE_FAILED_MESSAGE,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ProofVerdict(Enum):
|
|
53
|
+
"""The result of running one language's changed tests on the head and the base."""
|
|
54
|
+
|
|
55
|
+
HEAD_FAILED = "head_failed"
|
|
56
|
+
BASE_UNAVAILABLE = "base_unavailable"
|
|
57
|
+
PASSES_ON_BASE = "passes_on_base"
|
|
58
|
+
PROVEN = "proven"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_arguments(all_arguments: list[str]) -> argparse.Namespace:
|
|
62
|
+
parser = argparse.ArgumentParser(prog="fix_pr_test_proof")
|
|
63
|
+
parser.add_argument("--title", required=True)
|
|
64
|
+
parser.add_argument("--base", dest="base_revision", required=True)
|
|
65
|
+
parser.add_argument("--repository-root", default=".")
|
|
66
|
+
return parser.parse_args(all_arguments)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_production_code_path(file_path: Path, repository_root: Path) -> bool:
|
|
70
|
+
"""Return whether a changed path is production code a fix must prove.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
file_path: An absolute changed path.
|
|
74
|
+
repository_root: The repository the path belongs to.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
True for a code file outside the test patterns and outside ``.github``.
|
|
78
|
+
"""
|
|
79
|
+
relative_path = file_path.relative_to(repository_root)
|
|
80
|
+
if relative_path.parts and relative_path.parts[0] == CI_CONFIG_DIRECTORY_NAME:
|
|
81
|
+
return False
|
|
82
|
+
if file_path.suffix not in ALL_PRODUCTION_CODE_SUFFIXES:
|
|
83
|
+
return False
|
|
84
|
+
return not is_test_path(relative_path.as_posix())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _changed_python_tests(all_changed_paths: list[Path]) -> list[Path]:
|
|
88
|
+
return [
|
|
89
|
+
each_path
|
|
90
|
+
for each_path in all_changed_paths
|
|
91
|
+
if each_path.suffix == ".py"
|
|
92
|
+
and is_test_path(each_path.as_posix())
|
|
93
|
+
and each_path.is_file()
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _changed_node_tests(all_changed_paths: list[Path]) -> list[Path]:
|
|
98
|
+
return [
|
|
99
|
+
each_path
|
|
100
|
+
for each_path in all_changed_paths
|
|
101
|
+
if each_path.name.endswith(ALL_NODE_TEST_SUFFIXES) and each_path.is_file()
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _python_proof_verdict(
|
|
106
|
+
all_changed_tests: list[Path], repository_root: Path, base_revision: str
|
|
107
|
+
) -> ProofVerdict:
|
|
108
|
+
all_targets = staged_test_running._pytest_target_paths(all_changed_tests)
|
|
109
|
+
all_tests_by_root = staged_test_running._group_staged_tests_by_root(
|
|
110
|
+
all_targets, repository_root
|
|
111
|
+
)
|
|
112
|
+
head_outcomes, base_outcomes = (
|
|
113
|
+
staged_test_regression.working_tree_and_revision_outcomes(
|
|
114
|
+
all_tests_by_root, repository_root, base_revision, all_changed_tests
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
for each_group_root, each_outcome in sorted(head_outcomes.items()):
|
|
118
|
+
if each_outcome.exit_code != 0:
|
|
119
|
+
sys.stderr.write(HEAD_FAILURE_MESSAGE.format(group_root=each_group_root) + "\n")
|
|
120
|
+
return ProofVerdict.HEAD_FAILED
|
|
121
|
+
if base_outcomes is None:
|
|
122
|
+
sys.stderr.write(WORKTREE_FAILED_MESSAGE.format(revision=base_revision) + "\n")
|
|
123
|
+
return ProofVerdict.BASE_UNAVAILABLE
|
|
124
|
+
base_failure_count = sum(
|
|
125
|
+
len(each_outcome.failing_identities) for each_outcome in base_outcomes.values()
|
|
126
|
+
)
|
|
127
|
+
if base_failure_count == 0:
|
|
128
|
+
return ProofVerdict.PASSES_ON_BASE
|
|
129
|
+
sys.stdout.write(PROVEN_MESSAGE.format(count=base_failure_count) + "\n")
|
|
130
|
+
return ProofVerdict.PROVEN
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _node_tests_pass(working_directory: Path, all_relative_tests: list[Path]) -> bool:
|
|
134
|
+
node_run = subprocess.run(
|
|
135
|
+
[*ALL_NODE_TEST_COMMAND, *(each_path.as_posix() for each_path in all_relative_tests)],
|
|
136
|
+
cwd=working_directory,
|
|
137
|
+
capture_output=True,
|
|
138
|
+
text=True,
|
|
139
|
+
check=False,
|
|
140
|
+
)
|
|
141
|
+
return node_run.returncode == 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _copy_tests_onto(
|
|
145
|
+
base_worktree: Path, repository_root: Path, all_relative_tests: list[Path]
|
|
146
|
+
) -> None:
|
|
147
|
+
for each_relative_test in all_relative_tests:
|
|
148
|
+
base_test_path = base_worktree / each_relative_test
|
|
149
|
+
base_test_path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
shutil.copy2(repository_root / each_relative_test, base_test_path)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _node_proof_verdict(
|
|
154
|
+
all_changed_tests: list[Path], repository_root: Path, base_revision: str
|
|
155
|
+
) -> ProofVerdict:
|
|
156
|
+
all_relative_tests = [each_path.relative_to(repository_root) for each_path in all_changed_tests]
|
|
157
|
+
if not _node_tests_pass(repository_root, all_relative_tests):
|
|
158
|
+
sys.stderr.write(HEAD_FAILURE_MESSAGE.format(group_root=repository_root) + "\n")
|
|
159
|
+
return ProofVerdict.HEAD_FAILED
|
|
160
|
+
with tempfile.TemporaryDirectory(
|
|
161
|
+
prefix=NODE_BASE_WORKTREE_TEMP_DIRECTORY_PREFIX, ignore_cleanup_errors=True
|
|
162
|
+
) as base_parent_text:
|
|
163
|
+
base_worktree = Path(base_parent_text) / NODE_BASE_WORKTREE_DIRECTORY_NAME
|
|
164
|
+
if not staged_test_regression._add_baseline_worktree(
|
|
165
|
+
repository_root, base_worktree, base_revision
|
|
166
|
+
):
|
|
167
|
+
sys.stderr.write(WORKTREE_FAILED_MESSAGE.format(revision=base_revision) + "\n")
|
|
168
|
+
return ProofVerdict.BASE_UNAVAILABLE
|
|
169
|
+
try:
|
|
170
|
+
_copy_tests_onto(base_worktree, repository_root, all_relative_tests)
|
|
171
|
+
passes_on_base = _node_tests_pass(base_worktree, all_relative_tests)
|
|
172
|
+
finally:
|
|
173
|
+
staged_test_regression._remove_baseline_worktree(repository_root, base_worktree)
|
|
174
|
+
if passes_on_base:
|
|
175
|
+
return ProofVerdict.PASSES_ON_BASE
|
|
176
|
+
sys.stdout.write(PROVEN_MESSAGE.format(count=len(all_relative_tests)) + "\n")
|
|
177
|
+
return ProofVerdict.PROVEN
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _proof_exit_code(
|
|
181
|
+
all_verdicts: list[ProofVerdict], base_revision: str
|
|
182
|
+
) -> int:
|
|
183
|
+
if any(
|
|
184
|
+
each_verdict in (ProofVerdict.HEAD_FAILED, ProofVerdict.BASE_UNAVAILABLE)
|
|
185
|
+
for each_verdict in all_verdicts
|
|
186
|
+
):
|
|
187
|
+
return FAILED_EXIT_CODE
|
|
188
|
+
if ProofVerdict.PROVEN in all_verdicts:
|
|
189
|
+
return PASSED_EXIT_CODE
|
|
190
|
+
sys.stderr.write(NO_BASE_FAILURE_MESSAGE.format(revision=base_revision) + "\n")
|
|
191
|
+
return FAILED_EXIT_CODE
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def main(all_arguments: list[str]) -> int:
|
|
195
|
+
"""Run the fix test-proof check.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
all_arguments: Command arguments naming the title, base, and repository.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
0 when the pull request passes, 1 when a fix lacks its proof test.
|
|
202
|
+
"""
|
|
203
|
+
parsed_arguments = _parse_arguments(all_arguments)
|
|
204
|
+
if not FIX_TITLE_PATTERN.match(parsed_arguments.title):
|
|
205
|
+
sys.stdout.write(NOT_A_FIX_MESSAGE + "\n")
|
|
206
|
+
return PASSED_EXIT_CODE
|
|
207
|
+
repository_root = Path(parsed_arguments.repository_root).resolve()
|
|
208
|
+
all_changed_paths = paths_from_git_diff(repository_root, parsed_arguments.base_revision)
|
|
209
|
+
if not any(
|
|
210
|
+
is_production_code_path(each_path, repository_root) for each_path in all_changed_paths
|
|
211
|
+
):
|
|
212
|
+
sys.stdout.write(NO_PRODUCTION_CHANGE_MESSAGE + "\n")
|
|
213
|
+
return PASSED_EXIT_CODE
|
|
214
|
+
all_changed_python_tests = _changed_python_tests(all_changed_paths)
|
|
215
|
+
all_changed_node_tests = _changed_node_tests(all_changed_paths)
|
|
216
|
+
if not all_changed_python_tests and not all_changed_node_tests:
|
|
217
|
+
sys.stderr.write(NO_CHANGED_TEST_MESSAGE + "\n")
|
|
218
|
+
return FAILED_EXIT_CODE
|
|
219
|
+
base_revision = parsed_arguments.base_revision
|
|
220
|
+
all_verdicts: list[ProofVerdict] = []
|
|
221
|
+
if all_changed_python_tests:
|
|
222
|
+
all_verdicts.append(
|
|
223
|
+
_python_proof_verdict(all_changed_python_tests, repository_root, base_revision)
|
|
224
|
+
)
|
|
225
|
+
if all_changed_node_tests:
|
|
226
|
+
all_verdicts.append(
|
|
227
|
+
_node_proof_verdict(all_changed_node_tests, repository_root, base_revision)
|
|
228
|
+
)
|
|
229
|
+
return _proof_exit_code(all_verdicts, base_revision)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Constants for the fix pull request test-proof check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
FIX_TITLE_PATTERN: re.Pattern[str] = re.compile(r"^fix(\([^)]*\))?!?:")
|
|
8
|
+
ALL_PRODUCTION_CODE_SUFFIXES: tuple[str, ...] = (
|
|
9
|
+
".py",
|
|
10
|
+
".mjs",
|
|
11
|
+
".js",
|
|
12
|
+
".cjs",
|
|
13
|
+
".ts",
|
|
14
|
+
".tsx",
|
|
15
|
+
".ps1",
|
|
16
|
+
".sh",
|
|
17
|
+
)
|
|
18
|
+
ALL_NODE_TEST_SUFFIXES: tuple[str, ...] = (".test.mjs", ".test.js", ".test.cjs")
|
|
19
|
+
ALL_NODE_TEST_COMMAND: tuple[str, ...] = ("node", "--test")
|
|
20
|
+
NODE_BASE_WORKTREE_TEMP_DIRECTORY_PREFIX: str = "fix_pr_test_proof_node_base_"
|
|
21
|
+
NODE_BASE_WORKTREE_DIRECTORY_NAME: str = "tree"
|
|
22
|
+
CI_CONFIG_DIRECTORY_NAME: str = ".github"
|
|
23
|
+
PASSED_EXIT_CODE: int = 0
|
|
24
|
+
FAILED_EXIT_CODE: int = 1
|
|
25
|
+
|
|
26
|
+
NOT_A_FIX_MESSAGE: str = "fix_pr_test_proof: the title is outside the fix type; nothing to prove."
|
|
27
|
+
NO_PRODUCTION_CHANGE_MESSAGE: str = (
|
|
28
|
+
"fix_pr_test_proof: the fix changes no production code; nothing to prove."
|
|
29
|
+
)
|
|
30
|
+
NO_CHANGED_TEST_MESSAGE: str = (
|
|
31
|
+
"fix_pr_test_proof: this fix changes production code and no Python or Node test. "
|
|
32
|
+
"Add a test that fails on the base and passes on the head."
|
|
33
|
+
)
|
|
34
|
+
WORKTREE_FAILED_MESSAGE: str = (
|
|
35
|
+
"fix_pr_test_proof: git could not check out the base revision {revision}."
|
|
36
|
+
)
|
|
37
|
+
HEAD_FAILURE_MESSAGE: str = (
|
|
38
|
+
"fix_pr_test_proof: a changed test fails on the head in {group_root}. "
|
|
39
|
+
"The proof test must pass once the fix is in."
|
|
40
|
+
)
|
|
41
|
+
NO_BASE_FAILURE_MESSAGE: str = (
|
|
42
|
+
"fix_pr_test_proof: every changed test passes on the base {revision}. "
|
|
43
|
+
"Add a test that fails without the fix."
|
|
44
|
+
)
|
|
45
|
+
PROVEN_MESSAGE: str = (
|
|
46
|
+
"fix_pr_test_proof: {count} changed test(s) fail on the base and pass on the head."
|
|
47
|
+
)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Behavioral tests for the fix pull request test-proof check.
|
|
2
|
+
|
|
3
|
+
Every case drives a git repository under ``tmp_path`` and a pytest or
|
|
4
|
+
``node --test`` subprocess, the same shape the CI job runs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import fix_pr_test_proof
|
|
12
|
+
from code_rules_gate_parts.tests._repo_test_helpers import (
|
|
13
|
+
repository_with_root_pytest_config,
|
|
14
|
+
run_git,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
BUGGY_PRODUCTION_TEXT = (
|
|
18
|
+
"def add(left: int, right: int) -> int:\n return left - right\n"
|
|
19
|
+
)
|
|
20
|
+
FIXED_PRODUCTION_TEXT = (
|
|
21
|
+
"def add(left: int, right: int) -> int:\n return left + right\n"
|
|
22
|
+
)
|
|
23
|
+
PROOF_TEST_TEXT = (
|
|
24
|
+
"from calc import add\n\n\ndef test_add_sums_both_operands() -> None:\n"
|
|
25
|
+
" assert add(1, 2) == 3\n"
|
|
26
|
+
)
|
|
27
|
+
ALWAYS_PASSING_TEST_TEXT = (
|
|
28
|
+
"def test_nothing_in_particular() -> None:\n assert True\n"
|
|
29
|
+
)
|
|
30
|
+
BUGGY_NODE_PRODUCTION_TEXT = "export function add(left, right) {\n return left - right;\n}\n"
|
|
31
|
+
FIXED_NODE_PRODUCTION_TEXT = "export function add(left, right) {\n return left + right;\n}\n"
|
|
32
|
+
NODE_PROOF_TEST_TEXT = (
|
|
33
|
+
"import { test } from 'node:test';\n"
|
|
34
|
+
"import { strict as assert } from 'node:assert';\n"
|
|
35
|
+
"import { add } from './calc.mjs';\n\n"
|
|
36
|
+
"test('add sums both operands', () => {\n assert.equal(add(1, 2), 3);\n});\n"
|
|
37
|
+
)
|
|
38
|
+
NODE_ALWAYS_PASSING_TEST_TEXT = (
|
|
39
|
+
"import { test } from 'node:test';\n\n"
|
|
40
|
+
"test('nothing in particular', () => {});\n"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _commit_files(repository_root: Path, all_file_texts: dict[str, str]) -> None:
|
|
45
|
+
for each_relative_path, each_text in all_file_texts.items():
|
|
46
|
+
file_path = repository_root / each_relative_path
|
|
47
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
file_path.write_text(each_text, encoding="utf-8")
|
|
49
|
+
run_git(repository_root, "add", "-A")
|
|
50
|
+
run_git(repository_root, "commit", "--no-verify", "-m", "change")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _head_revision(repository_root: Path) -> str:
|
|
54
|
+
return run_git(repository_root, "rev-parse", "HEAD").stdout.decode().strip()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _repository_at_buggy_base(tmp_path: Path) -> tuple[Path, str]:
|
|
58
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
59
|
+
_commit_files(repository_root, {"pkg/calc.py": BUGGY_PRODUCTION_TEXT})
|
|
60
|
+
return repository_root, _head_revision(repository_root)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _repository_at_buggy_node_base(tmp_path: Path) -> tuple[Path, str]:
|
|
64
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
65
|
+
_commit_files(repository_root, {"bin/calc.mjs": BUGGY_NODE_PRODUCTION_TEXT})
|
|
66
|
+
return repository_root, _head_revision(repository_root)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_check(repository_root: Path, title: str, base_revision: str) -> int:
|
|
70
|
+
return fix_pr_test_proof.main(
|
|
71
|
+
[
|
|
72
|
+
"--title",
|
|
73
|
+
title,
|
|
74
|
+
"--base",
|
|
75
|
+
base_revision,
|
|
76
|
+
"--repository-root",
|
|
77
|
+
str(repository_root),
|
|
78
|
+
]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_a_title_outside_the_fix_type_passes(tmp_path: Path) -> None:
|
|
83
|
+
repository_root, base_revision = _repository_at_buggy_base(tmp_path)
|
|
84
|
+
_commit_files(repository_root, {"pkg/calc.py": FIXED_PRODUCTION_TEXT})
|
|
85
|
+
|
|
86
|
+
assert _run_check(repository_root, "feat: add sums", base_revision) == 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_a_fix_that_changes_no_production_code_passes(tmp_path: Path) -> None:
|
|
90
|
+
repository_root, base_revision = _repository_at_buggy_base(tmp_path)
|
|
91
|
+
_commit_files(repository_root, {"docs/notes.md": "# Notes\n"})
|
|
92
|
+
|
|
93
|
+
assert _run_check(repository_root, "fix(docs): correct a link", base_revision) == 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_a_fix_with_no_changed_test_fails(
|
|
97
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
98
|
+
) -> None:
|
|
99
|
+
repository_root, base_revision = _repository_at_buggy_base(tmp_path)
|
|
100
|
+
_commit_files(repository_root, {"pkg/calc.py": FIXED_PRODUCTION_TEXT})
|
|
101
|
+
|
|
102
|
+
assert _run_check(repository_root, "fix: sum operands", base_revision) == 1
|
|
103
|
+
assert "no Python or Node test" in capsys.readouterr().err
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_a_fix_whose_test_fails_on_base_and_passes_on_head_passes(
|
|
107
|
+
tmp_path: Path,
|
|
108
|
+
) -> None:
|
|
109
|
+
repository_root, base_revision = _repository_at_buggy_base(tmp_path)
|
|
110
|
+
_commit_files(
|
|
111
|
+
repository_root,
|
|
112
|
+
{"pkg/calc.py": FIXED_PRODUCTION_TEXT, "pkg/test_calc.py": PROOF_TEST_TEXT},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
assert _run_check(repository_root, "fix(calc): sum operands", base_revision) == 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_a_fix_whose_changed_test_also_passes_on_base_fails(
|
|
119
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
120
|
+
) -> None:
|
|
121
|
+
repository_root, base_revision = _repository_at_buggy_base(tmp_path)
|
|
122
|
+
_commit_files(
|
|
123
|
+
repository_root,
|
|
124
|
+
{
|
|
125
|
+
"pkg/calc.py": FIXED_PRODUCTION_TEXT,
|
|
126
|
+
"pkg/test_calc.py": ALWAYS_PASSING_TEST_TEXT,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
assert _run_check(repository_root, "fix!: sum operands", base_revision) == 1
|
|
131
|
+
assert "passes on the base" in capsys.readouterr().err
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_a_fix_whose_changed_test_fails_on_head_fails(
|
|
135
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
136
|
+
) -> None:
|
|
137
|
+
repository_root, base_revision = _repository_at_buggy_base(tmp_path)
|
|
138
|
+
_commit_files(
|
|
139
|
+
repository_root,
|
|
140
|
+
{"pkg/test_calc.py": PROOF_TEST_TEXT, "pkg/extra.py": "VALUE = 1\n"},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
assert _run_check(repository_root, "fix: sum operands", base_revision) == 1
|
|
144
|
+
assert "fails on the head" in capsys.readouterr().err
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_a_node_fix_whose_test_fails_on_base_and_passes_on_head_passes(
|
|
148
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
149
|
+
) -> None:
|
|
150
|
+
repository_root, base_revision = _repository_at_buggy_node_base(tmp_path)
|
|
151
|
+
_commit_files(
|
|
152
|
+
repository_root,
|
|
153
|
+
{"bin/calc.mjs": FIXED_NODE_PRODUCTION_TEXT, "bin/calc.test.mjs": NODE_PROOF_TEST_TEXT},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
assert _run_check(repository_root, "fix(bin): sum operands", base_revision) == 0
|
|
157
|
+
assert "fail on the base and pass on the head" in capsys.readouterr().out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_a_node_fix_whose_changed_test_also_passes_on_base_fails(
|
|
161
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
162
|
+
) -> None:
|
|
163
|
+
repository_root, base_revision = _repository_at_buggy_node_base(tmp_path)
|
|
164
|
+
_commit_files(
|
|
165
|
+
repository_root,
|
|
166
|
+
{
|
|
167
|
+
"bin/calc.mjs": FIXED_NODE_PRODUCTION_TEXT,
|
|
168
|
+
"bin/calc.test.mjs": NODE_ALWAYS_PASSING_TEST_TEXT,
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
assert _run_check(repository_root, "fix(bin): sum operands", base_revision) == 1
|
|
173
|
+
assert "passes on the base" in capsys.readouterr().err
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_a_node_fix_whose_changed_test_fails_on_head_fails(
|
|
177
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
178
|
+
) -> None:
|
|
179
|
+
repository_root, base_revision = _repository_at_buggy_node_base(tmp_path)
|
|
180
|
+
_commit_files(
|
|
181
|
+
repository_root,
|
|
182
|
+
{"bin/calc.test.mjs": NODE_PROOF_TEST_TEXT, "bin/extra.mjs": "export const VALUE = 1;\n"},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
assert _run_check(repository_root, "fix(bin): sum operands", base_revision) == 1
|
|
186
|
+
assert "fails on the head" in capsys.readouterr().err
|