@jesdi/skills 0.0.50 → 0.0.51
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/package.json +2 -2
- package/skills/test-prune/SKILL.md +156 -0
- package/skills/test-prune/prune.py +202 -0
- package/skills/test-prune/prune_repo.py +319 -0
- package/skills/test-prune/prune_review.py +363 -0
- package/skills/test-prune/prune_runs.py +240 -0
- package/skills/test-prune/prune_source.py +237 -0
- package/skills/test-prune/prune_strengthen.py +301 -0
- package/skills/test-prune/tests/conftest.py +15 -0
- package/skills/test-prune/tests/prune_harness.py +205 -0
- package/skills/test-prune/tests/test_prune_guards.py +871 -0
- package/skills/test-prune/tests/test_prune_inputs.py +97 -0
- package/skills/test-prune/tests/test_prune_review.py +113 -0
- package/skills/test-prune/tests/test_prune_source.py +84 -0
- package/skills/test-prune/tests/test_prune_strengthen.py +183 -0
- package/skills/test-prune/tests/test_prune_unattended.py +225 -0
- package/skills/test-prune/tests/testdata/gh +16 -0
- package/skills/test-prune/tests/testdata/repo/Makefile +25 -0
- package/skills/test-prune/tests/testdata/repo/backend/mutation_baseline.json +16 -0
- package/skills/test-prune/tests/testdata/repo/backend/services/concentration.py +2 -0
- package/skills/test-prune/tests/testdata/repo/backend/services/rebalancer.py +11 -0
- package/skills/test-prune/tests/testdata/repo/backend/tests/conftest.py +6 -0
- package/skills/test-prune/tests/testdata/repo/backend/tests/test_concentration.py +73 -0
- package/skills/test-prune/tests/testdata/repo/backend/tests/test_rebalancer.py +77 -0
- package/skills/test-prune/tests/testdata/repo/fake_engine.py +289 -0
- package/skills/test-prune/tests/testdata/repo/kill_map.json +131 -0
- package/skills-manifest.json +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jesdi/skills",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.51",
|
|
4
4
|
"description": "jesdi's agent skills (content package consumed by @jesdi/skills-cli)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"scripts": {
|
|
30
30
|
"test": "vitest run",
|
|
31
31
|
"test:coverage": "vitest run --coverage --coverage.reportOnFailure",
|
|
32
|
-
"test:python": "mkdir -p .crap && python3 -m coverage run -m pytest skills/backlog skills/crap-gate -q && python3 -m coverage run -a -m pytest skills/implement-spec -q && python3 -m coverage json -o .crap/python.json",
|
|
32
|
+
"test:python": "mkdir -p .crap && python3 -m coverage erase && python3 -m coverage run -m pytest skills/backlog skills/crap-gate -q && python3 -m coverage run -a -m pytest skills/implement-spec -q && COVERAGE_SUBPROCESS_PATCH=subprocess python3 -m coverage run -m pytest skills/test-prune -q && python3 -m coverage combine --append -q && python3 -m coverage json -o .crap/python.json",
|
|
33
33
|
"crap-gate": "sh skills/crap-gate/run.sh",
|
|
34
34
|
"check": "sh scripts/check.sh",
|
|
35
35
|
"generate-manifest": "tsx scripts/generate-manifest.ts"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: test-prune
|
|
3
|
+
description: >-
|
|
4
|
+
Mutation-guided test pruning: a fixer proposes deleting redundant tests and strengthening
|
|
5
|
+
weak ones, an independent reviewer accepts or rejects each proposal, and prune.py applies
|
|
6
|
+
the accepted batch behind a cold mutation run and the ratchet. Use when the user says
|
|
7
|
+
"prune tests", "test-prune", "mutation test pruning" or "strengthen weak tests", or when a
|
|
8
|
+
scheduled job runs it with `--unattended`.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# test-prune: delete what the mutation ratchet proves redundant, strengthen what it proves weak
|
|
12
|
+
|
|
13
|
+
You are the orchestrator. `prune.py` owns every git, make and file operation and every safety
|
|
14
|
+
guard; the subagents only write JSON files or test commits. Each role is a fresh subagent with no
|
|
15
|
+
context from the others: the reviewer reads the fixer's proposals, reasons included, and judges
|
|
16
|
+
them against the code itself.
|
|
17
|
+
|
|
18
|
+
**Publishing belongs to prune.py.** In both modes you never run `git push`, `git merge`,
|
|
19
|
+
`gh pr create` or `gh pr merge` yourself, and neither does any subagent; `prune` and
|
|
20
|
+
`strengthen-finish` push their own `chore/test-*` branch and open its PR, and nothing merges.
|
|
21
|
+
|
|
22
|
+
The target repo provides the make targets `test`, `mutation`, `mutation-check`,
|
|
23
|
+
`mutation-baseline`, `test-candidates`, `prune-gate` and `crap`, and the layout `backend/tests/`,
|
|
24
|
+
`backend/mutation_baseline.json`. Every command:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
python3 "<skill-dir>/prune.py" <command> [--state DIR] [--date YYYY-MM-DD]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Run it from inside the target repo. State lives in `<git dir>/test-prune/` (outside the working
|
|
31
|
+
tree); each command reads what the previous one wrote there. Exit 0 is done (including nothing to
|
|
32
|
+
do), 1 is a failed make target, gate or guard, 2 is bad input or an unmet precondition; the
|
|
33
|
+
message names the offending ids or files. On exit 2 fix the input and rerun the same command; on
|
|
34
|
+
exit 1 stop and report the output.
|
|
35
|
+
|
|
36
|
+
Every command records itself in `<state>/status.json`. A CI job reads it after the headless
|
|
37
|
+
session (which cannot set the job's exit code) and fails the job when its `exit` is not 0; an
|
|
38
|
+
unattended run stays red there until the strengthen loop ends (`nothing to strengthen`, or
|
|
39
|
+
`strengthen-finish` exit 0). End a headless session by reporting its `reason` as the final line.
|
|
40
|
+
`--state` must be empty, new, or an earlier run's state dir.
|
|
41
|
+
|
|
42
|
+
When a push succeeded but `gh pr create` failed (exit 1, the branch is on origin with no PR),
|
|
43
|
+
open the PR by hand from `<state>/prune-pr.md` or `<state>/strengthen-pr.md` after fixing `gh`;
|
|
44
|
+
rerunning the command refuses because the branch exists.
|
|
45
|
+
|
|
46
|
+
**A run needs a checkout dedicated to it** for its whole duration: the CI checkout, or a worktree.
|
|
47
|
+
If anything else edits a file there mid-run, the run stops with exit 1 rather than overwrite it,
|
|
48
|
+
leaves the checkout as it is (HEAD may stay detached), names the files, and nothing is lost; keep
|
|
49
|
+
or discard the change, then check out the base branch. Races beyond that check are out of scope.
|
|
50
|
+
|
|
51
|
+
**Runtime.** Each cold `make mutation` takes about 30 minutes on a real repo, and a run makes
|
|
52
|
+
several (collect, each safety-check run, each strengthen proof). Run long commands with a timeout of
|
|
53
|
+
an hour or more, or in the background, and wait for them.
|
|
54
|
+
|
|
55
|
+
## Modes
|
|
56
|
+
|
|
57
|
+
- **Interactive** (default): nothing in the working tree changes before the human approves the
|
|
58
|
+
batch. Stop after `batch`, show its output, and run `apply` only on an explicit yes.
|
|
59
|
+
- **Unattended** (`collect --unattended`, run headless by the weekly CI job): only protected
|
|
60
|
+
candidates, at most 10 deleted test ids per run (a parametrized test goes whole, every case
|
|
61
|
+
counted; one with more than 10 cases is skipped, the rest left for next week), one PR from
|
|
62
|
+
`chore/test-prune-<date>` and one from `chore/test-strengthen-<date>`, both from the base
|
|
63
|
+
commit. The base branch never moves. A merge is the human's approval, so the run ends with open
|
|
64
|
+
PRs.
|
|
65
|
+
|
|
66
|
+
Two kinds of accepted proposal reach a human only: equivalent mutants (the skill writes no
|
|
67
|
+
equivalent id into the baseline) and "strengthen this candidate with this assertion" verdicts
|
|
68
|
+
(the skill applies no assertion to an existing candidate). `batch` prints both; unattended, every
|
|
69
|
+
PR body and the job summary carry them.
|
|
70
|
+
|
|
71
|
+
## Prune loop
|
|
72
|
+
|
|
73
|
+
1. `collect [--unattended | --protected-only]`. Needs a clean tracked tree on a branch. Runs the
|
|
74
|
+
base checks, a cold mutation run and `make test-candidates`, then writes
|
|
75
|
+
`fixer-input.json`. When it prints `no candidates` / `no protected candidates`, the weak spots
|
|
76
|
+
may still need the fixer; continue.
|
|
77
|
+
2. **Fixer.** Dispatch a subagent with `<state>/fixer-input.json`, read access to the repo, and
|
|
78
|
+
this brief: for every candidate decide `keep`, `delete` into one named test that contains it,
|
|
79
|
+
or `strengthen` with one named assertion; for every weak-spot group decide `strengthen`
|
|
80
|
+
(which test to add to or strengthen) or `equivalent` (with the argument why no test can tell
|
|
81
|
+
the mutant apart). A group with both kinds of mutant takes two entries, one `strengthen` and
|
|
82
|
+
one `equivalent`, each listing its own disjoint `mutant_ids`. Compare assertions, not coverage: a candidate whose assertions pin a value,
|
|
83
|
+
boundary or error the container never checks is `keep` or `strengthen`. It writes:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{"candidates": [{"test_id": "...", "verdict": "keep|delete|strengthen",
|
|
87
|
+
"into": "<test id, delete only>", "assertion": "<strengthen only>", "reason": "..."}],
|
|
88
|
+
"weak_spots": [{"group": "...", "verdict": "strengthen|equivalent",
|
|
89
|
+
"mutant_ids": ["<subset of the group's ids, omit for all>"],
|
|
90
|
+
"test": "<test id or test file>", "reason": "..."}]}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
One entry per candidate, one per group (or the strengthen + equivalent pair), no others.
|
|
94
|
+
3. `review-input --proposals <fixer file>` validates the file and writes
|
|
95
|
+
`<state>/review-input.json`: each proposal with the candidate's source and assertions and,
|
|
96
|
+
for a deletion, the containing test's source and assertions. A proposal's `id` is the
|
|
97
|
+
candidate's test id or the group; a split group's two proposals are `<group>:strengthen` and
|
|
98
|
+
`<group>:equivalent`.
|
|
99
|
+
4. **Reviewer.** Dispatch a *different* fresh subagent with `<state>/review-input.json` and read
|
|
100
|
+
access to the repo. Brief: accept a deletion only when every assertion of the candidate is
|
|
101
|
+
implied by an assertion of the `into` test (same inputs or a superset, an equal or stricter
|
|
102
|
+
check); covering the same lines is not enough. Accept a strengthen proposal only when the
|
|
103
|
+
named assertion checks behaviour a user would notice. Accept an equivalent only with a
|
|
104
|
+
concrete argument that no input distinguishes the mutant. Every reason names the assertion or
|
|
105
|
+
input it rests on. It writes, one entry per proposal:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{"reviews": [{"id": "<proposal id>", "verdict": "accept|reject", "reason": "..."}]}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
5. `batch --reviews <reviewer file>` writes `batch.json` and prints the accepted deletions (each
|
|
112
|
+
with its containing test and the reviewer's reason), strengthen items, equivalent proposals,
|
|
113
|
+
skipped deletions with the reason, and rejections. It edits nothing.
|
|
114
|
+
- Interactive: show that output to the human and stop. After approval, `apply` applies the
|
|
115
|
+
deletions, runs the safety check, restores every culprit and commits on the current branch.
|
|
116
|
+
- Unattended: `prune` applies the batch, runs the safety check, `make test`, commits, runs
|
|
117
|
+
`make prune-gate` and, only when it passes, pushes the branch and opens the PR.
|
|
118
|
+
|
|
119
|
+
**Safety check** (inside `apply` and `prune`): a cold mutation run plus `make mutation-check`
|
|
120
|
+
on the batch; every deletion that lets a module exceed its baseline is restored and reported.
|
|
121
|
+
|
|
122
|
+
## Strengthen loop
|
|
123
|
+
|
|
124
|
+
Runs after the prune loop, also in unattended mode, independently of the prune PR: run it even
|
|
125
|
+
when `prune` exited 1 (the status keeps that failure, so the job still goes red).
|
|
126
|
+
|
|
127
|
+
1. `strengthen-start` writes `<state>/strengthen-input.json` (each accepted weak-spot strengthen
|
|
128
|
+
item with the mutant ids it must kill) and prints the commit to build on; unattended it
|
|
129
|
+
detaches HEAD at the base commit. `nothing to strengthen` ends the loop.
|
|
130
|
+
2. **Strengthen agent.** Dispatch a fresh subagent with `strengthen-input.json`. Brief: per item,
|
|
131
|
+
test-first, write the assertion or test that fails against each named mutant and passes on
|
|
132
|
+
the real code; touch only `backend/tests/`; make one or more commits per item, each ending
|
|
133
|
+
with the trailer line `Test-Prune-Item: <item id>`; leave the tree clean.
|
|
134
|
+
3. `strengthen-verify` rejects commits without a known trailer or outside `backend/tests/`, then
|
|
135
|
+
proves each item on a cold run: every named id survived on the base and is killed on the
|
|
136
|
+
head. Unproven items are reverted and closed: a later commit for a closed item is refused. It
|
|
137
|
+
writes `<state>/strengthen-proof.json`, including the proven head. On failure HEAD stays
|
|
138
|
+
where the commits are (detached, unattended): fix and rerun. To abandon the loop instead,
|
|
139
|
+
`git checkout <base branch>`; the item commits stay reachable through `git reflog`.
|
|
140
|
+
4. **Strengthen reviewer.** Dispatch a fresh subagent with `strengthen-proof.json` and the diff
|
|
141
|
+
of the proven items (`git log -p <start>..HEAD`). Brief: accept an item only when its test
|
|
142
|
+
asserts observable behaviour through the public interface, rather than restating the
|
|
143
|
+
implementation; one review per proven item, same schema as the prune reviewer.
|
|
144
|
+
5. `strengthen-finish --reviews <file>` refuses a HEAD other than the proven one (rerun
|
|
145
|
+
`strengthen-verify` after any new commit); on any exit 2 HEAD stays at the proven head, so fix
|
|
146
|
+
the input and rerun it. It reverts rejected items, reruns the proof cold, runs
|
|
147
|
+
`make mutation-check`, `make test`, `make crap` and `make mutation-baseline`, refuses a
|
|
148
|
+
baseline that gained an equivalent id, and commits the lower baseline. Unattended it opens the
|
|
149
|
+
strengthen PR listing each changed test with the ids it now kills; interactive it leaves the
|
|
150
|
+
commits on the current branch for the human.
|
|
151
|
+
|
|
152
|
+
## Report
|
|
153
|
+
|
|
154
|
+
End with what the last command printed: deleted, restored, skipped and left-for-next-week tests,
|
|
155
|
+
strengthened tests with their killed ids, the PR links, and the equivalent proposals. In CI the
|
|
156
|
+
same summaries land in `$GITHUB_STEP_SUMMARY`.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""test-prune: drive the fixer/reviewer prune loop and the strengthen loop over a target repo.
|
|
3
|
+
|
|
4
|
+
collect → (fixer) → review-input → (reviewer) → batch → apply (interactive) | prune (unattended);
|
|
5
|
+
strengthen-start → (strengthen agent) → strengthen-verify → (reviewer) → strengthen-finish.
|
|
6
|
+
The script owns every git, make and file operation; the agents only write JSON or test commits.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from prune_repo import BadInput, Fail, Repo, State, read_agent_json
|
|
18
|
+
from prune_review import batch, collect, review_input
|
|
19
|
+
from prune_runs import apply, prune
|
|
20
|
+
from prune_strengthen import strengthen_finish, strengthen_start, strengthen_verify
|
|
21
|
+
|
|
22
|
+
COMMANDS = {
|
|
23
|
+
"collect": collect, "review-input": review_input, "batch": batch, "apply": apply, "prune": prune,
|
|
24
|
+
"strengthen-start": strengthen_start, "strengthen-verify": strengthen_verify,
|
|
25
|
+
"strengthen-finish": strengthen_finish,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Parser(argparse.ArgumentParser):
|
|
30
|
+
"""An argument error raises BadInput (exit 2, recorded in the run status) instead of exiting."""
|
|
31
|
+
|
|
32
|
+
def error(self, message):
|
|
33
|
+
raise BadInput(f"{self.prog}: {message}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _common():
|
|
37
|
+
common = Parser(add_help=False)
|
|
38
|
+
common.add_argument("--state", help="run state directory (default: <git dir>/test-prune)")
|
|
39
|
+
common.add_argument("--date", help="branch date YYYY-MM-DD (default: today, UTC)")
|
|
40
|
+
return common
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_parser():
|
|
44
|
+
common = _common()
|
|
45
|
+
parser = Parser(prog="prune.py", description=__doc__)
|
|
46
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
47
|
+
|
|
48
|
+
collect_cmd = sub.add_parser("collect", parents=[common])
|
|
49
|
+
collect_cmd.add_argument("--unattended", action="store_true")
|
|
50
|
+
collect_cmd.add_argument("--protected-only", action="store_true")
|
|
51
|
+
|
|
52
|
+
sub.add_parser("review-input", parents=[common]).add_argument("--proposals", required=True)
|
|
53
|
+
sub.add_parser("batch", parents=[common]).add_argument("--reviews", required=True)
|
|
54
|
+
sub.add_parser("apply", parents=[common])
|
|
55
|
+
sub.add_parser("prune", parents=[common])
|
|
56
|
+
sub.add_parser("strengthen-start", parents=[common])
|
|
57
|
+
sub.add_parser("strengthen-verify", parents=[common])
|
|
58
|
+
sub.add_parser("strengthen-finish", parents=[common]).add_argument("--reviews", required=True)
|
|
59
|
+
return parser
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Context:
|
|
63
|
+
"""What every command gets: the repo, the run state, the branch date and its arguments."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, args):
|
|
66
|
+
self.args = args
|
|
67
|
+
self.repo = Repo(Path.cwd())
|
|
68
|
+
self.state = State(Path(args.state).resolve() if args.state else self.repo.git_dir() / "test-prune")
|
|
69
|
+
self.date = args.date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
70
|
+
self.finished = False # the run is complete: the strengthen loop ended (set by its commands)
|
|
71
|
+
|
|
72
|
+
def validate(self) -> None:
|
|
73
|
+
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", self.date):
|
|
74
|
+
raise BadInput(f"--date must be YYYY-MM-DD, got {self.date!r}")
|
|
75
|
+
|
|
76
|
+
def base(self, mode: str | None = None) -> dict:
|
|
77
|
+
base = self.state.load("base.json")
|
|
78
|
+
if mode and base["mode"] != mode:
|
|
79
|
+
raise BadInput(f"`{self.args.command}` runs only in {mode} mode; this run was collected in {base['mode']} mode")
|
|
80
|
+
return base
|
|
81
|
+
|
|
82
|
+
def reviews(self) -> list:
|
|
83
|
+
return read_agent_json(self.args.reviews, "reviews")
|
|
84
|
+
|
|
85
|
+
def proposals(self) -> dict:
|
|
86
|
+
return {"candidates": read_agent_json(self.args.proposals, "candidates"),
|
|
87
|
+
"weak_spots": read_agent_json(self.args.proposals, "weak_spots")}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _run(ctx: Context) -> tuple[int, str]:
|
|
91
|
+
"""The command's exit code and the summary its status reason comes from. Staying off the base branch
|
|
92
|
+
(leave_on found changes the run did not make) is reported and fails a command that would pass."""
|
|
93
|
+
try:
|
|
94
|
+
ctx.validate()
|
|
95
|
+
code, summary = COMMANDS[ctx.args.command](ctx), "\n\n".join(ctx.state.summaries)
|
|
96
|
+
except BadInput as exc:
|
|
97
|
+
print(f"test-prune: {exc}", file=sys.stderr)
|
|
98
|
+
code, summary = 2, f"test-prune: {exc}"
|
|
99
|
+
except Fail as exc:
|
|
100
|
+
ctx.state.emit(f"test-prune: {exc}")
|
|
101
|
+
code, summary = 1, f"test-prune: {exc}"
|
|
102
|
+
except Exception as exc: # a bug or an operational surprise still fails the run in status.json
|
|
103
|
+
summary = f"test-prune: unexpected {type(exc).__name__}: {exc}"
|
|
104
|
+
ctx.state.emit(summary)
|
|
105
|
+
code = 1
|
|
106
|
+
if ctx.repo.stranded:
|
|
107
|
+
ctx.state.emit(ctx.repo.stranded)
|
|
108
|
+
if code == 0:
|
|
109
|
+
code, summary = 1, ctx.repo.stranded
|
|
110
|
+
return code, summary
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
RUN = "run" # the marker entry an unattended run stays red on until it finishes
|
|
114
|
+
INCOMPLETE = "test-prune: run incomplete: the strengthen loop has not finished"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _open(commands: list) -> list:
|
|
118
|
+
"""The entries that still fail the run: every exit 1 but the run marker, and every exit 2 (or the run
|
|
119
|
+
marker) its command has not since passed. On exit 2 the session fixes the input and reruns the
|
|
120
|
+
command; the run marker passes when the strengthen loop ends."""
|
|
121
|
+
def clears(c: dict) -> bool:
|
|
122
|
+
return c["exit"] == 2 or c["command"] == RUN
|
|
123
|
+
return [c for i, c in enumerate(commands) if c["exit"] and not (clears(c) and any(
|
|
124
|
+
later["command"] == c["command"] and later["exit"] == 0 for later in commands[i + 1:]))]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _earlier(previous, command: str) -> list:
|
|
128
|
+
"""The run's commands so far: none for `collect` (a new run) or a missing or unreadable status."""
|
|
129
|
+
commands = previous.get("commands") if isinstance(previous, dict) and command != "collect" else None
|
|
130
|
+
return commands if isinstance(commands, list) else []
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def run_status(previous, command: str, code: int, summary: str, extra=()) -> dict:
|
|
134
|
+
"""The run's status, sticky across commands: the highest exit code still open and its reason (the
|
|
135
|
+
latest on a tie), plus every command in order, then the extra entries. `collect` starts a new run; a
|
|
136
|
+
missing or unreadable status starts fresh rather than failing the command."""
|
|
137
|
+
reason = next((line for line in summary.splitlines() if line.strip()), "")
|
|
138
|
+
commands = _earlier(previous, command) + [{"command": command, "exit": code, "reason": reason}]
|
|
139
|
+
ran_prune = any(c["command"] == "prune" for c in commands)
|
|
140
|
+
commands += [e for e in extra if e["exit"] or ran_prune] # the run finishes only after prune ran
|
|
141
|
+
worst = max(reversed(_open(commands)), key=lambda c: c["exit"], default={"exit": 0, "reason": reason})
|
|
142
|
+
return {"exit": worst["exit"], "reason": worst["reason"], "commands": commands}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _record(state: State, command: str, code: int, summary: str, extra=()) -> None:
|
|
146
|
+
"""Add the command to <state>/status.json, when the state dir is the run's (see State.owned)."""
|
|
147
|
+
if not state.owned():
|
|
148
|
+
return
|
|
149
|
+
path = state.path / "status.json"
|
|
150
|
+
try:
|
|
151
|
+
previous = json.loads(path.read_text()) if path.exists() else None
|
|
152
|
+
except (OSError, ValueError):
|
|
153
|
+
previous = None
|
|
154
|
+
state.save("status.json", run_status(previous, command, code, summary, extra))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _markers(ctx: Context) -> list:
|
|
158
|
+
"""The run marker: opened by an unattended collect, passed when the strengthen loop ends after
|
|
159
|
+
prune ran (see run_status)."""
|
|
160
|
+
if ctx.args.command == "collect" and ctx.args.unattended:
|
|
161
|
+
return [{"command": RUN, "exit": 1, "reason": INCOMPLETE}]
|
|
162
|
+
return [{"command": RUN, "exit": 0, "reason": "run finished"}] if ctx.finished else []
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _state_arg(argv) -> str | None:
|
|
166
|
+
"""The --state value, read off argv without a parser (the arguments just failed to parse)."""
|
|
167
|
+
for i, arg in enumerate(argv):
|
|
168
|
+
if arg == "--state" and i + 1 < len(argv):
|
|
169
|
+
return argv[i + 1]
|
|
170
|
+
if arg.startswith("--state="):
|
|
171
|
+
return arg[len("--state="):]
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _record_bad_arguments(argv, exc: BadInput) -> None:
|
|
176
|
+
"""An argument error is recorded as exit 2 when the command and the state dir can still be told."""
|
|
177
|
+
command = next((a for a in argv if a in COMMANDS), None)
|
|
178
|
+
state = _state_arg(argv)
|
|
179
|
+
try:
|
|
180
|
+
path = Path(state).resolve() if state else Repo(Path.cwd()).git_dir() / "test-prune"
|
|
181
|
+
except BadInput:
|
|
182
|
+
return
|
|
183
|
+
if command:
|
|
184
|
+
_record(State(path), command, 2, f"test-prune: {exc}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def main(argv=None):
|
|
188
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
189
|
+
try:
|
|
190
|
+
args = build_parser().parse_args(argv)
|
|
191
|
+
ctx = Context(args)
|
|
192
|
+
except BadInput as exc:
|
|
193
|
+
print(f"test-prune: {exc}", file=sys.stderr)
|
|
194
|
+
_record_bad_arguments(argv, exc)
|
|
195
|
+
return 2
|
|
196
|
+
code, summary = _run(ctx)
|
|
197
|
+
_record(ctx.state, args.command, code, summary, _markers(ctx))
|
|
198
|
+
return code
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
sys.exit(main())
|