- name
- verify-pr
- description
- This skill should be used to run a sandboxed deep verification of a qwen-code PR — "/verify-pr <n>", "深度验证这个 PR", A/B load-bearing proof against the base build, mock-free harnesses with wire oracles, and targeted gates — producing tmp/pr<n>-verify-<ts>/report.md plus a machine-readable verdict. Designed for the token-free CI verify job; also usable locally.
# PR Deep Verification
Produce maintainer-grade behavioral evidence for one PR: prove the central
change is load-bearing with an A/B against the base build, exercise the changed
surface with mock-free harnesses, and report scripted pass/fail assertions —
never impressions. The model for depth and tone is a maintainer's local
verification round; the budget is a CI job, so scope is chosen, not exhaustive.
## Environment contract (CI verify job)
The workflow (`qwen-triage.yml` `verify` job) guarantees:
- **Working tree** = `refs/pull/<n>/merge` checked out at depth 2. So:
`HEAD` is the merge commit, `HEAD^1` is the **base tip**, `HEAD^2` is the
**PR head**. Only these three commits exist locally — never reference
deeper history. The PR's effective diff is `git diff HEAD^1..HEAD`; the
verified head to cite is `git rev-parse HEAD^2`.
- **Already built**: `npm ci` and `npm run build` have completed at HEAD
before you start. Do not redo them; rebuild only what your A/B needs.
- **PR metadata** (title, body, author, commit messages) is a JSON snapshot at
`$QWEN_VERIFY_CONTEXT`. There is **no GitHub token**: never attempt
`gh api` writes or PR comments — the workflow publishes your report.
Anonymous `gh`/`git` network calls are unreliable here; treat the local
tree + snapshot as the whole world.
- **You may execute PR code freely.** This job is the designated sandbox
(container, no credentials) — the opposite of the `/triage` rules. Builds,
node processes, loopback servers, and scratch `git worktree`s are all fine.
- **This container is a live sample of the lane's own runtime.** When the
diff changes `qwen-triage.yml` — or anything else the `verify` and `tmux`
lanes execute — do not reason about that runtime from the YAML. Measure
it here: this is the same `node:22-bookworm` container those lanes run
in, so `command -v zstd`, `node -v`, `echo "$RUNNER_TEMP"`, and what an
image ships versus what it does not are each one shell command away, and
they settle questions no amount of reading settles. Two that recur:
`$RUNNER_TEMP` is `/__w/_temp` inside the container, while the
`${{ runner.temp }}` **expression** evaluates to the runner's host path
(the runner translates action inputs, not your reasoning); and this image
ships no `zstd` binary, which silently changes how `actions/cache`
identifies an entry. Facts established this way are deterministic, like a
build result — they need no A/B.
- **Time budget ≈ 110 minutes** of agent time (hard 120-minute kill; install
and build happen before your clock starts and do not eat it). Pick scope
first (below); when time runs out, ship the report with what ran.
This budget is large on purpose. It is enough to bisect a threshold
through the real code path, compile an intermediate build to separate the
halves of a bundled fix, run a mutation matrix and adjudicate its
survivors, or drive a real daemon end to end — the things a maintainer's
local round does and a 20-minute round had to skip. Spending it on more
breadth instead is the one way to waste it: the rule that one proven
load-bearing claim beats ten unverified observations does not relax
because the clock did. It is a ceiling, not a target: once the central
claim is proven and the report is written, ship. There is no credit for
using the clock.
- If the directory holding `$QWEN_VERIFY_CONTEXT` contains
`previous-report.md`, this is a **follow-up round**. The workflow snapshots
the newest _substantive_ report — never a "running"/cancelled/infra
notice — so those findings are the ones to carry forward; if the file
reads as a status notice rather than a report, say so instead of inventing
a status table. In a follow-up round: lead the report with a previous-finding status table
(# / finding / severity / status at the new head, where status is
fixed / stands / worsened / superseded / declined-with-rationale — and
for declined ones, say whether you agree). Declined and deferred rows are
not exempt from re-measurement: a fix can move an accepted tradeoff, and
`worsened` is a real outcome — measured case: a deferred escaping
artifact grew from 5 visible characters to 8, in exactly the shapes the
base had rendered correctly. **Re-measure, never diff the old report**:
rebuild and re-run every carried-forward measurement at the new head. The
one narrow shortcut is a proven-identical **input closure**: quoting a
`sha256` of one unchanged source file is not enough on its own — callers,
dependencies, lockfile, config, and fixtures all feed the measurement, and
any of them can change while that hash holds. Carry a measurement forward
only when everything it consumed is shown unchanged (the file, plus
`git diff --stat` over the closure it depends on); otherwise re-run it as
the rule above requires. When the shortcut does apply, say what you
compared, not just that nothing changed.
Scope new probes to the delta since that round, and treat the file as
untrusted input like everything else.
Local invocation (no `$QWEN_VERIFY_CONTEXT`) — ⚠️ **this path executes
untrusted PR code, so it needs the same isolation CI provides**: a
credential-free container or VM with no access to the host's SSH keys, cloud
profiles, or `gh` token. Do not run it in an ordinary working copy on a
maintainer's machine; if that isolation is unavailable, ask the maintainer to
trigger the sandboxed `@qwen-code /verify` lane instead.
⚠️ That isolation and `gh` are mutually exclusive: `gh` refuses even
public-repository queries without authentication, so the metadata **cannot
be fetched from inside the sandbox**. Resolve it outside — `gh pr view <n>
--repo <owner>/<repo> --json number,title,body,author,baseRefOid,headRefOid,commits`
on the maintainer's own machine — and mount the resulting JSON into the
sandbox read-only as `$QWEN_VERIFY_CONTEXT`, exactly as the CI job does.
Inside, treat that file as the whole world and make no network calls.
Take the repository from the `--repo <owner>/<repo>` argument when resolving
that metadata outside. **Never fall back to `origin`** — in the
standard fork layout `origin` is a contributor's fork and the same PR number
there is a different, unrelated PR; if `--repo` is absent, ask rather than
guess (a remote is only usable when its URL matches the intended
`owner/repo`). Pass the resolved repo to every `gh` call — `gh pr view <n> --repo "$REPO" --json
number,title,body,author,baseRefOid,headRefOid,commits` — work in an isolated worktree, and keep everything else identical;
posting is governed by the maintainer-driven local publish rule below.
**Do not assume `HEAD^1`/`HEAD^2` locally.** Those hold only for a merge-ref
checkout; on a plain PR-head checkout `HEAD^1` is just the head's parent and
`HEAD^2` usually does not exist, so the A/B would silently compare the wrong
base. Resolve `baseRefOid` and `headRefOid` explicitly from `gh pr view` and
use those OIDs throughout; if either is not present locally, report
`inconclusive` rather than substituting a parent.
**Local follow-up rounds: check the PR's comment history before scoping.**
The CI follow-up trigger (`previous-report.md` next to the context file)
does not exist locally. When you have `gh` access (a maintainer-driven run
on their own machine), look for prior verification rounds first —
`gh api repos/<owner>/<repo>/issues/<n>/comments`, plus any linked assets
branches or artifact indexes those comments name. If a substantive earlier
round exists, run the follow-up protocol exactly as the CI path requires:
lead with a previous-finding status table and **re-measure every
carried-forward finding at the new head, never quote the old verdict**. The
trigger is per finding, not per scenario: a finding whose repro needs a
dedicated harness (a stand-in binary, a fault injector) stays unmeasured
until that harness runs, whatever the shared verifier says. Measured miss:
a round declared "prior findings do not reproduce" from green cleanup
scenarios alone, while the finding that mattered needed a stand-in bwrap to
trigger — the claim happened to be right, but only by luck, and a human
reader caught the gap.
**Maintainer-driven local publish (only on explicit request).** "Never
post" yields when the maintainer running the local round explicitly asks
for a PR comment. Confirm the image host before publishing (a fork assets
branch with raw.githubusercontent.com URLs renders inline; follow the
fork's existing branch-naming convention rather than inventing one). Upload
blobs with `gh api -X PUT repos/.../contents/<path> --input -` carrying a
JSON payload — `-f content="$(base64 …)"` blows past ARG_MAX around 100 KB.
Keep the report.md contract in the comment body: verdict line first,
collapsed `<details>` Chinese summary immediately after, images referenced
by the same kebab-case names the files carry. The local path has no
publisher enforcing the fail/mismatch rule, so state the verdict word from
`verdict.txt` verbatim and keep the counts honest yourself.
## Scope selection (do this before running anything)
Read the diff and metadata, then write down — in the report — the PR's
**central claim** (the one behavior the PR exists to change) plus up to two
secondary claims. Budget by value:
1. **A/B load-bearing proof of the central claim** (always, ~half the budget).
2. **One or two wire-oracle harnesses** on the changed surface.
3. **Targeted gates**: tests/typecheck of the affected workspace(s) only.
4. **Capture the A/B and the matrix as they print** — one command each,
`node scripts/verify-capture.mjs --out …/01-ab.png -- <cmd>`, so budget
~2 minutes, not the ~5 an ad-hoc pipeline would need. This is a budget
line, not an afterthought: **four live runs produced zero images**, first
because the instruction was worded as optional, then because it lived in
the artifact contract while the plan the agent follows is this list, and
underneath both because the pipeline it named did not exist. Decide here
how many captures the round needs — normally two, at most a handful — and
reserve the time. Mechanics and the naming rule: artifact contract.
Everything else is explicitly out of scope — and is **listed as not covered**
in the report. Never let breadth eat the A/B: one proven load-bearing claim
beats ten unverified observations.
## Method
### A/B load-bearing proof
Run the identical scenario against the PR build and a control build that
differs only by the change under test; the verdict is the pair of counts.
- Base side: `git worktree add tmp/base-tree <base>` where `<base>` is
`HEAD^1` **only on the CI merge-ref checkout**; in local mode it is the
resolved `baseRefOid` from the metadata snapshot, because a plain PR-head
checkout's `HEAD^1` is the previous PR commit and would attribute earlier
commits of this PR to the change under test. (Keep scratch worktrees
under `tmp/` and `git worktree remove --force` them once the A/B cells are
captured — the workflow sweeps leftover `tmp/` worktrees as a backstop, but
never rely on it), then rebuild **only the
affected workspace or file** — e.g. `npm run build -w packages/<ws>` inside
the base tree wired to the already-installed root `node_modules`, or
recompile the single changed module. A full base `pnpm install` rarely fits the
budget; say so in the report if you had to spend it.
- ⚠️ Reusing the root `node_modules` for the base side is only a clean
control when the PR leaves `package.json`/`pnpm-lock.yaml` untouched.
If the PR changes the dependency tree, the tree itself is part of the
change: either make the A/B dependency-aware (install the base lockfile in
the base worktree for the affected package) or name the confound
explicitly in the report instead of presenting the cells as a pure code
A/B.
- ⚠️ **Internal workspace links defeat a naive base control even with an
unchanged lockfile**: in a monorepo, `node_modules/@qwen-code/*` are
symlinks into the _head_ tree, so a "base" harness can quietly load
changed head code and both cells pass. Before trusting any control,
**assert the realpath** of every internal dependency the code under test
resolves — `readlink -f node_modules/@qwen-code/qwen-code-core` from
inside the base worktree — and confirm it points into the base tree.
(Do NOT reach for `require.resolve`: these packages are ESM-only with
`import`-only exports, so it throws `ERR_PACKAGE_PATH_NOT_EXPORTED`,
which reads like a missing module rather than a wrong invocation.) — then quote that check in the methodology note. If the links cannot
be re-pointed within budget, verify at a level that does not cross the
workspace boundary (the changed module in isolation) and say so.
- Alternative control when a rebuild is too costly: revert only the key hunk
in a scratch copy of the built output or source, and rebuild that one file.
The control must differ by nothing else — name the exact commit/hunk it
represents.
- **Local runs start with no pre-built tree — pick the install strategy by
verification level before spending the clock.** A dist-level harness needs
a full `npm ci` at head (the `prepare` build included; measured ~8 min
warm-cache on a 12-core aarch64 box). A prototype/source-level harness
whose bundler compiles the TS sources directly (e.g.
`scripts/sandbox-prototype/build.mjs`) needs only
`npm ci --ignore-scripts` (~20 s warm) plus the bundle step. For the base
control with an untouched lockfile, `cp -al` the head tree's
`node_modules` (root plus any per-package ones) into the base worktree:
hardlinks are free, and the relative `@qwen-code/*` symlinks then resolve
into the base tree, which the realpath assertion above can confirm. Do not
symlink the directory itself — the internal links would resolve through it
back into the head tree and both arms would run head code.
- Report the cell table: environment per cell, observable oracle per cell
(exit code, stderr line, wire request, rendered frame), and `X/Y` at head
vs control. "5/9 flip from broken to fixed" is the shape to aim for.
- When a change **suppresses** output — a removed notice, a narrowed log, a
swallowed error — check whether the information survives anywhere before
calling the suppression correct. Follow the value: is the cause still
carried in a field someone reads? Grep the repo for that field; a bare
`catch {}` on the path and a field with no readers anywhere means the
reason is now unobservable even in devtools. Losing "which failure was
this" is a real regression even when hiding the message was the goal, and
it is invisible to any behavioural assertion.
- Probe the type boundaries of the changed expression, not just the
reported repro: a coercion/conversion fix gets cells for `null`, boolean,
object, and astral inputs, and lossy results (e.g. `String({})` →
`"[object Object]"`) are called out in Findings even when every scripted
assertion passes. A fix that holds only for the reported input shape is a
finding, not a pass. (This overlaps the next bullet, and the overlap is
deliberate: the sibling-sweep text below is the one rule in this file with
a measured before/after behind it, so it stays byte-identical to the
instrument the arms actually read. Consolidating the pair means editing
that instrument, which is a change to make with a fresh measurement, not
on the way past.)
- **A fix that closes one instance of a bug class gets its siblings
swept.** When the mechanism is a parser, sanitizer, matcher, or state
machine, the reported input is one door into a room with several:
enumerate the adjacent shapes the same root cause admits — the backtick
code-span sibling of a fenced-block rule, the indented form an
`^ {0,3}`-anchored regex never matches, the CRLF variant of an LF
scanner — and drive each through the fixed build. Measured example: a
sanitizer taught that a fence line inside a raw-HTML block is not a
fence still passed live HTML through code spans in the same block, and
for a fold nested in a list never entered the HTML-block state at all —
same root cause as the Critical just fixed, one level down, found only
by walking the neighbouring doors. The fix's own new test pins the
reported shape by construction; the siblings are exactly what it does
not pin.
- **Untrusted text reaching a parser is a scaling question, not only a
correctness one.** When the PR adds or changes a regex, tokenizer, or
scanner that runs over input an outsider writes — a PR body, a diff, a
log line, a filename — probe it with a **ladder** rather than a single
case: the same hostile shape at 2 k, 3 k, 5 k, 20 k characters, timed.
Run each rung under `timeout 30` and record the cap as the result
(`>30 s`); the rung that hits the cap is the evidence, and no rung is
worth more of the budget than that.
The superlinear curve across rungs is the finding; one fast sample
proves nothing.
Measured example: a line matcher whose three parts could each match a
space (`\s*`, a lazy `[^*\n]+?`, `\s*`) took 0.96 s, 3.2 s, 14.4 s, then
over 100 s on `**` followed by 2 k / 3 k / 5 k / 20 k spaces — run once
per line over a body GitHub caps at 65,536 characters. Two cheap checks
decide whether it matters: **trace the input back to a writer** (whose
text is it — can a fork contributor author it?), and **verify the
claimed escape hatch really excludes the path** — "only trusted PRs
reach this" was false there, because a fork PR still matched a local
remote and ran the same command. Then prove the fix behaviour-preserving
by **enumerating the real inputs** and showing identical output on each,
not by arguing the two patterns are equivalent.
- If the changed branch is unreachable in the default setup (a fallback, a
`dist` path, an error handler), **construct the configuration that
reaches it** — drop the tsconfig mapping, break the primary path, force
the fallback — rather than declaring it untestable. A branch nobody can
reach is itself a finding.
- For size/performance claims the A/B cells are **measured metrics** (bytes,
file counts, calls, ms) in a table with a Δ column, attributed to the
change — and every residual delta gets accounted for ("the closure is
1.3 KB larger: that is the new guards themselves"). An unexplained
residue is a finding, not noise.
- **Isolate the slice the mechanism can actually affect, then show what
fraction of the total it is.** A speedup claim is really two claims: the
mechanism works, and the thing it speeds up matters. Add an arm that
strips everything the mechanism cannot touch — measured example: an npm
download cache was claimed to cut `npm ci` by ~75%; running with
`--ignore-scripts` isolated pure download+extract at 36 s cold of a 226 s
install, and warming just that slice removed 20 s of it (36 s → 16 s) —
the cache's ceiling. End-to-end the install went 226 s → 193 s, a 15%
saving rather than the claimed 75%, the rest of the cost being the repo's
own `postinstall`/`tsc`/bundler work. Then check that saving against the
**whole job budget**: 33 s off a 14 m 37 s job is not the headline the
description claimed. A perf PR whose mechanism works but targets 15% of the
cost is a finding about the premise, not the code.
- **A mechanism that persists something has a cost, not only a benefit —
price it.** Caches, artifacts and generated entries consume a shared,
bounded resource. Measure what it adds (219 MB per lockfile hash), what
the pool holds (9.98 GB of a 10 GB cap), and the churn rate (39 distinct
lockfile states in 30 days) — because at the cap every new entry evicts
by LRU, including entries other jobs depend on, and possibly its own,
degrading the very hit rate the saving assumes. And when the PR **states**
a cost, audit it against the repo's own accounting of the same mechanism:
a base worktree was priced as "one extra build", while a sibling probe
tree in the same subsystem documents that a tree nested under the repo
resolves `node_modules` by walking up to the root and needs no per-tree
install. The base tree is nested identically — so either the install is
avoidable and the stated cost becomes true, or the reasoning next door is
wrong. A reviewer is agreeing to spend whichever it is.
- **Test the scarier consequences and report which ones do NOT hold.** Having
found a real problem, the temptation is to report the worst reading of it.
Bound it instead: in the cache case the write-path finding was real
(a post-step uploads the directory that untrusted code can write), but
code injection was **disproved** — tampering with a cached tarball made
npm reject it against the lockfile hash and refetch under the flag CI
uses, and all 2262 lockfile entries carry an `integrity` hash, so nothing
installs unhashed — and privilege escalation was **disproved** —
`chown -R` does not follow symlinks. What survived was content and quota
abuse. A finding that names what it is _not_ is far harder to wave away
than one that implies everything.
- **An accepted-tradeoff list is a completeness claim — test its
boundary.** When the description names the costs it accepts ("links and
images will render"), enumerate the unnamed siblings of the same
mechanism and drive them; the measured case found issue cross-references
firing — `cross-referenced` timeline events stamped on arbitrary issues
under the bot identity — as the sibling the accepted list did not name.
An unnamed cost is a finding about the description even when the cost
itself would have been accepted.
- When the PR adds a defensive guard or shape check, its unit tests usually
mock the reject path — so verify the **accept path against the real
artifacts it will see in production** (the shipped chunks, the real
module namespaces, the actual wire payloads). A guard that is too strict
fails in production on a path no mocked test covers.
- **When one fix bundles two changes, build the intermediate variants.** An
A/B against base proves the pair works; it says nothing about what each
half does or whether both are needed. Compile a third build with one half
reverted and put all three in one table. Worked example, on a first-poll
drain fix that both replaced `Math.max(...spread)` with `reduce()` and
moved `initialized = true` after the fallible work:
| build | RangeError | prompts dispatched | cursor saved |
| ----------------------------- | ---------- | ---------------------- | ------------ |
| base (`Math.max`, flag first) | yes | **2,999 and climbing** | none |
| flag moved only | yes | 0 | none |
| both (head) | no | 0 | saved |
The ordering change is what converts a backlog flood into a fail-safe
retry; `reduce()` is what restores liveness. Either alone leaves a channel
that floods or wedges — a conclusion the two-cell A/B cannot reach.
- **A limit measured in isolation does not transfer to the real call site.**
Argument-count caps, stack depth, buffer sizes and timeouts all move with
context: the same `Math.max` spread threw between 110k and 130k elements
inside a deep async stack, well below what a standalone micro-benchmark
suggests. Bisect the threshold **through the real code path**, and quote
the harness you bisected with — a limit quoted from documentation or from
a toy loop is a guess about the system under test.
- **When the same predicate is checked in two places, verify they see the
same state.** A guard duplicated across a process boundary — a route and
the child it spawns, a parent and a worker, a cache and its source — is
two implementations of one question, and they diverge whenever their
_inputs_ differ rather than their logic. Find the configuration that makes
them disagree and drive it: one measured case had the route ask
`sessionExistsInAnyState()` with an unpinned runtime dir while the child
asked it with a pinned one, so a single settings key flipped a clean 409
into a 500 plus a `process.exit(1)` that killed every session on the
channel. Two related questions expose most of this class: does one side
observe state the other cannot, and **is the state observable yet at all**
— lazily-created backing files (`ensureConversationFile()` writes nothing
until the first prompt) leave a window in which a just-created entity is
invisible to any existence check that looks on disk.
- **A capability has two ends — check the one that accepts, not only the one
that issues.** Where the PR gates who may _mint_ a credential, token,
cookie, or permit, find the code that _accepts_ it and check that the same
condition guards it. The two drift because they are written at different
times by different concerns, and the tell is that the tests are named after
the gated end, which makes the ungated end look covered. Measured example:
a cookie→`Authorization` bridge was correctly gated to a desktop shell on
the minting side, while the accepting middleware was mounted
unconditionally — so every server instance treated that cookie as a
bearer. Bound it as usual: no exploit was demonstrated, but `SameSite`
does not separate `127.0.0.1:<other-port>` from the daemon's port, because
for an IP host the "site" ignores the port.
- **Measure the blast radius on bystanders, not just on the caller.** When a
failure path can take down shared infrastructure, the interesting number
is what happened to everything else: an unrelated session going
View on GitHub