@surlymochan/evalid 0.5.14 → 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,7 +3,24 @@
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
- ## [Unreleased]
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
+
16
+ ## [0.5.15] - 2026-11-09
17
+
18
+ ### Added
19
+ - **`xflow audit` standalone mode (#78, M2)**: verify any git diff / PR without adopting evalid workflows — `xflow audit --pr <n>` / `--diff <patch> --base <ref>` / `--base <ref> --head <ref>` extracts the test runs the change claims to have passed (English and Chinese claim phrasing, per-file counts summed for multi-file commands, clause-split so one line's two claims don't leak onto each other), replays each in a clean clone at the change's head (deps installed with `npm ci` in the scratch copy, `NODE_TEST_*` env stripped so a nested `node --test` really runs), and compares exit codes and pass/fail counts against the claim. Diffs carrying an evalid proof seal are replayed through the existing i6b validator (seal semantics untouched). Verdicts: `verified` / `mismatch` / `no_claims` / `unverifiable` (exit 0/1/3/1). Test evidence: 11 new tests in `test/xflow-audit.test.js` (claim extraction incl. #75/#68 bodies, diff/seal parsing, replay-count parsing, fixture-repo verified/mismatch/no_claims end-to-end) 11/11 pass; local full `npm test` 475 tests / 474 pass / 0 fail / 1 skipped (pre-existing); PR CI runs ledger 42516967938 and verify 42516967984 both success.
20
+
21
+ Milestone M2 "`xflow audit` standalone mode" acceptance evidence (issue #78, comment 6726208145): audits of real historical PRs #75 / #71 / #68 all `verified` (targeted run 24 passed / 0 failed / 24 total plus seal `valid` on #75; full `npm test` exit 0 on all three; #68's claimed 439/0/440 honestly counts-unparsed — multi-file runner has no single total).
22
+
23
+ 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. Audit gaps carried forward: no PR from a different repo or a non-evalid agent audited yet; pytest / go test / cargo test replay paths are parser-level only.
7
24
 
8
25
  ## [0.5.14] - 2026-11-09
9
26
 
package/README.md CHANGED
@@ -210,6 +210,27 @@ This repo dogfoods the Action on its own PRs via
210
210
  added or changed by the PR (`specs/changes/*/tdd/*.json`) and verifies
211
211
  each one.
212
212
 
213
+ ## `xflow audit`: verify any PR, no evalid workflow required
214
+
215
+ `xflow audit` checks a change you did *not* produce with evalid: point it
216
+ at any git diff or PR — Claude Code, Codex, or any other agent's output —
217
+ and it extracts the test runs the change claims to have passed, replays
218
+ each one in a clean clone at the change's head, and compares exit codes
219
+ and pass/fail counts against the claim. If the diff carries an evalid
220
+ proof seal, the seal is replayed through the existing i6b validator.
221
+
222
+ ```bash
223
+ xflow audit --pr 75 # audit a GitHub PR (body = claims)
224
+ xflow audit --diff change.patch --base HEAD~1 --claims-file claims.txt
225
+ xflow audit --base main --head feature/x --claims "npm test: exit 0"
226
+ ```
227
+
228
+ Verdicts: `verified` (every claim replayed as claimed), `mismatch` (a
229
+ replay contradicts a claim, or a seal is invalid), `no_claims` (no test
230
+ claims found in the supplied text), `unverifiable` (a claimed command
231
+ could not run). Exit codes are 0 / 1 / 3 / 1 respectively (2 = misuse).
232
+ `--json` prints the machine-readable report; `--out <file>` writes it.
233
+
213
234
  ## Commands
214
235
 
215
236
  The installed binary is `xflow` (`npm install -g evalid` installs the `xflow` CLI). Every
@@ -221,6 +242,7 @@ top-level command below was verified against `xflow <command> --help` at v0.5.7;
221
242
  | `xflow workflow run \| validate <yaml>` | Run a workflow (`yolo` / `corps` / a YAML path), or validate its YAML as preflight |
222
243
  | `xflow corps` | Run the heavy product/multi-agent workflow through its governed entry point |
223
244
  | `xflow proof` | Verify workflow completion evidence and write the proof file |
245
+ | `xflow audit` | Audit any git diff / PR: replay claimed test runs, verdict |
224
246
  | `xflow doctor` | Check local xflow readiness: package scripts, workflow YAMLs, skill topology |
225
247
  | `xflow init` | Create project-local `.as-xflow/config.json` with portable defaults |
226
248
  | `xflow goal show \| set \| audit \| clear` | Manage the project-level `.xflow/GOAL.md` anchor |
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,27 @@
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
+
15
+ ## 0.5.15 - xflow audit standalone mode (milestone M2 accepted)
16
+
17
+ Patch release. One new feature since 0.5.14, verified end to end in CI and locally.
18
+
19
+ **`xflow audit` standalone mode (#79).** Verify any git diff / PR without adopting evalid workflows: `xflow audit --pr <n>` extracts the test runs the change claims to have passed, replays each one in a clean clone at the change's head, and compares exit codes and pass/fail counts against the claim; diffs carrying an evalid proof seal are replayed through the existing i6b validator. Test evidence: 11 new tests in `test/xflow-audit.test.js` 11/11 pass; local full `npm test` 475 tests / 474 pass / 0 fail / 1 skipped (pre-existing); PR CI runs ledger 42516967938 and verify 42516967984 both success.
20
+
21
+ **Milestone M2 "`xflow audit` standalone mode" — accepted.** Evidence in issue #78, comment 6726208145: audits of real historical PRs #75 / #71 / #68 all `verified` — #75 targeted run 24 passed / 0 failed / 24 total plus seal `valid`, full `npm test` exit 0 on all three; #68's claimed 439/0/440 honestly counts-unparsed (the multi-file runner has no single total, so the verdict rests on exit code).
22
+
23
+ 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. Audit gaps carried forward: no PR from a different repo or a non-evalid agent audited yet; pytest / go test / cargo test replay paths are parser-level only.
24
+
3
25
  ## 0.5.14 - PR comment ledger (milestone M1 accepted)
4
26
 
5
27
  Patch release. One new feature since 0.5.13, verified end to end in CI and locally.
package/bin/xflow.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * workflow validate <yaml> Validate workflow YAML against schema
8
8
  * corps Run the heavy corps workflow through its governed entry
9
9
  * proof Verify workflow completion evidence and write proof
10
+ * audit Audit any git diff / PR: replay claimed test runs, verdict
10
11
  * doctor Check local xflow readiness
11
12
  * evaluate Show one-shot external evaluator brief
12
13
  * release status Show release-owner publish status
@@ -40,6 +41,7 @@ import { spawnSync } from 'child_process';
40
41
  import { createHash } from 'crypto';
41
42
  import { tmpdir } from 'os';
42
43
  import { ackPendingGate } from '../src/core/pending-gates.js';
44
+ import { runAudit, renderAuditReport } from '../src/core/audit.js';
43
45
  import { exportDomRectsToFile, resolveCliPath } from '../src/agent_team/atoms/visual/dom-rect-export.js';
44
46
  import { capturePageEvidence } from '../src/agent_team/atoms/visual/page-evidence-capture.js';
45
47
  import { computeImageDiffMetrics } from '../src/agent_team/atoms/visual/image-diff.js';
@@ -83,6 +85,9 @@ async function main() {
83
85
  case 'proof':
84
86
  await handleProof([subcommand, ...rest].filter(Boolean));
85
87
  break;
88
+ case 'audit':
89
+ await handleAudit([subcommand, ...rest].filter(Boolean));
90
+ break;
86
91
  case 'gate':
87
92
  await handleGate(subcommand, rest);
88
93
  break;
@@ -5742,6 +5747,77 @@ async function handleProof(args) {
5742
5747
  if (!proof.ok) process.exit(1);
5743
5748
  }
5744
5749
 
5750
+ // ─── audit ───────────────────────────────────────────────────────────────────
5751
+ // Standalone audit (M2, #78): verify any git diff / PR without adopting
5752
+ // evalid workflows. Claims are extracted from the PR body / claims text,
5753
+ // replayed in a clean clone at the PR head, and compared against the claim;
5754
+ // diffs carrying an evalid proof seal are replayed through the i6b validator
5755
+ // (see src/core/audit.js).
5756
+ async function handleAudit(args) {
5757
+ if (args.some(isHelpRequest)) {
5758
+ printAuditHelp();
5759
+ return;
5760
+ }
5761
+ const parsedArgs = parseCliArgs(args);
5762
+ const repoDir = resolve(parsedArgs.repo || process.cwd());
5763
+ const timeoutMs = parsedArgs.timeout ? Number(parsedArgs.timeout) * 1000 : 300000;
5764
+ let diffText = '';
5765
+ let claimsText = '';
5766
+ let baseRef = parsedArgs.base || null;
5767
+ let headRef = parsedArgs.head || null;
5768
+
5769
+ if (parsedArgs.pr) {
5770
+ const prNumber = String(parsedArgs.pr);
5771
+ const view = spawnSync('gh', ['pr', 'view', prNumber, '--json', 'body,baseRefOid,mergeCommit,headRefOid'], { cwd: repoDir, encoding: 'utf8' });
5772
+ if (view.status !== 0) {
5773
+ console.error(`xflow audit: gh pr view ${prNumber} failed: ${(view.stderr || view.stdout || '').trim().slice(-300)}`);
5774
+ process.exit(2);
5775
+ }
5776
+ let pr;
5777
+ try {
5778
+ pr = JSON.parse(view.stdout);
5779
+ } catch {
5780
+ console.error('xflow audit: could not parse gh pr view output');
5781
+ process.exit(2);
5782
+ }
5783
+ claimsText = pr.body || '';
5784
+ baseRef = pr.baseRefOid || null;
5785
+ headRef = pr.mergeCommit?.oid || pr.headRefOid || null;
5786
+ const diff = spawnSync('gh', ['pr', 'diff', prNumber], { cwd: repoDir, encoding: 'utf8' });
5787
+ if (diff.status !== 0) {
5788
+ console.error(`xflow audit: gh pr diff ${prNumber} failed: ${(diff.stderr || diff.stdout || '').trim().slice(-300)}`);
5789
+ process.exit(2);
5790
+ }
5791
+ diffText = diff.stdout || '';
5792
+ } else {
5793
+ if (parsedArgs.diff) {
5794
+ diffText = parsedArgs.diff === '-'
5795
+ ? readFileSync(0, 'utf8')
5796
+ : readFileSync(resolve(parsedArgs.diff), 'utf8');
5797
+ }
5798
+ if (parsedArgs.claims) claimsText = String(parsedArgs.claims);
5799
+ if (parsedArgs.claims_file) claimsText = readFileSync(resolve(parsedArgs.claims_file), 'utf8');
5800
+ if (!headRef && !baseRef) {
5801
+ console.error('Usage: xflow audit --pr <n> | --diff <patch> --base <ref> | --base <ref> --head <ref>');
5802
+ process.exit(2);
5803
+ }
5804
+ }
5805
+
5806
+ const report = runAudit({
5807
+ repoDir,
5808
+ diffText,
5809
+ baseRef,
5810
+ headRef,
5811
+ claimsText,
5812
+ timeoutMs,
5813
+ keepWorkdir: Boolean(parsedArgs.keep_workdir),
5814
+ });
5815
+ if (parsedArgs.out) writeFileSync(resolve(parsedArgs.out), `${JSON.stringify(report, null, 2)}\n`);
5816
+ if (parsedArgs.json) console.log(JSON.stringify(report, null, 2));
5817
+ else console.log(renderAuditReport(report));
5818
+ process.exit(report.exitCode);
5819
+ }
5820
+
5745
5821
  async function buildWorkflowProof({ track, workflowPath, projectRoot, changeId, write = true }) {
5746
5822
  const { load } = await import('../src/core/workflow-loader.js');
5747
5823
  const { readExecutionLog } = await import('../src/core/execution-log.js');
@@ -6654,6 +6730,37 @@ Options:
6654
6730
  `);
6655
6731
  }
6656
6732
 
6733
+ function printAuditHelp() {
6734
+ console.log(`
6735
+ Usage: xflow audit --pr <n> [--repo <path>] [--json] [--out <file>]
6736
+ xflow audit --diff <patch|-> --base <ref> [--claims-file <file>] [--json]
6737
+ xflow audit --base <ref> --head <ref> [--claims <text>] [--json]
6738
+
6739
+ Audit any git diff / PR without adopting evalid workflows: extract the test
6740
+ runs the change claims to have passed, replay each one in a clean clone at
6741
+ the change's head, and compare exit codes and pass/fail counts against the
6742
+ claim. Diffs carrying an evalid proof seal are replayed through the existing
6743
+ i6b seal validator. No seal semantics, CLI name, or bin path changes.
6744
+
6745
+ Verdicts / exit codes: verified 0, mismatch 1, unverifiable 1, misuse 2,
6746
+ no_claims 3 (no test claims found in the supplied text).
6747
+
6748
+ Options:
6749
+ --pr N Audit a GitHub PR (body = claims, merge commit = head)
6750
+ --diff FILE Unified diff to apply on top of --base ('-' reads stdin)
6751
+ --base REF Base commit for --diff, or diff base for --head
6752
+ --head REF Head commit to check out directly (diff not applied)
6753
+ --repo PATH Local repo to clone from, default: current directory
6754
+ --claims TEXT Claims text (default: PR body for --pr)
6755
+ --claims-file FILE Read claims text from a file
6756
+ --timeout SEC Per-command replay timeout, default: 300
6757
+ --json Print the machine-readable report
6758
+ --out FILE Write the JSON report to a file
6759
+ --keep-workdir Keep the scratch clone for debugging
6760
+ help, -h, --help Show this help message
6761
+ `);
6762
+ }
6763
+
6657
6764
  function printProofHelp() {
6658
6765
  console.log(`
6659
6766
  Usage: xflow proof [--track corps] --change-id <id> [--project-root <path>]
@@ -7148,6 +7255,7 @@ Usage:
7148
7255
  xflow workflow validate <yaml> Validate workflow YAML
7149
7256
  xflow corps --title <title> Run heavy track through governed entry
7150
7257
  xflow proof --track corps Verify final execution proof
7258
+ xflow audit --pr 75 [--json] Audit a PR: replay its claimed test runs
7151
7259
  xflow doctor [--json] Check local xflow readiness
7152
7260
  xflow init [--project-root .] Create project-local xflow config
7153
7261
  xflow goal set "<goal>" Write the project-level .xflow/GOAL.md anchor
@@ -1,6 +1,6 @@
1
1
  # evalid Launch Dossier
2
2
 
3
- Generated: 2026-11-09T01:50:00.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.14
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.14",
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,497 @@
1
+ /**
2
+ * xflow audit — standalone audit of any git diff / PR, without adopting
3
+ * evalid workflows.
4
+ *
5
+ * An audit answers one question: do the test runs this change *claims* to
6
+ * have passed actually pass when replayed from a clean copy at the change's
7
+ * head? It extracts claimed test commands from the PR body / claims text,
8
+ * replays each command in a scratch clone (base commit + diff applied, or
9
+ * the PR's merge commit checked out directly), and compares exit codes and
10
+ * pass/fail counts against the claim. Diffs that carry an evalid proof seal
11
+ * (specs/changes/<id>/tdd/<phase>-<attempt>.json) are additionally replayed
12
+ * through the existing i6b seal validator — the seal semantics are reused,
13
+ * not rebuilt.
14
+ *
15
+ * Verdicts:
16
+ * verified — every claim replayed as claimed (and every seal valid)
17
+ * mismatch — a replay contradicts a claim, or a seal is invalid
18
+ * no_claims — no test claims found in the supplied text
19
+ * unverifiable — a claimed command could not run (missing runner, timeout)
20
+ *
21
+ * Exit codes mirror the verdicts: 0 verified, 1 mismatch/unverifiable,
22
+ * 2 misuse, 3 no_claims (inconclusive, not a failure of the change).
23
+ */
24
+ import { spawnSync } from 'node:child_process';
25
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
26
+ import { tmpdir } from 'node:os';
27
+ import { join, resolve } from 'node:path';
28
+
29
+ /** Commands that count as claimed test runs. */
30
+ export const TEST_COMMAND_PATTERNS = [
31
+ /npm\s+test\b/,
32
+ /npm\s+run\s+[\w:.-]+/,
33
+ /pnpm\s+test\b/,
34
+ /yarn\s+test\b/,
35
+ /node\s+--test\b/,
36
+ /pytest\b/,
37
+ /python3?\s+-m\s+pytest\b/,
38
+ /go\s+test\b/,
39
+ /cargo\s+test\b/,
40
+ /mvn\s+(?:-[^ ]+\s+)*test\b/,
41
+ /npx\s+[\w@./-]+/,
42
+ ];
43
+
44
+ const COMMAND_CHARS = String.raw`[\w@./:+\-='"]`;
45
+ const COMMAND_TAIL = new RegExp(
46
+ String.raw`(?:npm\s+test|npm\s+run\s+[\w:.-]+|pnpm\s+test|yarn\s+test|node\s+--test(?:\s+${COMMAND_CHARS}+)*|pytest(?:\s+${COMMAND_CHARS}+)*|python3?\s+-m\s+pytest(?:\s+${COMMAND_CHARS}+)*|go\s+test(?:\s+${COMMAND_CHARS}+)*|cargo\s+test(?:\s+${COMMAND_CHARS}+)*|mvn\s+(?:-[^\s]+\s+)*test|npx\s+[\w@./-]+(?:\s+${COMMAND_CHARS}+)*)`,
47
+ );
48
+
49
+ export function isTestCommand(text) {
50
+ return TEST_COMMAND_PATTERNS.some((pattern) => pattern.test(text));
51
+ }
52
+
53
+ export function isTestFile(path) {
54
+ return /(^|\/)test(s)?\/|\.test\.[cm]?[jt]sx?$|_test\.go$|^test_[^/]*\.py$|_test\.py$|\.spec\.[cm]?[jt]sx?$/.test(path);
55
+ }
56
+
57
+ function normalizeCommand(raw) {
58
+ return String(raw || '').replace(/\s+/g, ' ').trim();
59
+ }
60
+
61
+ /**
62
+ * Pull pass/fail/total counts out of one line of claim text.
63
+ * Handles English ("18 tests, all pass", "6/6 pass", "439 pass, 0 fail")
64
+ * and the Chinese phrasing used in evalid PR bodies ("440 项、439 通过、0 失败").
65
+ */
66
+ export function parseClaimCounts(line) {
67
+ const text = String(line || '');
68
+ const counts = {};
69
+ let m = text.match(/(\d+)\s*\/\s*(\d+)\s*(?:pass|passed|通过|✔|✅)?/);
70
+ if (m) {
71
+ counts.passed = Number(m[1]);
72
+ counts.total = Number(m[2]);
73
+ counts.failed = counts.total - counts.passed;
74
+ return counts;
75
+ }
76
+ m = text.match(/(\d+)\s*(?:tests?|items?|项)[^0-9]{0,20}?(?:all\s+pass|pass|passed|通过)/i);
77
+ if (m) counts.passed = Number(m[1]);
78
+ // No \b after 通过/失败/项: \b is an ASCII word boundary and never
79
+ // fires after a CJK character, so "439 通过" would silently not match.
80
+ m = text.match(/(\d+)\s*(?:pass|passed|通过)/i);
81
+ if (m && counts.passed === undefined) counts.passed = Number(m[1]);
82
+ // "18 tests (long parenthetical ...) — all pass": the count and the
83
+ // verdict may be far apart, so an explicit all-pass fallback reads the
84
+ // first test/item count on the line as the passed count.
85
+ if (/all\s+pass(?:ing|es)?\b/i.test(text) && counts.passed === undefined) {
86
+ const n = text.match(/(\d+)\s*(?:tests?|items?|项)/);
87
+ if (n) counts.passed = Number(n[1]);
88
+ }
89
+ m = text.match(/(\d+)\s*(?:fail|failed|failures?|失败)/i);
90
+ if (m) counts.failed = Number(m[1]);
91
+ if (/all\s+pass(?:ing|es)?\b/i.test(text) || /全部通过/.test(text)) {
92
+ if (counts.failed === undefined) counts.failed = 0;
93
+ }
94
+ m = text.match(/(\d+)\s*(?:tests?|items?|项)/);
95
+ if (m && counts.total === undefined) counts.total = Number(m[1]);
96
+ return counts;
97
+ }
98
+
99
+ function parseClaimExit(line) {
100
+ const m = String(line || '').match(/exit(?:\s+code)?\s+(\d+)/i);
101
+ return m ? Number(m[1]) : null;
102
+ }
103
+
104
+ /**
105
+ * Extract claimed test runs from free text (a PR body, commit message, or
106
+ * a --claims file). Returns one claim per distinct command, with any
107
+ * counts/exit found on the same line or in the same paragraph attached.
108
+ * A line that names a test file plus counts ("18 tests ... all pass") is
109
+ * attached to the command claim that runs that file.
110
+ */
111
+ export function extractClaims(text) {
112
+ const lines = String(text || '').split('\n');
113
+ const claims = [];
114
+ const byCommand = new Map();
115
+
116
+ function upsert(command, source, counts, exitCode) {
117
+ const key = normalizeCommand(command);
118
+ if (!key) return null;
119
+ let claim = byCommand.get(key);
120
+ if (!claim) {
121
+ claim = { command: key, claimedExit: null, claimedPassed: null, claimedFailed: null, claimedTotal: null, source };
122
+ byCommand.set(key, claim);
123
+ claims.push(claim);
124
+ }
125
+ if (counts) {
126
+ if (counts.passed !== undefined && claim.claimedPassed === null) claim.claimedPassed = counts.passed;
127
+ if (counts.failed !== undefined && claim.claimedFailed === null) claim.claimedFailed = counts.failed;
128
+ if (counts.total !== undefined && claim.claimedTotal === null) claim.claimedTotal = counts.total;
129
+ }
130
+ if (exitCode !== null && exitCode !== undefined && claim.claimedExit === null) claim.claimedExit = exitCode;
131
+ return claim;
132
+ }
133
+
134
+ // Counts/exit attach line-by-line, and within a line only within the
135
+ // same clause: one line may state two claims ("...passed 6/6 after.
136
+ // Full `npm test` passes (exit 0...)") and the first file's counts must
137
+ // not leak onto the second command. Clauses split on ". " / "; ".
138
+ // (File paths contain dots but never ". ", so this does not split them.)
139
+ for (const line of lines) {
140
+ const clauses = line.split(/(?:[.;]\s+|\s+;\s*)/);
141
+ for (const clause of clauses) {
142
+ const commands = new Set();
143
+ const spans = [...clause.matchAll(/`([^`]+)`/g)].map((span) => span[1]);
144
+ for (const span of spans) {
145
+ const found = span.match(COMMAND_TAIL);
146
+ if (found) commands.add(normalizeCommand(found[0]));
147
+ }
148
+ const bare = clause.match(COMMAND_TAIL);
149
+ if (bare) commands.add(normalizeCommand(bare[0]));
150
+ if (commands.size === 0) continue;
151
+ const counts = parseClaimCounts(clause);
152
+ const exitCode = parseClaimExit(clause);
153
+ const hasCounts = Object.keys(counts).length > 0;
154
+ for (const command of commands) {
155
+ upsert(command, clause.trim().slice(0, 200), hasCounts ? counts : null, exitCode);
156
+ }
157
+ }
158
+ }
159
+
160
+ // File-scoped counts: "test/foo.test.js: 18 tests, all pass" attaches to
161
+ // the command claim whose argv names that file. A command that runs
162
+ // several files sums their per-file counts (18 + 6 = 24 for the combined
163
+ // run), and only fills claims that have no same-line counts.
164
+ const fileSums = new Map();
165
+ for (const line of lines) {
166
+ const fileMatch = line.match(/([^\s`'"]+\.test\.[cm]?[jt]sx?|[^\s`'"]+_test\.go|[^\s`'"]+test_[^/\s`'"]*\.py)/);
167
+ if (!fileMatch) continue;
168
+ const counts = parseClaimCounts(line);
169
+ if (Object.keys(counts).length === 0) continue;
170
+ for (const claim of claims) {
171
+ if (!claim.command.includes(fileMatch[1])) continue;
172
+ const sum = fileSums.get(claim.command) || { passed: 0, failed: 0, total: 0, hasPassed: false, hasFailed: false, hasTotal: false };
173
+ if (counts.passed !== undefined) { sum.passed += counts.passed; sum.hasPassed = true; }
174
+ if (counts.failed !== undefined) { sum.failed += counts.failed; sum.hasFailed = true; }
175
+ if (counts.total !== undefined) { sum.total += counts.total; sum.hasTotal = true; }
176
+ fileSums.set(claim.command, sum);
177
+ }
178
+ }
179
+ for (const claim of claims) {
180
+ const sum = fileSums.get(claim.command);
181
+ if (!sum) continue;
182
+ if (claim.claimedPassed === null && sum.hasPassed) claim.claimedPassed = sum.passed;
183
+ if (claim.claimedFailed === null && sum.hasFailed) claim.claimedFailed = sum.failed;
184
+ if (claim.claimedTotal === null && sum.hasTotal) claim.claimedTotal = sum.total;
185
+ }
186
+
187
+ // A claim that says tests pass but states no exit code is a claim of exit 0.
188
+ for (const claim of claims) {
189
+ if (claim.claimedExit === null && (claim.claimedPassed !== null || claim.claimedFailed === 0)) {
190
+ claim.claimedExit = 0;
191
+ }
192
+ }
193
+ return claims;
194
+ }
195
+
196
+ /** Parse a unified diff: touched files, touched test files, added evalid seals. */
197
+ export function parseDiffFiles(diffText) {
198
+ const files = [];
199
+ const seals = [];
200
+ for (const line of String(diffText || '').split('\n')) {
201
+ const m = line.match(/^\+\+\+ b\/(.+)$/) || line.match(/^\+\+\+ (.+)$/);
202
+ if (!m || m[1] === '/dev/null') continue;
203
+ const path = m[1].trim();
204
+ files.push(path);
205
+ const seal = path.match(/^specs\/changes\/([^/]+)\/tdd\/([a-z]+)-(\d+)\.json$/);
206
+ if (seal) seals.push({ changeId: seal[1], phase: seal[2], attempt: Number(seal[3]), path });
207
+ }
208
+ return { files, testFiles: files.filter(isTestFile), seals };
209
+ }
210
+
211
+ /**
212
+ * Parse pass/fail counts out of a test runner's output.
213
+ * node --test ("# pass 18", "# fail 0"), pytest ("18 passed, 0 failed"),
214
+ * mocha ("18 passing"), npm-wrapped node --test (same "# pass" lines).
215
+ */
216
+ export function parseReplayCounts(output, command) {
217
+ const text = String(output || '');
218
+ // node --test prints one summary block per invocation, but multi-file
219
+ // runners (npm test -> run-tests.mjs, one node --test per file) print one
220
+ // per file. A single summary is a comparable count; several are not a
221
+ // total, so report unparsed and let the caller compare on exit code only.
222
+ const nodePasses = [...text.matchAll(/(?:#|ℹ)\s*pass\s+(\d+)/g)];
223
+ const nodeFails = [...text.matchAll(/(?:#|ℹ)\s*fail\s+(\d+)/g)];
224
+ if (nodePasses.length === 1 && nodeFails.length <= 1) {
225
+ const nodePass = Number(nodePasses[0][1]);
226
+ const nodeFail = nodeFails.length === 1 ? Number(nodeFails[0][1]) : null;
227
+ return { passed: nodePass, failed: nodeFail, total: nodeFail !== null ? nodePass + nodeFail : null };
228
+ }
229
+ if (nodePasses.length > 1 || nodeFails.length > 1) return null;
230
+ let m = text.match(/(\d+)\s+passed?(?:,\s*(\d+)\s+failed?)?/);
231
+ if (m) {
232
+ return { passed: Number(m[1]), failed: m[2] !== undefined ? Number(m[2]) : null, total: null };
233
+ }
234
+ m = text.match(/(\d+)\s+passing(?:,\s*(\d+)\s+failing)?/);
235
+ if (m) {
236
+ return { passed: Number(m[1]), failed: m[2] !== undefined ? Number(m[2]) : null, total: null };
237
+ }
238
+ if (/test[s]?\s+passed/i.test(text) && /0\s+failed/i.test(text)) return { passed: null, failed: 0, total: null };
239
+ return null;
240
+ }
241
+
242
+ function splitCommand(command) {
243
+ const parts = [];
244
+ let current = '';
245
+ let quote = null;
246
+ for (const char of String(command)) {
247
+ if (quote) {
248
+ if (char === quote) quote = null;
249
+ else current += char;
250
+ } else if (char === '"' || char === "'") {
251
+ quote = char;
252
+ } else if (/\s/.test(char)) {
253
+ if (current) { parts.push(current); current = ''; }
254
+ } else {
255
+ current += char;
256
+ }
257
+ }
258
+ if (current) parts.push(current);
259
+ return parts;
260
+ }
261
+
262
+ function runShell(cwd, argv, timeoutMs) {
263
+ const started = Date.now();
264
+ // A replay must run in a clean test-runner context: when audit itself runs
265
+ // under `node --test` (unit tests, or CI invoking it from a test file),
266
+ // NODE_TEST_CONTEXT / NODE_TEST_WORKER_ID leak into the child and make
267
+ // `node --test` refuse to run files ("called recursively within a test
268
+ // file", exit 0, zero tests). Strip them so the replay really runs.
269
+ const env = { ...process.env };
270
+ delete env.NODE_TEST_CONTEXT;
271
+ delete env.NODE_TEST_WORKER_ID;
272
+ const result = spawnSync(argv[0], argv.slice(1), { cwd, encoding: 'utf8', timeout: timeoutMs, env });
273
+ const output = `${result.stdout || ''}${result.stderr || ''}`;
274
+ return {
275
+ exit: result.status === null ? null : result.status,
276
+ timedOut: result.status === null && result.error && /timed out/i.test(String(result.error.message || '')),
277
+ spawnError: result.error && result.status === null ? String(result.error.message || result.error) : null,
278
+ output,
279
+ outputTail: output.split('\n').slice(-40).join('\n'),
280
+ durationMs: Date.now() - started,
281
+ };
282
+ }
283
+
284
+ function replaySeal(workdir, repoRoot, seal, python, timeoutMs) {
285
+ const validator = resolve(repoRoot, 'xflow', 'atoms', 'i6b_tdd_proof_validate.py');
286
+ if (!existsSync(validator)) {
287
+ return { ...seal, ok: false, verdict: 'validator_missing', error: `validator not found: ${validator}` };
288
+ }
289
+ const result = spawnSync(python, [
290
+ validator,
291
+ '--project-root', workdir,
292
+ '--change-id', seal.changeId,
293
+ '--phase', seal.phase,
294
+ '--attempt', String(seal.attempt),
295
+ ], { cwd: workdir, encoding: 'utf8', timeout: timeoutMs });
296
+ const lines = String(result.stdout || '').split('\n').reverse();
297
+ let payload = null;
298
+ for (const line of lines) {
299
+ const trimmed = line.trim();
300
+ if (!trimmed.startsWith('{')) continue;
301
+ try {
302
+ const parsed = JSON.parse(trimmed);
303
+ if (parsed && typeof parsed.verdict === 'string') { payload = parsed; break; }
304
+ } catch { /* not the verdict line */ }
305
+ }
306
+ return {
307
+ ...seal,
308
+ ok: payload?.ok === true && result.status === 0,
309
+ verdict: payload?.verdict || 'unparseable_output',
310
+ exit: result.status,
311
+ stderr: String(result.stderr || '').slice(-500),
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Run a full audit. Options:
317
+ * repoDir — local repo to clone from (required)
318
+ * diffText — unified diff to apply on top of baseRef (or '' when headRef is checked out directly)
319
+ * baseRef — commit to check out before applying diffText
320
+ * headRef — commit to check out directly (diffText ignored; used for merged PRs)
321
+ * claimsText — PR body / claims text to extract claims from
322
+ * timeoutMs — per-command timeout (default 300_000)
323
+ * python — python binary for the i6b validator (default 'python3')
324
+ * keepWorkdir — keep the scratch clone for debugging (default false)
325
+ */
326
+ export function runAudit(options = {}) {
327
+ const {
328
+ repoDir,
329
+ diffText = '',
330
+ baseRef = null,
331
+ headRef = null,
332
+ claimsText = '',
333
+ timeoutMs = 300000,
334
+ python = process.env.PYTHON || 'python3',
335
+ keepWorkdir = false,
336
+ } = options;
337
+ if (!repoDir || !existsSync(repoDir)) {
338
+ return { ok: false, verdict: 'misuse', error: `repo not found: ${repoDir}`, exitCode: 2 };
339
+ }
340
+ if (!headRef && !baseRef) {
341
+ return { ok: false, verdict: 'misuse', error: 'one of --head or --base is required', exitCode: 2 };
342
+ }
343
+
344
+ const started = Date.now();
345
+ const claims = extractClaims(claimsText);
346
+ const diff = parseDiffFiles(diffText);
347
+ const workdir = mkdtempSync(join(tmpdir(), 'evalid-audit-'));
348
+ try {
349
+ // --no-hardlinks: /tmp may live on another filesystem than the repo,
350
+ // and a hardlinked local clone then fails with "Invalid cross-device link".
351
+ const clone = runShell(tmpdir(), ['git', 'clone', '--quiet', '--no-hardlinks', '--local', resolve(repoDir), workdir], 120000);
352
+ if (clone.exit !== 0) {
353
+ return { ok: false, verdict: 'unverifiable', error: `git clone failed: ${clone.outputTail.slice(-300)}`, claims, diff, exitCode: 1 };
354
+ }
355
+ const checkoutRef = headRef || baseRef;
356
+ const checkout = runShell(workdir, ['git', 'checkout', '--quiet', checkoutRef], 60000);
357
+ if (checkout.exit !== 0) {
358
+ return { ok: false, verdict: 'unverifiable', error: `git checkout ${checkoutRef} failed: ${checkout.outputTail.slice(-300)}`, claims, diff, exitCode: 1 };
359
+ }
360
+ if (diffText && !headRef) {
361
+ const diffFile = join(workdir, '.audit.diff');
362
+ writeFileSync(diffFile, diffText);
363
+ const apply = runShell(workdir, ['git', 'apply', '--whitespace=nowarn', diffFile], 60000);
364
+ rmSync(diffFile, { force: true });
365
+ if (apply.exit !== 0) {
366
+ return { ok: false, verdict: 'unverifiable', error: `git apply failed: ${apply.outputTail.slice(-300)}`, claims, diff, exitCode: 1 };
367
+ }
368
+ }
369
+
370
+ // Replay setup: a fresh clone has no node_modules, so a claimed command
371
+ // that imports a dependency would fail for environmental reasons, not
372
+ // because the change is broken. Install deps when package.json exists
373
+ // and node_modules is absent (npm ci with a lockfile, npm install
374
+ // without one); fall back to copying the source repo's node_modules.
375
+ // Skipped entirely when there is nothing to replay (no claims, no seals).
376
+ let setup = { deps: 'none' };
377
+ if ((claims.length > 0 || diff.seals.length > 0)
378
+ && existsSync(join(workdir, 'package.json')) && !existsSync(join(workdir, 'node_modules'))) {
379
+ const hasLock = existsSync(join(workdir, 'package-lock.json'));
380
+ const installArgs = hasLock
381
+ ? ['npm', 'ci', '--no-audit', '--no-fund']
382
+ : ['npm', 'install', '--no-audit', '--no-fund'];
383
+ const install = runShell(workdir, installArgs, 300000);
384
+ if (install.exit === 0) {
385
+ setup = { deps: hasLock ? 'npm ci' : 'npm install' };
386
+ } else if (existsSync(join(resolve(repoDir), 'node_modules'))) {
387
+ const copy = runShell(tmpdir(), ['cp', '-r', join(resolve(repoDir), 'node_modules'), join(workdir, 'node_modules')], 120000);
388
+ setup = copy.exit === 0
389
+ ? { deps: 'copied from source repo node_modules' }
390
+ : { deps: 'none', error: `${installArgs[1]} failed and node_modules copy failed` };
391
+ } else {
392
+ setup = { deps: 'none', error: `${installArgs[1]} failed: ${install.outputTail.slice(-300)}` };
393
+ }
394
+ }
395
+ if (setup.error) {
396
+ return { ok: false, verdict: 'unverifiable', error: `replay setup failed (${setup.error})`, claims, diff, setup, exitCode: 1 };
397
+ }
398
+
399
+ const claimResults = [];
400
+ let mismatch = null;
401
+ for (const claim of claims) {
402
+ const argv = splitCommand(claim.command);
403
+ if (argv.length === 0) {
404
+ claimResults.push({ ...claim, replay: null, match: false, note: 'empty command' });
405
+ mismatch = mismatch || 'empty command';
406
+ continue;
407
+ }
408
+ const replay = runShell(workdir, argv, timeoutMs);
409
+ const counts = parseReplayCounts(replay.output, claim.command);
410
+ const expectedExit = claim.claimedExit ?? 0;
411
+ let match = replay.exit === expectedExit && !replay.timedOut && !replay.spawnError;
412
+ let note = null;
413
+ if (match && counts) {
414
+ if (claim.claimedPassed !== null && counts.passed !== null && counts.passed !== claim.claimedPassed) {
415
+ match = false;
416
+ note = `passed count: claimed ${claim.claimedPassed}, replayed ${counts.passed}`;
417
+ }
418
+ if (match && claim.claimedFailed !== null && counts.failed !== null && counts.failed !== claim.claimedFailed) {
419
+ match = false;
420
+ note = `failed count: claimed ${claim.claimedFailed}, replayed ${counts.failed}`;
421
+ }
422
+ } else if (match && !counts && (claim.claimedPassed !== null || claim.claimedFailed !== null)) {
423
+ note = 'counts_unparsed: exit matches, counts could not be parsed from replay output';
424
+ }
425
+ if (replay.timedOut) note = `replay timed out after ${timeoutMs}ms`;
426
+ if (replay.spawnError) note = `could not run claimed command: ${replay.spawnError}`;
427
+ if (!match && !note) note = `exit: claimed ${expectedExit}, replayed ${replay.exit}`;
428
+ claimResults.push({ ...claim, replay: { ...replay, counts }, match, note });
429
+ if (!match) mismatch = mismatch || note || 'claim mismatch';
430
+ }
431
+
432
+ const sealResults = diff.seals.map((seal) => replaySeal(workdir, resolve(repoDir), seal, python, timeoutMs));
433
+ const badSeal = sealResults.find((seal) => !seal.ok);
434
+
435
+ let verdict;
436
+ let exitCode;
437
+ if (claims.length === 0 && sealResults.length === 0) {
438
+ verdict = 'no_claims';
439
+ exitCode = 3;
440
+ } else if (mismatch || badSeal) {
441
+ verdict = 'mismatch';
442
+ exitCode = 1;
443
+ } else if (claimResults.some((entry) => entry.replay && (entry.replay.timedOut || entry.replay.spawnError))) {
444
+ verdict = 'unverifiable';
445
+ exitCode = 1;
446
+ } else {
447
+ verdict = 'verified';
448
+ exitCode = 0;
449
+ }
450
+
451
+ return {
452
+ ok: verdict === 'verified',
453
+ verdict,
454
+ exitCode,
455
+ claims: claimResults,
456
+ seals: sealResults,
457
+ files: diff.files,
458
+ testFiles: diff.testFiles,
459
+ setup,
460
+ workdir: keepWorkdir ? workdir : null,
461
+ durationMs: Date.now() - started,
462
+ };
463
+ } finally {
464
+ if (!keepWorkdir) rmSync(workdir, { recursive: true, force: true });
465
+ }
466
+ }
467
+
468
+ export function auditExitCode(verdict) {
469
+ return { verified: 0, mismatch: 1, unverifiable: 1, misuse: 2, no_claims: 3 }[verdict] ?? 1;
470
+ }
471
+
472
+ export function renderAuditReport(report) {
473
+ const lines = [`xflow audit: ${report.verdict}`];
474
+ if (report.error) lines.push(`error: ${report.error}`);
475
+ if (report.setup?.deps && report.setup.deps !== 'none') lines.push(`replay deps: ${report.setup.deps}`);
476
+ if (report.files) {
477
+ lines.push(`files touched: ${report.files.length} (${report.testFiles?.length ?? 0} test files)`);
478
+ for (const file of report.testFiles || []) lines.push(` test: ${file}`);
479
+ }
480
+ for (const claim of report.claims || []) {
481
+ const replay = claim.replay;
482
+ const replayText = replay
483
+ ? `replay exit ${replay.exit}${replay.counts && replay.counts.passed !== null ? `, pass ${replay.counts.passed}` : ''}${replay.counts && replay.counts.failed !== null ? `, fail ${replay.counts.failed}` : ''}`
484
+ : 'not replayed';
485
+ const claimedText = [
486
+ claim.claimedExit !== null ? `exit ${claim.claimedExit}` : null,
487
+ claim.claimedPassed !== null ? `${claim.claimedPassed} passed` : null,
488
+ claim.claimedFailed !== null ? `${claim.claimedFailed} failed` : null,
489
+ ].filter(Boolean).join(', ') || 'no counts stated';
490
+ lines.push(`- ${claim.match ? '✓' : '✗'} ${claim.command} (claimed ${claimedText}; ${replayText})${claim.note ? ` — ${claim.note}` : ''}`);
491
+ }
492
+ for (const seal of report.seals || []) {
493
+ lines.push(`- ${seal.ok ? '✓' : '✗'} seal ${seal.changeId}/${seal.phase}/${seal.attempt}: ${seal.verdict}`);
494
+ }
495
+ if (report.durationMs !== undefined) lines.push(`duration: ${(report.durationMs / 1000).toFixed(1)}s`);
496
+ return lines.join('\n');
497
+ }
@@ -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())