ci(lint): run each scoped Lint & Repo Gates family only on the paths it reads - #16754
Conversation
) Lint & Repo Gates is the merge queue's critical path after #16453 (median 1468 s over the last ten merge-group runs; the PM dispatch-gates self-test alone 597 s), and none of its expensive steps read a merge group's file surface. A selector under scripts/ci/ -- the shape select-shard-packages.sh set -- classifies the changed paths on merge_group and pull_request and skips a family only when every path is one that family provably never reads. Every doubt runs every family: an unscoped event, an unresolvable base, an empty or failed diff, an unclassified path, a deletion or rename. The workflow steps spell `!= 'skip'`, so an absent output runs the step too. Five families are scoped, each with its read-set derived from the gate's own source: the PM dispatch-gates self-test, the query-options erasure ratchet, the slot-lookup ratchet, the verify-lock self-test and the comment-mask corpus walk. ESLint is not scoped. push on main and any scheduled run keep the full battery. The self-test drives every branch offline and also reads lint.yml to pin the YAML half of the contract: the selector step's id and env, the `!= 'skip'` spelling on every scoped step, and that the scoped set equals the decided set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
…rivation The self-test now spells its fixture paths unquoted and pins the verify-lock step by its script name, so scripts/pm/dispatch-gates.mjs extracts exactly the two files it reads (the selector and lint.yml) and check-self-test-wired does not read a pinned command fragment as a flag of this file. Header paths in both scripts are spelled without backticks for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
…han their gates pm_dispatch_gates now runs on a modification of any JS/TS source anywhere and of any nested .gitignore: the dispatch-gates self-test reads the content of every source file (compound-anchor census, exposed-scratch-dir sweep) and consults nested ignore rules, so a workspace source edit could have reddened the step the selector skipped. verify_lock now runs on a modification of any top-level scripts/*.mjs and any workspace package.json: case (h) of its suite runs the real entry point from the repo root, which preflights through scripts/pnpm-filter-targets.mjs and enumerates the workspace. Headers, the lint.yml notes and the self-test cases say so; two new cases pin the nested .gitignore and the non-source workspace file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Contract review (
|
| family | what the gate really reads (my derivation) | selector at 59e6122 |
verdict |
|---|---|---|---|
query_options_erasure / slot_lookup |
scripts/check-*-ratchet.mjs: LINT_TARGET = packages/**/*.{ts,tsx,mts,cts} (overrideConfigFile: true, no projectService, so no package tsconfigs), baseline JSON at HEAD and git show <merge-base>: , eslint.config.mjs (which itself readFileSyncs both baselines; NEVER_LINTED/SLOT_LOOKUP_UNSWEPT inline), imports ./import-prerequisite.mjs, ./invoked-as.mjs, ./eslint-fatal-guard.mjs, ./eslint-stack-headroom.mjs (all top-level scripts/*.mjs) |
packages/** TS, scripts/*.mjs, scripts/*.json, root-config |
complete |
comment_mask_corpus |
collectSources() walks the tree by SOURCE_EXTENSIONS (.ts .tsx .mts .cts .js .mjs .cjs .jsx) outside SKIPPED_DIRECTORIES; imports ./js-comment-mask.mjs, ./invoked-as.mjs, ./import-prerequisite.mjs; the independent parser comes from node_modules (pnpm-lock.yaml = root-config) |
any masked-source path anywhere, checked before class | complete |
verify_lock |
mode_self_test reads $SELF and a temp dir — and its case (h) (os-verify-lock.sh ~L4087–4160) runs bash "$SELF" -c 'echo pnpm --filter @objectstack/adapter-hono test' from repo_root ($(dirname "$SELF")/../..), which goes through filter_preflight → node scripts/pnpm-filter-targets.mjs --preflight, which imports ./invoked-as.mjs + ./workspace-enumerator.mjs and reads pnpm-workspace.yaml and every workspace package.json; the cases assert that @objectstack/adapter-hono is refused (exit 2) and @objectstack/hono (= packages/adapters/hono/package.json) is accepted |
scripts/pm/os-verify-lock.sh + root-config only; class scripts and class workspace (incl. */package.json) → skip |
incomplete → F1 |
pm_dispatch_gates |
scripts/pm/check-dispatch-gates.mjs spawns dispatch-gates.mjs --self-test. Confirmed reads: every workflow file, root + package package.json scripts, every gate source under scripts/** and packages/*/scripts/** (watch hints, argv defaults), .claude/** + skills/** via the frame-sync COPIES table, tsconfig.json, git ls-files name sweeps. Also, and not in the PR's read-set: the CONTENT of every JS/TS file in the tree — exposedScratchDirs({}) (L20445) reads every tracked .[cm]?[jt]sx? body via inTreeScratchDirs and asserts exposed.length === 0 (consulting nested .gitignores); the stampsAnErrorCodeLiteral census (L16533–16544) reads every non-test .ts body and asserts the specimen packages/spec/src/conversions/types.ts still stamps a code and the hit ratio stays in band |
M to a workspace file that is neither package.json nor under scripts/ → skip |
incomplete → F2 |
3. Fail-open (scripts/ci/select-gate-families.sh)
Traced every exit: event ∉ {merge_group,pull_request} → run all (no warning, by design); empty OS_GATE_PR_BASE_REF / OS_GATE_MERGE_GROUP_BASE_SHA → warn + run all; fetch failure → warn, then resolution decides; unresolvable merge-base / base sha → warn + run all; git diff non-zero → run all; control character in a path → run all; empty changed list → warn + run all (both events); any status other than A/M (D, T; renames arrive as D+A under --no-renames) → run all; any unknown class (new top-level dir, unlisted root file such as lychee.toml, tsup.config.ts, .mcp.json) → run all. family_reads has *) return 0 on every case; only positively-classified classes return 1. Exit 0 on every decision, exit 2 only on missing RUNNER_TEMP. YAML half: all five conditions spell != 'skip'; no == 'run'; an absent output therefore runs the step, and the self-test pins both spellings against the real lint.yml. pull_request diffs against merge-base(origin/<base>, HEAD) on the merge ref; merge_group diffs base_sha..HEAD so a multi-PR group is the union. The mechanism is sound; the two holes are in the read-sets (F1, F2), not in the fail-open plumbing.
4. Governed paths
Touched: no. Diff files are exactly .github/workflows/lint.yml, package.json, scripts/ci/select-gate-families.sh, scripts/ci/select-gate-families.selftest.sh. Nothing under docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**. .github/workflows/** is CI, not a governed surface — but this PR makes a required check (Lint & Repo Gates) verify fewer families at merge time, i.e. clause-② per card ruling 4, so this is a maintainer-only merge regardless of any review verdict; needs:contract-review is on both carriers. The PR is a draft, mergeable_state: blocked.
5. Changeset
skip-changeset is correct: nothing published moves — one private root package.json script entry (check:select-gate-families), scripts/ci/**, .github/**. Check Changeset green on 59e6122.
6. CI on 59e6122d8 (41 check runs at my last poll before the head moved)
29 success, 11 skipped (opt-in / path-filtered jobs), 0 failure, 1 in progress: Lint & Repo Gates — its log ends ##[error]The operation was canceled at 03:57:33Z because the branch was force-advanced to f12f2e1 (see §8). So the reviewed head never produced a Lint & Repo Gates verdict, and its own selector step output could not be read from the log. No red gate on 59e6122.
7. Tests
scripts/ci/select-gate-families.selftest.sh exists, is wired as root check:select-gate-families and run by the Gate-family selection self-test step in the same job. check-self-test-wired keys on scripts carrying a --self-test literal; neither new file carries one, so it has nothing to demand here (the self-test is wired by the workflow step regardless). The card's four minimum cases are present by name: M:scripts/pm/tool.mjs (dispatch-gates runs), docs-only group (0 run / 5 skipped, summary row pinned), M:.github/workflows/lint.yml (runs), A:brand-new-dir/thing.txt and M:lychee.toml (every family). Also covered: empty diff, no base, unfetchable base, shallow clone fetch-by-sha, deletion, rename, control-char path, PR base fetch, and the YAML pin. 39 cases at 59e6122 (37 run_case + 2 manual).
8. Head moved during this review
At 03:57:24Z the branch advanced to f12f2e1c7 ("widen two read-sets the contract review measured narrower than their gates"). I diffed 59e6122..f12f2e1: it adds is_masked_source || */.gitignore → run to pm_dispatch_gates, adds */package.json and top-level scripts/*.mjs to verify_lock, updates both script/workflow notes, and adds/rewrites 6 self-test cases. That delta closes F1 and F2 as I derived them (the preflight's two imports are both top-level scripts/*.mjs; the specimen and scratch-dir census files are all masked sources). It is not the reviewed head, so this verdict stands for 59e6122; a re-read at f12f2e1 is owed once its Lint & Repo Gates is green.
Findings
F1 — verify_lock skips on changes its self-test reads (at 59e6122). Case (h) of mode_self_test exercises scripts/pnpm-filter-targets.mjs --preflight against the real workspace and asserts on real package names; the selector skips the family for scripts/*.mjs (class scripts) and for any packages/*/package.json (class workspace, including status A — only pm_dispatch_gates has the "any A runs" rule). A group that changes the preflight's exit-code contract, or adds a package named @objectstack/adapter-hono, would go red on push to main only. The PR body's "reads exactly one file in the tree (itself); measured by grepping its mode_self_test body" is false. Expectation: key verify_lock on top-level scripts/*.mjs, every */package.json and pnpm-workspace.yaml (done in f12f2e1), and correct the family-list row in the PR body, which ruling 4 makes part of the contract.
F2 — pm_dispatch_gates skips on modified workspace source it reads (at 59e6122). The self-test's live exposedScratchDirs({}) and stampsAnErrorCodeLiteral censuses read the body of every tracked JS/TS file and assert over them (e.g. packages/spec/src/conversions/types.ts must still stamp a code; every mkdtempSync/mkdirSync site must be ignore-covered, consulting nested .gitignores); the selector treats an M to any packages|apps|examples/** file that is not a manifest or under scripts/ as inert. Expectation: any masked-source M anywhere and any .gitignore runs the family (done in f12f2e1), and the PR body's read-set row is updated to say so.
F3 — PR body family table is stale relative to the code that fixes F1/F2. Ruling 4: "the family list is part of the PR body." The body at the time of this review still states the 59e6122 read-sets. Expectation: the author (not this seat — I do not edit the PR) updates the table rows for pm_dispatch_gates and verify_lock to match the script header at f12f2e1 before the maintainer merges.
F4 — Acceptance item 2 cannot be shown by this PR's own merge-group run. Its diff touches root package.json (root-config → every family), so its summary will read 5 run / 0 skipped. The PR declares this. Expectation: the seat reads the first post-merge docs-only or changeset-only group's job summary and records it on #16496 before the card is closed; the card's acceptance line should not be ticked on this PR's run alone.
F5 — Card premise correction, informational. The card's "Measured" paragraph ("exercises scripts/pm/** and .claude/** only") is wrong, and the PR's own first derivation was also narrower than the gate (F1/F2). Ruling 3's "reads cannot be named → not scoped" is only as safe as the naming; the selector self-test proves the selector, not the read-set. Expectation: none for this PR beyond F1–F3; if a scoped family ever goes red on the hourly full run (#16467) while green in the queue, treat it as a read-set gap first, per acceptance item 3.
No approval, review, label or merge action was taken by this seat.
Generated by Claude Code
Fixes #16496
Lint & Repo Gatesis the merge queue's critical path after #16453. This lands the selector the card rules for: onmerge_groupandpull_request, five gate families run only when the changed paths touch the files that family reads;pushonmainand any scheduled run keep the full battery. One script underscripts/ci/with a self-test beside it, in the shapeselect-shard-packages.shset.Clause-②: yes (card ruling 4) — a required check verifies fewer families at merge time.
needs:contract-reviewis hung on both carriers.The family list (card ruling 4)
pm_dispatch_gates.github/**,scripts/**(every gate source, not onlyscripts/pm/),packages/*/scripts/**, everypackage.json,.claude/**,skills/**(the frame-sync COPIES table),AGENTS.md,CLAUDE.md,tsconfig.json, every.gitignore(nested ones included, via the exposed-scratch-dir sweep); the CONTENT of every JS/TS file in the tree (compound-anchor census offunction ...SelfTest...(declarations,exposedScratchDirsover everymkdtempSync/mkdirSynccaller), so a modification of ANY masked-source path runs it; PLUS the tracked NAME set viagit ls-files(test-file residue, dead-literal andscripts/**/*.d.mtsassertions), so any ADDED path runs it tooquery_options_erasurepackages/**/*.{ts,tsx,mts,cts},scripts/query-options-erasure-baseline.json(HEAD and merge base),eslint.config.mjs, top-levelscripts/*.mjshelpers it importsslot_lookupscripts/slot-lookup-baseline.jsonverify_lockscripts/pm/os-verify-lock.sh($SELF), plus what case (h) reaches by running the real entry point from the repo root:filter_preflight->scripts/pnpm-filter-targets.mjs --preflight, its top-levelscripts/*.mjsimports (invoked-as.mjs,workspace-enumerator.mjs),pnpm-workspace.yaml(root config) and every workspacepackage.jsoncomment_mask_corpus.ts .tsx .mts .cts .js .mjs .cjs .jsxfile outside build directories (SOURCE_EXTENSIONS/SKIPPED_DIRECTORIESin the script)Root configuration (
package.json,pnpm-lock.yaml,pnpm-workspace.yaml,turbo.json,tsconfig.json,eslint.config.mjs,.gitignore,.gitattributes,.npmrc,.nvmrc) runs every family. ESLint is not scoped (card ruling 2). No other step is scoped: every remaining step is either under 30 s at the median or its reads could not be named from its source in this sitting (card ruling 3: not named, not scoped).A docs-only or changeset-only group therefore skips 913 s of the 1468.5 s job median.
Fail-open, and how it is proved
The selector (
scripts/ci/select-gate-families.sh) runs EVERY family when: the event is notmerge_group/pull_request; the base is absent or unresolvable (after a fetch attempt);git difffails; the changed list is EMPTY (both events — PM ruling, the #10057 shape on a pull_request); any path is one the classifier does not recognise (a new top-level directory, an unlisted root file); any path carries a control character; any change is a deletion, rename or type change (--no-renames, so a rename is D + A). A family skips ONLY when every changed path is positively classified into a class it provably never reads; everycasein the script falls through to "run".The YAML half spells
if: steps.gate-families.outputs.FAMILY != 'skip', so an ABSENT output (selector never ran, wrote nothing) runs the step.scripts/ci/select-gate-families.selftest.sh(39 cases, 187 checks, offline, ~3 s) drives every branch above and additionally reads the reallint.ymlto pin: the selector step's id and env, the!= 'skip'spelling on every scoped step (and the absence of any== 'run'), that the scoped set equals--families, and that eachif:sits on the step running that family's command. The card's four minimum cases are present by name.Live runs against this branch (both shapes) select everything:
.github/workflows/lint.yml(M, workflow) andpackage.json(M, root-config) — so this PR's own merge-group summary will show 5 run / 0 skipped with reasons, not a skipped family. The skipped rendering is pinned by the self-test's step-summary check and will first be seen live on the next docs-only group; the seat reads that one. Recorded as a deviation from acceptance item 2 rather than worked around.Measured first (card ruling 3; PM assumption A1)
Medians over the last ten completed
merge_groupruns ofLint & Type Check(run ids 34175859565 … 34177868806, all 2026-09-08T01:12Z–02:13Z; 161 steps each): job median 1468.5 s (min 1194, max 1539). The card's single-sample figures moved: dispatch-gates self-test 518 → 597 s, erasure ratchet 112 → 110.5, verify-lock 100 → 100, ESLint 69 → 66. Two families the card did not name sit above 45 s: slot-lookup ratchet 57.5 s and the comment-mask corpus 48 s — both have nameable read-sets and are scoped. The full 161-row table with a read-set column is in the report comment on #16496.The load-bearing premise, tested per step (A2)
scripts/pm/**and.claude/**only". False:check-dispatch-gates.mjsspawns the tool's full--self-test, whose live-tree cases discover every workflow file, resolve everycheck:*script through root and packagepackage.jsons, read every gate source underscripts/**andpackages/*/scripts/**for watch hints, reachskills/objectstack-pm-dispatch/SKILL.mdthrough the frame-sync COPIES table, readtsconfig.jsonand.gitignore, and sweepgit ls-filesfor hint reachability and test-file residue. It also reads the CONTENT of every JS/TS file (the compound-anchor census and the exposed-scratch-dir sweep, both with assertions that can go red) and nested.gitignorefiles — measured by the contract reviewer in patch round 1 after round 0 had under-derived it. The key list is implemented wider than card ruling 2's list accordingly: any masked-source modification, any.gitignore, and additions/deletions anywhere run it.bash "$SELF" -c ...from the real repo root, which preflights throughscripts/pnpm-filter-targets.mjsand enumerates the workspace. Scoped to itself, top-levelscripts/*.mjs, and everypackage.json.packages/**TS only (LINT_TARGET), their baseline (also at the merge base),eslint.config.mjswhere both rules are defined inline, and top-levelscripts/*.mjshelpers.Contention and the name collision (A3, A4)
.github/workflows/lint.yml,scripts/ci/, rootpackage.json; control on the read: 100.changeset/entries on chore: version packages #15334's first page.lint.ymlis unchanged onmainsince the branch point5e53d73d.Lint & Repo Gatesstring incross-repo-issue-closer.ymlis a COMMENT naming the required status context; there is one job by that name, inlint.yml. Nothing else was touched.A prose conflict, resolved in favour of the card
The comments beside the dispatch-gates and verify-lock steps said the steps are "deliberately UNCONDITIONAL … weakening this gate is a maintainer call, and no reading of this note authorises one." The card carries that call (「同意你的建议,你负责执行派发所有可行的优化」, card ruling 2 names these two families first). Both notes are rewritten to say what changed and why the read-set-scoped
if:is not the exemption they refused (a label or a paths filter with no reader behind it). The notes beside the other self-test steps still say "unconditional, like the self-tests above" — those steps ARE still unconditional, so they were left as they are.Gates run locally (final commit
f12f2e1c7)Derived with
node scripts/pm/dispatch-gates.mjs --changed --commandson the deepened checkout; reconciled with--ran: "55 derived famil(ies) accounted for — 55 run, 0 NOT-MEASURED". All green with their own verdict lines, includingpnpm check:select-gate-families(all 39 cases passed, 187 checks),pnpm check:pm-dispatch-gates(✓ dispatch-gates self-test: 1534 cases pass — run detached per its header and waited on in the foreground; one earlier run of the same tree failed one CONTROL case only because this container's checkout was shallow with a movedorigin/main, which is the tool's documented SHALLOW refusal, not the tree),check-self-test-wired(190/190 self-tests wired),check:bash32-floor(31 tracked shell files),check:declared-population-live,check:watch-hint-literal,check:nul-bytes, the step-collector and workflow-command gates,check:required-contexts,check:workflow-status-functions, the comment-mask corpus walk (6343 files, 0 disagree).NOT MEASURED locally, declared to CI (each printed
PREREQUISITE NOT MET, exit 3, because they read builtdist/and this diff builds no package):check:dts-closure,check:dual-build-cjs-loads,check:sourcemap-no-sources-content,check:type-check-debt.pnpm lint(repo-level scan) is CI's run and was not run here; the diff touches no ESLint population file. Changeset: none — nothing published moves (scripts/ci/**,.github/**and a private rootpackage.jsonscript entry are outside every released package'sfiles[]);skip-changesetis hung.The dispatch derivation is a consumer of this change:
extractWatchHintsover the new files yields exactlyscripts/ci/select-gate-families.shand.github/workflows/lint.ymlfor the self-test and nothing for the selector (the precedent yields one and one), so no fabricated MATCHED lead is added.Patch round 1 (contract review FAIL -> fixed)
The reviewer measured two key sets narrower than their gates' true read-sets:
pm_dispatch_gatesskipped a workspace source edit although the self-test reads every source's content, andverify_lockwas described as reading one file although its case (h) preflights throughscripts/pnpm-filter-targets.mjsand the workspace manifests. Bothfamily_readsarms are widened (commitf12f2e1c7), the headers andlint.ymlnotes corrected, the two self-test cases that pinned the wrong behaviour flipped, and two cases added (a nested.gitignorerunspm_dispatch_gates; a non-source, non-manifest workspace file still skips every family). Re-run on the patched tree: self-test 39/39, and the tenlint.yml/scripts/**readers named above pluscheck:pm-dispatch-gates(verdict in the report comment). Derived set unchanged: 55 derived, 55 run, 0 unrun.验收备注
scripts/pm/**and.claude/**only; corrected in this PR's read-set, no separate card (承接者: this PR)..githooks/,docker/,.vscode/and unlisted root files classify asunknownand run every family — conservative by design, revisit only with a measured reason (承接者: none).package.jsonis treated as root configuration (every family) although ascripts:-only edit moves none of them; the path alone cannot tell a dependency bump from a script entry, andpnpm-lock.yamlis the resolver (承接者: none).lint.ymlhas noschedule:trigger at all, its push-on-mainrun iscancel-in-progress, and no workflow consumes a red push-mainrun ofLint & Type Check(merge-queue-triage.ymllistens toCIonly); ci: push runs on main use the affected set; an hourly scheduled run keeps the full list and files a card on red (maintainer-directed) #16467 is filed againstci.ymland will not coverlint.ymlby construction. Recorded on ci: push runs on main use the affected set; an hourly scheduled run keeps the full list and files a card on red (maintainer-directed) #16467 (comment 5578952497) so its scope explicitly includeslint.yml. The selector already runs every family on anyscheduleevent, so nothing further changes here once that lands.Generated by Claude Code