claude-dev-env 8.10.0 → 8.11.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/_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 +132 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +43 -0
- package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +126 -0
- package/bin/codex-skill-load-block.mjs +45 -0
- package/bin/codex-skill-load-block.test.mjs +72 -0
- package/bin/install.agents-home.test.mjs +76 -1
- package/bin/install.mjs +53 -31
- package/bin/install.test.mjs +37 -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/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/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/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 +126 -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/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,132 @@
|
|
|
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
|
+
changed test passes on head, fails on base -> pass
|
|
9
|
+
changed test passes on both -> fail
|
|
10
|
+
|
|
11
|
+
The base run reuses the regression gate's detached worktree: the changed test
|
|
12
|
+
files are copied onto the base checkout, which runs them with import isolation.
|
|
13
|
+
Python tests only.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from code_rules_gate_parts import staged_test_regression, staged_test_running
|
|
21
|
+
from code_rules_gate_parts.git_file_sets import paths_from_git_diff
|
|
22
|
+
from code_rules_gate_parts.wrapper_plumb_check import is_test_path
|
|
23
|
+
from pr_loop_shared_constants.fix_pr_test_proof_constants import (
|
|
24
|
+
ALL_PRODUCTION_CODE_SUFFIXES,
|
|
25
|
+
CI_CONFIG_DIRECTORY_NAME,
|
|
26
|
+
FAILED_EXIT_CODE,
|
|
27
|
+
FIX_TITLE_PATTERN,
|
|
28
|
+
HEAD_FAILURE_MESSAGE,
|
|
29
|
+
NO_BASE_FAILURE_MESSAGE,
|
|
30
|
+
NO_CHANGED_TEST_MESSAGE,
|
|
31
|
+
NO_PRODUCTION_CHANGE_MESSAGE,
|
|
32
|
+
NOT_A_FIX_MESSAGE,
|
|
33
|
+
PASSED_EXIT_CODE,
|
|
34
|
+
PROVEN_MESSAGE,
|
|
35
|
+
WORKTREE_FAILED_MESSAGE,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_arguments(all_arguments: list[str]) -> argparse.Namespace:
|
|
40
|
+
parser = argparse.ArgumentParser(prog="fix_pr_test_proof")
|
|
41
|
+
parser.add_argument("--title", required=True)
|
|
42
|
+
parser.add_argument("--base", dest="base_revision", required=True)
|
|
43
|
+
parser.add_argument("--repository-root", default=".")
|
|
44
|
+
return parser.parse_args(all_arguments)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_production_code_path(file_path: Path, repository_root: Path) -> bool:
|
|
48
|
+
"""Return whether a changed path is production code a fix must prove.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
file_path: An absolute changed path.
|
|
52
|
+
repository_root: The repository the path belongs to.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
True for a code file outside the test patterns and outside ``.github``.
|
|
56
|
+
"""
|
|
57
|
+
relative_path = file_path.relative_to(repository_root)
|
|
58
|
+
if relative_path.parts and relative_path.parts[0] == CI_CONFIG_DIRECTORY_NAME:
|
|
59
|
+
return False
|
|
60
|
+
if file_path.suffix not in ALL_PRODUCTION_CODE_SUFFIXES:
|
|
61
|
+
return False
|
|
62
|
+
return not is_test_path(relative_path.as_posix())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _changed_python_tests(all_changed_paths: list[Path]) -> list[Path]:
|
|
66
|
+
return [
|
|
67
|
+
each_path
|
|
68
|
+
for each_path in all_changed_paths
|
|
69
|
+
if each_path.suffix == ".py"
|
|
70
|
+
and is_test_path(each_path.as_posix())
|
|
71
|
+
and each_path.is_file()
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _proof_exit_code(
|
|
76
|
+
all_changed_tests: list[Path], repository_root: Path, base_revision: str
|
|
77
|
+
) -> int:
|
|
78
|
+
all_targets = staged_test_running._pytest_target_paths(all_changed_tests)
|
|
79
|
+
all_tests_by_root = staged_test_running._group_staged_tests_by_root(
|
|
80
|
+
all_targets, repository_root
|
|
81
|
+
)
|
|
82
|
+
head_outcomes, base_outcomes = (
|
|
83
|
+
staged_test_regression.working_tree_and_revision_outcomes(
|
|
84
|
+
all_tests_by_root, repository_root, base_revision, all_changed_tests
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
for each_group_root, each_outcome in sorted(head_outcomes.items()):
|
|
88
|
+
if each_outcome.exit_code != 0:
|
|
89
|
+
sys.stderr.write(HEAD_FAILURE_MESSAGE.format(group_root=each_group_root) + "\n")
|
|
90
|
+
return FAILED_EXIT_CODE
|
|
91
|
+
if base_outcomes is None:
|
|
92
|
+
sys.stderr.write(WORKTREE_FAILED_MESSAGE.format(revision=base_revision) + "\n")
|
|
93
|
+
return FAILED_EXIT_CODE
|
|
94
|
+
base_failure_count = sum(
|
|
95
|
+
len(each_outcome.failing_identities) for each_outcome in base_outcomes.values()
|
|
96
|
+
)
|
|
97
|
+
if base_failure_count == 0:
|
|
98
|
+
sys.stderr.write(NO_BASE_FAILURE_MESSAGE.format(revision=base_revision) + "\n")
|
|
99
|
+
return FAILED_EXIT_CODE
|
|
100
|
+
sys.stdout.write(PROVEN_MESSAGE.format(count=base_failure_count) + "\n")
|
|
101
|
+
return PASSED_EXIT_CODE
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main(all_arguments: list[str]) -> int:
|
|
105
|
+
"""Run the fix test-proof check.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
all_arguments: Command arguments naming the title, base, and repository.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
0 when the pull request passes, 1 when a fix lacks its proof test.
|
|
112
|
+
"""
|
|
113
|
+
parsed_arguments = _parse_arguments(all_arguments)
|
|
114
|
+
if not FIX_TITLE_PATTERN.match(parsed_arguments.title):
|
|
115
|
+
sys.stdout.write(NOT_A_FIX_MESSAGE + "\n")
|
|
116
|
+
return PASSED_EXIT_CODE
|
|
117
|
+
repository_root = Path(parsed_arguments.repository_root).resolve()
|
|
118
|
+
all_changed_paths = paths_from_git_diff(repository_root, parsed_arguments.base_revision)
|
|
119
|
+
if not any(
|
|
120
|
+
is_production_code_path(each_path, repository_root) for each_path in all_changed_paths
|
|
121
|
+
):
|
|
122
|
+
sys.stdout.write(NO_PRODUCTION_CHANGE_MESSAGE + "\n")
|
|
123
|
+
return PASSED_EXIT_CODE
|
|
124
|
+
all_changed_tests = _changed_python_tests(all_changed_paths)
|
|
125
|
+
if not all_changed_tests:
|
|
126
|
+
sys.stderr.write(NO_CHANGED_TEST_MESSAGE + "\n")
|
|
127
|
+
return FAILED_EXIT_CODE
|
|
128
|
+
return _proof_exit_code(all_changed_tests, repository_root, parsed_arguments.base_revision)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
CI_CONFIG_DIRECTORY_NAME: str = ".github"
|
|
19
|
+
PASSED_EXIT_CODE: int = 0
|
|
20
|
+
FAILED_EXIT_CODE: int = 1
|
|
21
|
+
|
|
22
|
+
NOT_A_FIX_MESSAGE: str = "fix_pr_test_proof: the title is outside the fix type; nothing to prove."
|
|
23
|
+
NO_PRODUCTION_CHANGE_MESSAGE: str = (
|
|
24
|
+
"fix_pr_test_proof: the fix changes no production code; nothing to prove."
|
|
25
|
+
)
|
|
26
|
+
NO_CHANGED_TEST_MESSAGE: str = (
|
|
27
|
+
"fix_pr_test_proof: this fix changes production code and no Python test. "
|
|
28
|
+
"Add a test that fails on the base and passes on the head."
|
|
29
|
+
)
|
|
30
|
+
WORKTREE_FAILED_MESSAGE: str = (
|
|
31
|
+
"fix_pr_test_proof: git could not check out the base revision {revision}."
|
|
32
|
+
)
|
|
33
|
+
HEAD_FAILURE_MESSAGE: str = (
|
|
34
|
+
"fix_pr_test_proof: a changed test fails on the head in {group_root}. "
|
|
35
|
+
"The proof test must pass once the fix is in."
|
|
36
|
+
)
|
|
37
|
+
NO_BASE_FAILURE_MESSAGE: str = (
|
|
38
|
+
"fix_pr_test_proof: every changed test passes on the base {revision}. "
|
|
39
|
+
"Add a test that fails without the fix."
|
|
40
|
+
)
|
|
41
|
+
PROVEN_MESSAGE: str = (
|
|
42
|
+
"fix_pr_test_proof: {count} changed test(s) fail on the base and pass on the head."
|
|
43
|
+
)
|
|
@@ -0,0 +1,126 @@
|
|
|
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 subprocess,
|
|
4
|
+
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
|
+
|
|
31
|
+
|
|
32
|
+
def _commit_files(repository_root: Path, all_file_texts: dict[str, str]) -> None:
|
|
33
|
+
for each_relative_path, each_text in all_file_texts.items():
|
|
34
|
+
file_path = repository_root / each_relative_path
|
|
35
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
file_path.write_text(each_text, encoding="utf-8")
|
|
37
|
+
run_git(repository_root, "add", "-A")
|
|
38
|
+
run_git(repository_root, "commit", "--no-verify", "-m", "change")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _head_revision(repository_root: Path) -> str:
|
|
42
|
+
return run_git(repository_root, "rev-parse", "HEAD").stdout.decode().strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _repository_at_buggy_base(tmp_path: Path) -> tuple[Path, str]:
|
|
46
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
47
|
+
_commit_files(repository_root, {"pkg/calc.py": BUGGY_PRODUCTION_TEXT})
|
|
48
|
+
return repository_root, _head_revision(repository_root)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _run_check(repository_root: Path, title: str, base_revision: str) -> int:
|
|
52
|
+
return fix_pr_test_proof.main(
|
|
53
|
+
[
|
|
54
|
+
"--title",
|
|
55
|
+
title,
|
|
56
|
+
"--base",
|
|
57
|
+
base_revision,
|
|
58
|
+
"--repository-root",
|
|
59
|
+
str(repository_root),
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_a_title_outside_the_fix_type_passes(tmp_path: Path) -> None:
|
|
65
|
+
repository_root, base_revision = _repository_at_buggy_base(tmp_path)
|
|
66
|
+
_commit_files(repository_root, {"pkg/calc.py": FIXED_PRODUCTION_TEXT})
|
|
67
|
+
|
|
68
|
+
assert _run_check(repository_root, "feat: add sums", base_revision) == 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_a_fix_that_changes_no_production_code_passes(tmp_path: Path) -> None:
|
|
72
|
+
repository_root, base_revision = _repository_at_buggy_base(tmp_path)
|
|
73
|
+
_commit_files(repository_root, {"docs/notes.md": "# Notes\n"})
|
|
74
|
+
|
|
75
|
+
assert _run_check(repository_root, "fix(docs): correct a link", base_revision) == 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_a_fix_with_no_changed_test_fails(
|
|
79
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
80
|
+
) -> None:
|
|
81
|
+
repository_root, base_revision = _repository_at_buggy_base(tmp_path)
|
|
82
|
+
_commit_files(repository_root, {"pkg/calc.py": FIXED_PRODUCTION_TEXT})
|
|
83
|
+
|
|
84
|
+
assert _run_check(repository_root, "fix: sum operands", base_revision) == 1
|
|
85
|
+
assert "no Python test" in capsys.readouterr().err
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_a_fix_whose_test_fails_on_base_and_passes_on_head_passes(
|
|
89
|
+
tmp_path: Path,
|
|
90
|
+
) -> None:
|
|
91
|
+
repository_root, base_revision = _repository_at_buggy_base(tmp_path)
|
|
92
|
+
_commit_files(
|
|
93
|
+
repository_root,
|
|
94
|
+
{"pkg/calc.py": FIXED_PRODUCTION_TEXT, "pkg/test_calc.py": PROOF_TEST_TEXT},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
assert _run_check(repository_root, "fix(calc): sum operands", base_revision) == 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_a_fix_whose_changed_test_also_passes_on_base_fails(
|
|
101
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
102
|
+
) -> None:
|
|
103
|
+
repository_root, base_revision = _repository_at_buggy_base(tmp_path)
|
|
104
|
+
_commit_files(
|
|
105
|
+
repository_root,
|
|
106
|
+
{
|
|
107
|
+
"pkg/calc.py": FIXED_PRODUCTION_TEXT,
|
|
108
|
+
"pkg/test_calc.py": ALWAYS_PASSING_TEST_TEXT,
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
assert _run_check(repository_root, "fix!: sum operands", base_revision) == 1
|
|
113
|
+
assert "passes on the base" in capsys.readouterr().err
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_a_fix_whose_changed_test_fails_on_head_fails(
|
|
117
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
118
|
+
) -> None:
|
|
119
|
+
repository_root, base_revision = _repository_at_buggy_base(tmp_path)
|
|
120
|
+
_commit_files(
|
|
121
|
+
repository_root,
|
|
122
|
+
{"pkg/test_calc.py": PROOF_TEST_TEXT, "pkg/extra.py": "VALUE = 1\n"},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
assert _run_check(repository_root, "fix: sum operands", base_revision) == 1
|
|
126
|
+
assert "fails on the head" in capsys.readouterr().err
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const SKILL_LOAD_BLOCK_START = '<!-- claude-dev-env skill load: start -->';
|
|
5
|
+
export const SKILL_LOAD_BLOCK_END = '<!-- claude-dev-env skill load: end -->';
|
|
6
|
+
export const SKILL_LOAD_INSTRUCTION = 'Load the pstack:poteto-mode skill before your first tool call or reply: '
|
|
7
|
+
+ 'open its SKILL.md from your skills list and follow it. This binds every Codex agent, '
|
|
8
|
+
+ 'including a helper started with spawn_agent, whatever its task message says.';
|
|
9
|
+
|
|
10
|
+
const SKILL_LOAD_BLOCK = `${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n${SKILL_LOAD_BLOCK_END}\n`;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Return the Codex guidance text with the package's skill-load block in it.
|
|
14
|
+
*
|
|
15
|
+
* Codex starts a spawn_agent helper without running any hook, so the global
|
|
16
|
+
* AGENTS.md is the one channel that reaches every helper. A block already in
|
|
17
|
+
* the text is replaced where it stands; otherwise the block goes first.
|
|
18
|
+
*/
|
|
19
|
+
export function withSkillLoadBlock(guidanceText) {
|
|
20
|
+
const startIndex = guidanceText.indexOf(SKILL_LOAD_BLOCK_START);
|
|
21
|
+
const endIndex = startIndex === -1 ? -1 : guidanceText.indexOf(SKILL_LOAD_BLOCK_END, startIndex);
|
|
22
|
+
if (endIndex === -1) {
|
|
23
|
+
return guidanceText ? `${SKILL_LOAD_BLOCK}\n${guidanceText}` : SKILL_LOAD_BLOCK;
|
|
24
|
+
}
|
|
25
|
+
let afterBlockIndex = endIndex + SKILL_LOAD_BLOCK_END.length;
|
|
26
|
+
if (guidanceText[afterBlockIndex] === '\n') afterBlockIndex += 1;
|
|
27
|
+
return guidanceText.slice(0, startIndex) + SKILL_LOAD_BLOCK + guidanceText.slice(afterBlockIndex);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Write the skill-load block into CODEX_HOME/AGENTS.md.
|
|
32
|
+
*
|
|
33
|
+
* Returns the file path when the file changed, and null when it already held
|
|
34
|
+
* the current block.
|
|
35
|
+
*/
|
|
36
|
+
export function writeCodexSkillLoadBlock(codexHome) {
|
|
37
|
+
const agentsPath = join(codexHome, 'AGENTS.md');
|
|
38
|
+
const hasGuidance = lstatSync(agentsPath, { throwIfNoEntry: false }) !== undefined;
|
|
39
|
+
const currentText = hasGuidance ? readFileSync(agentsPath, 'utf8') : '';
|
|
40
|
+
const updatedText = withSkillLoadBlock(currentText);
|
|
41
|
+
if (updatedText === currentText) return null;
|
|
42
|
+
mkdirSync(codexHome, { recursive: true });
|
|
43
|
+
writeFileSync(agentsPath, updatedText, 'utf8');
|
|
44
|
+
return agentsPath;
|
|
45
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
SKILL_LOAD_BLOCK_END,
|
|
8
|
+
SKILL_LOAD_BLOCK_START,
|
|
9
|
+
SKILL_LOAD_INSTRUCTION,
|
|
10
|
+
withSkillLoadBlock,
|
|
11
|
+
writeCodexSkillLoadBlock,
|
|
12
|
+
} from './codex-skill-load-block.mjs';
|
|
13
|
+
|
|
14
|
+
const EXPECTED_BLOCK = `${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n${SKILL_LOAD_BLOCK_END}\n`;
|
|
15
|
+
|
|
16
|
+
function makeCodexHome(context) {
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), 'cde-skill-load-'));
|
|
18
|
+
context.after(() => rmSync(root, { recursive: true, force: true }));
|
|
19
|
+
return join(root, '.codex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('the instruction names the skill and binds spawned helpers', () => {
|
|
23
|
+
assert.match(SKILL_LOAD_INSTRUCTION, /pstack:poteto-mode/);
|
|
24
|
+
assert.match(SKILL_LOAD_INSTRUCTION, /spawn_agent/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('a missing Codex guidance file is created holding only the block', (context) => {
|
|
28
|
+
const codexHome = makeCodexHome(context);
|
|
29
|
+
|
|
30
|
+
const writtenPath = writeCodexSkillLoadBlock(codexHome);
|
|
31
|
+
|
|
32
|
+
assert.equal(writtenPath, join(codexHome, 'AGENTS.md'));
|
|
33
|
+
assert.equal(readFileSync(writtenPath, 'utf8'), EXPECTED_BLOCK);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('existing guidance keeps every line and gains the block first', (context) => {
|
|
37
|
+
const codexHome = makeCodexHome(context);
|
|
38
|
+
mkdirSync(codexHome);
|
|
39
|
+
writeFileSync(join(codexHome, 'AGENTS.md'), '# pstack model configuration\n\nbug-fix: gpt-6-astra\n');
|
|
40
|
+
|
|
41
|
+
writeCodexSkillLoadBlock(codexHome);
|
|
42
|
+
|
|
43
|
+
assert.equal(
|
|
44
|
+
readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'),
|
|
45
|
+
`${EXPECTED_BLOCK}\n# pstack model configuration\n\nbug-fix: gpt-6-astra\n`,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a repeat install leaves the file unchanged and reports no write', (context) => {
|
|
50
|
+
const codexHome = makeCodexHome(context);
|
|
51
|
+
mkdirSync(codexHome);
|
|
52
|
+
writeFileSync(join(codexHome, 'AGENTS.md'), 'Custom agents\n');
|
|
53
|
+
writeCodexSkillLoadBlock(codexHome);
|
|
54
|
+
const afterFirstInstall = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
|
|
55
|
+
|
|
56
|
+
assert.equal(writeCodexSkillLoadBlock(codexHome), null);
|
|
57
|
+
assert.equal(readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'), afterFirstInstall);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('an outdated block is replaced in place and the text around it is kept', () => {
|
|
61
|
+
const outdated = `Intro\n${SKILL_LOAD_BLOCK_START}\nOld wording\n${SKILL_LOAD_BLOCK_END}\nOutro\n`;
|
|
62
|
+
|
|
63
|
+
assert.equal(withSkillLoadBlock(outdated), `Intro\n${EXPECTED_BLOCK}Outro\n`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a start marker with no end marker gets a fresh block and keeps the stray text', () => {
|
|
67
|
+
const broken = `${SKILL_LOAD_BLOCK_START}\nHalf a block\n`;
|
|
68
|
+
|
|
69
|
+
const repaired = withSkillLoadBlock(broken);
|
|
70
|
+
|
|
71
|
+
assert.equal(repaired, `${EXPECTED_BLOCK}\n${broken}`);
|
|
72
|
+
});
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
writeFileSync,
|
|
19
19
|
} from 'node:fs';
|
|
20
20
|
import { tmpdir } from 'node:os';
|
|
21
|
-
import { dirname, join } from 'node:path';
|
|
21
|
+
import { dirname, join, resolve } from 'node:path';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
import { CONTENT_DIRECTORIES } from './install.mjs';
|
|
24
24
|
import {
|
|
@@ -209,6 +209,81 @@ test('a full install writes skills and agents under .agents and points .claude a
|
|
|
209
209
|
}
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* @param {string} hubPath
|
|
214
|
+
* @returns {string}
|
|
215
|
+
*/
|
|
216
|
+
function resolveHubImportTarget(hubPath) {
|
|
217
|
+
const hubText = readFileSync(hubPath, 'utf8').trim();
|
|
218
|
+
assert.match(hubText, /^@\S+$/, 'the Claude hub holds one import line');
|
|
219
|
+
return resolve(dirname(hubPath), hubText.slice(1));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const eachLayout of [
|
|
223
|
+
{ label: 'default home', extraArguments: [], claudeHomeName: '.claude', agentsHomeName: '.agents' },
|
|
224
|
+
{
|
|
225
|
+
label: 'profile root',
|
|
226
|
+
extraArguments: ['--target', '.claude-profile-a'],
|
|
227
|
+
claudeHomeName: '.claude-profile-a',
|
|
228
|
+
agentsHomeName: '.claude-profile-a.agents',
|
|
229
|
+
},
|
|
230
|
+
]) {
|
|
231
|
+
test(`a ${eachLayout.label} install loads the package guidance from the agents home through the Claude hub`, () => {
|
|
232
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-agents-hub-'));
|
|
233
|
+
const claudeHome = join(homeDirectory, eachLayout.claudeHomeName);
|
|
234
|
+
const sharedGuidancePath = join(homeDirectory, eachLayout.agentsHomeName, 'AGENTS.md');
|
|
235
|
+
try {
|
|
236
|
+
runInstaller(homeDirectory, eachLayout.extraArguments.map(
|
|
237
|
+
(eachArgument) => (eachArgument.startsWith('.') ? join(homeDirectory, eachArgument) : eachArgument),
|
|
238
|
+
));
|
|
239
|
+
|
|
240
|
+
assert.equal(
|
|
241
|
+
readFileSync(sharedGuidancePath, 'utf8'),
|
|
242
|
+
readFileSync(join(PACKAGE_DIRECTORY, 'AGENTS.md'), 'utf8'),
|
|
243
|
+
'the package guidance lives once in the agents home',
|
|
244
|
+
);
|
|
245
|
+
assert.equal(
|
|
246
|
+
resolveHubImportTarget(join(claudeHome, 'CLAUDE.md')),
|
|
247
|
+
sharedGuidancePath,
|
|
248
|
+
'the Claude hub import resolves to the shared guidance file',
|
|
249
|
+
);
|
|
250
|
+
assert.equal(
|
|
251
|
+
existsSync(join(claudeHome, 'AGENTS.md')),
|
|
252
|
+
false,
|
|
253
|
+
'no second guidance copy lands in the Claude home',
|
|
254
|
+
);
|
|
255
|
+
} finally {
|
|
256
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
test('an upgrade moves the guidance copy an earlier install left in the Claude home into the run backup', () => {
|
|
262
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-agents-hub-upgrade-'));
|
|
263
|
+
const claudeHome = join(homeDirectory, '.claude');
|
|
264
|
+
const retiredGuidancePath = join(claudeHome, 'AGENTS.md');
|
|
265
|
+
const retiredGuidanceText = 'guidance an earlier install copied here\n';
|
|
266
|
+
try {
|
|
267
|
+
runInstaller(homeDirectory, []);
|
|
268
|
+
writeFileSync(retiredGuidancePath, retiredGuidanceText);
|
|
269
|
+
const manifestPath = join(claudeHome, '.claude-dev-env-manifest.json');
|
|
270
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
271
|
+
manifest.files.push(retiredGuidancePath);
|
|
272
|
+
writeFileSync(manifestPath, JSON.stringify(manifest));
|
|
273
|
+
|
|
274
|
+
runInstaller(homeDirectory, []);
|
|
275
|
+
|
|
276
|
+
assert.equal(existsSync(retiredGuidancePath), false, 'the old Claude-home copy is gone');
|
|
277
|
+
const backupRoot = join(claudeHome, '.claude-dev-env-pruned');
|
|
278
|
+
const backupName = readdirSync(backupRoot, { recursive: true })
|
|
279
|
+
.find(name => name.endsWith('AGENTS.md'));
|
|
280
|
+
assert.ok(backupName, 'the old copy sits in the run backup');
|
|
281
|
+
assert.equal(readFileSync(join(backupRoot, backupName), 'utf8'), retiredGuidanceText);
|
|
282
|
+
} finally {
|
|
283
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
212
287
|
test('installs omit retired agents in each active agents home', () => {
|
|
213
288
|
const runRoot = mkdtempSync(join(tmpdir(), 'cdev-active-roots-'));
|
|
214
289
|
const homeDirectory = join(runRoot, 'home');
|