@surlymochan/evalid 0.5.15 → 0.5.16
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/CHANGELOG.md +10 -0
- package/RELEASE_NOTES.md +12 -0
- package/docs/launch-dossier.md +2 -2
- package/package.json +1 -1
- package/xflow/atoms/i6d_assertion_execution.py +450 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
All notable changes to this project are documented here. Detailed notes live in
|
|
4
4
|
[RELEASE_NOTES.md](RELEASE_NOTES.md).
|
|
5
5
|
|
|
6
|
+
## [0.5.16] - 2026-11-09
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **M3 slice 1: pytest assertion-execution tracking (#82)**: `xflow/atoms/i6d_assertion_execution.py` now collects every `assert` in a pytest run (file, line, enclosing test) and reports which assertions actually executed, so an unexecuted assertion is red instead of silently green. Test evidence: 5 new tests in `test/i6d-assertion-execution.test.js` 5/5 pass; local full `npm test` 480 tests passed; main CI run 42525749105 success.
|
|
10
|
+
- **M3 slice 2: diff-scoped assertion execution (#84)**: assertion-execution verdicts are now scoped to the diff — only new/changed assertions are in scope, legacy dead asserts outside the diff are reported out of scope instead of failing the run. Test evidence: 4 new tests in `test/i6d-assertion-execution.test.js` 4/4 pass (i6d file 9/9 pass); local full `npm test` exit 0; PR CI runs verify 42535505270 and ledger 42535505267 both pass; merged main CI run 42535987353 success.
|
|
11
|
+
|
|
12
|
+
Milestone M3 "pytest assertion-execution tracking" — **not yet accepted**: slices 1 and 2 are merged checkpoints only; slice 3 is still to be done and the end-to-end acceptance evidence (real-scenario run + data on disk) is not yet in place. This release does not claim M3 acceptance.
|
|
13
|
+
|
|
14
|
+
Acceptance debt, stated plainly: the bench-number acceptance for P1-2 / P1-3 / P2-1 / P2-3 still awaits the MiniMax full-matrix batch (15 instances × 4 arms × reps 3), which is stopped on MiniMax Token Plan quota exhaustion. No bench numbers are claimed in this release.
|
|
15
|
+
|
|
6
16
|
## [0.5.15] - 2026-11-09
|
|
7
17
|
|
|
8
18
|
### Added
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Release Notes
|
|
2
2
|
|
|
3
|
+
## 0.5.16 - M3 slices 1–2: pytest assertion-execution tracking, diff-scoped (milestone M3 not yet accepted)
|
|
4
|
+
|
|
5
|
+
Patch release. Two merged slices since 0.5.15, both verified end to end in CI and locally.
|
|
6
|
+
|
|
7
|
+
**M3 slice 1: pytest assertion-execution tracking (#82).** Every `assert` in a pytest run is now collected (file, line, enclosing test) and the run reports which assertions actually executed, so an unexecuted assertion turns the verdict red instead of passing silently. Test evidence: 5 new tests in `test/i6d-assertion-execution.test.js` 5/5 pass; local full `npm test` 480 tests passed; main CI run 42525749105 success.
|
|
8
|
+
|
|
9
|
+
**M3 slice 2: diff-scoped assertion execution (#84).** Verdicts are now scoped to the diff: only new/changed assertions count, and legacy dead asserts outside the diff are reported out of scope rather than failing the run. Test evidence: 4 new tests in `test/i6d-assertion-execution.test.js` 4/4 pass (i6d file 9/9 pass); local full `npm test` exit 0; PR CI runs verify 42535505270 and ledger 42535505267 both pass; merged main CI run 42535987353 success.
|
|
10
|
+
|
|
11
|
+
**Milestone M3 — not yet accepted.** Slices 1 and 2 are merged checkpoints only; slice 3 is still to be done and the end-to-end acceptance evidence (real-scenario run + data on disk) is not yet in place. This release makes no M3 acceptance claim.
|
|
12
|
+
|
|
13
|
+
Acceptance debt, stated plainly: the bench-number acceptance for P1-2, P1-3, P2-1 and P2-3 — the wall-clock/token/resolved lines from issue #5 — still awaits the MiniMax full-matrix batch (15 instances × 4 arms × reps 3), which is stopped on MiniMax Token Plan quota exhaustion. No bench numbers are claimed in this release.
|
|
14
|
+
|
|
3
15
|
## 0.5.15 - xflow audit standalone mode (milestone M2 accepted)
|
|
4
16
|
|
|
5
17
|
Patch release. One new feature since 0.5.14, verified end to end in CI and locally.
|
package/docs/launch-dossier.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# evalid Launch Dossier
|
|
2
2
|
|
|
3
|
-
Generated: 2026-11-
|
|
3
|
+
Generated: 2026-11-08T23:43:07.000Z
|
|
4
4
|
Mode: post_publish
|
|
5
5
|
Verdict: conditionally ready: engine and adoption evidence are strong, registry publication remains required for a splash launch
|
|
6
6
|
Launch ready: not yet
|
|
@@ -117,7 +117,7 @@ Evidence:
|
|
|
117
117
|
- Adoption records: 1; status: pass
|
|
118
118
|
- Goal audit: pass; goal: Keep evalid credible as an open-source agent delivery runtime: repo-owned workflow contracts, executable verification gates, durable project context, and honest benchmark evidence.
|
|
119
119
|
- Package evidence: missing; source: registry_json
|
|
120
|
-
- Package name/version: @surlymochan/evalid@0.5.
|
|
120
|
+
- Package name/version: @surlymochan/evalid@0.5.16
|
|
121
121
|
|
|
122
122
|
## Passing Evidence
|
|
123
123
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@surlymochan/evalid",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.16",
|
|
4
4
|
"description": "Claimed != verified: a runtime verification layer for coding agents. It seals each test run and replays it from a clean snapshot, with a zero-judge gate pipeline (spec clarification, code review, TDD proof) and the xflow CLI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
I6d assertion-execution gate, slice 1: pytest-side assertion tracking.
|
|
4
|
+
|
|
5
|
+
M3 (parent #69) asks the green phase to prove that every added/changed
|
|
6
|
+
assertion really executed -- an assertion that never runs must not count
|
|
7
|
+
as green. The static I6c review already catches three decidable shapes
|
|
8
|
+
of wrong assertions; this module adds the runtime half for pytest:
|
|
9
|
+
|
|
10
|
+
* statically, AST-collect every ``assert`` statement in the collected
|
|
11
|
+
test files (file, line, source, enclosing test);
|
|
12
|
+
* at runtime, trace executed lines with ``sys.settrace`` while pytest runs;
|
|
13
|
+
* afterwards, write evidence JSON: which asserts executed, which did not,
|
|
14
|
+
and which belong to tests that were skipped or never selected (those are
|
|
15
|
+
reported as skipped, not red -- only asserts in tests that actually ran
|
|
16
|
+
and never hit their line fail the gate).
|
|
17
|
+
|
|
18
|
+
Use as a pytest plugin (``-p i6d_assertion_execution``) or through the CLI
|
|
19
|
+
below, which runs pytest for you and exits 0 on pass, 1 when unexecuted
|
|
20
|
+
assertions (or a failing pytest run) are found, 2 on misuse/errors.
|
|
21
|
+
|
|
22
|
+
Out of scope for slice 1 (later slices): scoping to added/changed asserts
|
|
23
|
+
only (needs git diff), asserts in non-test helper modules, non-pytest
|
|
24
|
+
runners, and wiring into ``xflow audit`` replay / the I6c gate.
|
|
25
|
+
|
|
26
|
+
Slice 2 adds that diff scoping as reusable pieces (importable, so slice 3
|
|
27
|
+
can reuse them when it wires the gate into ``xflow audit`` replay):
|
|
28
|
+
|
|
29
|
+
* ``resolve_diff_base(repo, base)`` -- the merge-base of HEAD and ``base``
|
|
30
|
+
(default ``main``), i.e. the commit the current branch forked from;
|
|
31
|
+
* ``changed_lines(repo, base)`` -- ``{abspath: {new-side line numbers}}``
|
|
32
|
+
from ``git diff <merge-base> HEAD -U0`` (committed changes only;
|
|
33
|
+
uncommitted working-tree edits are *not* in scope);
|
|
34
|
+
* ``assert_in_scope(entry, changed)`` -- an assert is in scope when any
|
|
35
|
+
of its lines [line, end_line] was added/changed.
|
|
36
|
+
|
|
37
|
+
The plugin honours ``EVALID_I6D_ONLY_CHANGED=1`` (+ ``EVALID_I6D_DIFF_BASE``)
|
|
38
|
+
and the CLI exposes the same as ``--only-changed`` / ``--diff-base``.
|
|
39
|
+
With scoping on, asserts outside the diff are reported as ``outOfScope``
|
|
40
|
+
(never red), an empty diff passes (exit 0), and only in-scope unexecuted
|
|
41
|
+
asserts fail the gate. Without the flag, slice 1 behaviour is unchanged.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import ast
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
import re
|
|
49
|
+
import subprocess
|
|
50
|
+
import sys
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
|
|
53
|
+
EVIDENCE_ENV = "EVALID_I6D_EVIDENCE"
|
|
54
|
+
DIFF_ONLY_ENV = "EVALID_I6D_ONLY_CHANGED"
|
|
55
|
+
DIFF_BASE_ENV = "EVALID_I6D_DIFF_BASE"
|
|
56
|
+
VERSION = 2
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Static collection
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def _source_lines(path):
|
|
64
|
+
return Path(path).read_text(encoding="utf-8").splitlines()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def collect_assertions(path):
|
|
68
|
+
"""Return every ``assert`` statement in one test file.
|
|
69
|
+
|
|
70
|
+
Each entry: ``{file, line, end_line, source, test}`` where ``test`` is
|
|
71
|
+
the outermost enclosing function whose name starts with ``test`` (the
|
|
72
|
+
test that owns the assert, even when the assert sits inside a nested
|
|
73
|
+
callback), or the outermost enclosing function, or None for module level.
|
|
74
|
+
"""
|
|
75
|
+
abspath = os.path.realpath(path)
|
|
76
|
+
lines = _source_lines(abspath)
|
|
77
|
+
tree = ast.parse("\n".join(lines), filename=abspath)
|
|
78
|
+
found = []
|
|
79
|
+
|
|
80
|
+
def enclosing_test(stack):
|
|
81
|
+
for node in stack:
|
|
82
|
+
if node.name.startswith("test"):
|
|
83
|
+
return node.name
|
|
84
|
+
return stack[0].name if stack else None
|
|
85
|
+
|
|
86
|
+
def visit(node, stack):
|
|
87
|
+
if isinstance(node, ast.Assert):
|
|
88
|
+
segment = ast.get_source_segment("\n".join(lines), node)
|
|
89
|
+
source = (segment or lines[node.lineno - 1]).strip()
|
|
90
|
+
found.append({
|
|
91
|
+
"file": abspath,
|
|
92
|
+
"line": node.lineno,
|
|
93
|
+
"end_line": node.end_lineno,
|
|
94
|
+
"source": source,
|
|
95
|
+
"test": enclosing_test(stack),
|
|
96
|
+
})
|
|
97
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
98
|
+
stack = [*stack, node]
|
|
99
|
+
for child in ast.iter_child_nodes(node):
|
|
100
|
+
visit(child, stack)
|
|
101
|
+
|
|
102
|
+
visit(tree, [])
|
|
103
|
+
return found
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Diff scoping (slice 2) -- reusable pieces for slice 3
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def _git(repo, *argv):
|
|
111
|
+
proc = subprocess.run(
|
|
112
|
+
["git", *argv], cwd=repo, capture_output=True, text=True, timeout=30,
|
|
113
|
+
)
|
|
114
|
+
if proc.returncode != 0:
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
f"git {' '.join(argv)} failed ({proc.returncode}): "
|
|
117
|
+
f"{proc.stderr.strip()}"
|
|
118
|
+
)
|
|
119
|
+
return proc.stdout
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def resolve_diff_base(repo, base="main"):
|
|
123
|
+
"""Merge-base SHA of HEAD and ``base`` (the branch point of this work)."""
|
|
124
|
+
return _git(repo, "merge-base", "HEAD", base).strip()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def changed_lines(repo, base="main"):
|
|
131
|
+
"""Return ``(merge_base_sha, {abspath: {new-side line numbers}})``.
|
|
132
|
+
|
|
133
|
+
The diff is ``git diff <merge-base> HEAD -U0``: committed changes on
|
|
134
|
+
this branch only. A file's changed lines are the new-side lines of
|
|
135
|
+
every hunk (for a brand-new file, every line). Deleted files contribute
|
|
136
|
+
nothing; uncommitted working-tree edits are not included.
|
|
137
|
+
"""
|
|
138
|
+
merge_base = resolve_diff_base(repo, base)
|
|
139
|
+
raw = _git(repo, "diff", merge_base, "HEAD", "-U0", "--")
|
|
140
|
+
changed = {}
|
|
141
|
+
current = None
|
|
142
|
+
for line in raw.splitlines():
|
|
143
|
+
if line.startswith("+++ "):
|
|
144
|
+
target = line[4:].strip()
|
|
145
|
+
if target in ("/dev/null", ""):
|
|
146
|
+
current = None
|
|
147
|
+
continue
|
|
148
|
+
if target.startswith("b/"):
|
|
149
|
+
target = target[2:]
|
|
150
|
+
if len(target) >= 2 and target.startswith('"') and target.endswith('"'):
|
|
151
|
+
target = json.loads(target)
|
|
152
|
+
current = os.path.realpath(os.path.join(repo, target))
|
|
153
|
+
changed.setdefault(current, set())
|
|
154
|
+
elif line.startswith("@@ ") and current is not None:
|
|
155
|
+
match = _HUNK_RE.match(line)
|
|
156
|
+
if match is None:
|
|
157
|
+
continue
|
|
158
|
+
start = int(match.group(1))
|
|
159
|
+
count = int(match.group(2)) if match.group(2) is not None else 1
|
|
160
|
+
changed[current].update(range(start, start + count))
|
|
161
|
+
return merge_base, {path: lines for path, lines in changed.items() if lines}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def assert_in_scope(entry, changed):
|
|
165
|
+
"""True when any line of the assert (line..end_line) changed in the diff."""
|
|
166
|
+
lines = changed.get(os.path.realpath(entry["file"]))
|
|
167
|
+
if not lines:
|
|
168
|
+
return False
|
|
169
|
+
return any(
|
|
170
|
+
lineno in lines
|
|
171
|
+
for lineno in range(entry["line"], (entry.get("end_line") or entry["line"]) + 1)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def load_diff_scope(repo, base="main"):
|
|
176
|
+
"""``{'base': <merge-base sha>, 'changed': {path: [lines]}}`` (JSON-safe)."""
|
|
177
|
+
merge_base, changed = changed_lines(repo, base)
|
|
178
|
+
return {
|
|
179
|
+
"base": merge_base,
|
|
180
|
+
"changed": {path: sorted(lines) for path, lines in changed.items()},
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
# Pytest plugin
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
class _AssertionExecutionPlugin:
|
|
189
|
+
def __init__(self, rootdir):
|
|
190
|
+
self.rootdir = os.path.realpath(rootdir)
|
|
191
|
+
self.executed = set() # {(realpath, lineno)}
|
|
192
|
+
self.asserts = [] # collect_assertions() entries
|
|
193
|
+
self.outcomes = {} # nodeid -> 'passed' | 'failed' | 'skipped'
|
|
194
|
+
self._old_trace = None
|
|
195
|
+
self.diff_changed = None # None = no scoping; else {path: set(lines)}
|
|
196
|
+
self.diff_base = None
|
|
197
|
+
self.diff_error = None
|
|
198
|
+
|
|
199
|
+
# -- tracing ---------------------------------------------------------
|
|
200
|
+
def _global_trace(self, frame, event, arg):
|
|
201
|
+
if event != "call":
|
|
202
|
+
return None
|
|
203
|
+
filename = frame.f_code.co_filename
|
|
204
|
+
if not filename.endswith(".py"):
|
|
205
|
+
return None
|
|
206
|
+
if os.path.realpath(filename).startswith(self.rootdir + os.sep):
|
|
207
|
+
return self._local_trace
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
def _local_trace(self, frame, event, arg):
|
|
211
|
+
if event == "line":
|
|
212
|
+
self.executed.add(
|
|
213
|
+
(os.path.realpath(frame.f_code.co_filename), frame.f_lineno)
|
|
214
|
+
)
|
|
215
|
+
return self._local_trace
|
|
216
|
+
|
|
217
|
+
# -- hooks -----------------------------------------------------------
|
|
218
|
+
def pytest_sessionstart(self, session):
|
|
219
|
+
self._old_trace = sys.gettrace()
|
|
220
|
+
sys.settrace(self._global_trace)
|
|
221
|
+
if os.environ.get(DIFF_ONLY_ENV):
|
|
222
|
+
try:
|
|
223
|
+
base = os.environ.get(DIFF_BASE_ENV) or "main"
|
|
224
|
+
self.diff_base, self.diff_changed = changed_lines(
|
|
225
|
+
self.rootdir, base
|
|
226
|
+
)
|
|
227
|
+
except Exception as exc: # noqa: BLE001 - surface as evidence, not a crash
|
|
228
|
+
self.diff_error = str(exc)
|
|
229
|
+
|
|
230
|
+
def pytest_collection_modifyitems(self, session, config, items):
|
|
231
|
+
seen_files = set()
|
|
232
|
+
for item in items:
|
|
233
|
+
path = str(item.path)
|
|
234
|
+
if path not in seen_files:
|
|
235
|
+
seen_files.add(path)
|
|
236
|
+
self.asserts.extend(collect_assertions(path))
|
|
237
|
+
|
|
238
|
+
def pytest_runtest_logreport(self, report):
|
|
239
|
+
if report.skipped:
|
|
240
|
+
self.outcomes[report.nodeid] = "skipped"
|
|
241
|
+
elif report.when == "call":
|
|
242
|
+
self.outcomes[report.nodeid] = report.outcome
|
|
243
|
+
|
|
244
|
+
def pytest_sessionfinish(self, session, exitstatus):
|
|
245
|
+
sys.settrace(self._old_trace)
|
|
246
|
+
evidence = build_evidence(
|
|
247
|
+
rootdir=self.rootdir,
|
|
248
|
+
asserts=self.asserts,
|
|
249
|
+
executed=self.executed,
|
|
250
|
+
outcomes=self.outcomes,
|
|
251
|
+
pytest_exit=exitstatus,
|
|
252
|
+
changed=self.diff_changed,
|
|
253
|
+
diff_base=self.diff_base,
|
|
254
|
+
diff_error=self.diff_error,
|
|
255
|
+
)
|
|
256
|
+
write_evidence(evidence)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
_PLUGIN = None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def pytest_sessionstart(session):
|
|
263
|
+
global _PLUGIN
|
|
264
|
+
_PLUGIN = _AssertionExecutionPlugin(session.config.rootdir)
|
|
265
|
+
_PLUGIN.pytest_sessionstart(session)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def pytest_collection_modifyitems(session, config, items):
|
|
269
|
+
_PLUGIN.pytest_collection_modifyitems(session, config, items)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def pytest_runtest_logreport(report):
|
|
273
|
+
_PLUGIN.pytest_runtest_logreport(report)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
277
|
+
_PLUGIN.pytest_sessionfinish(session, exitstatus)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# Evidence
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
def _test_function_name(nodeid):
|
|
285
|
+
"""Last :: component of a nodeid, without parametrisation."""
|
|
286
|
+
last = nodeid.rsplit("::", 1)[-1]
|
|
287
|
+
return last.split("[", 1)[0]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _test_ran(nodeid_file, test_name, outcomes):
|
|
291
|
+
"""True when at least one collected item for this test actually ran."""
|
|
292
|
+
if test_name is None:
|
|
293
|
+
# Module-level asserts execute at import, i.e. during collection.
|
|
294
|
+
return True
|
|
295
|
+
for nodeid, outcome in outcomes.items():
|
|
296
|
+
if outcome not in ("passed", "failed"):
|
|
297
|
+
continue
|
|
298
|
+
if _test_function_name(nodeid) != test_name:
|
|
299
|
+
continue
|
|
300
|
+
item_file = nodeid.split("::", 1)[0]
|
|
301
|
+
if os.path.realpath(item_file) == os.path.realpath(nodeid_file):
|
|
302
|
+
return True
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def build_evidence(*, rootdir, asserts, executed, outcomes, pytest_exit,
|
|
307
|
+
changed=None, diff_base=None, diff_error=None):
|
|
308
|
+
"""Classify collected asserts.
|
|
309
|
+
|
|
310
|
+
Without ``changed`` (slice 1): executed / unexecuted-in-ran-tests /
|
|
311
|
+
skipped. With ``changed`` (slice 2 diff scoping): asserts outside the
|
|
312
|
+
diff go to ``outOfScope`` first and are never red; only in-scope
|
|
313
|
+
unexecuted asserts fail the gate, so an empty diff passes.
|
|
314
|
+
"""
|
|
315
|
+
executed_list = []
|
|
316
|
+
unexecuted = []
|
|
317
|
+
skipped = []
|
|
318
|
+
out_of_scope = []
|
|
319
|
+
for entry in asserts:
|
|
320
|
+
if changed is not None and not assert_in_scope(entry, changed):
|
|
321
|
+
out_of_scope.append(entry)
|
|
322
|
+
continue
|
|
323
|
+
key = (entry["file"], entry["line"])
|
|
324
|
+
if key in executed:
|
|
325
|
+
executed_list.append(entry)
|
|
326
|
+
elif _test_ran(entry["file"], entry["test"], outcomes):
|
|
327
|
+
unexecuted.append(entry)
|
|
328
|
+
else:
|
|
329
|
+
skipped.append(entry)
|
|
330
|
+
if diff_error is not None:
|
|
331
|
+
verdict = "diff_error"
|
|
332
|
+
elif unexecuted:
|
|
333
|
+
verdict = "unexecuted_assertions"
|
|
334
|
+
elif pytest_exit != 0:
|
|
335
|
+
verdict = "pytest_failed"
|
|
336
|
+
else:
|
|
337
|
+
verdict = "pass"
|
|
338
|
+
evidence = {
|
|
339
|
+
"tool": "i6d_assertion_execution",
|
|
340
|
+
"version": VERSION,
|
|
341
|
+
"root": os.path.realpath(rootdir),
|
|
342
|
+
"pytestExit": pytest_exit,
|
|
343
|
+
"verdict": verdict,
|
|
344
|
+
"total": len(asserts),
|
|
345
|
+
"executed": len(executed_list),
|
|
346
|
+
"unexecuted": unexecuted,
|
|
347
|
+
"skipped": skipped,
|
|
348
|
+
}
|
|
349
|
+
if changed is not None or diff_base is not None or diff_error is not None:
|
|
350
|
+
evidence["diffBase"] = diff_base
|
|
351
|
+
evidence["inScope"] = len(executed_list) + len(unexecuted) + len(skipped)
|
|
352
|
+
evidence["outOfScope"] = out_of_scope
|
|
353
|
+
if diff_error is not None:
|
|
354
|
+
evidence["diffError"] = diff_error
|
|
355
|
+
return evidence
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def evidence_path():
|
|
359
|
+
override = os.environ.get(EVIDENCE_ENV)
|
|
360
|
+
if override:
|
|
361
|
+
return override
|
|
362
|
+
return os.path.join(os.getcwd(), ".evalid-i6d-evidence.json")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def write_evidence(evidence):
|
|
366
|
+
path = evidence_path()
|
|
367
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
368
|
+
json.dump(evidence, handle, indent=2)
|
|
369
|
+
return path
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
# CLI: run pytest under the plugin, then judge the evidence
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
def run_gate(*, root, output, pytest_python, pytest_args,
|
|
377
|
+
only_changed=False, diff_base="main"):
|
|
378
|
+
atoms_dir = str(Path(__file__).resolve().parent)
|
|
379
|
+
env = dict(os.environ)
|
|
380
|
+
env[EVIDENCE_ENV] = os.path.abspath(output)
|
|
381
|
+
if only_changed:
|
|
382
|
+
env[DIFF_ONLY_ENV] = "1"
|
|
383
|
+
env[DIFF_BASE_ENV] = diff_base
|
|
384
|
+
env["PYTHONPATH"] = atoms_dir + os.pathsep + env.get("PYTHONPATH", "")
|
|
385
|
+
command = [pytest_python, "-m", "pytest", "-p", "i6d_assertion_execution", *pytest_args]
|
|
386
|
+
try:
|
|
387
|
+
proc = subprocess.run(command, cwd=root, env=env)
|
|
388
|
+
except OSError as exc:
|
|
389
|
+
print(f"i6d: could not run pytest: {exc}", file=sys.stderr)
|
|
390
|
+
return 2
|
|
391
|
+
try:
|
|
392
|
+
with open(os.path.abspath(output), encoding="utf-8") as handle:
|
|
393
|
+
evidence = json.load(handle)
|
|
394
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
395
|
+
print(f"i6d: no evidence written ({exc}); pytest exit {proc.returncode}", file=sys.stderr)
|
|
396
|
+
return 2
|
|
397
|
+
evidence["pytestExit"] = proc.returncode
|
|
398
|
+
if evidence["verdict"] == "unexecuted_assertions" and proc.returncode == 0:
|
|
399
|
+
pass # verdict already reflects the gate; pytest itself passed.
|
|
400
|
+
elif proc.returncode != 0 and not evidence["unexecuted"]:
|
|
401
|
+
evidence["verdict"] = "pytest_failed"
|
|
402
|
+
with open(os.path.abspath(output), "w", encoding="utf-8") as handle:
|
|
403
|
+
json.dump(evidence, handle, indent=2)
|
|
404
|
+
summary = (
|
|
405
|
+
f"i6d: {evidence['verdict']} "
|
|
406
|
+
f"({evidence['executed']}/{evidence['total']} assertions executed, "
|
|
407
|
+
f"{len(evidence['unexecuted'])} unexecuted, "
|
|
408
|
+
f"{len(evidence['skipped'])} skipped; pytest exit {proc.returncode})"
|
|
409
|
+
)
|
|
410
|
+
if "outOfScope" in evidence:
|
|
411
|
+
summary += f" [{len(evidence['outOfScope'])} out of diff scope, base {evidence.get('diffBase')}]"
|
|
412
|
+
print(summary)
|
|
413
|
+
for entry in evidence["unexecuted"]:
|
|
414
|
+
print(f" unexecuted: {entry['file']}:{entry['line']}: {entry['source']}")
|
|
415
|
+
if evidence["verdict"] == "diff_error":
|
|
416
|
+
print(f" diff error: {evidence.get('diffError')}", file=sys.stderr)
|
|
417
|
+
return 2
|
|
418
|
+
return 0 if evidence["verdict"] == "pass" else 1
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def main(argv=None):
|
|
422
|
+
parser = argparse.ArgumentParser(
|
|
423
|
+
description="I6d assertion-execution gate: prove every assert in "
|
|
424
|
+
"the pytest run really executed (slice 2 adds --only-changed to "
|
|
425
|
+
"judge only asserts added/changed vs a diff base, default main)."
|
|
426
|
+
)
|
|
427
|
+
parser.add_argument("--root", default=os.getcwd(), help="project root to run pytest in")
|
|
428
|
+
parser.add_argument("--output", default=".evalid-i6d-evidence.json",
|
|
429
|
+
help="where to write the evidence JSON")
|
|
430
|
+
parser.add_argument("--pytest-python", default=sys.executable,
|
|
431
|
+
help="python binary to run pytest with")
|
|
432
|
+
parser.add_argument("--only-changed", action="store_true",
|
|
433
|
+
help="only judge asserts in the diff vs --diff-base")
|
|
434
|
+
parser.add_argument("--diff-base", default="main",
|
|
435
|
+
help="diff base ref (merge-base of HEAD and this ref; default main)")
|
|
436
|
+
parser.add_argument("pytest_args", nargs="*", help="arguments passed to pytest (after --)")
|
|
437
|
+
args = parser.parse_args(argv)
|
|
438
|
+
# argparse keeps a literal "--" out of pytest_args already.
|
|
439
|
+
return run_gate(
|
|
440
|
+
root=args.root,
|
|
441
|
+
output=args.output,
|
|
442
|
+
pytest_python=args.pytest_python,
|
|
443
|
+
pytest_args=args.pytest_args,
|
|
444
|
+
only_changed=args.only_changed,
|
|
445
|
+
diff_base=args.diff_base,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
if __name__ == "__main__":
|
|
450
|
+
raise SystemExit(main())
|