Files
deer-flow/.agent/skills/blocking-io-guard/references/good-anchor-rules.md
T
AochenShen99 dc2ababf00 feat(skill): add blocking-io-guard — SOP skill for blocking-IO triage and runtime anchors (#3503)
* feat(blocking-io): add changed-lines blocking-IO scanner (L1)

* feat(blocking-io): add scan-changed CLI wrapper

* feat(skill): add blocking-io-guard developer SOP skill

* docs(blocking-io): point contributors at the blocking-io-guard skill

* style(blocking-io): apply ruff format to scanner and tests

* docs(backend): document changed-lines blocking-IO scanner in CLAUDE.md

* feat(skill): add post-fix re-scan check and PR batching policy

* refactor(skill): fix SOP step ordering, align template with repo conventions

- Move re-scan into an explicit 'apply the fix' step (was wedged after
  anchor generation while telling you to go back before the anchor)
- Renumber steps 0-6; drop undefined 'L1' jargon
- Mode A: document that the diff is <base>...HEAD (commit first)
- Mode B: prefer make detect-blocking-io + findings JSON file
- anchor template: module-level pytestmark per tests/blocking_io convention
- CLAUDE.md: fix 'git diff --base' phrasing

* fix(skill): catch findings introduced without touching the blocking line

Review follow-up: changed-line intersection alone misses the case where a
new async caller exposes an old sync helper — the static finding sits on
the untouched blocking line, so Mode A returned empty and the SOP stopped
on a false 'no blocking-IO surface'.

Selection is now a union over the changed files:
- findings on added lines of git diff <base>...HEAD (kept: a second
  identical symbol in an already-flagged function collides on the stable
  key and only this selection sees it);
- findings new versus the merge base, matched by (path, function,
  symbol) — never line numbers.

Base sources are materialized via git show <merge-base>:<path>; files
absent at base count every head finding as new. SKILL.md now states the
residual same-file-only blind spot (cross-file async callers) instead of
treating an empty list as proof of zero exposure, and only requires
reading sop-skeleton.md when generalizing to another detector domain.

* docs(skill): examples teach test-writing, the teeth check defines the rule

All examples in the references/template are filesystem-flavored; make
explicit that they are instances, not the SOP's boundary — the same rules
apply to every detector category (FILE_IO, HTTP, SUBPROCESS, SLEEP) and
acceptance is always red/green teeth, never similarity to an example.
Neutralize the template's arrange comment accordingly.

* fix(blocking-io): harden changed-lines scanner per review

- Dedup the union selection by the stable key (path, function, symbol)
  instead of dict identity, so a future selector returning copied dicts
  cannot silently empty the result.
- parse_changed_lines now handles any unified diff: context lines advance
  the new-file counter, \-markers and deletions do not, and the counter
  resets at each +++ header. Previously correct only for --unified=0.
- Add blocking_io_static.scan_source (in-memory scan); base-version
  comparison no longer round-trips through temp files.
- Empty Mode A report now prints the same-file-only reachability caveat
  at the point of use instead of relying on the SOP text alone.

* docs(skill): bound best-effort cleanup when the offload sits in finally

Lesson from the #3505 review: the SOP routinely drives 'offload the
cleanup branch' transformations, and an awaited cleanup in finally can
mask or stall the primary exception. One sentence in Step 2 closes that
gap at the point where the fix is written.
2026-06-12 10:20:38 +08:00

3.1 KiB

Good anchor rules + teeth (blocking-IO fill)

Distilled from backend/docs/BLOCKING_IO_DETECTION.md. An anchor lives in backend/tests/blocking_io/; the suite's conftest runs each test under the strict Blockbuster gate scoped to app.* / deerflow.*.

The examples in this file and in templates/ are all filesystem-flavored. They demonstrate how to write the test, not what the SOP covers: the same rules apply to every category the detector reports (FILE_IO, HTTP, SUBPROCESS, SLEEP), and the acceptance criterion is always the teeth check below — never similarity to an example.

A good anchor

  • Calls the real production async entry point — not a low-level helper, unless that helper is the entry point production executes.
  • Does not bypass the blocking surface with a test-only asyncio.to_thread / run_in_executor wrapper.
  • Uses real local filesystem inputs when the bug shape is filesystem IO.
  • Mocks only the external dependency boundary (network service, third-party saver), never the offload being guarded.
  • Drives the specific branch you are protecting (error / cleanup / 404 / 409), not just the happy path.

Teeth (the acceptance test)

An anchor only counts if the gate actually fires when the code blocks:

  1. Reintroduce the block (revert the offload, or run pre-fix code).
  2. cd backend && make test-blocking-io → the anchor must fail (RED).
  3. Restore the fix → the anchor must pass (GREEN).

A green-on-happy-path anchor with no proven red is fake coverage. Don't ship it.

The RULE route (rare; strict admission criteria)

Blockbuster's built-in rules cover the common blocking primitives well. The two deliberate openings in this SOP are:

  1. Coverage opening (the normal case): the rules already see the primitive — you only need an anchor so runtime detection executes the real business path and CI prevents regression.
  2. Rule opening (rare): you reintroduced a real block and the gate stayed GREEN — Blockbuster has no rule for that primitive.

A project rule lives in _PROJECT_BLOCKING_RULES inside backend/tests/support/detectors/blocking_io_runtime.py and changes detection for the entire blocking-IO suite — global blast radius. Admission criteria for adding one:

  • You have the fails-to-fail anchor as evidence: a good anchor (per the rules above) that drives a genuinely blocking path and stays green. No evidence, no rule.
  • The primitive is a real blocking call (verified against its implementation or docs), not a false positive of the static detector.
  • The rule ships in its own commit, naming the primitive, the anchor that exposed the gap, and the suite-wide impact. Run the full make test-blocking-io suite after adding it — a new rule can turn other previously-green tests red, and each such red is either a real latent bug (fix it) or rule overreach (narrow the rule).
  • If you are not in a position to own that blast radius (e.g. external contributor), escalate to a maintainer with the evidence instead.

Never add a runtime rule just because a path is untested — that case needs an anchor, not a rule.