Skip to content

feat(pm): derive test-convention gates by change kind in dispatch-gates - #8159

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-8117-gates-change-kind
Aug 12, 2026
Merged

feat(pm): derive test-convention gates by change kind in dispatch-gates#8159
hotlong merged 1 commit into
mainfrom
claude/issue-8117-gates-change-kind

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#8117

Scoped to direction 2 of the finding, exactly as the finding-grading comment (2026-08-12 17:59Z) fixed it: derive by change kind, not a hand-maintained repo-wide shortlist (direction 1) and not nothing (direction 3).

The problem

scripts/pm/dispatch-gates.mjs matches gate families to a card's file surface by the path literals discoverable in each gate's own source. Two gates redden nearly every PR that adds test code, and the path derivation can never reach either — both compute their population instead of naming it, so their sources carry no literal to match:

  • check:query-options-erasure — its test-surface ceiling counts sites in *.test / *.spec files;
  • check:type-check-coverage — TEST_DEBT ratchets a package's test-layer type errors.

Both sit permanently in the "repo-wide / undetermined" bucket, so no per-card gate list could name them, and both failures were only ever found after CI ran.

What changed

dispatch-gates.mjs now derives these by change kind and prints them under a heading distinct from the path-matched list, so they read as convention-triggered leads rather than path matches. Three things keep it from becoming the rot-prone list direction 1 was rejected for:

  1. The predicate is aligned with what the two gates themselves count, not invented. Both classify by the filename infix (*.test.* / *.spec.*), never by directory — QUERY_OPTIONS_TEST_GLOBS in eslint.config.mjs and TEST_FILE = /\.(test|spec)\.tsx?$/ in check-type-check-coverage.mjs. So a helper at __tests__/fixtures.ts deliberately does not qualify: neither gate counts it, and matching directories would name two gates that cannot move. The extension set is the union of the two gates' own (one also counts .mts / .cts); these are leads to run locally, not verdicts.
  2. Every gate name is resolved against the families discovered at runtime. A renamed or retired gate does not silently stop being suggested — the run prints it as STALE and names the table to fix. A hand-written list that reports its own rot is a different object from one that quietly ages.
  3. The entry is deletable, with a stated criterion: when a gate here grows a discoverable path literal, the ordinary derivation names it and its line becomes redundant.

The closing note is touched up so it no longer describes as PM judgment a convention the script now derives itself — and it builds its convention list from the table rather than restating it, so the prose cannot drift from the code.

Considered and rejected: deriving the pair from sources

The tempting generalisation is to scan every discovered check script for *.test.ts-shaped literals and call those the test-sensitive gates. Measured against this tree, that names 22 families, because scripts mention test paths in their fixtures, self-tests and comments. "Mentions a test file" is not "counts test files", and 22 leads is the same as none. Hence a named table of two, with the three properties above as its price.

Rider (declared in the grading comment)

scripts/pm/ensure-pm-labels.sh seeded no target: vocabulary while all three backlogs use it. Seeded now for the current release window, three repos, following the file's existing loop shape. target:v17 verified present in all three repos today (colour ededed), so the seeding is a no-op there — creation is create-if-missing and never edits an existing label's colour or description. Only the current window is seeded: a major seeded ahead of its window puts an empty board into every autocomplete with no producer behind it, the same harm as the retired-lane caveat above it. The target: family is not a retired lane, so that caveat is unaffected.

target: meets the file's own invariant that every label it seeds is consumed by a named query: SKILL.md's label table carries target:(major), and references/seat-post-protocol.md names the consumer as three per-repo queries, label:target:(major) is:open.

Verification

Self-test grew from 17 to 28 cases (node scripts/pm/dispatch-gates.mjs --self-test, all pass), pinning the predicate in both directions, the emission, and the STALE branch.

Before, on a real test path:

$ node scripts/pm/dispatch-gates.mjs packages/objectql/src/engine.test.ts
Local gates for this card (paste into the dispatch prompt):
- pnpm check:durability-log-level [lint.yml] matched via ... 'packages/objectql/src'
- pnpm check:engine-double-contract [lint.yml] matched via ... 'packages/objectql/src'
- node scripts/check-engine-split-ratio.mjs [engine-split-metric.yml] matched via ... 'packages/objectql'
Repo-wide / undetermined (no path literals discoverable — not known irrelevant): 25 famil(ies).
Judgment stays with the PM: convention-scoped gates (...) match by what the change IS, not where it lives.

After, same path — the path-matched list is unchanged, the new section is additive:

Convention-triggered gates (this change KIND moves them; no path derivation can name them):
adds or edits a test file: packages/objectql/src/engine.test.ts
- pnpm check:query-options-erasure — its test-surface ceiling counts sites in *.test/*.spec files, so new test code moves it
- pnpm check:type-check-coverage — TEST_DEBT ratchets a package's test-layer type errors, so a new test file that does not typecheck cleanly moves it
Repo-wide / undetermined (no path literals discoverable — not known irrelevant): 25 famil(ies).
Convention-scoped gates match by what the change IS, not where it lives. This script derives the conventions it can detect mechanically (adds or edits a test file) and prints them above when they hit; the rest stay the PM judgment call — new fake engine ⇒ check:engine-double-contract, new error code ⇒ check:error-code-casing, any edit ⇒ check:nul-bytes.

After, on a non-test path (this PR's own two files) — no convention section, and the closing note still reads correctly with nothing above it:

$ node scripts/pm/dispatch-gates.mjs scripts/pm/dispatch-gates.mjs scripts/pm/ensure-pm-labels.sh
No check family names the given paths in its own source.
Repo-wide / undetermined (no path literals discoverable — not known irrelevant): 25 famil(ies).
Convention-scoped gates match by what the change IS, not where it lives. This script derives the conventions ...

Reverse verification of the rot detector, direction predicted before running: rename one gate in the table to a name no workflow runs, and the live run must print STALE rather than crashing or going silent. Observed exactly that, with the sibling still resolving:

 - ⚠ check:query-options-erasure-RENAMED: STALE — no workflow runs a gate under this name. It was renamed or retired; fix CHANGE_KIND_GATES in this script.
- pnpm check:type-check-coverage — TEST_DEBT ratchets a package's test-layer type errors, ...

Restored from the commit afterwards; git status clean, so the restore is byte-identical.

Gates run locally: npx eslint scripts/pm/dispatch-gates.mjs (exit 0), pnpm check:nul-bytes (7422 files, OK), plus the two pm-adjacent gates check:pm-skill-ratchet and check:pm-skill-id-lint (both pass, unaffected). Re-derived on the actual changed paths after the change: no path-matched family, so the named list stands.

No changeset: internal PM tooling, nothing user-visible ships from either file.


Generated by Claude Code

…: labels
`scripts/pm/dispatch-gates.mjs` matches gate families to a card's file surface
by the path literals discoverable in each gate's own source. Two gates redden
almost every PR that adds test code and can never be reached that way, because
they compute their population instead of naming it:
- check:query-options-erasure — its test-surface ceiling counts sites in
*.test/*.spec files;
- check:type-check-coverage — TEST_DEBT ratchets a package's test-layer
type errors.
Both sit permanently in the "repo-wide / undetermined" bucket, so no per-card
gate list could name them and both failures were only ever found by CI.
Derive them by change KIND instead — the convention the script's own closing
note already prescribes — and print them under a heading distinct from the
path-matched list, so they read as convention-triggered leads rather than path
matches. The predicate is aligned with what the two gates themselves count
(filename infix, never directory), every name is resolved against the families
discovered at runtime so a renamed gate reports itself STALE, and the closing
note now derives its convention list from the table rather than restating it.
Rider: seed the `target:<major>` release-board family in ensure-pm-labels.sh,
which seeded no `target:` vocabulary while all three backlogs use it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01139NJ9Wg5pFeZi1Zh8WLg6
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 6:18pm

Request Review

@hotlonghotlong added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 12, 2026 — with Claude
@hotlong
hotlong marked this pull request as ready for review August 12, 2026 18:37
@hotlong
hotlong added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit cc63bc7Aug 12, 2026
24 checks passed
@hotlong
hotlong deleted the claude/issue-8117-gates-change-kind branch August 12, 2026 18:54
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] The two gates that most often redden a PR adding tests are not path-derivable, so no per-card gate list can ever name them

2 participants

@hotlong@claude