|
43 | 43 | * many gates read the whole tree (check:nul-bytes) or a convention rather |
44 | 44 | * than a path. The PM's judgment call stays a judgment call; what this |
45 | 45 | * 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. |
47 | 51 | * |
48 | 52 | * The output is print-only and exits 0 on a completed derivation; a run that |
49 | 53 | * cannot read the workflows or package.json exits non-zero (#4690: unreadable |
@@ -134,6 +138,103 @@ export function hintCovers(hint, inputPath) { |
134 | 138 | return inputPath.startsWith(plain) || plain.startsWith(inputPath); |
135 | 139 | } |
136 | 140 |
|
| 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 | + |
137 | 238 | // --------------------------------------------------------------------------- |
138 | 239 | // Live derivation |
139 | 240 | // --------------------------------------------------------------------------- |
@@ -201,9 +302,20 @@ function derive(paths) { |
201 | 302 | } else { |
202 | 303 | console.log('No check family names the given paths in its own source.'); |
203 | 304 | } |
| 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 | + |
204 | 314 | console.log( |
205 | 315 | `\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.', |
207 | 319 | ); |
208 | 320 | } |
209 | 321 |
|
@@ -264,6 +376,28 @@ function selfTest() { |
264 | 376 | t('input dir covers hint below it', hintCovers('packages/spec/scripts/check-x.mjs', 'packages/spec')); |
265 | 377 | t('unrelated path does not match', !hintCovers('.claude/agents', 'packages/rest/src/server.ts')); |
266 | 378 |
|
| 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 | + |
267 | 401 | let failed = 0; |
268 | 402 | for (const [name, cond] of cases) { |
269 | 403 | if (!cond) failed++; |
|
0 commit comments