@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 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.
@@ -1,6 +1,6 @@
1
1
  # evalid Launch Dossier
2
2
 
3
- Generated: 2026-11-08T19:55:19.000Z
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.15
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.15",
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())