Skip to content

ci(devx): assert every cross-package declared glob can schedule the test job - #10801

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-10379-crosspkg-filter-parity
Aug 21, 2026
Merged

ci(devx): assert every cross-package declared glob can schedule the test job#10801
os-zhuang merged 3 commits into
mainfrom
claude/issue-10379-crosspkg-filter-parity

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes#10379

The residual hole

CROSS_PACKAGE_TEST_INPUTS has three layers acting on it, and pnpm check:cross-package-test-inputs verified two:

what it holdschecked before this PR
Layer A--union-into adds the declaring package to the shard's package setyes — the gate finds escaping tests itself
Layer Bturbo.json carries a matching $TURBO_ROOT$/… input, so the task hash movesyes — --verify
Layer Cthe scheduler starts the job Layer A's step lives insideno

ci.yml's crosspkg: filter is a second, hand-kept recognizer of the same declarations. Nothing held the two in step, so a declaration landing in a root no entry covers left the gate green, the turbo hash moving, and the test still not running at PR time — #7802's shape one layer up.

The hole, reproduced before it was closed

A docker/** glob was added to @objectstack/metadata-protocol's declaration (with a heldBy witness and the matching $TURBO_ROOT$/docker/** input, so both existing layers were satisfied):

pnpm check:cross-package-test-inputs
-> exit 0
OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
picomatch 4.0.5 (the matcher dorny/paths-filter@v4 uses), docker/Dockerfile against ci.yml's filters
core=false crosspkg=false => test job scheduled: false

Green gate, moving hash, unschedulable test. With this PR's gate on the same tree:

node scripts/check-ci-filter-parity.mjs
-> exit 1
docker/** (declared by @objectstack/metadata-protocol)

The demonstration edits were reverted; git hash-object confirms both files byte-identical to HEAD.

Route 2, and why not route 1

Route 1 (deriving the filter entries from the declarations at CI time) puts a new failure mode inside the filter job — the one job whose death THE FILTER CONTRACT (#4928) was written about — and makes the entries unreadable where a human looks for them. This is route 2: a static parity gate at lint time.

The coverage rule is pure string:

a declared glob is covered iff a scheduling list contains it verbatim, or contains <prefix>/** for a directory prefix of its leading literal segments

Deliberately not a file-instantiating matcher. core: carries the extglob apps/!(docs)/**, which the sibling gate's dependency-free globToRegExp does not support — matching would need a picomatch-compatible engine, i.e. a third recognizer of the same declarations, which is the defect this card is about. A pure-string rule needs no matcher at all.

Its error direction is stated in the header and pinned in the self-test: sound (never reports covered for a path the scheduler would miss — both limbs imply containment) and knowingly incomplete (an entry carrying a wildcard of its own covers nothing, so a future declaration under apps/ reads as uncovered until an entry names it — one line of YAML, versus a silent scheduling gap in the other direction).

The classification, measured on this branch's base

count
declarations (package, glob) pairs82
unique declared globs71
uncovered today0
uncovered with crosspkg rolled back to its pre-#10015 list (scripts/** alone)10

Those ten are exactly the ten #10015 fixed — including .github/workflows/scaffold-e2e.yml, the same-root-different-file case a naive root-level rule false-greens (core names .github/workflows/ci.yml, a different file under the same root). The card measured 55 unique globs / 45 green on f3996182b; the tree has since grown to 71 / 61. The ten are unchanged and the gate's self-test pins that rollback.

Cross-checked once against the real matcher rather than argued: instantiating all 71 unique globs to the tracked files they match and running each through core + crosspkg with picomatch 4.0.5, the pure-string rule and picomatch agree on 71 of 71 rows, with no glob matching zero tracked files. That measurement is evidence, deliberately not machinery — reproducing it inside the gate is the third recognizer the rule refuses.

What else the gate holds

  • It reads the test job's if: and refuses if core or crosspkg has left the OR. Without that limb, deleting crosspkg from the condition would reopen the whole hole while the gate reported parity against a list that schedules nothing.
  • It reports a crosspkg entry that covers no declaration any more — the staleness direction, so the hand-kept list cannot accumulate entries that make it look maintained.
  • Seven refusals rather than a clean zero over a subject it could not read (unparseable ci.yml, no filter job, no dorny/paths-filter step, a filters: input that is not a string or not lists of strings, a missing scheduling filter, a filter gone from the if:, an empty declaration table).
  • It imports CROSS_PACKAGE_TEST_INPUTS from the sibling gate (newly exported) rather than holding a copy — a copy here would be the same defect one file further out. Importing that module runs nothing; its dispatch is behind isEntrypoint and its own self-test spawns a child to pin that.

Verification

All at 27f2dd8954 (the final commit, after merging origin/main), each exit code captured before any pipe:

commandexitthe gate's own verdict line
node scripts/check-ci-filter-parity.mjs --self-test0✓ check-ci-filter-parity --self-test: 36 assertions — …
node scripts/check-ci-filter-parity.mjs0OK: all 82 declared cross-package glob(s) (71 unique) are covered by core or crosspkg, every crosspkg entry still covers one, and the test job's if: still names both filters. (backticks the gate prints around the four names elided for the table)
pnpm check:cross-package-test-inputs0All 90 self-test cases passed. / OK: 13 package(s) read outside themselves, all declared…
pnpm check:entry-guard0✓ check:entry-guard: 128 scripts/ file(s) — every entry guard goes through invoked-as.mjs…
node scripts/check-parse-guard.mjs0(clean)
node scripts/check-nul-bytes.mjs0check-nul-bytes: OK (scanned 6250 text file(s)…; no raw ASCII control bytes)
node scripts/pm/dispatch-gates.mjs --self-test0✓ dispatch-gates self-test: 463 cases pass.
pnpm check:required-contexts0(clean)
pnpm check:workflow-status-functions0check-workflow-status-functions: OK (scanned 26 workflow file(s)…)
node scripts/check-aggregator-roster.mjs0(clean)
pnpm check:shard-attestation0✓ check-shard-attestation: 2 aggregate gate(s) count 3 declared leg(s)…
pnpm check:node-version0(clean)
node scripts/check-ratchet-remedy-authority.mjs0OK check-ratchet-remedy-authority: 114 scripts swept…
pnpm check:type-check-coverage0check-type-check-coverage: OK — 64/77 workspace packages type-checked…

node scripts/pm/dispatch-gates.mjs (no paths — derived from the merge base) named the same twelve families before and after the merge; every one was run except check:type-check-debt.

One declared narrowing, and it is narrower than the family name suggests.pnpm check:type-check-debt is --self-test && --re-measure; pnpm check:type-check-coverage is --self-test && <scan>. Both legs are the same script, and the limbs that read .github/workflows/lint.yml — the file this PR actually changes — live in the scan, asserting that the workflow still invokes typecheck:root and turbo run typecheck. That leg ran and passed (exit 0, table above). What did not run locally is --re-measure, which reads no workflow at all: it refuses here with --re-measure cannot run: 55 workspace dependenc(ies) … have no built type entry point on disk, the gate correctly declining to measure a different world rather than a failure. Building the whole workspace closure to satisfy it buys nothing — this diff contains no TypeScript (one new .mjs, one .mjs edit, one workflow), so no ledger number it measures can move. lint.yml builds the closure before that step, so CI runs it for real.

Ablation

The coverage rule's final fallthrough was mutated so it can never report uncovered:

return { covered: false, via: null, kind: null };
-> return { covered: true, via: glob, kind: 'literal' }; // ABLATION-10379

Confirmed on disk by anchor count, not by an editor's exit code: the removed text went 1 -> 0 occurrences and the injected marker 0 -> 1. Predicted: the ten named assertions go red. Observed: 10 of 36, and they are exactly the ten enumerated — the docker/** case, the extglob bound, both same-root-different-file cases, the covered-by-neither pair, the mixed table, the stale limb, main()'s report path, and the pre-#10015 rollback (got 0, from 10). (The count first written alongside that enumeration said "9"; that was an arithmetic slip in the enumeration, not a different prediction.)

The second observation is the one worth keeping: under the same ablation, the gate over the real tree still printed OK and exited 0, because today's tree has no uncovered glob. The self-test is the only thing holding this rule — which is why it is wired into lint.yml as both legs.

Restored and re-measured: marker 1 -> 0, git hash-object byte-identical to HEAD, self-test back to 36 assertions / exit 0.

Notes

Generated by Claude Code


Generated by Claude Code

…est job
CROSS_PACKAGE_TEST_INPUTS has three layers acting on it and only two were
checked. `check:cross-package-test-inputs` finds the escaping tests itself
(Layer A's roster) and `--verify` makes turbo.json hash every declared glob so
the task hash moves (Layer B). Nothing verified the third: the scheduler has to
start the ci.yml `test` job that Layer A's `--union-into` step lives inside, and
it decides that from `crosspkg:` — a hand-kept second recognizer of the same
declarations. A declaration landing in a root no entry covers left the gate
green, the turbo hash moving, and the test still not running at PR time.
check-ci-filter-parity.mjs closes it with a pure-string coverage rule: a
declared glob is covered iff a scheduling list contains it verbatim or contains
`<prefix>/**` for a directory prefix of its leading literal segments. It reads
the declaration table rather than copying it, reads the `test` job's `if:` so a
filter leaving the OR is a refusal rather than a silent pass, and reports a
`crosspkg` entry that covers no declaration any more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-zhuang@claude