Skip to content

ci(lint): key the PM dispatch-gates family on the files its battery opens - #22092

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22076-narrow-dispatch-gates-family
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22076-narrow-dispatch-gates-family

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22076
Clause-②: no

What changes

The pm_dispatch_gates arm of scripts/ci/select-gate-families.sh decides the if: of the PM dispatch-gates self-test step in Lint & Repo Gates on pull_request and merge_group. It now keys on the files the battery actually opens, measured, instead of on whole directories. Three classes stop selecting the step:

  1. Data and prose under scripts/ outside scripts/pm/ (.json, .md, .txt: ratchet baselines, pinned ledgers, fixture logs). Exception: a file whose basename some file spells as a quoted relative specifier (./… or ../…) still runs it. That is the one edge by which the discovery could open such a file: it follows imports out of gate sources.
  2. Agent prose and JSON outside the pm-dispatch rulebook: skills/**, AGENTS.md, CLAUDE.md, .claude/agents/**, .claude/settings.json, and the other skills under .claude/skills/.
  3. A test under a workspace package's scripts/ whose basename no package.json and nothing under .github/ spells.

Unchanged:

  • push to main and the scheduled run take the selector's unscoped *) event branch and run the whole battery (select-gate-families.sh:27–:28).
  • Lint & Repo Gates stays a required context.
  • The battery keeps one tier.
  • There is no edit to scripts/pm/dispatch-gates.mjs, scripts/pm/check-dispatch-gates.mjs or .github/workflows/lint.yml.
  • The other nine families are untouched.

Both new guards fail open: any git grep answer other than "no match" runs the family. Every extension the arm does not list also runs it.

Read-set: before → after (the pm_dispatch_gates arm)

change touches before after
scripts/pm/** (any file) run run
a code file under the root scripts/ run run
.json / .md / .txt under scripts/, outside scripts/pm/ run skip, unless a relative specifier names it
a non-test file under a package's scripts/ run run
a test under a package's scripts/ run skip, unless a manifest or .github/ names it
any package.json run run
.github/** run run
.claude/skills/pm-dispatch/** run run
.claude/** code (hook .sh, workflow .js) run run
skills/**, AGENTS.md, CLAUDE.md, other .claude/** .md/.json run skip
product code, docs, changesets skip skip
root config, unknown path, deletion/rename, empty diff every family every family

How the read-set was measured

I ran the full battery once (node scripts/pm/dispatch-gates.mjs --self-test, tree 3d9188502e, a detached worktree) under a preload hook. The hook was installed through NODE_OPTIONS=--import, so every node child the battery spawns carried it too. It recorded:

  • every readFileSync / readdirSync / existsSync / statSync / openSync under the repo root, with the innermost selfTest() line that caused it;
  • every child process with its argv and cwd.

Result: 1,976 cases pass, exit 0. There were 66 node processes, 8,430 distinct files opened under the repo root, and 57 git ls-files calls at the root.

What the battery opens, by reader:

reader files opened
compound-anchor census (dispatch-gates.mjs:18406) and exposed-scratch-dir sweep (:26643), whole tree every masked source, 8,319 (.ts .tsx .mts .mjs .js)
error-code literal census (:20834) every non-test TypeScript file, 3,208
shell-mask census (:19045) every tracked .sh, 30 (10 of them .claude/hooks/*.sh)
discovery and live cases all 39 workflows and 2 composite actions; 281 code files under scripts/ and 40 under scripts/pm/; 31 under packages/{spec,lint,platform-objects,services/service-messaging}/scripts/ (29 gate sources, 2 i18n-extract.config.ts, no test); root package.json, packages/{client,lint,spec}/package.json, root tsconfig.json, root .gitignore
rulebook live cases (:26122–:26290) 32 .md under .claude/skills/pm-dispatch/
live specimens 10 named product files (e.g. packages/objectql/src/engine.ts, packages/rest/src/rest-server.ts)

Never opened: any .json beyond those four manifests and tsconfig.json, and any .md outside the rulebook. The same holds for:

  • any .txt;
  • any .yml outside workflows and actions;
  • anything in skills/**, AGENTS.md and CLAUDE.md;
  • .claude/agents/**, .claude/settings.json, .claude/launch.json and the other .claude/skills/*;
  • 136 of the 167 files under package scripts/ dirs.

Name-level reads only:

  • existsSync on .claude/agents/os-dev.md, skills/ and three skills/*/SKILL.md. These are the governed read floor and the frame-sync COPIES table, which the battery reads by importing scripts/check-skill-frame-sync.mjs.
  • The .github/workflows/ listing.
  • The tracked name set.

Why the narrowing does not depend on this one tree:

  • resolveCheckToFiles admits only scripts/…\.(mjs|cjs|js|sh|ts|mts|cts) as a gate source.
  • firstPartyImportBindings follows only .//../ specifiers that resolve under the root scripts/, and never into a package's scripts/.
  • The rulebook is read by a hard-coded root (SKILL_RULEBOOK_ROOT).

So the only tree-dependent ways into the three classes are a relative import, and a manifest or workflow naming a package-scripts test. Both are guarded.

Which input classes can move a verdict, and how each was measured

input class battery reader can a modification move a verdict? measured by
scripts/pm/**, root scripts/ code, package scripts/ gate sources, workflows/actions, the four manifests, rulebook discovery and live cases yes read hook (content opens attributed to non-census selfTest() lines)
.claude/** and packages/** code, .sh anywhere whole-tree censuses yes, census-grade read hook (census lines :18406, :26643, :20834, :19045)
10 named product files live specimens yes, already off the PR path since #19498 read hook
scripts/ .json/.md/.txt, agent prose/JSON nobody; existence only for 5 names no read hook (zero opens); resolveCheckToFiles / import-follow code reading
package scripts/ tests, unwired the two whole-tree censuses only census-grade only, as for a src/ test read hook; git grep of every manifest and .github/ for each of the 67 such tests (2 are named: root-entry-type-nameability.pin.test.ts is a lint.yml step, and export-list.test.ts is mentioned in a ci.yml comment)
tracked names hint reachability, test-file residue an addition can git ls-files spawns in the hook log

Coverage that moves from PR time to push / scheduled runs

Effect, replayed

I replayed the real selector, before and after, over the last 300 first-parent merges on main ending at 3d9188502e. Each merge ran as a merge_group entry with base = first parent, in a no-checkout worktree.

  • The family is selected on 95 → 55 merges (31.7% → 18.3%). Forty flip run → skip and none flip the other way.
  • Of those 40:
  • 3 merges stay run because the specifier guard's superset grep hit:
    • eslint.config.mjs reads ./scripts/query-options-erasure-baseline.json through new URL;
    • a comment in scripts/docs-audit/README.md names handwritten-docs.json.

Pins (selector self-test case names)

Triage pin 1, "a PR touching only product code skips the family":

Triage pin 2, "a PR touching a gate's source, a workflow's gate wiring or scripts/pm/** runs it":

  • pull_request: a PR touching scripts/pm/** runs the PM self-test, prose included (#22076 pin) (new)
  • merge_group: a data file under scripts/pm still runs the PM self-test -- scripts/pm is in its read-set whole (new)
  • merge_group: a test under a package scripts/ that a check script names is a gate source and still runs the PM self-test (new)
  • merge_group: a data file a relative import specifier names still runs the PM self-test -- the one edge by which the discovery opens a data file (new)
  • merge_group: a workflow change runs every family built on the dispatch derivation, and not the scripts-only sweep (kept)
  • merge_group: a composite action is part of the workflow tree the derivation discovers (kept)
  • merge_group: a scripts/ subdirectory script is a gate source only (kept)
  • merge_group: a package-local script is a gate source (the derivation families) and a masked source (corpus) (kept)

Triage pin 3, "the scheduled run still runs it (control)":

  • schedule: a change the PR path skips the PM self-test for still runs it -- the hourly full run is the backstop (#22076 control) (new)
  • push: the same change on main still runs the PM self-test (#22076 control) (new)
  • Non-vacuity partner: merge_group: the same change, scoped, skips the PM self-test -- so the two controls above are not vacuous (new)

Other new or re-pinned cases:

  • a ratchet baseline … not the PM self-test, re-pinned. It previously expected the PM self-test to run.
  • agent prose the battery never opens … runs no family, re-pinned. It previously expected the PM self-test alone.
  • prose and a fixture log under scripts/ …
  • CLAUDE.md and the .claude settings JSON run no family
  • the pm-dispatch rulebook runs the PM self-test alone
  • a .claude hook shell script still runs the PM self-test alone
  • a scripts/ file of a kind the arm does not list still runs the PM self-test -- fail-open on the extension
  • a test under a package scripts/ that no manifest or workflow names … skips

The floor goes from 56 cases / 293 checks to 71 cases / 369 checks.

Reverse verification (on committed 1ed9df2285)

  • Leg A: the selector restored to 3d9188502e (git restore --source; on-disk blob e6c2f3c2 == base blob), run under the new self-test. Exit 1, with 7 cases red and 20 checks failing — every narrowing case, plus the non-vacuity partner. Restore: blob 76f368b7 == HEAD, and git diff HEAD is empty.
  • Leg B: node scripts/ablation-replace.mjs --delete on the named_by_relative_specifier call (anchor 1 → 0). Exit 1; exactly a data file a relative import specifier names still runs the PM self-test goes red (3 checks). Restore proven: blob == HEAD.
  • Leg C: the same on the named_in_gate_wiring call. Exit 1; exactly a test under a package scripts/ that a check script names … still runs the PM self-test goes red (3 checks). Restore proven.

Gates (head 1ed9df2285)

I derived the gate list with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 27 commands. I ran each one and wrote its exit code to disk before reading it.

All 26 non-battery commands exit 0. They include:

  • pnpm check:select-gate-families (71 cases / 369 checks)
  • check:bash32-floor, check:parse-guard, check:nul-bytes
  • check-ci-filter-parity, check-self-test-workflow-commands (with and without --self-test), check-comment-mask-corpus, check:entry-guard

pnpm check:pm-dispatch-gates (the control) ran detached on 1ed9df2285 (not under the verify lock), on a shared 4-core container at load 5–8. Result: exit 0, ✓ dispatch-gates self-test: 1976 cases pass., the battery took 1004.0s on this box, wall 1,006 s (2026-10-07T14:39:27Z → 14:56:13Z). That is a reading of a contended agent box, not of a runner. The instrumented measurement run at 3d9188502e also passed all 1,976 cases. The record is reconciled with --ran: ✓ dispatch-gates --ran: 27 derived famil(ies) accounted for — 27 run, 0 NOT-MEASURED.

Premise checks

  1. "Selects the step on most PRs." By rule, any package.json and anything under any scripts/ selected it. Measured over 300 merges, it selected 95, which is 31.7%, not most. The scripts data files carried 32 of those 95 on their own.

  2. "The censuses are deliberately off the PR path." In code this means the selector returns skip for product code, so those inputs never select the family. It does not mean the censuses are skipped once the family is selected: the battery has one tier, and every selected run pays all of them. The hook found four whole-tree censuses, not the two the old comment named. The two it did not name are the error-code literal census over 3,208 files and the .sh census. It also found 10 live product specimens. The header now names all of them.

  3. "A package.json change that does not touch a check:* script may not move the verdict." It may not, but the narrowing is not taken. In the replay, 13 merges touched a workspace manifest:

    • 10 also touched pnpm-lock.yaml (root config, so every family runs anyway);
    • 2 are structural (a deletion runs every family anyway);
    • 1 alone would gain.

    The set of manifests the discovery reads is also tree-dependent: on this tree it is root, client, lint and spec, decided by the --filter rows workflows spell. A scripts/name field diff would need node in the selector and a parse fail-open branch to save about 1 run in 300.

Notes

  • File surface. The selector has no --self-test flag. Its self-test is the sibling scripts/ci/select-gate-families.selftest.sh (pnpm check:select-gate-families), which is where the pins live, so the diff is those two files.
  • Not taken: the card's "two tiers". Triage did not take it: it edits the frozen dispatch-gates.mjs.
  • NOT MEASURED: the card's "wall under 18 minutes" and "the merge_group run's critical path with run ids". Reason: they can be read only from CI runs after this lands, on a product-only PR and on a queue entry.
  • Not narrowed: .github/ files outside workflows and actions (CODEOWNERS, labeler.yml, dependabot.yml). The battery never opens them, but none of the 300 merges touched them, so the arm would buy nothing.
  • Not narrowed: the three other derivation families. declared_population_live, bare_root_worklist and self_test_workflow_commands share reads_gate_tree and still run on scripts data and on package-scripts tests. Whether they can narrow too is not measured here.

Generated by Claude Code

claude added 2 commits October 7, 2026 14:16
…pens

The pm_dispatch_gates arm of select-gate-families.sh ran the PM self-test
for every change under scripts/ and every piece of agent configuration.
One full battery run under an fs/child-process read hook shows the battery
never opens a data or prose file under scripts/ outside scripts/pm/, nor
skills/**, AGENTS.md, CLAUDE.md or the .claude/ prose and JSON outside the
pm-dispatch rulebook. Those now skip the step on pull_request and
merge_group; scripts/pm/** whole, every code file under any scripts/, the
rulebook, .claude code, the workflow tree and every package manifest still
run it, and a scripts/ data file a relative import specifier names runs it
(the one edge by which the discovery could open one). push on main and the
scheduled run keep the whole battery. Self-test: 12 new or re-pinned cases,
floor raised to 68 cases / 354 checks.

Claude-Session: https://claude.ai/code/session_01VF48aw8RPG6wzDnMgp6rtw
Co-authored-by: Claude <noreply@anthropic.com>
…kips the PM self-test

The PM dispatch-gates discovery opens a file under a workspace package's
scripts/ only as a gate source, which is a file a check:* command in a
manifest or a workflow step spells; it follows no import into a package's
scripts/. A test there that no package.json and nothing under .github/
names is therefore read only by the two whole-tree censuses, as a test under
src/ is, and it now skips the step on pull_request and merge_group. A
basename either place spells still runs it (superset grep, fail-open on any
git grep error). Self-test: three more cases, floor 71 cases / 369 checks.

Claude-Session: https://claude.ai/code/session_01VF48aw8RPG6wzDnMgp6rtw
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants