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.
Files changed (81) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +85 -3
  2. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
  3. package/_shared/pr-loop/scripts/fix_pr_test_proof.py +233 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +47 -0
  5. package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +186 -0
  6. package/bin/codex-skill-load-block.mjs +93 -0
  7. package/bin/codex-skill-load-block.test.mjs +134 -0
  8. package/bin/install.agents-home.test.mjs +76 -1
  9. package/bin/install.mjs +55 -31
  10. package/bin/install.test.mjs +57 -0
  11. package/bin/install.transaction.test.mjs +29 -7
  12. package/bin/seed-codex-pstack-models.mjs +27 -0
  13. package/bin/seed-codex-pstack-models.test.mjs +72 -0
  14. package/docs/CODE_RULES.md +5 -1
  15. package/hooks/blocking/code_rules_enforcer.py +1 -3
  16. package/hooks/blocking/code_rules_magic_values.py +1 -3
  17. package/hooks/blocking/code_rules_paired_test.py +5 -6
  18. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -3
  19. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
  20. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
  21. package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
  22. package/hooks/blocking/tests/test_repository_exemption.py +7 -0
  23. package/hooks/followup_ledger.py +8 -1
  24. package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
  25. package/hooks/hooks.json +15 -0
  26. package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
  27. package/hooks/hooks_constants/messages.py +0 -1
  28. package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
  29. package/hooks/hooks_constants/test_messages.py +1 -7
  30. package/hooks/lifecycle/nested_project_hooks.py +395 -0
  31. package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
  32. package/hooks/test_followup_ledger.py +18 -0
  33. package/package.json +2 -1
  34. package/pstack-codex-models.md +37 -0
  35. package/rules/bdd.md +1 -1
  36. package/rules/build-needs-a-user.md +53 -0
  37. package/rules/code-standards.md +1 -1
  38. package/rules/flag-non-breaking-findings.md +7 -0
  39. package/rules/paired-test-coverage.md +4 -2
  40. package/rules/testing.md +5 -1
  41. package/scripts/cde_lint.py +47 -1
  42. package/scripts/policy_lint/adapter_callers.py +124 -0
  43. package/scripts/policy_lint/adapter_pairing.py +4 -2
  44. package/scripts/policy_lint/adapter_support.py +4 -3
  45. package/scripts/policy_lint/adapters.py +15 -0
  46. package/scripts/policy_lint/check_catalog.py +24 -0
  47. package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
  48. package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
  49. package/scripts/policy_lint/config/constants.py +18 -0
  50. package/scripts/policy_lint/engine.py +9 -3
  51. package/scripts/policy_lint/model.py +6 -2
  52. package/scripts/policy_lint/registry.py +6 -0
  53. package/scripts/policy_lint/selection.py +2 -0
  54. package/scripts/repository_checks/tracked_private_terms.py +15 -1
  55. package/scripts/tests/test_cde_lint.py +50 -0
  56. package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
  57. package/scripts/tests/test_policy_lint_rules_callers.py +140 -0
  58. package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
  59. package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
  60. package/scripts/tests/test_policy_lint_selection.py +35 -0
  61. package/scripts/tests/test_tracked_private_terms.py +35 -0
  62. package/hooks/blocking/tdd_enforcer.py +0 -195
  63. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
  64. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
  65. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
  66. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
  67. package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
  68. package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
  69. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
  70. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
  71. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
  72. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
  73. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
  74. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
  75. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
  76. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
  77. package/hooks/blocking/test_tdd_enforcer.py +0 -1506
  78. package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
  79. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
  80. package/scripts/codex_compat_watcher.py +0 -502
  81. 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(repository_root: Path, baseline_worktree: Path) -> bool:
389
- """Attach a detached-HEAD checkout at *baseline_worktree*; True when git created it."""
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), GIT_HEAD_REVISION),
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