Files
qwen-code/scripts/verify-pr-check.mjs
Shaojin WenandQwen-Coder 68c9fa27fb feat(verify-pr): add local Docker recipe with a host-side model proxy (#16077)
* 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 at 70aed891bf). 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 at 3509c2d1aa reported 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 at 3ccf38697d reported 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 at 441a523927:

- 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 at 19b14b1a8f left 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 at b5c717a5d5:
- 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]>
2026-11-03 14:13:59 +08:00

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));
}