Repository navigation
Description
Found while implementing #10015 (which makes the ten then-unschedulable declarations schedule). Filed unassigned.
The residual hole
After #9829/#10014 and #10015, ci.yml carries a crosspkg paths-filter whose entries mirror the
top-level roots that CROSS_PACKAGE_TEST_INPUTS declares and core does not already match:
crosspkg:
- 'scripts/**'
- 'content/**'
- 'docs/audits/2026-07-unknown-key-strictness-ledger.md'
- 'skills/objectstack-formula/**'
- '.github/workflows/scaffold-e2e.yml'pnpm check:cross-package-test-inputs verifies two of the three layers the mechanism has:
- it finds escaping tests itself, statically, and fails naming any package that has one and no
declaration; --verifyrequiresturbo.jsonto carry a matching$TURBO_ROOT$/…input, so the task hash
moves with the declared path (Layer B).
Nothing verifies the third: that the scheduler can start the job in which Layer A's
--union-into step lives. The five entries above are a second recognizer of the same declarations,
kept in step by hand. Add a declaration in a root none of them covers — tools/, docker/,
paseo.json, a second skills/* bundle, another docs/… file — and the gate stays green, the
turbo hash still moves, and the test still does not run at PR time. That is exactly the #7802
shape, one layer up, and it is the failure #10015 was filed for after #9829 fixed one root.
Measured, on f3996182b
Instantiating every $TURBO_ROOT$ declaration to a real tracked file and running it through
ci.yml's filters with picomatch (the matcher dorny/paths-filter@v4 uses), under both 2.3.1
(bundled by the action) and 4.0.5 (this tree) — the two agree on every row:
| declarations | core=false | of those, covered by crosspkg |
|
|---|---|---|---|
| before #10015 | 64 | 19 | 9 |
| after #10015 | 64 | 19 | 19 |
The "after" row is green today, and no gate holds it there.
Two routes, both previously considered
-
Derive the filter entries from
CROSS_PACKAGE_TEST_INPUTSat CI time — option 1 of ci.yml'scorepaths-filter names no path underscripts/, so a scripts-only PR skips Test Core — and with it the--union-intostep three cross-package declarations depend on #9829's
own list, held there because it collided with fix(ci): reconstruct the paths cross-package tests really read, instead of trusting quoted prose #9826's rewrite of
scripts/check-cross-package-test-inputs.mjs. fix(ci): reconstruct the paths cross-package tests really read, instead of trusting quoted prose #9826 has since merged, so the collision is
gone.dorny/paths-filteraccepts itsfiltersinput as a string, and a YAML flow sequence
(crosspkg: ["scripts/**", …]) is a single line, so an earlier step could emit it into
$GITHUB_OUTPUTwith no indentation problem. Cost: a new failure mode inside thefilterjob —
the one job whose death THE FILTER CONTRACT (filterjob 一旦失败,Test Core / Build Core / Dogfood 会全部 skipped 而分支保护判为通过 —— 隐式 success() 今天已第三次咬人 #4928) was written about — and the entries stop
being readable in the workflow file. -
A static parity gate: assert at lint time that every declared glob is covered by
coreor
crosspkg. No CI-time coupling, entries stay readable and commented. Cost: it needs a
coverage decision. Instantiating to tracked files needs a picomatch-compatible matcher for
core'sapps/!(docs)/**extglob, which the gate's deliberately dependency-free
globToRegExpdoes not support — i.e. a third recognizer, with its own divergence risk. A
pure-string rule avoids that: a declared glob is covered iff some list literally contains it, or
contains<prefix>/**for a directory prefix of its leading literal segments. Checked by hand
against all 55 unique declared globs onf3996182b, that rule classifies every one correctly —
red on the exact ten ci.yml's core filter misses ten more declared cross-package test inputs — content/ (7), docs/, skills/, .github/workflows/ #10015 fixes (including.github/workflows/scaffold-e2e.yml, the
same-root-different-file case a root-level rule would false-green), green on the other 45.
Route 2's pure-string rule looks like the cheaper and safer of the two, but choosing between them
is a mechanism decision, which is why this is a card rather than a rider on #10015's PR.
Refs: #10015 (the four roots), #9829 / #10014 (the scripts/ root), #9826 (the merged rewrite that
unblocked route 1), #7802 (the defect the mechanism exists for), #4928 (THE FILTER CONTRACT).
Activity
os-zhuang commentedon Aug 21, 2026
Triage:
pm:queue,Task,domain:devx(ci.yml + lint-time gate work). Route choice is lane discretion, not a maintainer decision — no product semantics move. The card's own measured analysis favours route 2 (static parity gate, pure-string coverage rule): hand-checked against all 55 declared globs, red on exactly the ten #10015 fixed, and it avoids both the filter-job death mode (#4928) and a third glob dialect. Dispatch as PM-suggested route (falsifiable — if the dev finds a declared glob the pure-string rule misclassifies, report the fork rather than silently switching to route 1). Wire the new check into CI and dispatch-gates population literals. Size/model: M,mode:subagent, default judgment tier.Generated by Claude Code
claude commentedon Aug 21, 2026
{ "issue": 10379, "status": "done", "branch": "claude/issue-10379-crosspkg-filter-parity", "pr": "https://github.com/objectstack-ai/objectstack/pull/10801", "premise_still_valid": true, "summary": "Took route 2 as directed: a static parity gate, scripts/check-ci-filter-parity.mjs, wired into lint.yml's 'Lint & Repo Gates' job by direct `node scripts/...` invocation (both legs), with no root package.json alias (#9465 fence) and no edit to ci.yml (THE FILTER CONTRACT, #4928, untouched). It asserts Layer C: every glob CROSS_PACKAGE_TEST_INPUTS declares is reachable by ci.yml's `core` or `crosspkg` filter, using the card's pure-string rule verbatim. The declaration table is now exported and IMPORTED by the new gate rather than copied -- a copy would be the same defect one file further out. Two limbs beyond the card's ask: it reads the `test` job's `if:` and REFUSES if either filter has left the OR (without which deleting `crosspkg` from the condition would reopen the whole hole while the gate reported parity), and it reports a `crosspkg` entry that covers no declaration any more. Seven refusals cover every state where the subject could not be read, so a parity gate that cannot find the filters never compares two empty sets and passes.", "tests": "ALL AT 27f2dd8954 (final commit, after merging origin/main), every exit code captured BEFORE any pipe (`cmd > file 2>&1; EXIT=$?`).\n\nTHE HOLE, REPRODUCED BEFORE IT WAS CLOSED (the card's primary evidence). Added `docker/**` to @objectstack/metadata-protocol's declaration with a `heldBy` witness and the matching `$TURBO_ROOT$/docker/**` turbo input, so Layers A and B were both satisfied. Result: `pnpm check:cross-package-test-inputs` exit 0, printing 'OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.' -- while picomatch 4.0.5 (the matcher dorny/paths-filter@v4 uses) on docker/Dockerfile against ci.yml's filters gives core=false crosspkg=false, i.e. the test job is never scheduled. Green gate, moving hash, unschedulable test. The new gate on the same tree: exit 1, naming 'docker/** (declared by @objectstack/metadata-protocol)'. Demo edits reverted; git hash-object byte-identical to HEAD for both files, `git status --porcelain` empty (no MM).\n\nTHE CLASSIFICATION, REPRODUCED ON MY BASE (699132f259) -- MY NUMBERS DIFFER FROM THE CARD'S AND I TRUST MINE: 82 declarations / 71 unique globs, not the card's 64 / 55 on f3996182b -- the tree grew. Uncovered today: 0/71. With `crosspkg` rolled back to its pre-#10015 list (`scripts/**` alone): 10 uncovered, and they are EXACTLY the ten #10015 fixed, including .github/workflows/scaffold-e2e.yml (the same-root-different-file case: `core` names .github/workflows/ci.yml, a DIFFERENT file under the same root, which a naive root-level rule false-greens). The card's 45 green became 61; the ten are unchanged. The gate's self-test pins that rollback at exactly 10.\n\nTHE RULE CROSS-CHECKED AGAINST THE REAL MATCHER (evidence, deliberately not machinery): instantiating all 71 unique declared 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, 0 disagreements, and no glob matched zero tracked files. So I did NOT overturn the PM's route: the rule misclassifies nothing on today's tree. Its error direction is stated in the header and pinned: sound (never covered for a path the scheduler would miss) and knowingly incomplete (an entry with a wildcard of its own -- only `apps/!(docs)/**` today -- covers nothing, costing one line of YAML rather than a silent gap).\n\nBOTH DIRECTIONS PINNED IN THE SELF-TEST (36 assertions, exit 0): a glob covered by `core` passes and the verdict names `packages/**` as the coverer; one covered ONLY by `crosspkg` passes via `content/**`; one covered by neither FAILS naming both the glob and the declaring package; a mixed table separates the three rather than judging the table as one; the same-root-different-file case is pinned twice -- once observed FAILING with only `.github/workflows/ci.yml` present, once green after naming the file itself; the extglob bound; the stale-entry direction; seven refusals; the checked-in ci.yml; and the CI wiring read out of lint.yml (so a gate that exists and is not scheduled fails rather than sitting dormant).\n\nABLATION. Mutated the coverage rule's final fallthrough so it can never report uncovered: `return { covered: false, via: null, kind: null };` -> `return { covered: true, via: glob, kind: 'literal' };`. CONFIRMED ON DISK BY ANCHOR COUNT, not by an editor's exit code: removed text 1 -> 0 occurrences, injected marker ABLATION-10379 0 -> 1, and the mutated line read back off disk. No build/dist leg exists for a directly-run .mjs, so there was nothing to rebuild. PREDICTED: the ten assertions I enumerated go red. OBSERVED: 10 of 36, and they are exactly those ten (docker/** case, extglob bound, both same-root cases, the covered-by-neither pair, the mixed table, the stale limb, main()'s report path, and the pre-#10015 rollback reporting 'got 0'). HONEST CORRECTION: the count I wrote alongside that enumeration said '9' -- an arithmetic slip in my own list, not a different prediction; the predicted SET was exact. SECOND OBSERVATION 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 lint.yml runs both legs. Restored: marker 1 -> 0, git hash-object byte-identical to HEAD, self-test back to 36 assertions / exit 0.\n\nGATES. `node scripts/pm/dispatch-gates.mjs` with NO paths (it derives the change set from the merge base itself) named 12 families, the same 12 before and after the merge; change set correctly derived as 3 paths (.github/workflows/lint.yml, scripts/check-ci-filter-parity.mjs, scripts/check-cross-package-test-inputs.mjs). Exit codes, each with the gate's OWN printed verdict line: node scripts/check-ci-filter-parity.mjs --self-test = 0 ('check-ci-filter-parity --self-test: 36 assertions -- ...'); node scripts/check-ci-filter-parity.mjs = 0 ('OK: 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.'); pnpm check:cross-package-test-inputs = 0 ('All 90 self-test cases passed.' + 'OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.'); pnpm check:entry-guard = 0 ('check:entry-guard: 128 scripts/ file(s) -- every entry guard goes through invoked-as.mjs; 86 export bindings, 76 of them inert on import'); node scripts/check-parse-guard.mjs = 0; node scripts/check-nul-bytes.mjs = 0 ('check-nul-bytes: OK (scanned 6250 text file(s) ... no raw ASCII control bytes)'); node scripts/pm/dispatch-gates.mjs --self-test = 0 ('dispatch-gates self-test: 463 cases pass.'); pnpm check:required-contexts = 0; pnpm check:workflow-status-functions = 0 ('OK (scanned 26 workflow file(s), 49 job(s), 24 job-level if: expression(s))'); node scripts/check-aggregator-roster.mjs = 0; pnpm check:shard-attestation = 0 ('2 aggregate gate(s) count 3 declared leg(s) across 3 attesting job(s)'); pnpm check:node-version = 0; node scripts/check-ratchet-remedy-authority.mjs = 0 ('114 scripts swept ... 103 hand out no ratchet-expanding remedy'); pnpm check:type-check-coverage = 0 ('OK -- 64/77 workspace packages type-checked'). I ran entry-guard and parse-guard BY HAND per the dispatch's #10784 warning; both are blind to my new script in the derivation and both passed.\n\nONE 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>`. Same script. The limbs that READ .github/workflows/lint.yml -- the file this PR changes -- live in the SCAN (they assert the workflow still invokes `typecheck:root` and `turbo run typecheck`), and that leg ran green (exit 0). What did not run 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. Building the whole workspace closure buys nothing -- this diff contains no TypeScript (one new .mjs, one .mjs edit, one workflow), so no ledger number it measures can move, and lint.yml builds the closure before that step in CI.\n\nMERGE. origin/main moved 2 commits (#10780 docs config, #10755 entry guards OUTSIDE scripts/) while I worked; merged them in, no conflicts, no os-regen-pending deferral (neither commit touches a driver-managed artifact), and re-ran the entire gate union at the merged HEAD -- all the exit codes above are from that run. Overlap with my three files: empty.", "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code