Skip to content

Commit dfa427b

Browse files
ci(lint): key the PM dispatch-gates family on the files its battery opens (#22092)
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 - **For the two ratchet-grade classes** (scripts data/prose, agent prose/JSON): only the **name half** moves. - An added file there is seen by the tracked-name sweep, and is now judged by its class, as every other class has been since #19498. - No content coverage moves: no case reads these bytes. - A deletion or rename still runs every family. - **For unwired package-scripts tests:** the compound-anchor census and the exposed-scratch-dir sweep over those files. This is the same census coverage #19498 already moved for tests under `src/`. - **Nothing else moves.** A data file a gate imports, and a test a manifest or workflow wires, both still run the step at PR time. ## 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: - 32 were selected only by a scripts data file outside `scripts/pm/`. 25 of them are `scripts/engine-double-contract.pinned.json`, plus `doc-authoring-prose-id.baseline.json`, `test-shard-timings.json` and others. - 7 touch agent prose: `skills/**`. - 1 touches a package-scripts test. That one is `93125aeeb8`, PR #22048, the queue entry the card measured at 10.2 min for the step. It touched `packages/spec/scripts/conversions-major18-merge.test.ts` and `pure-schema-construction.test.ts`. - 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": - `pull_request: a PR touching only packages/*/src/** runs no PM self-test (#22076 pin)` (new; the card's own pin) - `pull_request: a spec source, an unwired test under packages/spec/scripts and a changeset run no PM self-test (#22076)` (new; the #22048 shape) - `pull_request: the PR #19314 shape pays the two ratchets and the corpus, and no tooling self-test (the measurement this narrowing was ruled from)` (kept) 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](https://claude.ai/code/session_01VF48aw8RPG6wzDnMgp6rtw)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aa71c4d commit dfa427b

2 files changed

Lines changed: 311 additions & 29 deletions

File tree

‎scripts/ci/select-gate-families.selftest.sh‎

Lines changed: 153 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@
2020
# edit, an added test, an added changeset) must skip every tooling self-test,
2121
# while the tool's own inputs must still run them.
2222
#
23+
# Since #22076 the same section pins the pm_dispatch_gates read-set to the
24+
# files the battery OPENS, as measured: a ratchet baseline, prose under
25+
# scripts/ and agent prose outside the pm-dispatch rulebook skip it, while
26+
# scripts/pm/** whole, the rulebook, a `.claude/**` shell script, a data file a
27+
# relative specifier names and a scripts/ file of an unlisted kind still run
28+
# it; a test under a package's scripts/ skips it unless a manifest or a
29+
# workflow names it; a pull request touching only packages/*/src/** runs no PM
30+
# self-test, and `schedule` and `push` still run it over the very changes the
31+
# PR path skips.
32+
#
2333
# Since #19753 it covers migration_registry, the one family that ADDS a gate
2434
# to the PR path: an entry, the generated registry, the generator and the two
2535
# package files that decide how it runs must each select it; a spec source
@@ -77,13 +87,17 @@ mkdir -p "$UP/packages/a/scripts" "$UP/packages/a/src" "$UP/apps/site/src" "$UP/
7787
"$UP/packages/spec/src/migrations/entries/semantic" "$UP/packages/spec/scripts"
7888
printf '{"name":"fixture","private":true}\n' > "$UP/package.json"
7989
printf 'packages:\n - packages/*\n' > "$UP/pnpm-workspace.yaml"
80-
printf '{"name":"a"}\n' > "$UP/packages/a/package.json"
90+
printf '{"name":"a","scripts":{"check:gate":"vitest run scripts/gate.test.ts"}}\n' > "$UP/packages/a/package.json"
8191
printf 'export const a = 1;\n' > "$UP/packages/a/src/index.ts"
8292
printf 'export const t = 1;\n' > "$UP/packages/a/src/index.test.ts"
8393
printf '{"rows":[]}\n' > "$UP/packages/a/src/data.json"
8494
printf '#!/usr/bin/env bash\necho foo\n' > "$UP/packages/a/foo.sh"
8595
printf 'dist/\n' > "$UP/packages/a/.gitignore"
8696
printf 'console.log(1);\n' > "$UP/packages/a/scripts/build.mjs"
97+
# Two tests under a package's scripts/: one a check:* command in its manifest
98+
# names (a gate source), one nothing names (#22076).
99+
printf 'export const g = 1;\n' > "$UP/packages/a/scripts/gate.test.ts"
100+
printf 'export const b = 1;\n' > "$UP/packages/a/scripts/build.test.ts"
87101
printf 'export const site = 1;\n' > "$UP/apps/site/src/page.tsx"
88102
printf '# guide\n' > "$UP/docs/guide.md"
89103
printf '# page\n' > "$UP/content/docs/page.mdx"
@@ -96,7 +110,20 @@ printf '#!/usr/bin/env bash\necho ci\n' > "$UP/scripts/ci/tool.sh"
96110
printf 'name: lint\n' > "$UP/.github/workflows/lint.yml"
97111
printf '# agent\n' > "$UP/.claude/agents/os-dev.md"
98112
printf '# skill\n' > "$UP/skills/x/SKILL.md"
113+
# The pm_dispatch_gates read-set as measured (#22076): the rulebook its live
114+
# cases open, a hook shell script the shell-mask census opens, and a data file
115+
# under scripts/ that a module imports through a relative specifier. The
116+
# specifier is assembled from an unquoted word on purpose, for the reason the
117+
# two repo paths at the top are spelled as they are.
118+
mkdir -p "$UP/.claude/skills/pm-dispatch/references" "$UP/.claude/hooks"
119+
printf '# rules\n' > "$UP/.claude/skills/pm-dispatch/references/rules.md"
120+
printf '#!/usr/bin/env bash\necho guard\n' > "$UP/.claude/hooks/guard.sh"
121+
printf '{}\n' > "$UP/.claude/settings.json"
122+
printf '{"rows":[]}\n' > "$UP/scripts/table.json"
123+
TABLE_SPECIFIER=./table.json
124+
printf "import table from '%s' with { type: 'json' };\nexport const rows = table.rows;\n" "$TABLE_SPECIFIER" > "$UP/scripts/reads-table.mjs"
99125
printf '# rules\n' > "$UP/AGENTS.md"
126+
printf '# claude\n' > "$UP/CLAUDE.md"
100127
printf '# readme\n' > "$UP/README.md"
101128
printf -- '---\n"a": patch\n---\nchange\n' > "$UP/.changeset/first.md"
102129
# The migration_registry read-set (#19753), and its nearest neighbours outside it.
@@ -107,6 +134,7 @@ printf 'export const registry = [];\n' > "$UP/packages/spec/src/migrations/regis
107134
printf 'export const entry = 1;\n' > "$UP/packages/spec/src/migrations/entries/semantic/17.x.ts"
108135
printf 'export const gen = 1;\n' > "$UP/packages/spec/scripts/build-migration-registry.ts"
109136
printf 'export const schemas = 1;\n' > "$UP/packages/spec/scripts/build-schemas.ts"
137+
printf 'export const merge = 1;\n' > "$UP/packages/spec/scripts/conversions-merge.test.ts"
110138
git_q -C "$UP" add -A
111139
git_q -C "$UP" commit -q -m 'C0: root'
112140
C0=$(git_q -C "$UP" rev-parse HEAD)
@@ -297,6 +325,27 @@ run_case 'push: the event decides, not the variables that happen to be set' "$RE
297325
expect_rc 0
298326
expect_all_run
299327

328+
# The backstop, held over the very change set the PR path now lets the PM
329+
# self-test skip (#22076): an unscoped event never reads the diff.
330+
S=$(scenario M:scripts/slot-lookup-baseline.json M:AGENTS.md M:skills/x/SKILL.md)
331+
run_case 'schedule: a change the PR path skips the PM self-test for still runs it -- the hourly full run is the backstop (#22076 control)' "$REPO" schedule '' ''
332+
expect_rc 0
333+
expect_warnings '' ''
334+
expect_all_run
335+
expect_reason pm_dispatch_gates "event 'schedule' is not scoped"
336+
337+
run_case 'push: the same change on main still runs the PM self-test (#22076 control)' "$REPO" push main "$C0"
338+
expect_rc 0
339+
expect_warnings '' ''
340+
expect_all_run
341+
expect_reason pm_dispatch_gates "event 'push' is not scoped"
342+
343+
run_case 'merge_group: the same change, scoped, skips the PM self-test -- so the two controls above are not vacuous' "$REPO" merge_group '' "$C0"
344+
expect_rc 0
345+
expect_warnings '' ''
346+
expect_verdicts slot_lookup query_options_erasure entry_guard declared_population_live bare_root_worklist self_test_workflow_commands
347+
expect_reason pm_dispatch_gates 'no changed path is in its read-set'
348+
300349
# ── merge_group: the card's four cases ──────────────────────────────────────
301350
S=$(scenario M:scripts/pm/tool.mjs)
302351
run_case 'merge_group: a scripts/pm change runs every tooling self-test (and, for a .mjs, the corpus walk)' "$REPO" merge_group '' "$C0"
@@ -502,6 +551,20 @@ run_case 'merge_group: a package-local script is a gate source (the derivation f
502551
expect_rc 0
503552
expect_verdicts comment_mask_corpus pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands
504553

554+
S=$(scenario M:packages/a/scripts/build.test.ts)
555+
run_case 'merge_group: a test under a package scripts/ that no manifest or workflow names is product test code to the PM self-test, which skips (#22076)' "$REPO" merge_group '' "$C0"
556+
expect_rc 0
557+
expect_warnings '' ''
558+
expect_verdicts slot_lookup query_options_erasure comment_mask_corpus declared_population_live bare_root_worklist self_test_workflow_commands
559+
expect_reason pm_dispatch_gates 'no changed path is in its read-set'
560+
561+
S=$(scenario M:packages/a/scripts/gate.test.ts)
562+
run_case 'merge_group: a test under a package scripts/ that a check script names is a gate source and still runs the PM self-test' "$REPO" merge_group '' "$C0"
563+
expect_rc 0
564+
expect_warnings '' ''
565+
expect_verdicts slot_lookup query_options_erasure comment_mask_corpus pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands
566+
expect_reason pm_dispatch_gates 'packages/a/scripts/gate.test.ts (M, workspace)'
567+
505568
S=$(scenario M:scripts/pm/os-verify-lock.sh)
506569
run_case 'merge_group: the lock script runs its own self-test and every family that reads scripts/' "$REPO" merge_group '' "$C0"
507570
expect_rc 0
@@ -516,19 +579,68 @@ expect_reason verify_lock scripts/helper.mjs
516579
expect_reason migration_registry 'no changed path is in its read-set'
517580

518581
S=$(scenario M:scripts/slot-lookup-baseline.json)
519-
run_case 'merge_group: a ratchet baseline runs the ratchets and every family that reads scripts/' "$REPO" merge_group '' "$C0"
582+
run_case 'merge_group: a ratchet baseline runs the ratchets and the families that walk scripts/, and not the PM self-test -- the battery opens no data file (#22076)' "$REPO" merge_group '' "$C0"
520583
expect_rc 0
584+
expect_warnings '' ''
585+
expect_verdicts slot_lookup query_options_erasure entry_guard declared_population_live bare_root_worklist self_test_workflow_commands
586+
expect_reason pm_dispatch_gates 'no changed path is in its read-set'
587+
expect_reason entry_guard '(M, scripts)'
588+
589+
S=$(scenario M:scripts/table.json)
590+
run_case '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' "$REPO" merge_group '' "$C0"
591+
expect_rc 0
592+
expect_warnings '' ''
521593
expect_verdicts slot_lookup query_options_erasure entry_guard pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands
594+
expect_reason pm_dispatch_gates 'scripts/table.json (M, scripts)'
595+
596+
S=$(scenario A:scripts/notes.md A:scripts/ci/fixtures/job-log.txt)
597+
run_case 'merge_group: prose and a fixture log under scripts/ run the families that walk scripts/, and not the PM self-test (#22076)' "$REPO" merge_group '' "$C0"
598+
expect_rc 0
599+
expect_warnings '' ''
600+
expect_verdicts entry_guard declared_population_live bare_root_worklist self_test_workflow_commands
601+
expect_reason pm_dispatch_gates 'no changed path is in its read-set'
602+
603+
S=$(scenario A:scripts/ci/matrix.yaml)
604+
run_case 'merge_group: a scripts/ file of a kind the arm does not list still runs the PM self-test -- fail-open on the extension' "$REPO" merge_group '' "$C0"
605+
expect_rc 0
606+
expect_verdicts entry_guard pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands
607+
expect_reason pm_dispatch_gates 'scripts/ci/matrix.yaml (A, scripts)'
608+
609+
S=$(scenario A:scripts/pm/ledger.json)
610+
run_case 'merge_group: a data file under scripts/pm still runs the PM self-test -- scripts/pm is in its read-set whole' "$REPO" merge_group '' "$C0"
611+
expect_rc 0
612+
expect_verdicts entry_guard pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands
613+
expect_reason pm_dispatch_gates 'scripts/pm/ledger.json (A, scripts)'
522614

523615
S=$(scenario M:scripts/ci/tool.sh)
524616
run_case 'merge_group: a scripts/ subdirectory script is a gate source only' "$REPO" merge_group '' "$C0"
525617
expect_rc 0
526618
expect_verdicts entry_guard pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands
527619

528620
S=$(scenario M:.claude/agents/os-dev.md M:skills/x/SKILL.md M:AGENTS.md)
529-
run_case 'merge_group: agent configuration runs the PM self-test alone -- it is the only battery here that reads it' "$REPO" merge_group '' "$C0"
621+
run_case 'merge_group: agent prose the battery never opens (an agent definition, a published skill, AGENTS.md) runs no family (#22076)' "$REPO" merge_group '' "$C0"
622+
expect_rc 0
623+
expect_warnings '' ''
624+
expect_verdicts
625+
expect_reason pm_dispatch_gates 'no changed path is in its read-set'
626+
627+
S=$(scenario M:CLAUDE.md M:.claude/settings.json)
628+
run_case 'merge_group: CLAUDE.md and the .claude settings JSON run no family (#22076)' "$REPO" merge_group '' "$C0"
629+
expect_rc 0
630+
expect_verdicts
631+
632+
S=$(scenario M:.claude/skills/pm-dispatch/references/rules.md)
633+
run_case 'merge_group: the pm-dispatch rulebook runs the PM self-test alone -- its live cases open it' "$REPO" merge_group '' "$C0"
530634
expect_rc 0
635+
expect_warnings '' ''
531636
expect_verdicts pm_dispatch_gates
637+
expect_reason pm_dispatch_gates '(M, agent-config)'
638+
639+
S=$(scenario M:.claude/hooks/guard.sh)
640+
run_case 'merge_group: a .claude hook shell script still runs the PM self-test alone -- only prose and JSON left the read-set' "$REPO" merge_group '' "$C0"
641+
expect_rc 0
642+
expect_verdicts pm_dispatch_gates
643+
expect_reason pm_dispatch_gates '.claude/hooks/guard.sh (M, agent-config)'
532644

533645
S=$(scenario M:package.json)
534646
run_case 'merge_group: root configuration runs every family' "$REPO" merge_group '' "$C0"
@@ -649,6 +761,43 @@ expect_verdicts slot_lookup query_options_erasure comment_mask_corpus
649761
expect_reason pm_dispatch_gates 'no changed path is in its read-set'
650762
expect_line 'Gate families: 3 run, 7 skipped'
651763

764+
# The card's pin and triage's (#22076): product code alone runs no PM
765+
# self-test; scripts/pm/** runs it, prose included.
766+
git_q -C "$REPO" checkout -q -B feature-22076-src "$C0"
767+
printf 'export const a = 3;\n' > "$REPO/packages/a/src/index.ts"
768+
printf 'export const index = 2;\n' > "$REPO/packages/spec/src/index.ts"
769+
git_q -C "$REPO" commit -q -am 'F4: product source in two packages, nothing else'
770+
run_case 'pull_request: a PR touching only packages/*/src/** runs no PM self-test (#22076 pin)' "$REPO" pull_request main ''
771+
expect_rc 0
772+
expect_warnings '' ''
773+
expect_verdicts slot_lookup query_options_erasure comment_mask_corpus
774+
expect_reason pm_dispatch_gates 'no changed path is in its read-set'
775+
expect_changed 'two package sources' "M packages/a/src/index.ts
776+
M packages/spec/src/index.ts"
777+
778+
git_q -C "$REPO" checkout -q -B feature-22076-pm "$C0"
779+
printf '# pm, revised on a feature branch\n' > "$REPO/scripts/pm/README.md"
780+
git_q -C "$REPO" commit -q -am 'F5: prose under scripts/pm'
781+
run_case 'pull_request: a PR touching scripts/pm/** runs the PM self-test, prose included (#22076 pin)' "$REPO" pull_request main ''
782+
expect_rc 0
783+
expect_warnings '' ''
784+
expect_verdicts entry_guard pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands
785+
expect_reason pm_dispatch_gates 'scripts/pm/README.md (M, scripts)'
786+
787+
# The shape the card measured paying the step: a spec source, tests under
788+
# packages/spec/scripts/ that nothing wires as a gate, and a changeset.
789+
git_q -C "$REPO" checkout -q -B feature-22076-spec-tests "$C0"
790+
printf 'export const merge = 2;\n' > "$REPO/packages/spec/scripts/conversions-merge.test.ts"
791+
printf 'export const index = 3;\n' > "$REPO/packages/spec/src/index.ts"
792+
printf -- '---\n"spec": patch\n---\nthe shared entry\n' > "$REPO/.changeset/shared-entry.md"
793+
git_q -C "$REPO" add -A
794+
git_q -C "$REPO" commit -q -m 'F6: a spec source, an unwired test under packages/spec/scripts, a changeset'
795+
run_case 'pull_request: a spec source, an unwired test under packages/spec/scripts and a changeset run no PM self-test (#22076)' "$REPO" pull_request main ''
796+
expect_rc 0
797+
expect_warnings '' ''
798+
expect_verdicts slot_lookup query_options_erasure comment_mask_corpus declared_population_live bare_root_worklist self_test_workflow_commands
799+
expect_reason pm_dispatch_gates 'no changed path is in its read-set'
800+
652801
git_q -C "$REPO" checkout -q -B feature-19753 "$C0"
653802
printf 'export const entry = 2;\n' > "$REPO/packages/spec/src/migrations/entries/semantic/17.x.ts"
654803
git_q -C "$REPO" commit -q -am 'F3: an entry edited on a feature branch, registry not regenerated'
@@ -753,7 +902,7 @@ pin_step migration_registry 'pnpm --filter @objectstack/spec check:migration-reg
753902

754903
# ── Verdict ─────────────────────────────────────────────────────────────────
755904
# #4690: a battery that ran nothing is a failure, never a pass.
756-
if [ "$cases" -lt 56 ] || [ "$checks" -lt 293 ]; then
905+
if [ "$cases" -lt 71 ] || [ "$checks" -lt 369 ]; then
757906
echo "SELFTEST FAILED: only $cases case(s) / $checks check(s) ran -- the battery is short"
758907
exit 1
759908
fi

0 commit comments

Comments
 (0)