Skip to content

Commit cc63bc7

Browse files
hotlongclaude
andauthored
feat(pm): derive test-convention gates in dispatch-gates; seed target: labels (#8159)
`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. Claude-Session: https://claude.ai/code/session_01139NJ9Wg5pFeZi1Zh8WLg6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5247fda commit cc63bc7

2 files changed

Lines changed: 156 additions & 2 deletions

File tree

scripts/pm/dispatch-gates.mjs

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@
4343
* many gates read the whole tree (check:nul-bytes) or a convention rather
4444
* than a path. The PM's judgment call stays a judgment call; what this
4545
* script removes is the memory-shaped half (which named checks exist and
46-
* where they live).
46+
* where they live);
47+
* - a CONVENTION-TRIGGERED check is one the path derivation can never reach,
48+
* because it counts a population it computes for itself and so names no
49+
* path literal to match. Those are derived from the change's KIND instead
50+
* and printed under their own heading — see CHANGE_KIND_GATES.
4751
*
4852
* The output is print-only and exits 0 on a completed derivation; a run that
4953
* cannot read the workflows or package.json exits non-zero (#4690: unreadable
@@ -134,6 +138,103 @@ export function hintCovers(hint, inputPath) {
134138
return inputPath.startsWith(plain) || plain.startsWith(inputPath);
135139
}
136140

141+
// ---------------------------------------------------------------------------
142+
// Change-kind derivation — the gates a path match can never reach
143+
// ---------------------------------------------------------------------------
144+
145+
/**
146+
* Is this path a test file, judged the way the gates below judge it?
147+
*
148+
* Both gates classify by the FILENAME infix (`*.test.*` / `*.spec.*`), not by
149+
* directory: a helper at `__tests__/fixtures.ts` is test-adjacent but neither
150+
* gate counts it — it falls in their NON-test population, where the ordinary
151+
* blocking lint rule applies instead. Matching directories here would name two
152+
* gates that cannot move, which is the failure mode this whole script exists to
153+
* avoid. The extension set is the UNION of the two gates' own (one counts
154+
* `.ts`/`.tsx`, the other also `.mts`/`.cts`); these are leads to run locally,
155+
* not verdicts, so the wider side is the safe one.
156+
*/
157+
export function isTestFilePath(path) {
158+
return /\.(test|spec)\.(ts|tsx|mts|cts)$/.test(path);
159+
}
160+
161+
/**
162+
* Gates that fire on what a change IS, keyed by a mechanically-detectable
163+
* convention. Everything else in this script is derived at runtime and lists
164+
* nothing; this table is the one exception, and it is bounded on purpose.
165+
*
166+
* ## Why these two cannot be derived like the rest
167+
*
168+
* The path derivation matches a gate when the gate's own source names a
169+
* directory that covers your file. Both gates here compute their population
170+
* instead of naming it — one lints a glob set that lives in the shared ESLint
171+
* config, the other walks the workspace members — so neither source carries a
172+
* literal to match, and both sit permanently in the "undetermined" bucket. No
173+
* per-card gate list derived from paths can ever name them, however the
174+
* derivation improves.
175+
*
176+
* ## Why a named table and not a wider heuristic
177+
*
178+
* The tempting generalisation — scan every discovered check script for
179+
* `*.test.ts`-shaped literals and call those the test-sensitive gates — was
180+
* measured against this tree and names 22 families, because a script's source
181+
* mentions test paths in its fixtures, its self-test and its comments.
182+
* "Mentions a test file" is not "counts test files", and 22 leads is the same
183+
* as none. So the pair is written down, and the cost of writing it down is paid
184+
* back by the two properties below.
185+
*
186+
* ## How this entry stays honest
187+
*
188+
* - Every `name` here is resolved against the families actually discovered in
189+
* the workflows at runtime. A gate that is renamed, retired or dropped from
190+
* CI does not silently stop being suggested — the run prints it as STALE and
191+
* says to fix this table. A hand-written list that reports its own rot is a
192+
* different object from one that quietly ages.
193+
* - The entry is deletable, with a stated criterion: when a gate here grows a
194+
* discoverable path literal, the ordinary derivation names it and its line
195+
* below becomes redundant. Delete it then.
196+
*/
197+
export const CHANGE_KIND_GATES = [
198+
{
199+
kind: 'adds or edits a test file',
200+
matches: isTestFilePath,
201+
gates: [
202+
{
203+
name: 'check:query-options-erasure',
204+
why: 'its test-surface ceiling counts sites in *.test/*.spec files, so new test code moves it',
205+
},
206+
{
207+
name: 'check:type-check-coverage',
208+
why: "TEST_DEBT ratchets a package's test-layer type errors, so a new test file that does not typecheck cleanly moves it",
209+
},
210+
],
211+
},
212+
];
213+
214+
/**
215+
* Render the convention-triggered section. Pure over its inputs so the
216+
* self-test can drive both the hit and the STALE branch offline;
217+
* `resolveInvocation` returns a runnable command for a gate the live run
218+
* discovered, or null for one it did not.
219+
*/
220+
export function changeKindLines(paths, resolveInvocation, kinds = CHANGE_KIND_GATES) {
221+
const lines = [];
222+
for (const { kind, matches, gates } of kinds) {
223+
const hits = paths.filter((p) => matches(p));
224+
if (hits.length === 0) continue;
225+
lines.push(` ${kind}: ${hits.join(', ')}`);
226+
for (const { name, why } of gates) {
227+
const invocation = resolveInvocation(name);
228+
lines.push(
229+
invocation
230+
? ` - ${invocation}${why}`
231+
: ` - ⚠ ${name}: STALE — no workflow runs a gate under this name. It was renamed or retired; fix CHANGE_KIND_GATES in this script.`,
232+
);
233+
}
234+
}
235+
return lines;
236+
}
237+
137238
// ---------------------------------------------------------------------------
138239
// Live derivation
139240
// ---------------------------------------------------------------------------
@@ -201,9 +302,20 @@ function derive(paths) {
201302
} else {
202303
console.log('No check family names the given paths in its own source.');
203304
}
305+
const kindLines = changeKindLines(paths, (name) => {
306+
const entry = byCheck.get(name);
307+
return entry ? runnableInvocation(entry) : null;
308+
});
309+
if (kindLines.length) {
310+
console.log('\nConvention-triggered gates (this change KIND moves them; no path derivation can name them):');
311+
for (const line of kindLines) console.log(line);
312+
}
313+
204314
console.log(
205315
`\nRepo-wide / undetermined (no path literals discoverable — not known irrelevant): ${undetermined.length} famil(ies).` +
206-
'\nJudgment stays with the PM: convention-scoped gates (new fake engine ⇒ check:engine-double-contract, new error code ⇒ check:error-code-casing, any edit ⇒ check:nul-bytes) match by what the change IS, not where it lives.',
316+
'\nConvention-scoped gates match by what the change IS, not where it lives. This script derives the conventions it can detect mechanically ' +
317+
`(${CHANGE_KIND_GATES.map((k) => k.kind).join('; ')}) and prints them above when they hit; the rest stay the PM judgment call — ` +
318+
'new fake engine ⇒ check:engine-double-contract, new error code ⇒ check:error-code-casing, any edit ⇒ check:nul-bytes.',
207319
);
208320
}
209321

@@ -264,6 +376,28 @@ function selfTest() {
264376
t('input dir covers hint below it', hintCovers('packages/spec/scripts/check-x.mjs', 'packages/spec'));
265377
t('unrelated path does not match', !hintCovers('.claude/agents', 'packages/rest/src/server.ts'));
266378

379+
// Change-kind derivation. The predicate is pinned in BOTH directions against
380+
// what the two gates themselves count: filename infix, never directory — a
381+
// helper inside `__tests__/` is in their non-test population.
382+
t('test file by .test infix', isTestFilePath('packages/objectql/src/engine.test.ts'));
383+
t('test file by .spec infix', isTestFilePath('packages/rest/src/server.spec.tsx'));
384+
t('test file with an mts extension', isTestFilePath('packages/spec/src/x.test.mts'));
385+
t('a __tests__ helper is NOT a test file to these gates', !isTestFilePath('packages/core/src/__tests__/fixtures.ts'));
386+
t('a plain source file is not a test file', !isTestFilePath('packages/objectql/src/engine.ts'));
387+
t('a non-TS file named test is not a test file', !isTestFilePath('docs/how.test.md'));
388+
389+
const resolved = (name) => `pnpm ${name}`;
390+
const kindHit = changeKindLines(['packages/objectql/src/engine.test.ts'], resolved);
391+
t('a test path emits the convention section', kindHit.length === 3 && kindHit[0].includes('adds or edits a test file'));
392+
t('the section names both convention gates, runnably', kindHit.some((l) => l.includes('pnpm check:query-options-erasure')) && kindHit.some((l) => l.includes('pnpm check:type-check-coverage')));
393+
t('a non-test path emits nothing', changeKindLines(['scripts/pm/dispatch-gates.mjs'], resolved).length === 0);
394+
395+
// The table's own rot detector: a name no live run discovers must say so,
396+
// never disappear quietly.
397+
const stale = changeKindLines(['a.test.ts'], () => null);
398+
t('an undiscoverable gate renders as STALE', stale.filter((l) => l.includes('STALE')).length === 2);
399+
t('every declared convention gate carries a reason', CHANGE_KIND_GATES.every((k) => k.gates.every((g) => g.name && g.why)));
400+
267401
let failed = 0;
268402
for (const [name, cond] of cases) {
269403
if (!cond) failed++;

scripts/pm/ensure-pm-labels.sh

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,24 @@ for D in engine-core metadata drivers services identity devx spec spec-surface c
3939
gh label create "domain:$D" -R objectstack-ai/objectstack -c bfd4f2 -d "Domain lane — seat card indexed by label:pm:seat" 2>/dev/null || true
4040
done
4141

42+
# Release board — `target:<major>` marks a release BLOCKER for that major
43+
# (SKILL.md "发版板"). Its consumer is a named query, one per backlog:
44+
# `label:target:<major> is:open`, and all three boards reading empty IS the
45+
# release condition — so the family belongs in all three repos, not just the
46+
# main one.
47+
#
48+
# Seed the CURRENT release window only. A major seeded ahead of its window puts
49+
# an empty board into every autocomplete with no producer behind it — the same
50+
# harm as recreating a retired lane above. At the start of a new window MOVE the
51+
# value here rather than accumulating them; a closed window's label object stays
52+
# in the repos as history and needs no entry.
53+
#
54+
# Creation is create-if-missing, so where these already exist this is a no-op
55+
# and their current colour and description are left exactly as they are.
56+
for V in v17; do
57+
for R in objectstack-ai/objectstack objectstack-ai/objectui objectstack-ai/cloud; do
58+
gh label create "target:$V" -R "$R" -c ededed -d "Release blocker for $V — stays on the release board until fixed, dropped as no longer valid, or explicitly accepted for GA" 2>/dev/null || true
59+
done
60+
done
61+
4262
echo "✓ ensure-pm-labels: label vocabulary ensured (idempotent)."

0 commit comments

Comments
 (0)