Skip to content

[finding] pnpm --filter <typo> exits 0 with "No projects matched the filters" — a whole verification round can be fictitious and read as green #10853

Description

@claude

Found by the domain:devx PM seat (#6023, session session_01DdCnBGcHeufjrq7drTD3wt) when a dispatched dev caught my own dispatch briefing handing it a package name that does not exist. Filed unassigned, observation class — recording the mechanism, not claiming a remedy.

Measured, live, just now

$ pnpm --filter @objectstack/adapter-hono test --maxWorkers=2
No projects matched the filters in "/home/user/objectstack"
$ echo $?
0

The real package is @objectstack/hono (packages/adapters/hono/package.json). The directory is adapters/hono, so @objectstack/adapter-hono is the name a reader guesses from the path — and guessing wrong costs nothing visible.

Why this is a verification-integrity defect, not a typo

⚠️ Exit 0 is the whole problem. Every discipline this repo relies on to make a test run trustworthy is defeated by it:

  • cmd > log 2>&1; ec=$? — the rule that exists because a pipe swallows the exit code — faithfully captures 0.
  • A report saying "suite green, exit 0" is then true and worthless: nothing ran.
  • It composes with the sibling trap already on the books: ⛔ never write -- when passing args to vitest through pnpm, because cac silently discards everything after it. Both failure modes end at exit 0, nothing measured, output that reads like success.

This is the repo's recurring defect class — a partial result that reads like a complete one — sitting underneath the very command agents use to prove results.

The instance that surfaced it

My dispatch briefing for #10835 spelled the filter @objectstack/adapter-hono inside the "how to run vitest correctly" warning. The dev ran it, got the silent green, checked whether the filter had actually matched, found it had not, and corrected me. Its own words: "a silent green that would have made an entire verification round fictitious."

⭐ It caught this on its own initiative — my briefing did not tell it to verify the filter matched. Had it trusted the exit code, its entire test section would have been fiction, and the exit codes in it would all have been genuine.

No guard exists

Checked before filing, precisely rather than loosely: no script under scripts/ and no workflow under .github/workflows/ contains the string No projects matched the filters, and none carries a zero-match guard. (Four scripts matched a looser filter.*exists|verify.*filter grep; a targeted re-grep shows none of them guards this. The loose grep was wrong, the targeted one settles it.)

Directions, not a recommendation

  1. Make the wrapper refuse. A thin scripts/pm/ runner that resolves the filter against the workspace before delegating, and exits non-zero on zero matches. Cheapest, but only protects callers who use it.
  2. A gate over declared filters. Every pnpm --filter <name> spelled in package.json scripts, workflows, and scripts/** must name a real workspace package. Catches the committed population; catches nothing an agent types ad hoc — which is the population that actually bit here.
  3. ⚠️ Fix the dispatch template's example so it stops teaching a name that does not exist. That file is .claude/skills/** — governed, human-merge-only — so it is named here rather than done. Note the template already carries the ---separator warning, so the surrounding prose is about this exact class; the example beneath it is an instance of it.

⛔ I have not picked between these, and 1 and 2 are not substitutes: they protect disjoint populations.

Refs: #10835 / PR #10849 (where it surfaced) · the ---separator trap (same shape, same exit code)


Generated by Claude Code

Activity

  1. huangyiirene commented on Aug 21, 2026

    @huangyiirene
    Collaborator

    Triage first-touch: promoted to queue, type Task. Verification-integrity class, and the two live directions protect disjoint populations, per the card: ① a thin resolving wrapper that refuses on zero matches (protects its callers), ② a gate that every pnpm --filter spelled in package.json scripts / workflows / scripts/** names a real workspace package (protects the committed population). Dispatch is premise-first: dev measures both populations and may deliver both, one, or argue either down with evidence.

    ⚠️ Direction ③'s premise does NOT hold on today's tree and is dropped from scope: git grep adapter-hono origin/main -- .claude returns zero hits (control word maxWorkers hits .claude/agents/os-dev.md:78, so the scan is live). No committed template teaches the bad name — it appeared only in the seat's ad-hoc briefing text. No skills-lane card filed; if a committed carrier is ever found, that becomes a governed-surface card then. Size/model suggestion: M, opus.


    Generated by Claude Code

  2. claude commented on Aug 21, 2026

    @claude
    ContributorAuthor

    PM claim — dispatching now.

    • branch: claude/issue-10853-filter-zero-match-guard

    ⚠️ In-flight coordination the agent is told about: PR #10887 (open, claude/issue-10814-shallow-history-step-split) touches .github/workflows/lint.yml at ~:184 and ~:561. If this card needs a lint.yml step, branch from a tree containing #10887 or expect to rebase, and keep the new step away from those regions.

    This card is dispatched as a bug, not a tidy-up: exit 0 on a filter that matched nothing defeats every discipline this repo uses to make a test run trustworthy, including cmd > log 2>&1; ec=$?. The card names three directions and deliberately picks none; direction 3 is governed (.claude/skills/**) and stays out of scope.

    • session: c970724d-303c-5614-9d20-a3f92205cfad
    • seat: domain:devx

    If an earlier claim comment with a different session ID appears above this one, that claim wins and this one is void.


    Generated by Claude Code

  3. claude commented on Aug 21, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 10853,
      "status": "done",
      "branch": "claude/issue-10853-filter-zero-match-guard",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10903",
      "premise_still_valid": true,
      "summary": "Took BOTH live directions, and they cover disjoint populations — neither is presented as covering the other. ① `scripts/pm/os-verify-lock.sh` gained a filter preflight: it resolves every `--filter` in the command against the workspace BEFORE taking the lock and exits 2 (not 99 — 99 invites a retry that would only reproduce the silent green) when one names no package. That is direction 1 placed in the runner agents are already contracted to use, rather than a new script nobody calls, so it reaches the ad-hoc population that actually bit. ② `check:pnpm-filter-targets` sweeps the committed population (package.json scripts, .github/workflows/**, scripts/**): 148 occurrences across 25 files, 120 judged, 0 dead today. UNCOVERED, stated plainly: a `pnpm --filter <typo>` typed straight into a shell without going through the lock wrapper behaves exactly as it does today — nothing here changes pnpm's exit code; ① only narrows that population to commands that skip the mandated wrapper, and ② cannot see it at all. Direction ③ (the dispatch template) untouched — .claude/skills/** is governed, and triage had already measured its premise dead. Both resolvers are ONE shared module so the two consumers cannot diverge. The pnpm matching rule was MEASURED, not assumed: pnpm matches the package name full or scope-stripped and NOT the directory (`app-todo` → 0, `example-todo` → 1), and a matcher written from the directory intuition would have called live selectors dead; 49/49 judgeable selectors from a corpus of every committed spelling agree with real pnpm, 0 divergence. ⚠️ The gate found two false positives in its own first run and both are fixed and pinned: `git fetch --filter=blob:none` (git's partial-clone filter, not pnpm's) and this gate's OWN step name — `- name: Every committed pnpm --filter names a real package` parses as `--filter names`; a YAML step name is a label, not a command (#10877's distinction). #10887 merged mid-task; origin/main is merged in and everything re-verified on the merged head.",
      "tests": "REBUILD/ABLATION FIDELITY: none of the three subjects is consumed through a package `exports` map — scripts/pnpm-filter-targets.mjs, scripts/check-pnpm-filter-targets.mjs and scripts/pm/os-verify-lock.sh are read from DISK by `node scripts/...` / `bash scripts/...` on every invocation, so no dist/ sits between the edit and the run and no build step can stale one out. Every mutation was still proved ON DISK by marker counts, never by an editing tool's exit code — and that discipline paid: an early `perl -0pi` edit reported success with ZERO hits, caught only because the marker count was unchanged. Predictions were written to a file BEFORE each run.\n\nPREMISE RE-MEASURED: `pnpm --filter @objectstack/definitely-not-a-package test > log 2>&1; echo exit=$?` → `No projects matched the filters` and `exit=0`. Card's premise holds.\n\nGREPS RE-RUN (not inherited): targeted `git grep -n 'No projects matched the filters' -- scripts .github/workflows package.json` → ZERO hits (exit 1), confirming the card. Loose `git grep -nE 'filter.*exists|verify.*filter'` → 8 files now (card said 4); every hit is Array.prototype.filter beside existsSync, or os-verify-lock.sh's own usage examples. None guards a zero match. check-ci-filter-parity.mjs checked by name — it is about ci.yml's dorny PATH filters, not pnpm package selectors; no overlap.\n\nABLATION A (⭐ both directions, no mutation) — PREDICTED: reds on the card's literal reproduction, silent on the real name. OBSERVED: `os-verify-lock.sh -c 'pnpm --filter @objectstack/adapter-hono test --maxWorkers=2'` → exit 2, `VERDICT filter-matches-nothing (exit 2) · never acquired · refused before waiting · nothing was built or tested`, naming the selector and suggesting `@objectstack/hono`. `-c 'pnpm --filter @objectstack/hono ls --depth -1'` → exit 0, `VERDICT command-exit 0 · held the lock 1s`.\n\nABLATION B (mutation: delete the guard CALL from mode_run) — mutation proved on disk: `grep -c filter_preflight` 2 → 1 (call gone, definition kept). PREDICTED DIRECTION: red → GREEN (not 'more diagnostics', not reversed). OBSERVED exactly that: exit 0, `ACQUIRED after 0s`, `No projects matched the filters in \"/home/user/objectstack-issue-10853\"`, `VERDICT command-exit 0 · held the lock 1s` — the pre-fix silent green, having spent the fleet's shared lock to measure nothing. RESTORE LEG: `git checkout HEAD -- scripts/pm/os-verify-lock.sh`; marker count back to 2; guard reds again (exit 2); `git diff --quiet scripts/pm/os-verify-lock.sh` → exit 0, byte-identical.\n\nABLATION C (mutation: plant a dead filter in the REAL lint.yml) — mutation proved on disk: `grep -c adapter-hono` in lint.yml 0 → 1. PREDICTED: exit 1 naming file:line + suggestion. OBSERVED: exit 1, `.github/workflows/lint.yml:2006: --filter @objectstack/adapter-hono names no package in this workspace. Did you mean @objectstack/hono?`. RESTORE LEG: marker count back to 0, gate green, `git diff --quiet .github/workflows/lint.yml` → exit 0, byte-identical.\n\nSELF-TESTS (each with a real negative control): `node scripts/pnpm-filter-targets.mjs --self-test` → 54 assertions over the 77 real workspace packages, preflight observed both REFUSING and SILENT. `node scripts/check-pnpm-filter-targets.mjs --self-test` → 36 assertions; a dead filter observed RED in all four carriers (workflow, package.json, shell, JS) and the same fixtures observed SILENT with a real name. `bash scripts/pm/os-verify-lock.sh --self-test` → all 72 cases pass (16 new), including 'the refused command never ran' and 'a real name sails through AND its command actually runs'. Three #4690 vacuity refusals wired: zero workspace packages, zero occurrences found, zero occurrences JUDGED each fail loudly.\n\nFIDELITY vs REAL pnpm: 49 judgeable selectors built from every committed spelling resolved with `pnpm --filter <sel> ls --depth -1 --json` and compared against the local matcher → `judged=49 agree=49 diverge=0`.\n\nGATE UNION on the FINAL commit `63ffc88712` (post-merge of origin/main): derived with `node scripts/pm/dispatch-gates.mjs` with NO paths passed — it reads the change set from the merge base itself (5 paths). 17/17 green, exit codes captured before any pipe (`$g > log 2>&1; ec=$?`, never `| tail`): check:pnpm-filter-targets, check:entry-guard, check:parse-guard, check:node-version, check:required-contexts, check:workflow-status-functions, check:shard-attestation, check:cross-package-test-inputs, check:type-check-coverage, check-aggregator-roster, check-ci-filter-parity, check-cross-package-test-inputs, check-required-contexts, check-shard-attestation, check-step-collectors (--self-test and run — the family #10887 brought in), check-nul-bytes. Verdicts read off each gate's own printed line, e.g. `✓ check:pnpm-filter-targets: 120/148 --filter occurrence(s) across 25 file(s) resolve against 77 workspace package(s); 28 not judged (2 foreign, 6 interpolated, 20 path)`.\n\n⚠️ ONE DECLARED NARROWING: `check:type-check-debt` was NOT completed. Its `--re-measure` half refuses to run without a fully built workspace closure (a deliberate #6376 refusal: measuring without it silently measures a different world — packages/lint reports 19 built vs 147 unbuilt). Its `--self-test` half passed (152 cases) and `check:type-check-coverage` passed. This diff adds no TypeScript to any package's tsc program — two .mjs scripts, a bash script, a workflow step and a package.json script key — so it cannot move a DEBT/TEST_DEBT number. CI runs it with the closure built. Declaring rather than silently skipping.\n\nCI: not awaited, per the standing draft-PR-time reporting contract.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions