@surlymochan/evalid 0.5.14 → 0.5.15
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 +8 -1
- package/README.md +22 -0
- package/RELEASE_NOTES.md +10 -0
- package/bin/xflow.js +108 -0
- package/docs/launch-dossier.md +2 -2
- package/package.json +1 -1
- package/src/core/audit.js +497 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,7 +3,14 @@
|
|
|
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
|
-
## [
|
|
6
|
+
## [0.5.15] - 2026-11-09
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **`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.
|
|
10
|
+
|
|
11
|
+
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).
|
|
12
|
+
|
|
13
|
+
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
14
|
|
|
8
15
|
## [0.5.14] - 2026-11-09
|
|
9
16
|
|
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,15 @@
|
|
|
1
1
|
# Release Notes
|
|
2
2
|
|
|
3
|
+
## 0.5.15 - xflow audit standalone mode (milestone M2 accepted)
|
|
4
|
+
|
|
5
|
+
Patch release. One new feature since 0.5.14, verified end to end in CI and locally.
|
|
6
|
+
|
|
7
|
+
**`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.
|
|
8
|
+
|
|
9
|
+
**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).
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
3
13
|
## 0.5.14 - PR comment ledger (milestone M1 accepted)
|
|
4
14
|
|
|
5
15
|
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
|
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-08T19:55:19.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.15
|
|
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.15",
|
|
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
|
+
}
|