diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 81139e44eb..7b78557dd9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -174,6 +174,45 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # ── Gate-family selection (#16496) ─────────────────────────────────── + # After #16453 this job is the merge queue's critical path (median 1468 s + # over the ten merge-group runs measured for #16496; the PM dispatch-gates + # self-test alone 597 s), and none of its expensive steps read a + # merge group's file surface: a docs-only group paid the full battery. + # So on `merge_group` and `pull_request` the FIVE scoped families below + # -- each step carrying `if: steps.gate-families.outputs. != 'skip'` + # -- run only when the changed paths touch the files that family reads. + # `push` on main and the scheduled full run keep the whole battery: the + # script runs everything for any event it does not scope. + # + # ⭐ The invariant is FAIL-OPEN, and it holds at both layers. The script + # runs every family when the base cannot be resolved, the diff fails or + # is empty, a path is one it does not classify (a new top-level + # directory, an unlisted root file), or any change is a deletion, rename + # or type change; a family is skipped ONLY when every changed path is + # positively classified into a class that family provably never reads. + # The `!= 'skip'` spelling means an ABSENT output -- the selector never + # ran, or wrote nothing -- also runs the step. Both halves are pinned by + # `scripts/ci/select-gate-families.selftest.sh` (`check:select-gate- + # families`, further down this job), which also reads THIS file and + # refuses any other `if:` spelling and any family the script does not + # decide. The per-family read-sets, and how each was measured, are in + # the script's header. + # + # The selection itself lives in scripts/ci/select-gate-families.sh: + # environment in, `$RUNNER_TEMP/gate-families.txt` + `$GITHUB_OUTPUT` + # out, and a ran/skipped table in the job summary (the seat reads it). + # This step only exports the event's fields, so the same script -- every + # branch of it -- runs locally and under its self-test. + - name: Select the gate families this run pays for + id: gate-families + env: + OS_GATE_EVENT_NAME: ${{ github.event_name }} + OS_GATE_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + OS_GATE_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} + run: | + bash scripts/ci/select-gate-families.sh + # #7484 docs anchors: `lychee.toml` sets `include_fragments = "none"`, so # the `Check Documentation Links` job resolves a link's FILE and never its # `#anchor` — measured with the pinned lychee 0.24.2 under the CI argv, a @@ -251,7 +290,12 @@ jobs: # file is ignored COMPLETELY, so new erasures added to one ride the old # entry in silence. This re-measures those files with the grandfathering # lifted and holds them to a per-file count, so the list can only shrink. + # + # Scoped (#16496): reads `packages/**/*.{ts,tsx,mts,cts}`, its baseline, + # `eslint.config.mjs` and the top-level `scripts/*.mjs` it imports; a + # group touching none of those skips it (selection step above). - name: Slot-lookup ratchet + if: steps.gate-families.outputs.slot_lookup != 'skip' run: pnpm check:slot-lookup # Engine query-options erasure ratchet (#4918). The slot-lookup rule above @@ -269,7 +313,11 @@ jobs: # slot-lookup for the non-test residual, plus one aggregate decrease-only # number for test code (a test whose subject IS off-contract engine input # must be able to build it). Runs its own --self-test first. + # + # Scoped (#16496): the same population and inputs as the slot-lookup + # ratchet above, with `scripts/query-options-erasure-baseline.json`. - name: Engine query-options erasure ratchet + if: steps.gate-families.outputs.query_options_erasure != 'skip' run: pnpm check:query-options-erasure # @objectstack/verify stand-in erasure guard (#6399). The third and @@ -620,7 +668,13 @@ jobs: # Cost, measured on a contended 4-vCPU container: 4,741 files, 72 MB of # source, 45–48 s, of which the parser is 45 s. The step prints CI's own # number on every run. + # + # Scoped (#16496): the walk reads every `.ts .tsx .mts .cts .js .mjs + # .cjs .jsx` file in the tree, so a group that changes no file of those + # extensions (and no root configuration) skips it -- selection step at + # the top of this job. - name: Comment mask agrees with a real parser over the whole corpus + if: steps.gate-families.outputs.comment_mask_corpus != 'skip' run: node scripts/check-comment-mask-corpus.mjs # Stack-collection enumerations vs the schema (#6242). `stack.zod.ts` @@ -745,9 +799,28 @@ jobs: # green and surfaced later as a prompt naming the wrong gate families — # output that reads as correct, produced by the one tool whose purpose is # to stop gate lists being memory-shaped. Same family as the - # `Changeset-family gate self-tests` step above, and like that one this - # step is deliberately UNCONDITIONAL: no `if:`, no label read, no paths - # filter. An exemption is what a self-test must not have, or the gap moves. + # `Changeset-family gate self-tests` step above. Until #16496 this step + # was deliberately UNCONDITIONAL -- no `if:`, no label read, no paths + # filter -- on the argument that an exemption is what a self-test must + # not have, or the gap moves. That argument was about an exemption a + # PATH FILTER or a LABEL would grant: a paths: trigger or a label read + # skips the step on a card that never touched the paths someone once + # listed, with no reader of what the test actually reads. What runs + # here now is different in kind and was the maintainer's call (#16496, + # 「同意你的建议,你负责执行派发所有可行的优化」): the selection step at + # the top of this job classifies every changed path against this + # self-test's MEASURED read-set (every workflow, every gate source under + # `scripts/**` and `packages/*/scripts/**`, every `package.json`, + # `.claude/**`, `skills/**`, `AGENTS.md`, `CLAUDE.md`, `tsconfig.json`, + # every `.gitignore` -- nested ones included -- the CONTENT of every + # JS/TS file in the tree (the compound-anchor census of + # `function ...SelfTest...(` declarations and the exposed-scratch-dir + # sweep of every mkdtempSync/mkdirSync caller both assert over it), and + # the tracked NAME set it sweeps -- so any added, deleted or renamed + # file runs it too), and skips this step only when every path is one + # the test provably never reads: a modified doc, changeset or non-source + # workspace file. Every doubt runs it, the self-test of the selector + # pins that, and `push` on main keeps it unconditional. # # The gate runs the SELF-TEST only. The live derivation # (`node scripts/pm/dispatch-gates.mjs `) answers a question about a @@ -775,13 +848,17 @@ jobs: # counts stand here either, for the same reason — they rot the same way, # and it is the SHAPE of the work, not its size, that the argument needs. # - # ⛔ Cost was never what makes this step unconditional. The FIRST - # paragraph above is, and it stands without this half: an exemption is - # what a self-test must not have, or the gap moves. Losing the estimate is - # therefore NOT an argument for an `if:`, a label read or a paths filter — - # weakening this gate is a maintainer call, and no reading of this note - # authorises one. + # ⛔ Cost was never what decided this step's conditionality, and the + # measured medians that motivated #16496 (597 s here, over ten + # merge-group runs) live in that card and its PR, not in this note. + # Losing the estimate is NOT an argument for a label read or a paths + # filter, and the read-set-scoped `if:` below is not one of those: it + # was a maintainer call (the first paragraph names it), and widening the + # skip -- any change to the classes the selector treats as inert for + # this family -- is again a maintainer call, taken in + # scripts/ci/select-gate-families.sh under its self-test. - name: PM dispatch-gates self-test + if: steps.gate-families.outputs.pm_dispatch_gates != 'skip' run: pnpm check:pm-dispatch-gates # Every ROOT_DIR_WATCH_HINTS declaration stays READABLE BY A TEXT SCANNER @@ -1318,9 +1395,21 @@ jobs: # # It runs entirely on a private lock under a temp dir; it never touches # /tmp/os-heavy-verify.lock, so a runner and an agent container behave the - # same. Unconditional and un-`if:`-ed, like the self-tests above — an - # exemption is what a self-test must not have. No network, ~40 s. + # same. No network; median 100 s over the ten merge-group runs measured + # for #16496. + # + # Scoped (#16496): the suite reads the script itself (`$SELF`) and a + # private temp dir, and its case (h) runs the real entry point from the + # repo root, which routes through filter_preflight -> + # scripts/pnpm-filter-targets.mjs --preflight and so reads the top-level + # scripts/*.mjs helpers that imports, pnpm-workspace.yaml and every + # workspace package.json. A group touching none of those skips it. That + # is not the exemption the self-tests above refuse (a label or a paths + # filter with no reader behind it): the selection step at the top of + # this job runs this step on every doubt, and `push` on main keeps it + # unconditional. - name: Verify-lock entry-point self-test + if: steps.gate-families.outputs.verify_lock != 'skip' run: bash scripts/pm/os-verify-lock.sh --self-test # ci-failure self-test (#9898). Fifth member of the PM self-test family @@ -3299,6 +3388,21 @@ jobs: - name: Test Core package selection self-test run: pnpm check:select-shard-packages + # Gate-family selection self-test (#16496). The "Select the gate families + # this run pays for" step at the top of this job is a thin call into + # scripts/ci/select-gate-families.sh, and its `merge_group` branch cannot + # run before a PR is already in the queue, so it is proved here: every + # event, the card's four cases (a scripts/pm change, a docs-only group, a + # workflow change, an unknown path), and every fail-open branch (empty + # diff, unresolvable base, deletion / rename, unspellable path), pinning + # which families ran and skipped, for which reason, and which + # `::warning::` lines fired. It also reads THIS file and pins the YAML + # half: the selector step's id and env, the `!= 'skip'` spelling on every + # scoped step, and that the scoped set equals the decided set. Offline; + # ~3 s. + - name: Gate-family selection self-test + run: pnpm check:select-gate-families + # Aggregator roster gate (#10490). Three required contexts are aggregate # jobs standing in for a set of real jobs — `Test Core` and `Dogfood # Regression Gate` in ci.yml, `TypeScript Type Check` in this file — and diff --git a/package.json b/package.json index 0921d4e26b..0c50739f45 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "check:pnpm-acquisition": "node scripts/check-pnpm-acquisition.mjs --self-test && node scripts/check-pnpm-acquisition.mjs", "check:workflow-status-functions": "node scripts/check-workflow-status-functions.mjs --self-test && node scripts/check-workflow-status-functions.mjs", "check:select-shard-packages": "bash scripts/ci/select-shard-packages.selftest.sh", + "check:select-gate-families": "bash scripts/ci/select-gate-families.selftest.sh", "check:shard-attestation": "node scripts/check-shard-attestation.mjs --self-test && node scripts/check-shard-attestation.mjs", "check:required-contexts": "node scripts/check-required-contexts.mjs --self-test && node scripts/check-required-contexts.mjs", "check:cross-package-test-inputs": "node scripts/check-cross-package-test-inputs.mjs --self-test && node scripts/check-cross-package-test-inputs.mjs", diff --git a/scripts/ci/select-gate-families.selftest.sh b/scripts/ci/select-gate-families.selftest.sh new file mode 100644 index 0000000000..cb812fc28e --- /dev/null +++ b/scripts/ci/select-gate-families.selftest.sh @@ -0,0 +1,586 @@ +#!/usr/bin/env bash +# +# Self-test for scripts/ci/select-gate-families.sh (#16496). +# +# The script decides which scoped `Lint & Repo Gates` families a merge group +# or pull request pays for, and its one invariant is FAIL-OPEN: every doubt +# runs every family. So the property under test is not "does it exit 0" (it +# nearly always does) but WHICH families it ran and skipped, for WHICH reason, +# which `::warning::` lines it printed and which it must not, and what landed +# in `gate-families.txt` / `$GITHUB_OUTPUT` / `$GITHUB_STEP_SUMMARY`. The +# card's four minimum cases are here by name -- a scripts/pm/ change, a +# docs-only group, a workflow change, an unknown path -- and the fail-open +# branches around them: empty diff, unresolvable base, structural change, +# unspellable path, an event that is not scoped. +# +# The last section reads the REAL lint.yml and pins the YAML half of the +# contract: the selector step exists under the id the `if:` lines name, every +# scoped step spells `!= 'skip'` (an absent output runs the step), and the set +# of families the workflow scopes equals the set the script decides -- a +# family the script emits that no step reads is dead, and a step reading a +# family the script never emits would run unconditionally while looking +# scoped. +# +# Hermetic and offline: a throwaway upstream + clone (+ a shallow clone) +# under $TMPDIR; no fake tools are needed because the script calls nothing +# but git. Needs git and bash; ~3s. +# +# Run: bash scripts/ci/select-gate-families.selftest.sh +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" || exit 1 + +# The two repo paths this self-test reads, spelled as quoted repo-relative +# literals on purpose: the dispatch derivation (scripts/pm/dispatch-gates.mjs) +# reads a gate's quoted path literals as the population it watches, so a card +# touching either schedules this family. Every other path below is a +# throwaway fixture under $TMPDIR, and none is spelled as a bare repo path -- +# a quoted literal with a slash in it would read as a declared population +# that reaches nothing (check:declared-population-live refuses exactly that). +SCRIPT_REL='scripts/ci/select-gate-families.sh' +WORKFLOW_REL='.github/workflows/lint.yml' +SCRIPT="$PWD/$SCRIPT_REL" +FIX=$(mktemp -d "${TMPDIR:-/tmp}/os-select-gate-families-selftest.XXXXXX") || exit 1 +trap 'rm -rf "$FIX"' EXIT INT TERM + +fail=0 +cases=0 +checks=0 + +git_q() { + git -c user.name=selftest -c user.email=selftest@example.invalid -c commit.gpgsign=false "$@" +} + +ALL='pm_dispatch_gates query_options_erasure slot_lookup verify_lock comment_mask_corpus' + +# ── Fixture repositories ──────────────────────────────────────────────────── +# C0 carries one representative file of every class the classifier names, so +# a scenario is "modify / add / delete one of them on top of C0". +UP="$FIX/upstream" +mkdir -p "$UP" +git_q -C "$UP" init -q +git_q -C "$UP" symbolic-ref HEAD refs/heads/main +mkdir -p "$UP/packages/a/scripts" "$UP/packages/a/src" "$UP/apps/site/src" "$UP/docs" "$UP/content/docs" \ + "$UP/scripts/pm" "$UP/scripts/ci" "$UP/.github/workflows" "$UP/.claude/agents" "$UP/skills/x" "$UP/.changeset" +printf '{"name":"fixture","private":true}\n' > "$UP/package.json" +printf 'packages:\n - packages/*\n' > "$UP/pnpm-workspace.yaml" +printf '{"name":"a"}\n' > "$UP/packages/a/package.json" +printf 'export const a = 1;\n' > "$UP/packages/a/src/index.ts" +printf 'export const t = 1;\n' > "$UP/packages/a/src/index.test.ts" +printf '{"rows":[]}\n' > "$UP/packages/a/src/data.json" +printf 'dist/\n' > "$UP/packages/a/.gitignore" +printf 'console.log(1);\n' > "$UP/packages/a/scripts/build.mjs" +printf 'export const site = 1;\n' > "$UP/apps/site/src/page.tsx" +printf '# guide\n' > "$UP/docs/guide.md" +printf '# page\n' > "$UP/content/docs/page.mdx" +printf '#!/usr/bin/env bash\necho lock\n' > "$UP/scripts/pm/os-verify-lock.sh" +printf 'export const pm = 1;\n' > "$UP/scripts/pm/tool.mjs" +printf '# pm\n' > "$UP/scripts/pm/README.md" +printf 'export const helper = 1;\n' > "$UP/scripts/helper.mjs" +printf '{}\n' > "$UP/scripts/slot-lookup-baseline.json" +printf '#!/usr/bin/env bash\necho ci\n' > "$UP/scripts/ci/tool.sh" +printf 'name: lint\n' > "$UP/.github/workflows/lint.yml" +printf '# agent\n' > "$UP/.claude/agents/os-dev.md" +printf '# skill\n' > "$UP/skills/x/SKILL.md" +printf '# rules\n' > "$UP/AGENTS.md" +printf '# readme\n' > "$UP/README.md" +printf -- '---\n"a": patch\n---\nchange\n' > "$UP/.changeset/first.md" +git_q -C "$UP" add -A +git_q -C "$UP" commit -q -m 'C0: root' +C0=$(git_q -C "$UP" rev-parse HEAD) +printf '# guide, revised upstream\n' > "$UP/docs/guide.md" +git_q -C "$UP" commit -q -am 'C1: docs move on main' +C1=$(git_q -C "$UP" rev-parse HEAD) +# A merge group's base is fetched BY SHA; a local upstream has to be told to +# serve one (GitHub does so for every reachable commit). +git_q -C "$UP" config uploadpack.allowReachableSHA1InWant true +git_q -C "$UP" config uploadpack.allowAnySHA1InWant true + +REPO="$FIX/repo" +git_q clone -q "$UP" "$REPO" +# Created AFTER the clone, so the clone has no refs/remotes/origin/release: +# the pull_request fetch path has something to fetch. +git_q -C "$UP" branch release "$C0" + +SHALLOW="$FIX/shallow" +git_q clone -q --depth 1 "file://$UP" "$SHALLOW" 2>/dev/null + +ZEROS=0000000000000000000000000000000000000000 + +# scenario -- a detached commit on top of C0 in $REPO carrying the +# edits, each spelled `M:` (modify), `A:` (add), `D:` (delete), +# `R::` (rename). Prints the commit sha. +scenario() { + git_q -C "$REPO" checkout -q --detach "$C0" + local edit kind rel to + for edit in "$@"; do + kind=${edit%%:*} + rel=${edit#*:} + case "$kind" in + M) printf '%s\n' "revised $RANDOM" >> "$REPO/$rel" ;; + A) mkdir -p "$(dirname "$REPO/$rel")"; printf 'added\n' > "$REPO/$rel" ;; + D) rm -f "$REPO/$rel" ;; + R) to=${rel#*:}; rel=${rel%%:*}; mkdir -p "$(dirname "$REPO/$to")"; mv "$REPO/$rel" "$REPO/$to" ;; + esac + done + git_q -C "$REPO" add -A + git_q -C "$REPO" commit -q -m "scenario: $*" + git_q -C "$REPO" rev-parse HEAD +} + +# ── The runner and the assertions ─────────────────────────────────────────── +RT='' +rc=0 +# run_case