mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* feat(verify-pr): add local Docker recipe with a host-side model proxy A local /verify-pr round needs the same isolation the CI lane provides, but the skill only documented the boundary check, not how to run the model credential through it. Add scripts/verify-pr-model-proxy.mjs, a host-side relay that holds the real key and lets the container call the model with a per-run bearer token, mirroring the CI lane's inline proxy (POST /chat/completions only, nonce health check, per-chunk idle watchdog). The listen address is a flag so the proxy can bind the Docker bridge, and readiness is printed as one JSON line. The SKILL recipe pairs it with a docker run template: the token travels as QWEN_VERIFY_PROXY_TOKEN (not OPENAI_API_KEY, which the isolation gate refuses), and the container's provider entry reads it via envKey. The CI lane keeps its inline copy — the proxy holds the real key, so it must not execute a script from the PR-head checkout. Verified: 11 new pin tests plus all existing verify-pr suites (120 passed), and a real docker smoke — the gate passes inside node:22-bookworm with the proxy token, refuses OPENAI_API_KEY with exit 4, and a container POST reaches a mock upstream with the real key injected and the /v1 prefix preserved. * fix(verify-pr): harden the model proxy after adversarial audit Audit round 1 found the relay forwarded client hop-by-hop headers (connection, transfer-encoding, expect, proxy-authorization, ...) to the upstream, which RFC 7230 forbids and which fights undici's own framing for the relayed stream. Strip them, with a chunked-body test that pins the behaviour. Round 1 also found the CLI entry had no coverage: add tests for the missing-config exit code and the printed JSON coordinates (token override, base-path prefix, no real-key leak, health nonce), and document that the printed baseUrl uses the listen address, so binding 0.0.0.0 yields a URL clients cannot dial. Rounds 2 and 3 (open-ended re-read plus adversarial passes over the updated diff) found nothing further. All verify-pr suites: 123 passed. * fix(verify-pr): address review findings in the model proxy and checker Round-1 review (12 inline findings at70aed891bf). Proxy: abort the upstream when the client disconnects before headers instead of paying for a completion nobody reads (R1-1); make the idle watchdog pause-aware, re-arm on drain, and match the client's own 240 s stream tolerance (R1-3); bracket IPv6 listen hosts via an exported formatter and normalize base paths once (R1-12). Checker: add --context so a host-held snapshot pins the expected head instead of the writable artifact copy, and refuse an unusable snapshot rather than silently falling back (R1-9). Each guard is pinned by a test that was verified to go red when the guard is removed (six mutations: pre-header abort, pause guard, per-chunk re-arm, body error handler, IPv6 bracket, --context). * docs(verify-pr): harden the local container recipe and record the design Recipe: run the key-holding proxy and the checker from a trusted checkout, never the PR tree (R1-6, R1-9); background the proxy with a pid file and stop it at the end (R1-4); read the key instead of inlining it (R1-5); use a named detached container driven by docker exec (R1-7); require a standalone clone, the host uid and a fresh HOME (R1-8); mount the full host-held metadata snapshot rather than the four-key artifact copy (R1-10); and attest only inside, after rejecting credential-shaped ignored files (R1-11). Add the bilingual design record (R1-2), cross-link it from the local-round design, and pin the recipe's trust lines in tests so reverting them turns the suite red. * fix(verify-pr): address round-2 verification findings Round 2 at3509c2d1aareported one Low and four Suggestions. - Relay the exact <basePath>/chat/completions path. A suffix match also forwarded /admin/chat/completions to the upstream. - Pin the response content-encoding strip with a gzip upstream: undici hands the proxy decoded bytes, so forwarding the header makes the client gunzip plain text. - Reject credential-shaped ignored entries at any depth (a nested secrets/.env included) instead of only a root .env/.env.*. - Name the round's engine as the released CLI and offer the tree build for a round that must exercise the PR's own CLI. The CI inline copies keep their existing two-header strip: that lane is untouched by design and a change there needs its own workflow review. Tests: 141 passed. Three mutations verified red (exact path, gzip header, nested credential pathspec). The review-pr check at this PR is red because the bot lane exhausted ten retries on "429 rpm exceeded", not because of this diff. * fix(verify-pr): close round-3 trust-boundary findings Round 3 at3ccf38697dreported two Criticals against the recipe, one fix-induced Critical, and four Suggestions. - prepare, the credential scan, the isolation gate and the checker now all come from <trusted-clone>; prepare runs with the PR clone as its cwd so the HEAD check still applies (R2-1). - The base commit's .qwen is extracted and mounted read-only over the tree, so the round's instructions and project settings cannot come from the PR (R2-2). - The attestation is passed to the shell that starts the round, because every docker exec is a fresh shell (R1-11, fix-induced). - The proxy token is exported from proxy.json and passed with a bare -e; the file is chmod 600 and removed at cleanup (R2-3). - Connection-nominated hop-by-hop headers are stripped (R2-6). - Recipe pins now enumerate every checker call site, require a non-empty docker-run slice, and seed the writable checker snapshot so a fallback regression is caught (R2-4, R2-5). - New verify-pr-credential-scan.mjs lists ignored files one by one, refuses credential-shaped names at any depth, and leaves node_modules and dist alone — replacing the pathspec check that refused every installed or built tree (round-3 Low). Tests: 148 passed; ten mutations verified red. * fix(verify-pr): address round-4 review findings Four P2 findings at441a523927: - The credential scan now limits git enumeration with credential pathspecs and depth-aware node_modules/dist excludes, so an installed tree's multi-megabyte ignored listing never overflows the output buffer. - Those excludes apply inside workspace packages too, so packages/*/dist and packages/*/node_modules are not mistaken for project credentials. - The recipe waits for proxy.json to carry a token and validates the exported value, so a truncated background write cannot reach Docker. - The pre-header disconnect test waits until the mock upstream has recorded the request before destroying the downstream socket, instead of racing the proxy's own (correct) early abort. Tests: 150 passed; three mutations verified red (enumeration pathspecs, excludes, readiness loop). * fix(verify-pr): refuse nested repositories in the credential scan Round-4 verification at19b14b1a8fleft three Suggestions. - A nested git repository is now refused instead of silently skipped: git does not descend into it, so a .env under tmp/pr-clone/ would pass. A vendored .git under node_modules/dist stays excluded like the files there. - isExcluded is removed: the pathspecs already exclude those directories, and the surviving JS filter was unpinnable. - The spaced Connection-nomination list is pinned; dropping trim made the proxy answer 502. - The node_modules/dist credential exclusions are documented as deliberate. Tests: 152 passed; two mutations verified red (nested refusal, trim). * fix(verify-pr): address round-5 review findings Four Criticals: - The key entry works without a TTY: an interactive shell prompts, an agent's shell reads REVIEW_OPENAI_KEY_FILE and fails with a named error. - prepare disables fsmonitor, hooks and credential helpers on the PR-controlled clone, and the scan refuses credential-bearing remote URLs. - The proxy token is re-derived from proxy.json in the docker run step, because each step is its own shell. - The credential scan covers .env~, *.p12, id_rsa*, gha-creds-*, and the other credential families the repo's own .gitignore names. Suggestions: - Guard call site moved to <trusted-clone>, <clone> defined for watch mode. - The newest verify comment is snapshotted as previous-report.md. - The real key is unset once the proxy owns it; the token is unset at cleanup. - `{1..100}` replaces seq for macOS; --user and the proxy token's non-credential status are pinned; the scan abort is structural (`|| exit 1`). - Step 6 wording matches the base-pin; the docs narrow the base-pin scope for root AGENTS.md/QWEN.md, list the real CI divergence, explain content-encoding in English, fix the zh token wording, and record the checker --context contract and the ownership attribution. - The readiness gate compares the health nonce; --port is removed. Test gaps closed: gzip header assertion, mid-body disconnect, reset event, PUT, bare-base exact path, positive header passthrough, 502 transport failure, full paused-stream equality, token shape, publish --context pin, checker second positional, scan path fixtures, headers-only idle window. Tests: 159 passed; twelve mutations verified red. * fix(verify-pr): close round-5 verification findings Two Lows reproduced atb5c717a5d5: - prepare's hardening named clean filters but only disabled fsmonitor, hooks and credential helpers. It now enumerates every local filter.*.clean/smudge/process and empties it with a -c override before any git read; a planted-filter fixture proves the override with a positive control that fires without it. - The nested-repository refusal over-refused installed trees whose node_modules holds a real vendored git repo (git reports the directory entry, which the exclude pathspec cannot match). The enumeration is now segment-filtered in JS, so vendored dependencies stay excluded while a nested project repository is still refused. Suggestions and nits: - The remote-URL guard reads url.*.insteadOf as well as remote.*.url and refuses any http(s) userinfo, including a token used as the username; ssh://git@ stays benign. - Public keys (.pub) are no longer treated as credentials, and a backup.env~ fixture pins the trailing-tilde family. Tests: 163 passed; six mutations verified red (filter overrides, vendored segment filter, userinfo breadth, insteadOf enumeration, public-key exclusion, backup.env~ pathspec). * fix(verify-pr): close round-6 verification findings Co-authored-by: Qwen-Coder <[email protected]> * fix(verify-pr): close round-7 verification findings Co-authored-by: Qwen-Coder <[email protected]> * fix(verify-pr): address round-8 review findings Co-authored-by: Qwen-Coder <[email protected]> * fix(verify-pr): close round-9 review findings Co-authored-by: Qwen-Coder <[email protected]> * fix(verify-pr): close round-10 review findings Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]>
537 lines
21 KiB
JavaScript
537 lines
21 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Applies the publisher's consistency rules to a `/verify-pr` round.
|
|
*
|
|
* The CI publish job runs this file from its trusted checkout and does not
|
|
* trust a verdict it refuses, so a `merge-ready` report carrying `fail: 7`
|
|
* degrades to "no usable structured verdict". A local round has no publisher,
|
|
* so the skill runs it before posting: the one step of that path a machine
|
|
* should own.
|
|
*
|
|
* Usage:
|
|
* node scripts/verify-pr-check.mjs <artifact-dir>
|
|
*
|
|
* Exit codes: 0 when no error was found (warnings may still print), 1 on any
|
|
* error, 2 on a usage error.
|
|
*/
|
|
|
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
import { isEntryPoint } from './lib/entry-point.js';
|
|
|
|
export const VERDICTS = ['merge-ready', 'findings', 'blocked', 'inconclusive'];
|
|
|
|
/**
|
|
* Who caused a failed assertion. `pr` is the only one that can stamp a defect
|
|
* on the PR: an `environment` red belongs in an expected-red assertion once an
|
|
* A/A control proves both arms fail, and a `harness` fault is `inconclusive`.
|
|
*/
|
|
export const ATTRIBUTIONS = ['pr', 'harness', 'environment'];
|
|
|
|
/**
|
|
* Above this many assertions, a round has almost certainly folded an existing
|
|
* test suite into its own counts. The four such rounds among 18 recent CI
|
|
* artifacts (3918 to 7737) each counted whole workspace suites; one counted
|
|
* them again for every mutant run, so 7737 held a few dozen checks of its own.
|
|
*/
|
|
const SUITE_SIZED_TOTAL = 1000;
|
|
|
|
const KEBAB_PNG = /^[a-z0-9]+(?:-[a-z0-9]+)*\.png$/;
|
|
const OID = /^[0-9a-f]{7,40}$/;
|
|
// GFM renders an ATX heading indented up to three spaces as a heading too
|
|
// (four spaces become a code block), so the indent is part of the shape,
|
|
// not a way around it.
|
|
const HEADING = /^ {0,3}#{1,6}\s/;
|
|
// A heading is a short title; past this it is a paragraph wearing a heading's
|
|
// font size. GitHub renders an H1 at 2em, so a long heading line is a wall of
|
|
// giant text however well-formed the Markdown is.
|
|
const HEADING_TITLE_MAX = 120;
|
|
const FOOTER =
|
|
/^_— .+ via Qwen Code \/verify-pr \(v[^;]+; skill [0-9a-f]{12}\)_$/;
|
|
|
|
const HELP = `Usage: node scripts/verify-pr-check.mjs [--context <snapshot>] <artifact-dir>
|
|
|
|
Checks a local round against the rules the CI publisher enforces:
|
|
|
|
- report.md, verdict.txt and assertions.json exist and carry content
|
|
- verdict.txt is exactly one of merge-ready | findings | blocked | inconclusive
|
|
- assertions.json holds non-negative integers, a positive total, and
|
|
total == pass + fail
|
|
- assertions.json gates, when present, lists { suite, pass, fail } entries
|
|
(skipped optional) with non-negative integer counts up to 2^53-1
|
|
- a merge-ready verdict carries no failed assertion
|
|
- a findings/blocked verdict whose failures ledger attributes no failure to
|
|
the PR is a contradiction: expected reds are passes
|
|
- a mutations.json claiming per-commit coverage covers every commit it lists,
|
|
and lists at least one commit (an empty set makes the claim vacuous)
|
|
|
|
and warns (without failing) when the report does not lead with its verdict and
|
|
head OID, when the verdict line is a heading (GitHub renders the whole
|
|
paragraph as one giant H1), when evidence/ carries no kebab-case PNG capture,
|
|
when a round with failures carries no failures ledger at all, when over
|
|
${SUITE_SIZED_TOTAL} assertions come with no gate results (an existing test suite
|
|
counted as the round's own), or when comment.md — the exact body a local round
|
|
posts, optional — repeats the heading shape or lacks the attribution footer.
|
|
|
|
With --context <snapshot>, the head-OID check reads that file instead of the
|
|
round's own context.json. Pass a copy the round never saw: the artifact
|
|
directory is writable by the PR code the round executed.
|
|
|
|
Exit codes: 0 no errors, 1 errors found, 2 usage.
|
|
`;
|
|
|
|
function readIfExists(path) {
|
|
return existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
}
|
|
|
|
/** OIDs are quoted at every length from 7 to 40; compare by prefix. */
|
|
function sameCommit(a, b) {
|
|
return a.startsWith(b) || b.startsWith(a);
|
|
}
|
|
|
|
/**
|
|
* A coverage claim is universally quantified over an enumerable set, so it has
|
|
* to be checked against that set rather than believed. A round wrote "every
|
|
* production commit since the previous round was mutated on its own" while two
|
|
* commits had no row; only a self-audit caught it.
|
|
*
|
|
* The enumeration comes from `git log --no-merges <previous>..<head> --not
|
|
* <target>` in the round, not from this script: a checker with no repository
|
|
* cannot derive it, so an incomplete list is the skill rule's job. What is
|
|
* machine-checkable is the claim against the list the round itself supplies —
|
|
* plus the one incomplete list that would otherwise pass every check, the
|
|
* empty one.
|
|
*/
|
|
export function checkMutationCoverage(text) {
|
|
const errors = [];
|
|
if (text === null || text === undefined || text.trim() === '') {
|
|
return { errors };
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(text);
|
|
} catch {
|
|
return { errors: ['mutations.json is not valid JSON'] };
|
|
}
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return { errors: ['mutations.json must be an object with rows'] };
|
|
}
|
|
if (parsed.coverageClaim !== 'per-commit') return { errors };
|
|
|
|
const commits = parsed.productionCommits;
|
|
const rows = parsed.rows;
|
|
if (!Array.isArray(commits) || !Array.isArray(rows)) {
|
|
errors.push(
|
|
"mutations.json claims per-commit coverage: it needs productionCommits[] from git log and rows[] carrying each row's commit",
|
|
);
|
|
return { errors };
|
|
}
|
|
if (!commits.every((oid) => typeof oid === 'string' && OID.test(oid))) {
|
|
errors.push(
|
|
'mutations.json productionCommits must be hex commit ids (7-40 characters)',
|
|
);
|
|
return { errors };
|
|
}
|
|
// An empty enumeration is the cheapest way to make the claim uncheckable:
|
|
// "every commit was mutated" is vacuously true over no commits at all.
|
|
if (commits.length === 0) {
|
|
errors.push(
|
|
'mutations.json claims per-commit coverage over an empty productionCommits list: a claim over an empty set is vacuously true, so enumerate the commits (git log --no-merges <previous>..<head> --not <target>) or drop the claim',
|
|
);
|
|
return { errors };
|
|
}
|
|
|
|
const mutated = rows
|
|
.map((row) => row?.commit)
|
|
.filter((oid) => typeof oid === 'string' && OID.test(oid));
|
|
const uncovered = commits.filter(
|
|
(oid) => !mutated.some((seen) => sameCommit(seen, oid)),
|
|
);
|
|
if (uncovered.length > 0) {
|
|
errors.push(
|
|
`mutations.json claims per-commit coverage but ${uncovered.length} of ${commits.length} production commits have no row: ${uncovered.join(', ')}`,
|
|
);
|
|
}
|
|
return { errors };
|
|
}
|
|
|
|
// GitHub renders CRLF and LF identically, so a Windows-authored artifact
|
|
// must not fail the line-anchored checks on a stray \r.
|
|
function normalizeNewlines(text) {
|
|
return text.replace(/\r\n?/g, '\n');
|
|
}
|
|
|
|
/**
|
|
* The verdict line is a plain paragraph. Three rounds of PR #15984 opened
|
|
* with `# PR … — **Verdict: …** — <the whole verdict paragraph>` on one line,
|
|
* which GitHub renders as a single giant H1 — structurally valid Markdown,
|
|
* visually a wall of bold text. A heading carrying the verdict, or any
|
|
* over-long heading in the lead, is that defect's shape.
|
|
*/
|
|
function leadShapeWarnings(text, verdict, label) {
|
|
const first = text.split('\n').find((line) => line.trim() !== '');
|
|
if (!first || !HEADING.test(first)) return [];
|
|
const holdsVerdict = verdict !== null && first.includes(verdict);
|
|
if (!holdsVerdict && first.length <= HEADING_TITLE_MAX) return [];
|
|
return [
|
|
`${label} opens with a heading${holdsVerdict ? ' carrying the verdict' : ` of ${first.length} characters`}: GitHub renders the whole line as one giant H1 — the verdict line is a plain paragraph (no leading #), and a heading is a short title on its own line`,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Pure check over already-read artifacts. `files` carries raw text or null;
|
|
* `evidence` is the PNG file names under evidence/.
|
|
*/
|
|
export function checkArtifacts({ files, evidence = [], expectedHead = null }) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
|
|
for (const name of ['report.md', 'verdict.txt', 'assertions.json']) {
|
|
const key = name.split('.')[0];
|
|
const text = files[key];
|
|
if (text === null || text === undefined) {
|
|
errors.push(`${name} is missing`);
|
|
} else if (text.trim() === '') {
|
|
errors.push(`${name} is empty`);
|
|
}
|
|
}
|
|
|
|
let verdict = null;
|
|
if (files.verdict && files.verdict.trim() !== '') {
|
|
verdict = files.verdict.trim();
|
|
if (!VERDICTS.includes(verdict)) {
|
|
errors.push(
|
|
`verdict.txt must be exactly one of ${VERDICTS.join(' | ')}; got ${JSON.stringify(verdict)}`,
|
|
);
|
|
verdict = null;
|
|
} else if (files.verdict.trimEnd() !== verdict) {
|
|
warnings.push('verdict.txt has leading whitespace; it must be one word');
|
|
}
|
|
}
|
|
|
|
let counts = null;
|
|
let attributions = null;
|
|
let ledgerPresent = false;
|
|
if (files.assertions && files.assertions.trim() !== '') {
|
|
let parsed;
|
|
let parsedOk = true;
|
|
try {
|
|
parsed = JSON.parse(files.assertions);
|
|
} catch {
|
|
parsedOk = false;
|
|
errors.push('assertions.json is not valid JSON');
|
|
}
|
|
// JSON.parse('null') succeeds, and the publisher's jq requires
|
|
// type == "object": without this branch a null or array artifact skips
|
|
// every count check and still reads as evidence.
|
|
if (
|
|
parsedOk &&
|
|
(parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
) {
|
|
errors.push(
|
|
'assertions.json must be an object with pass, fail and total',
|
|
);
|
|
} else if (parsedOk) {
|
|
const fields = ['pass', 'fail', 'total'];
|
|
const valid = fields.every(
|
|
(field) => Number.isInteger(parsed?.[field]) && parsed[field] >= 0,
|
|
);
|
|
if (!valid) {
|
|
errors.push(
|
|
'assertions.json must carry non-negative integers pass, fail and total',
|
|
);
|
|
} else if (parsed.total === 0) {
|
|
errors.push(
|
|
'assertions.json total is 0: a round with no executed assertion has no structured verdict',
|
|
);
|
|
} else if (parsed.total !== parsed.pass + parsed.fail) {
|
|
errors.push(
|
|
`assertions.json is inconsistent: total ${parsed.total} != pass ${parsed.pass} + fail ${parsed.fail}`,
|
|
);
|
|
} else {
|
|
counts = { pass: parsed.pass, fail: parsed.fail, total: parsed.total };
|
|
const ledger = parsed.failures;
|
|
if (ledger !== undefined) {
|
|
ledgerPresent = true;
|
|
if (!Array.isArray(ledger)) {
|
|
errors.push(
|
|
'assertions.json failures must be an array of { id, attribution } entries',
|
|
);
|
|
} else if (
|
|
ledger.some(
|
|
(entry) =>
|
|
!entry ||
|
|
typeof entry.id !== 'string' ||
|
|
entry.id.trim() === '' ||
|
|
!ATTRIBUTIONS.includes(entry.attribution),
|
|
)
|
|
) {
|
|
errors.push(
|
|
`assertions.json failures entries each need an id and an attribution of ${ATTRIBUTIONS.join(' | ')}`,
|
|
);
|
|
} else if (ledger.length !== counts.fail) {
|
|
errors.push(
|
|
`assertions.json lists ${ledger.length} failure${ledger.length === 1 ? '' : 's'} but counts fail ${counts.fail}: the ledger and the count must agree`,
|
|
);
|
|
} else {
|
|
attributions = ledger.map((entry) => entry.attribution);
|
|
}
|
|
}
|
|
// Gate counts are the repo's existing tests, kept apart so that
|
|
// pass/fail/total measure what this round wrote. The messages carry
|
|
// no suite name: the publisher forwards error lines to its log.
|
|
const gates = parsed.gates;
|
|
// The safe-integer ceiling keeps parity with the publisher's jq:
|
|
// a JSON literal like 1e309 parses to Infinity here, while jq
|
|
// clamps it to a finite double that would otherwise pass.
|
|
const isCount = (value) =>
|
|
Number.isInteger(value) &&
|
|
value >= 0 &&
|
|
value <= Number.MAX_SAFE_INTEGER;
|
|
// Blank means JSON whitespace only: trim() and jq's \S disagree on
|
|
// U+0085 and U+FEFF.
|
|
const gatesValid =
|
|
Array.isArray(gates) &&
|
|
gates.every(
|
|
(gate) =>
|
|
!!gate &&
|
|
typeof gate.suite === 'string' &&
|
|
/[^ \t\n\r]/.test(gate.suite) &&
|
|
isCount(gate.pass) &&
|
|
isCount(gate.fail) &&
|
|
(gate.skipped === undefined || isCount(gate.skipped)),
|
|
);
|
|
if (gates !== undefined && !gatesValid) {
|
|
errors.push(
|
|
'assertions.json gates must be an array of { suite, pass, fail } entries with non-negative integer counts (skipped optional)',
|
|
);
|
|
}
|
|
// Only recorded results silence the size warning: an empty list, or
|
|
// entries that count nothing, moved no suite out of the counts.
|
|
const gateResults = gatesValid
|
|
? gates.reduce((sum, gate) => sum + gate.pass + gate.fail, 0)
|
|
: 0;
|
|
if (counts.total > SUITE_SIZED_TOTAL && gateResults === 0) {
|
|
warnings.push(
|
|
`assertions.json counts ${counts.total} assertions and records no gate results: an existing test suite's results belong in gates, not in pass, fail and total`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (verdict === 'merge-ready' && counts && counts.fail > 0) {
|
|
errors.push(
|
|
`merge-ready with ${counts.fail} failed assertion${counts.fail === 1 ? '' : 's'}: expected A/B control reds count as passes, so a real fail contradicts the verdict`,
|
|
);
|
|
}
|
|
|
|
// The counting rule the prose taught only halfway: an A/A control proves a
|
|
// red is environmental, and nothing said how to count it afterwards. A round
|
|
// booked the same host red twice — head and its base control — and published
|
|
// `findings` while its own verdict line said neither reason was a PR defect.
|
|
if (counts && counts.fail > 0) {
|
|
const plural = counts.fail === 1 ? '' : 's';
|
|
if (attributions === null && !ledgerPresent) {
|
|
warnings.push(
|
|
`assertions.json carries no failures ledger for ${counts.fail} failed assertion${plural}: add failures[].attribution (${ATTRIBUTIONS.join(' | ')}) so a red the PR did not cause cannot decide the verdict`,
|
|
);
|
|
} else if (
|
|
attributions !== null &&
|
|
!attributions.includes('pr') &&
|
|
(verdict === 'findings' || verdict === 'blocked')
|
|
) {
|
|
errors.push(
|
|
`${verdict} with ${counts.fail} failed assertion${plural} and none attributed to the PR (${[...new Set(attributions)].join(', ')}): re-encode an A/A-proven environment red as an expected-red pass and move the lost coverage to Not covered, and report a harness fault as inconclusive`,
|
|
);
|
|
}
|
|
}
|
|
|
|
errors.push(...checkMutationCoverage(files.mutations).errors);
|
|
|
|
if (files.report && files.report.trim() !== '') {
|
|
const report = normalizeNewlines(files.report);
|
|
const lead = report
|
|
.split('\n')
|
|
.filter((line) => line.trim() !== '')
|
|
.slice(0, 10);
|
|
if (verdict && !lead.some((line) => line.includes(verdict))) {
|
|
warnings.push(
|
|
`report.md does not state "${verdict}" in its first 10 non-empty lines`,
|
|
);
|
|
}
|
|
const cited = lead.join('\n').match(/\b[0-9a-f]{40}\b/g) ?? [];
|
|
if (expectedHead) {
|
|
if (!cited.includes(expectedHead)) {
|
|
warnings.push(
|
|
`report.md does not name the verified head ${expectedHead} in its first 10 non-empty lines${cited.length > 0 ? ` (it cites ${cited.length} other OID${cited.length === 1 ? '' : 's'})` : ''}`,
|
|
);
|
|
}
|
|
} else if (cited.length === 0) {
|
|
warnings.push(
|
|
'report.md does not name a 40-character head OID in its first 10 non-empty lines',
|
|
);
|
|
}
|
|
warnings.push(...leadShapeWarnings(report, verdict, 'report.md'));
|
|
}
|
|
|
|
// comment.md is the exact body a local round posts: report content plus
|
|
// the attribution footer. report.md deliberately carries no footer (both
|
|
// publish paths append it at comment time), so this is the only artifact
|
|
// where the footer's presence is checkable. The workflow assembles its own
|
|
// body and never writes this file, so its absence is not a defect.
|
|
if (files.comment && files.comment.trim() !== '') {
|
|
const comment = normalizeNewlines(files.comment);
|
|
warnings.push(...leadShapeWarnings(comment, verdict, 'comment.md'));
|
|
const lines = comment.split('\n').filter((line) => line.trim() !== '');
|
|
if (!FOOTER.test(lines[lines.length - 1] ?? '')) {
|
|
warnings.push(
|
|
'comment.md does not end with the attribution footer `_— <model> via Qwen Code /verify-pr (v<version>; skill <12-hex>)_`: append the resolved footer before posting — omit it only when the model cannot be resolved',
|
|
);
|
|
}
|
|
}
|
|
|
|
if (evidence.length === 0) {
|
|
warnings.push(
|
|
'evidence/ holds no PNG capture: a table is the claim, a capture is the witness',
|
|
);
|
|
}
|
|
for (const name of evidence) {
|
|
if (!KEBAB_PNG.test(name)) {
|
|
warnings.push(
|
|
`evidence/${name} is not a kebab-case caption; the filename becomes the published caption`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return { errors, warnings, counts, verdict };
|
|
}
|
|
|
|
function main(argv) {
|
|
if (argv.includes('--help') || argv.includes('-h')) {
|
|
process.stdout.write(HELP);
|
|
return 0;
|
|
}
|
|
let contextPath = null;
|
|
const positional = [];
|
|
for (let index = 0; index < argv.length; index++) {
|
|
const arg = argv[index];
|
|
if (arg === '--context') {
|
|
contextPath = argv[++index];
|
|
if (!contextPath) {
|
|
process.stderr.write(
|
|
`verify-pr check: --context requires a snapshot path\n\n${HELP}`,
|
|
);
|
|
return 2;
|
|
}
|
|
} else if (arg.startsWith('-')) {
|
|
process.stderr.write(`verify-pr check: unknown option ${arg}\n\n${HELP}`);
|
|
return 2;
|
|
} else {
|
|
positional.push(arg);
|
|
}
|
|
}
|
|
const dir = positional[0];
|
|
if (!dir || positional.length > 1) {
|
|
process.stderr.write(
|
|
`verify-pr check: exactly one artifact directory is required\n\n${HELP}`,
|
|
);
|
|
return 2;
|
|
}
|
|
if (!existsSync(dir)) {
|
|
process.stderr.write(`verify-pr check: ${dir} does not exist\n`);
|
|
return 1;
|
|
}
|
|
|
|
const files = {
|
|
report: readIfExists(join(dir, 'report.md')),
|
|
verdict: readIfExists(join(dir, 'verdict.txt')),
|
|
assertions: readIfExists(join(dir, 'assertions.json')),
|
|
mutations: readIfExists(join(dir, 'mutations.json')),
|
|
comment: readIfExists(join(dir, 'comment.md')),
|
|
};
|
|
const evidenceDir = join(dir, 'evidence');
|
|
const evidence = existsSync(evidenceDir)
|
|
? readdirSync(evidenceDir).filter((name) =>
|
|
name.toLowerCase().endsWith('.png'),
|
|
)
|
|
: [];
|
|
|
|
// With the round's own snapshot, the report
|
|
// has to cite the head it verified — not merely some 40-hex token, which a
|
|
// base OID satisfies. A snapshot that exists but cannot be read is reported
|
|
// rather than silently downgrading the check.
|
|
let expectedHead = null;
|
|
let snapshotWarning = null;
|
|
let snapshotPath = null;
|
|
if (contextPath) {
|
|
// A host-held snapshot: the artifact directory is writable by the PR code
|
|
// the round just executed, so its own context.json cannot pin the head
|
|
// when the maintainer supplies a copy that code never saw.
|
|
try {
|
|
const snapshot = JSON.parse(readFileSync(contextPath, 'utf8'));
|
|
if (/^[0-9a-f]{40}$/.test(snapshot?.headRefOid ?? '')) {
|
|
expectedHead = snapshot.headRefOid;
|
|
snapshotPath = contextPath;
|
|
}
|
|
} catch {
|
|
// Reported below; never falls back to the writable artifact copy.
|
|
}
|
|
if (!expectedHead) {
|
|
process.stderr.write(
|
|
`verify-pr check: --context ${contextPath} is missing, unreadable, or carries no valid head OID\n`,
|
|
);
|
|
return 1;
|
|
}
|
|
} else {
|
|
snapshotPath = join(dir, 'context.json');
|
|
const prNumber = /^pr(\d+)-verify-/.exec(basename(resolve(dir)))?.[1];
|
|
if (!existsSync(snapshotPath) && prNumber) {
|
|
snapshotPath = join(
|
|
dirname(resolve(dir)),
|
|
'verify-context',
|
|
`pr${prNumber}.json`,
|
|
);
|
|
}
|
|
if (existsSync(snapshotPath)) {
|
|
try {
|
|
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
|
if (/^[0-9a-f]{40}$/.test(snapshot?.headRefOid ?? '')) {
|
|
expectedHead = snapshot.headRefOid;
|
|
} else {
|
|
snapshotWarning = `snapshot ${snapshotPath} has no valid head OID; the head-OID check fell back to presence-only`;
|
|
}
|
|
} catch {
|
|
snapshotWarning = `snapshot ${snapshotPath} is unreadable; the head-OID check fell back to presence-only`;
|
|
}
|
|
}
|
|
}
|
|
|
|
const result = checkArtifacts({ files, evidence, expectedHead });
|
|
if (snapshotWarning) result.warnings.unshift(snapshotWarning);
|
|
const lines = [`verify-pr check: ${dir}`];
|
|
if (expectedHead) lines.push(` head source: ${snapshotPath}`);
|
|
for (const warning of result.warnings) lines.push(` warn ${warning}`);
|
|
for (const error of result.errors) lines.push(` error ${error}`);
|
|
const counts = result.counts
|
|
? `${result.counts.pass} passed · ${result.counts.fail} failed · ${result.counts.total} total`
|
|
: 'no usable assertion counts';
|
|
lines.push(
|
|
` ${result.errors.length === 0 ? 'ok' : 'failed'}: verdict=${result.verdict ?? 'none'} (${counts})`,
|
|
);
|
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
return result.errors.length === 0 ? 0 : 1;
|
|
}
|
|
|
|
if (isEntryPoint(import.meta.url, process.argv[1])) {
|
|
process.exitCode = main(process.argv.slice(2));
|
|
}
|