acquit 0.0.1__py3-none-any.whl
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.
- acquit/__init__.py +3 -0
- acquit/__main__.py +6 -0
- acquit/cli.py +239 -0
- acquit/config.py +105 -0
- acquit/constants.py +19 -0
- acquit/errors.py +38 -0
- acquit/explain.py +104 -0
- acquit/gh/__init__.py +6 -0
- acquit/gh/comment.py +321 -0
- acquit/gh/outputs.py +100 -0
- acquit/graph/__init__.py +0 -0
- acquit/graph/build.py +181 -0
- acquit/graph/cache.py +172 -0
- acquit/graph/index.py +128 -0
- acquit/graph/model.py +66 -0
- acquit/graph/parse.py +308 -0
- acquit/graph/resolve.py +161 -0
- acquit/pipeline.py +248 -0
- acquit/plugin.py +163 -0
- acquit/policy/__init__.py +0 -0
- acquit/policy/engine.py +90 -0
- acquit/policy/model.py +51 -0
- acquit/policy/rules.py +369 -0
- acquit/py.typed +0 -0
- acquit/pytestmap/__init__.py +0 -0
- acquit/pytestmap/conftree.py +132 -0
- acquit/pytestmap/discover.py +95 -0
- acquit/pytestmap/pytestcfg.py +154 -0
- acquit/replay.py +181 -0
- acquit/report.py +244 -0
- acquit/select.py +284 -0
- acquit/study/__init__.py +14 -0
- acquit/study/aggregate.py +365 -0
- acquit/study/cli.py +127 -0
- acquit/study/compare.py +73 -0
- acquit/study/manifest.py +347 -0
- acquit/study/outcomes.py +109 -0
- acquit/study/runner.py +462 -0
- acquit/vcs.py +242 -0
- acquit/witness.py +65 -0
- acquit-0.0.1.dist-info/METADATA +60 -0
- acquit-0.0.1.dist-info/RECORD +45 -0
- acquit-0.0.1.dist-info/WHEEL +4 -0
- acquit-0.0.1.dist-info/entry_points.txt +6 -0
- acquit-0.0.1.dist-info/licenses/LICENSE +201 -0
acquit/__init__.py
ADDED
acquit/__main__.py
ADDED
acquit/cli.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Command line entry point.
|
|
2
|
+
|
|
3
|
+
Every failure path converges on a run-all report. The tool may only be wrong
|
|
4
|
+
in the safe direction.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import stat
|
|
10
|
+
import sys
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from acquit import __version__
|
|
17
|
+
from acquit.constants import (
|
|
18
|
+
DEFAULT_REPORT_FILE,
|
|
19
|
+
DEFAULT_SELECTION_FILE,
|
|
20
|
+
DEFAULT_WITNESSES_FILE,
|
|
21
|
+
)
|
|
22
|
+
from acquit.errors import AcquitError, ExitCode
|
|
23
|
+
from acquit.explain import explain_lines
|
|
24
|
+
from acquit.gh.comment import run_comment
|
|
25
|
+
from acquit.gh.outputs import run_ci_outputs
|
|
26
|
+
from acquit.graph.model import NodeKind
|
|
27
|
+
from acquit.pipeline import SelectResult, run_select, snapshot_working_tree
|
|
28
|
+
from acquit.policy.model import Finding, RuleId, Scope, ScopeKind
|
|
29
|
+
from acquit.replay import run_replay
|
|
30
|
+
from acquit.report import (
|
|
31
|
+
RunInfo,
|
|
32
|
+
SelectionMode,
|
|
33
|
+
build_report,
|
|
34
|
+
build_run_all_report,
|
|
35
|
+
build_run_all_selection,
|
|
36
|
+
build_selection_doc,
|
|
37
|
+
build_witnesses_doc,
|
|
38
|
+
to_canonical_json,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
43
|
+
parser = argparse.ArgumentParser(prog="acquit")
|
|
44
|
+
parser.add_argument("--version", action="version", version=f"acquit {__version__}")
|
|
45
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
46
|
+
|
|
47
|
+
select = subcommands.add_parser("select", help="decide which tests must run for a diff")
|
|
48
|
+
select.add_argument("--base", required=True, help="base git ref")
|
|
49
|
+
select.add_argument("--head", help="head git ref, defaults to the working tree")
|
|
50
|
+
select.add_argument("--report", default=DEFAULT_REPORT_FILE, help="report output path")
|
|
51
|
+
select.add_argument("--selection", default=DEFAULT_SELECTION_FILE, help="selection output path")
|
|
52
|
+
select.add_argument("--witnesses", default=DEFAULT_WITNESSES_FILE, help="witnesses output path")
|
|
53
|
+
select.add_argument("--durations", help="json file mapping test paths to seconds")
|
|
54
|
+
|
|
55
|
+
subcommands.add_parser("analyze", help="build the dependency graph and print its health")
|
|
56
|
+
|
|
57
|
+
explain = subcommands.add_parser("explain", help="explain the decision for one test file")
|
|
58
|
+
explain.add_argument("test", help="repo-relative test file path")
|
|
59
|
+
explain.add_argument("--base", required=True, help="base git ref")
|
|
60
|
+
explain.add_argument("--head", help="head git ref, defaults to the working tree")
|
|
61
|
+
|
|
62
|
+
replay = subcommands.add_parser("replay", help="re-verify the witnesses behind a report")
|
|
63
|
+
replay.add_argument("report", help="path to an acquit report file")
|
|
64
|
+
replay.add_argument("--witnesses", default=DEFAULT_WITNESSES_FILE, help="witnesses file path")
|
|
65
|
+
replay.add_argument("--selection", help="selection file to cross-check against the report")
|
|
66
|
+
|
|
67
|
+
comment = subcommands.add_parser(
|
|
68
|
+
"comment", help="post or update the sticky PR comment for a report; never fails CI"
|
|
69
|
+
)
|
|
70
|
+
comment.add_argument("report", help="path to an acquit report file")
|
|
71
|
+
comment.add_argument("--pr", type=int, help="pull request number; otherwise from GITHUB_REF")
|
|
72
|
+
|
|
73
|
+
# A separate subcommand rather than a comment flag: the action calls it on
|
|
74
|
+
# every run, comment or not, and its contract (runner files) is different.
|
|
75
|
+
ci_outputs = subcommands.add_parser(
|
|
76
|
+
"ci-outputs", help="write GitHub action outputs and a step summary; never fails CI"
|
|
77
|
+
)
|
|
78
|
+
ci_outputs.add_argument("report", help="path to an acquit report file")
|
|
79
|
+
ci_outputs.add_argument("selection", help="path to the selection file pytest will obey")
|
|
80
|
+
return parser
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _now() -> str:
|
|
84
|
+
return datetime.now(UTC).isoformat(timespec="seconds")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load_durations(path_arg: str | None) -> dict[str, float] | None:
|
|
88
|
+
if path_arg is None:
|
|
89
|
+
return None
|
|
90
|
+
data = json.loads(Path(path_arg).read_text(encoding="utf-8"))
|
|
91
|
+
if not isinstance(data, dict):
|
|
92
|
+
raise AcquitError(f"{path_arg}: durations must be a json object")
|
|
93
|
+
durations: dict[str, float] = {}
|
|
94
|
+
for key, value in data.items():
|
|
95
|
+
if (
|
|
96
|
+
not isinstance(key, str)
|
|
97
|
+
or isinstance(value, bool)
|
|
98
|
+
or not isinstance(value, int | float)
|
|
99
|
+
):
|
|
100
|
+
raise AcquitError(f"{path_arg}: durations must map test paths to seconds")
|
|
101
|
+
durations[key] = float(value)
|
|
102
|
+
return durations
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _summary(result: SelectResult) -> str:
|
|
106
|
+
decision = result.decision
|
|
107
|
+
total = sum(1 for node in result.head.graph.nodes.values() if node.kind is NodeKind.TEST)
|
|
108
|
+
if decision.mode is SelectionMode.SELECTIVE:
|
|
109
|
+
return (
|
|
110
|
+
f"acquit: selective: {len(decision.selected)} selected, "
|
|
111
|
+
f"{len(decision.always_run)} always-run, "
|
|
112
|
+
f"{len(decision.skipped)} skipped of {total} tests"
|
|
113
|
+
)
|
|
114
|
+
findings = result.outcome.findings
|
|
115
|
+
if findings:
|
|
116
|
+
top = "; ".join(f"{finding.rule}:{finding.subject}" for finding in findings[:3])
|
|
117
|
+
if len(findings) > 3:
|
|
118
|
+
top += f" and {len(findings) - 3} more"
|
|
119
|
+
else:
|
|
120
|
+
top = "no test could be proven unaffected"
|
|
121
|
+
return f"acquit: run-all: {top} ({total} tests)"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _write_document(path: Path, document: dict[str, Any]) -> None:
|
|
125
|
+
"""Write one document atomically: temp file in place, then replace."""
|
|
126
|
+
tmp = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp")
|
|
127
|
+
try:
|
|
128
|
+
tmp.write_text(to_canonical_json(document), encoding="utf-8")
|
|
129
|
+
try:
|
|
130
|
+
tmp.replace(path)
|
|
131
|
+
except PermissionError:
|
|
132
|
+
# Windows refuses to replace a read-only target; clear the bit.
|
|
133
|
+
path.chmod(stat.S_IWRITE)
|
|
134
|
+
tmp.replace(path)
|
|
135
|
+
except BaseException:
|
|
136
|
+
tmp.unlink(missing_ok=True)
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _run_select(args: argparse.Namespace) -> int:
|
|
141
|
+
created_at = _now()
|
|
142
|
+
durations = _load_durations(args.durations)
|
|
143
|
+
result = run_select(args.base, args.head, Path.cwd())
|
|
144
|
+
graph_hash = result.head.graph.graph_hash
|
|
145
|
+
report = build_report(result, created_at=created_at, durations=durations)
|
|
146
|
+
selection = build_selection_doc(
|
|
147
|
+
result.decision, graph_hash, result.head_sha, result.tree_fingerprint
|
|
148
|
+
)
|
|
149
|
+
witnesses = build_witnesses_doc(result.decision, graph_hash)
|
|
150
|
+
_write_document(Path(args.report), report)
|
|
151
|
+
_write_document(Path(args.selection), selection)
|
|
152
|
+
_write_document(Path(args.witnesses), witnesses)
|
|
153
|
+
print(_summary(result))
|
|
154
|
+
return ExitCode.OK
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _run_analyze() -> int:
|
|
158
|
+
snapshot = snapshot_working_tree(Path.cwd())
|
|
159
|
+
graph = snapshot.graph
|
|
160
|
+
health = {
|
|
161
|
+
"files": len(snapshot.files),
|
|
162
|
+
"nodes": graph.digraph.num_nodes(),
|
|
163
|
+
"edges": graph.digraph.num_edges(),
|
|
164
|
+
"tests": sum(1 for node in graph.nodes.values() if node.kind is NodeKind.TEST),
|
|
165
|
+
"tainted": sum(1 for node in graph.nodes.values() if node.tainted),
|
|
166
|
+
"roots": list(snapshot.index.roots),
|
|
167
|
+
"graph_hash": graph.graph_hash,
|
|
168
|
+
}
|
|
169
|
+
print(to_canonical_json(health), end="")
|
|
170
|
+
return ExitCode.OK
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _run_explain(args: argparse.Namespace) -> int:
|
|
174
|
+
result = run_select(args.base, args.head, Path.cwd())
|
|
175
|
+
lines, code = explain_lines(args.test, result)
|
|
176
|
+
stream = sys.stdout if code is ExitCode.OK else sys.stderr
|
|
177
|
+
for line in lines:
|
|
178
|
+
print(line, file=stream)
|
|
179
|
+
return code
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _run_replay(args: argparse.Namespace) -> int:
|
|
183
|
+
selection = None if args.selection is None else Path(args.selection)
|
|
184
|
+
lines, code = run_replay(Path(args.report), Path(args.witnesses), selection, Path.cwd())
|
|
185
|
+
stream = sys.stdout if code is ExitCode.OK else sys.stderr
|
|
186
|
+
for line in lines:
|
|
187
|
+
print(line, file=stream)
|
|
188
|
+
return code
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _write_failure_docs(args: argparse.Namespace, error: Exception) -> None:
|
|
192
|
+
finding = Finding(
|
|
193
|
+
rule=RuleId.INTERNAL_ERROR,
|
|
194
|
+
scope=Scope(kind=ScopeKind.GLOBAL),
|
|
195
|
+
subject="acquit",
|
|
196
|
+
reason=str(error) or type(error).__name__,
|
|
197
|
+
)
|
|
198
|
+
run = RunInfo(base_sha=args.base, head_sha=args.head, created_at=_now())
|
|
199
|
+
report = build_run_all_report(run, findings=[finding])
|
|
200
|
+
# The selection is what pytest obeys, so it must convert to run-all first,
|
|
201
|
+
# and a failure to convert it must be loud, never suppressed.
|
|
202
|
+
for path, document in ((args.selection, build_run_all_selection()), (args.report, report)):
|
|
203
|
+
try:
|
|
204
|
+
_write_document(Path(path), document)
|
|
205
|
+
except Exception as write_error:
|
|
206
|
+
print(
|
|
207
|
+
f"acquit: FAILED to write fail-closed document {path}: {write_error}",
|
|
208
|
+
file=sys.stderr,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def main(argv: list[str] | None = None) -> int:
|
|
213
|
+
parser = _build_parser()
|
|
214
|
+
args = parser.parse_args(argv)
|
|
215
|
+
try:
|
|
216
|
+
if args.command == "select":
|
|
217
|
+
return _run_select(args)
|
|
218
|
+
if args.command == "analyze":
|
|
219
|
+
return _run_analyze()
|
|
220
|
+
if args.command == "explain":
|
|
221
|
+
return _run_explain(args)
|
|
222
|
+
if args.command == "comment":
|
|
223
|
+
return run_comment(Path(args.report), args.pr)
|
|
224
|
+
if args.command == "ci-outputs":
|
|
225
|
+
return run_ci_outputs(Path(args.report), Path(args.selection))
|
|
226
|
+
return _run_replay(args)
|
|
227
|
+
except Exception as error: # fail closed on anything unexpected
|
|
228
|
+
if args.command in ("comment", "ci-outputs"):
|
|
229
|
+
# Delivery must never fail CI, even if its own guard rails break.
|
|
230
|
+
print(f"acquit: warning: {args.command} skipped: {error}", file=sys.stderr)
|
|
231
|
+
return ExitCode.OK
|
|
232
|
+
print(f"acquit: internal error, run all tests: {error}", file=sys.stderr)
|
|
233
|
+
if args.command == "select":
|
|
234
|
+
_write_failure_docs(args, error)
|
|
235
|
+
return ExitCode.INTERNAL
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
raise SystemExit(main())
|
acquit/config.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Acquit's own configuration.
|
|
2
|
+
|
|
3
|
+
Sources, first hit wins: .acquit.toml at the repo root (top-level keys), then
|
|
4
|
+
[tool.acquit] in pyproject.toml. Missing files mean defaults. Unknown keys are
|
|
5
|
+
rejected: a typo in a soundness-sensitive config must not be silently ignored.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import tomllib
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from acquit.errors import PolicyError
|
|
14
|
+
|
|
15
|
+
_KNOWN_KEYS = frozenset({"roots", "assume_inert", "waive"})
|
|
16
|
+
_WAIVER_KEYS = ("rule", "glob", "justification")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Waiver:
|
|
21
|
+
rule: str
|
|
22
|
+
glob: str
|
|
23
|
+
# Mandatory: a waiver nobody can justify is not reviewable.
|
|
24
|
+
justification: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class AcquitConfig:
|
|
29
|
+
roots: tuple[str, ...] = ()
|
|
30
|
+
# Globs of resource files the user vouches never affect tests.
|
|
31
|
+
assume_inert: tuple[str, ...] = ()
|
|
32
|
+
waivers: tuple[Waiver, ...] = ()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_config(repo_root: Path) -> AcquitConfig:
|
|
36
|
+
"""Load configuration from repo_root, falling back to defaults.
|
|
37
|
+
|
|
38
|
+
Raises PolicyError on unreadable TOML, unknown keys, or malformed waivers.
|
|
39
|
+
"""
|
|
40
|
+
acquit_toml = repo_root / ".acquit.toml"
|
|
41
|
+
if acquit_toml.is_file():
|
|
42
|
+
return _parse(_read_toml(acquit_toml), source=".acquit.toml")
|
|
43
|
+
pyproject = repo_root / "pyproject.toml"
|
|
44
|
+
if pyproject.is_file():
|
|
45
|
+
tool = _read_toml(pyproject).get("tool")
|
|
46
|
+
section = tool.get("acquit") if isinstance(tool, dict) else None
|
|
47
|
+
if section is not None:
|
|
48
|
+
if not isinstance(section, dict):
|
|
49
|
+
raise PolicyError("pyproject.toml: [tool.acquit] must be a table")
|
|
50
|
+
return _parse(section, source="pyproject.toml [tool.acquit]")
|
|
51
|
+
return AcquitConfig()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read_toml(path: Path) -> dict[str, Any]:
|
|
55
|
+
try:
|
|
56
|
+
with path.open("rb") as handle:
|
|
57
|
+
return tomllib.load(handle)
|
|
58
|
+
except OSError as error:
|
|
59
|
+
raise PolicyError(f"{path.name}: cannot read: {error}") from error
|
|
60
|
+
except tomllib.TOMLDecodeError as error:
|
|
61
|
+
raise PolicyError(f"{path.name}: invalid TOML: {error}") from error
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse(data: dict[str, Any], source: str) -> AcquitConfig:
|
|
65
|
+
unknown = sorted(set(data) - _KNOWN_KEYS)
|
|
66
|
+
if unknown:
|
|
67
|
+
raise PolicyError(f"{source}: unknown key(s): {', '.join(unknown)}")
|
|
68
|
+
return AcquitConfig(
|
|
69
|
+
roots=_string_tuple(data.get("roots", []), key="roots", source=source),
|
|
70
|
+
assume_inert=_string_tuple(data.get("assume_inert", []), key="assume_inert", source=source),
|
|
71
|
+
waivers=_parse_waivers(data.get("waive", []), source=source),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _string_tuple(value: Any, key: str, source: str) -> tuple[str, ...]:
|
|
76
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
77
|
+
raise PolicyError(f"{source}: {key!r} must be an array of strings")
|
|
78
|
+
return tuple(value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_waivers(value: Any, source: str) -> tuple[Waiver, ...]:
|
|
82
|
+
if not isinstance(value, list):
|
|
83
|
+
raise PolicyError(f"{source}: 'waive' must be an array of tables")
|
|
84
|
+
waivers: list[Waiver] = []
|
|
85
|
+
for position, entry in enumerate(value, start=1):
|
|
86
|
+
label = f"{source}: waive entry {position}"
|
|
87
|
+
if not isinstance(entry, dict):
|
|
88
|
+
raise PolicyError(f"{label} must be a table")
|
|
89
|
+
if isinstance(entry.get("rule"), str):
|
|
90
|
+
label = f"{label} (rule {entry['rule']})"
|
|
91
|
+
unknown = sorted(set(entry) - set(_WAIVER_KEYS))
|
|
92
|
+
if unknown:
|
|
93
|
+
raise PolicyError(f"{label}: unknown key(s): {', '.join(unknown)}")
|
|
94
|
+
missing = [key for key in _WAIVER_KEYS if key not in entry]
|
|
95
|
+
if missing:
|
|
96
|
+
raise PolicyError(f"{label}: missing key(s): {', '.join(missing)}")
|
|
97
|
+
for key in _WAIVER_KEYS:
|
|
98
|
+
if not isinstance(entry[key], str):
|
|
99
|
+
raise PolicyError(f"{label}: {key!r} must be a string")
|
|
100
|
+
if not entry["justification"].strip():
|
|
101
|
+
raise PolicyError(f"{label}: justification must not be empty")
|
|
102
|
+
waivers.append(
|
|
103
|
+
Waiver(rule=entry["rule"], glob=entry["glob"], justification=entry["justification"])
|
|
104
|
+
)
|
|
105
|
+
return tuple(waivers)
|
acquit/constants.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared constants. Values that appear in more than one module live here."""
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
REPORT_SCHEMA: Final = "acquit/report-v1"
|
|
6
|
+
SELECTION_SCHEMA: Final = "acquit/selection-v2"
|
|
7
|
+
WITNESSES_SCHEMA: Final = "acquit/witnesses-v1"
|
|
8
|
+
|
|
9
|
+
ENV_SELECTION_FILE: Final = "ACQUIT_SELECTION_FILE"
|
|
10
|
+
ENV_CACHE_DIR: Final = "ACQUIT_CACHE_DIR"
|
|
11
|
+
|
|
12
|
+
DEFAULT_REPORT_FILE: Final = "acquit-report.json"
|
|
13
|
+
DEFAULT_SELECTION_FILE: Final = "acquit-selection.json"
|
|
14
|
+
DEFAULT_WITNESSES_FILE: Final = "acquit-witnesses.json"
|
|
15
|
+
|
|
16
|
+
COMMENT_MARKER: Final = "<!-- acquit-report -->"
|
|
17
|
+
|
|
18
|
+
# Refuse selection documents larger than this before reading them.
|
|
19
|
+
SELECTION_SIZE_CAP: Final = 5 * 1024 * 1024
|
acquit/errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Error taxonomy and process exit codes.
|
|
2
|
+
|
|
3
|
+
User-facing failures must degrade to a fail-closed report, never a bare traceback.
|
|
4
|
+
The CLI catches AcquitError (and everything else) and converts it to a run-all decision.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from enum import IntEnum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExitCode(IntEnum):
|
|
11
|
+
OK = 0
|
|
12
|
+
USAGE = 2
|
|
13
|
+
INTERNAL = 3
|
|
14
|
+
REPLAY_MISMATCH = 4
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AcquitError(Exception):
|
|
18
|
+
"""Base for all errors raised by acquit."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GraphError(AcquitError):
|
|
22
|
+
"""The dependency graph could not be built."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ResolutionError(AcquitError):
|
|
26
|
+
"""An import statement could not be resolved."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PolicyError(AcquitError):
|
|
30
|
+
"""The policy engine could not evaluate its rules."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class VcsError(AcquitError):
|
|
34
|
+
"""Git information (refs, diffs, blobs) could not be obtained."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ParseFailure(GraphError):
|
|
38
|
+
"""A source file could not be parsed into an AST."""
|
acquit/explain.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Explanations for individual test decisions.
|
|
2
|
+
|
|
3
|
+
Pure rendering over a completed SelectResult: no git, no filesystem. The CLI
|
|
4
|
+
runs the pipeline and hands the outcome here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from acquit.errors import ExitCode
|
|
8
|
+
from acquit.graph.model import BuiltGraph, NodeKind
|
|
9
|
+
from acquit.pipeline import SelectResult
|
|
10
|
+
from acquit.policy.model import ScopeKind
|
|
11
|
+
|
|
12
|
+
_REACHABLE_PREFIX = "reachable-from:"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _dependency_path(graph: BuiltGraph, src: str, dst: str) -> tuple[str, ...] | None:
|
|
16
|
+
start = graph.index_of.get(src)
|
|
17
|
+
goal = graph.index_of.get(dst)
|
|
18
|
+
if start is None or goal is None:
|
|
19
|
+
return None
|
|
20
|
+
if start == goal:
|
|
21
|
+
return (src,)
|
|
22
|
+
parent: dict[int, int] = {}
|
|
23
|
+
seen = {start}
|
|
24
|
+
frontier = [start]
|
|
25
|
+
while frontier:
|
|
26
|
+
upcoming: list[int] = []
|
|
27
|
+
for node in frontier:
|
|
28
|
+
# Neighbors sorted by path make the BFS tie-break deterministic.
|
|
29
|
+
neighbors = sorted(
|
|
30
|
+
graph.digraph.successor_indices(node), key=lambda index: graph.digraph[index].path
|
|
31
|
+
)
|
|
32
|
+
for neighbor in neighbors:
|
|
33
|
+
if neighbor in seen:
|
|
34
|
+
continue
|
|
35
|
+
seen.add(neighbor)
|
|
36
|
+
parent[neighbor] = node
|
|
37
|
+
if neighbor == goal:
|
|
38
|
+
return _reconstruct(graph, parent, start, goal)
|
|
39
|
+
upcoming.append(neighbor)
|
|
40
|
+
frontier = upcoming
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _reconstruct(
|
|
45
|
+
graph: BuiltGraph, parent: dict[int, int], start: int, goal: int
|
|
46
|
+
) -> tuple[str, ...]:
|
|
47
|
+
chain = [goal]
|
|
48
|
+
while chain[-1] != start:
|
|
49
|
+
chain.append(parent[chain[-1]])
|
|
50
|
+
return tuple(graph.digraph[index].path for index in reversed(chain))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _explain_skipped(path: str, witness_id: str, result: SelectResult) -> tuple[str, ...]:
|
|
54
|
+
witness = next(w for w in result.decision.witnesses if w.id == witness_id)
|
|
55
|
+
closure = result.decision.closures[witness.closure_hash]
|
|
56
|
+
changed = ", ".join(witness.changed) if witness.changed else "(empty)"
|
|
57
|
+
return (
|
|
58
|
+
f"{path}: skipped",
|
|
59
|
+
f" witness: {witness.id}",
|
|
60
|
+
f" claim: {witness.claim}",
|
|
61
|
+
f" closure: {len(closure)} files, hash {witness.closure_hash}",
|
|
62
|
+
f" changed: {changed}",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _explain_selected(path: str, reasons: tuple[str, ...], graph: BuiltGraph) -> tuple[str, ...]:
|
|
67
|
+
lines = [f"{path}: selected"]
|
|
68
|
+
for reason in reasons:
|
|
69
|
+
lines.append(f" reason: {reason}")
|
|
70
|
+
if not reason.startswith(_REACHABLE_PREFIX):
|
|
71
|
+
continue
|
|
72
|
+
target = reason.removeprefix(_REACHABLE_PREFIX)
|
|
73
|
+
chain = _dependency_path(graph, path, target)
|
|
74
|
+
if chain is None:
|
|
75
|
+
lines.append(" (reachable only in the base graph)")
|
|
76
|
+
else:
|
|
77
|
+
lines.append(" " + " imports ".join(chain))
|
|
78
|
+
return tuple(lines)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def explain_lines(test: str, result: SelectResult) -> tuple[tuple[str, ...], ExitCode]:
|
|
82
|
+
"""Explain why one test was selected, skipped, or forced to run."""
|
|
83
|
+
path = test.replace("\\", "/").removeprefix("./")
|
|
84
|
+
node = result.head.graph.nodes.get(path)
|
|
85
|
+
if node is None or node.kind is not NodeKind.TEST:
|
|
86
|
+
return ((f"acquit: {path!r} is not a known test file at head",), ExitCode.USAGE)
|
|
87
|
+
|
|
88
|
+
global_findings = [f for f in result.outcome.findings if f.scope.kind is ScopeKind.GLOBAL]
|
|
89
|
+
if global_findings:
|
|
90
|
+
lines = [f"{path}: runs; global findings force the full suite:"]
|
|
91
|
+
lines.extend(f" {f.rule} {f.subject}: {f.reason}" for f in global_findings)
|
|
92
|
+
return (tuple(lines), ExitCode.OK)
|
|
93
|
+
|
|
94
|
+
for skipped in result.decision.skipped:
|
|
95
|
+
if skipped.path == path:
|
|
96
|
+
return (_explain_skipped(path, skipped.witness_id, result), ExitCode.OK)
|
|
97
|
+
for always in result.decision.always_run:
|
|
98
|
+
if always.path == path:
|
|
99
|
+
return ((f"{path}: always runs", f" finding: {always.finding}"), ExitCode.OK)
|
|
100
|
+
for selected in result.decision.selected:
|
|
101
|
+
if selected.path == path:
|
|
102
|
+
return (_explain_selected(path, selected.reasons, result.head.graph), ExitCode.OK)
|
|
103
|
+
# Unreachable when the decision covers every head test; answer safely anyway.
|
|
104
|
+
return ((f"{path}: runs (not covered by the decision, run-all default)",), ExitCode.OK)
|
acquit/gh/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""GitHub delivery: the sticky PR comment and the action's runner-file outputs.
|
|
2
|
+
|
|
3
|
+
Everything in this package is best-effort by design. A delivery failure is a
|
|
4
|
+
warning on stderr and a zero exit, because reporting must never fail CI; the
|
|
5
|
+
fail-closed guarantees all live upstream in selection and replay.
|
|
6
|
+
"""
|