From d66f710ce82a672b3f410ac92e4ef58a394eccc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 16:55:06 +0000 Subject: [PATCH] fix(pm): match a hint whose glob sits in a non-final segment `collapseHint` collapses globs by deletion, which is sound for a trailing glob (`packages/**` -> `packages`, the reduction `ROOT_DIR_WATCH_HINTS` is built on) but MANGLES a glob in a middle segment: `skills/*/references/_index.md` became `skills//references/_index.md`, a double separator no tree can hold. The hint named nine tracked files that `packages/spec/scripts/build-skill-references.ts` really generates and reached none of them, and `unreachableClass` then filed the family under "THE LAYOUT MOVED ... a real miss, worth triaging" with three TypeScript module specifiers as its evidence -- classification and evidence both wrong in the same row. No spelling of `collapseHint` can fix it, and the impossibility is structural: "by construction" needs `deepest(P) === P`, i.e. a tracked prefix, but any tracked prefix makes the hint live so it never enters `dead` to be classified. So `hintCovers` now matches that one shape through `triggerCovers` -- the file's own filter-pattern language, not a second one -- and everything else keeps the collapse untouched. Measured both ways over 170 families x 754 hints x 6816 files: pairs 72482 -> 86807, 4 family rows gaining and ZERO losing, 7 (check,hint) newly live and 0 newly inert, unreachable families 12 -> 11 and "layout moved" 1 -> 0. Matching ALL whole-segment globs was measured and refused (-7404 pairs each on check:test-source-alias, check:type-source-resolution, check:published-files). Also deletes `collapseHint`'s second trailing strip, which was unreachable: `/\/+$/` is greedy and anchored, so it changed the answer for 0 of 754 hints. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6 --- scripts/pm/dispatch-gates.mjs | 192 ++++++++++++++++++++++++++++++++-- 1 file changed, 186 insertions(+), 6 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 9bb9335c65..87ff088c30 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -1386,17 +1386,141 @@ export function runnableInvocation({ check, filter, direct }) { */ /** * A hint with its globs collapsed and its trailing separators dropped — the - * form `hintCovers` compares against, extracted so the reachability sweep can - * describe a dead hint in the SAME terms the comparison judged it by. The - * transformation is carried verbatim from where it was written inline; a - * second, separately-maintained copy of it is exactly the drift this file - * refuses everywhere else. + * form `hintCovers` compares against for every hint whose globs are TRAILING, + * extracted so the reachability sweep can describe a dead hint in the SAME + * terms the comparison judged it by. The transformation is carried verbatim + * from where it was written inline; a second, separately-maintained copy of it + * is exactly the drift this file refuses everywhere else. + * + * One strip, not two. The trailing-separator cleanup used to be written + * `.replace(/\/+$/, '').replace(/\/$/, '')`, and the second call was + * unreachable: `/\/+$/` is greedy and anchored, so after it runs no trailing + * separator survives for the second to find. Measured over all 754 distinct + * hints in the fleet — and over every probe string the shape admits (`a///`, + * `a/`, `a//`, `a/**\/`, `**\/`, `/`) — it changed the answer for zero of them. + * Deleted here rather than left as decoration, because a redundant strip reads + * as defence against a case the first one misses and there is no such case: + * what the anchored strips genuinely cannot touch is a separator left in the + * MIDDLE, which is the defect the branch below fixes rather than one more + * trailing pass would. */ export function collapseHint(hint) { - return hint.replace(/\*\*?/g, '').replace(/\/+$/, '').replace(/\/$/, ''); + return hint.replace(/\*\*?/g, '').replace(/\/+$/, ''); } +/** + * ## A glob in a NON-FINAL segment is not collapsible, and must not be collapsed + * + * Collapse-by-deletion is sound for a glob in the LAST segment, which is every + * shape the idioms above use: `packages/**` → `packages`, `packages/spec/src/**` + * → `packages/spec/src`, `packages/client*` → `packages/client`. Each names a + * subtree root the tree really has, and `ROOT_DIR_WATCH_HINTS` depends on + * exactly that reduction (`scripts/check-published-files.mjs:215-248` declares + * the workspace globs VERBATIM so "the glob collapse reduces each back to the + * root it names", justified there at 91.3%). + * + * A glob in a non-final segment is the one case where deletion does not reduce + * — it MANGLES. `skills/*\/references/_index.md` collapses to + * `skills//references/_index.md`, a double separator no tree can hold, so the + * hint matches nothing BY CONSTRUCTION while looking like an ordinary literal. + * `check:skill-refs` was the live specimen: `packages/spec/scripts/ + * build-skill-references.ts` generates nine `_index.md` files, `git ls-files + * 'skills/*\/references/_index.md'` returns those nine, and the derivation + * reached zero of them. + * + * It also produced the worst row this output can print. `unreachableClass` + * calls a family "THE LAYOUT MOVED … a real miss, worth triaging" when a dead + * hint's `deepest` differs from its collapsed form, and the mangled form makes + * that true for a gate whose layout did not move at all — with the three + * "reasons" printed beside it drawn from OTHER inert hints, TypeScript + * `paths`-mapped module specifiers that were never repo paths. Both halves of + * the row were wrong: the classification and its evidence. + * + * No spelling of `collapseHint` can fix this, which is why the branch is here + * rather than there, and the impossibility is structural rather than a matter + * of a cleverer string. For a family to be classed "by construction" its dead + * hint needs `deepest(P) === P`, i.e. P must be a tracked prefix — but any + * tracked prefix P makes the three comparisons below reach every file beneath + * it, so the hint goes LIVE and never enters `dead` to be classified at all. + * The two requirements are mutually exclusive. Re-checked on this tree against + * every candidate P the specimen admits: `skills` is a tracked prefix and + * reaches 50 files (live, never classified); `skills/*`, + * `skills/*\/references`, `skills/*\/references/_index.md`, `skills/references` + * and the mangled `skills//references/_index.md` are none of them tracked + * prefixes and all reach 0. + * + * ## The rule, and why it reuses `triggerCovers` + * + * When — and ONLY when — a glob sits in a non-final segment, the hint is + * matched as the filter pattern it visibly is, through `triggerCovers`. That is + * not a second matching language: `triggerCovers` is `triggerPatternRegex` plus + * the literal-prefix directory reach, both of which already live in this file + * for this exact job, and the docblock above refuses a second one on principle. + * It also lines the two directions up with the ones the collapse gives: the + * regex answers "does this pattern match the file" (`plain`-equality and + * subtree descent), and the literal prefix answers "is the input a DIRECTORY + * the pattern reaches into" (reverse containment). + * + * ## Measured, both directions, on 170 families × 754 distinct hints × 6816 + * ## tracked files + * + * Exactly SIX distinct hints in the whole fleet carry a glob in a non-final + * segment, so the blast radius is enumerable rather than estimated: + * + * packages/**\/*.ts 0 → 4693 check:cross-package-test-inputs (+2) + * packages/**\/*.object.ts 0 → 79 the same three families + * skills/*\/references/_index.md 0 → 9 check:skill-refs + * src/**\/* 0 → 0 check:type-source-resolution + * spec/src/*\/index.ts 0 → 0 check:type-source-resolution + * src/**\/*.zod.ts 0 → 0 check:published-files + * + * watch-hint (gate, file) pairs 72482 → 86807 + * families gaining coverage 4 rows / 3 gates, listed above; ZERO losing + * (check, hint) newly live 7; newly inert 0 + * unreachable families 12 → 11, and "layout moved" 1 → 0 + * `skills/*\/references/_index.md` claims the 9 real files and 0 others + * + * The narrower rule is deliberate. Matching ALL whole-segment globs this way + * was measured and REFUSED: it takes `check:test-source-alias` 7511 → 107, + * `check:type-source-resolution` 8682 → 1278 and `check:published-files` 7407 → + * 3 (−7404 each), because it stops collapsing the trailing `**` the + * `ROOT_DIR_WATCH_HINTS` idiom is built on. That is a regression, not a + * correction, and this branch keeps the idiom bit-for-bit: `packages/*` 5228, + * `examples/*` 241, `skills/**` 50, `content/**` 442, all unchanged. So is the + * DECIDED partial-segment trade — `packages/client*` still refuses + * `packages/client-react/src/index.ts` and still covers `packages/client`, + * because a partial-segment glob in the LAST segment is not this case. + * + * ## What the +14325 pairs are, and why they are not a widening + * + * All of the residual cost is two hints: `packages/**\/*.ts` and + * `packages/**\/*.object.ts`, declared in `scripts/cross-package-test-inputs.mjs` + * and inherited by three family rows. They reach 0 today ONLY because of this + * defect, so the question is not whether to widen a gate but whether a + * declaration that finally means what it says is wanted — and it is, checked at + * the declaration site rather than assumed: `packages/**\/*.ts` is held by + * `packages/core/src/security/operation-private-keys.pin.test.ts`, whose scan + * surface is spelled `git ls-files` over "every `.ts`/`.tsx` file under + * `packages/`", and `packages/**\/*.object.ts` by the two repo-wide + * `*.object.ts` walkers in `packages/spec`. Those tests really do read every + * matching file, so every recovered pair is a TRUE lead, not a fabricated one — + * which is the provenance criterion the docblock above prices, never volume. + * + * Its derivation cost was measured rather than feared, because "22 leads is the + * same as none" is the failure this file exists to avoid. Reading each tracked + * file as a one-file card surface: `check:cross-package-test-inputs` is named by + * 3323 cards today and 5561 after — 2238 additional cards, 32.8% of the tree. + * It is NOT "nearly every card touching `packages/**`": of the 4693 + * `packages/**\/*.ts` files, 2455 already name the family through some other + * hint and 2238 do not. Mean matched families per card goes 12.05 → 13.51 for a + * `packages/**\/*.ts` card and 11.99 → 12.89 fleet-wide — about one extra + * lead, against the +139084-pair explosion that took one card from 7 families + * to 34 and is the number this file calls unaffordable. So the two hints stay + * as declared; narrowing them at their declaration site was considered and is + * refused, because the narrowing would be the false statement. + */ export function hintCovers(hint, inputPath) { + if (globInNonFinalSegment(hint)) return triggerCovers(hint, inputPath); const plain = collapseHint(hint); if (plain.length < 2) return false; // `hint`, not `plain`: glob collapse destroys the separator this refusal is @@ -1409,6 +1533,20 @@ export function hintCovers(hint, inputPath) { ); } +/** + * Does a glob sit anywhere but this hint's LAST segment? The one question that + * decides which of the two rules above judges a hint, kept as its own named + * predicate so the branch reads as the case it is rather than as an inline + * condition. Segment-wise on purpose: `packages/client*` is a glob in the last + * segment and stays with the collapse (the DECIDED trade), while + * `packages/**\/*.ts` is not. + */ +export function globInNonFinalSegment(hint) { + const segments = hint.split('/'); + for (let i = 0; i < segments.length - 1; i++) if (segments[i].includes('*')) return true; + return false; +} + /** * Translate ONE GitHub filter pattern into an anchored regex, following the * documented filter-pattern semantics rather than approximating them: @@ -4661,6 +4799,48 @@ function selfTest() { t('the same glob still covers the package it names', hintCovers('packages/client*', 'packages/client/src/index.ts')); t('a segment-boundary glob is untouched by the trade', hintCovers('packages/client/**', 'packages/client/src/index.ts')); + // ── A glob in a NON-FINAL segment is matched, not collapsed (#12246) ────── + // + // Collapse-by-deletion mangles this one shape into a double separator no tree + // can hold, so the hint matched nothing BY CONSTRUCTION and its family was + // then misfiled as "THE LAYOUT MOVED". Both halves are pinned: what the rule + // now reaches, and — the larger half — everything it deliberately does NOT + // disturb, because the refused alternative (matching ALL whole-segment globs) + // breaks the ROOT_DIR_WATCH_HINTS idiom by −7404 pairs on each of three gates. + t('the predicate reads the LAST segment, so a trailing glob is not this case', !globInNonFinalSegment('packages/**')); + t('nor is a partial-segment glob in the last segment', !globInNonFinalSegment('packages/client*')); + t('a glob in a middle segment IS', globInNonFinalSegment('skills/*/references/_index.md')); + t('and a `**` in a middle segment IS', globInNonFinalSegment('packages/**/*.ts')); + // The live specimen: `packages/spec/scripts/build-skill-references.ts` emits + // these nine files and the derivation reached zero of them. If the skills + // layout ever changes, re-point this case at whatever mid-segment glob the + // fleet then declares rather than deleting it. + t('a mid-segment glob reaches the file it names', hintCovers('skills/*/references/_index.md', 'skills/objectstack-formula/references/_index.md')); + t('and does NOT claim the rest of the subtree it passes through', !hintCovers('skills/*/references/_index.md', 'skills/objectstack-formula/SKILL.md')); + t('a `**` crosses separators, a single `*` does not', hintCovers('packages/**/*.ts', 'packages/spec/src/data/filter.zod.ts')); + t('so a single `*` segment matches exactly one segment', !hintCovers('skills/*/references/_index.md', 'skills/a/b/references/_index.md')); + t('the extension the glob names is honoured', !hintCovers('packages/**/*.object.ts', 'packages/spec/src/index.ts')); + // Reverse containment, the direction a coarse card surface needs: the literal + // prefix before the first wildcard still reaches back to a directory surface, + // exactly as the collapsed form used to. + t('a directory surface above the glob still derives the gate', hintCovers('skills/*/references/_index.md', 'skills')); + t('but an unrelated root does not', !hintCovers('skills/*/references/_index.md', 'packages')); + // ⛔ The refused alternative, pinned as the loss it would be. Each of these + // is a trailing glob and MUST keep going through the collapse. + t('a trailing `**` still collapses to the root it names', hintCovers('packages/**', 'packages/spec/src/index.ts')); + t('the ROOT_DIR_WATCH_HINTS idiom is untouched', hintCovers('skills/**', 'skills/objectstack-formula/SKILL.md')); + t('and so is a trailing single `*`', hintCovers('examples/*', 'examples/app-showcase/src/x.ts')); + t('the DECIDED partial-segment trade still refuses the sibling', !hintCovers('packages/client*', 'packages/client-react/src/index.ts')); + + // The trailing-separator strip is ONE call, not two: `/\/+$/` is greedy and + // anchored, so nothing survives for a second `/\/$/` to remove. Measured at + // zero of 754 hints; pinned on the probes that could tell them apart, so a + // future reader does not restore the redundant call as defence-in-depth. + t('the greedy trailing strip removes every trailing separator', collapseHint('a///') === 'a'); + t('including the one a trailing `**` leaves behind', collapseHint('a/**/') === 'a'); + t('and a hint that is nothing but separators collapses to empty', collapseHint('**/') === ''); + t('what it cannot touch is a separator left in the MIDDLE', collapseHint('skills/*/references/_index.md') === 'skills//references/_index.md'); + // ── A declared SUBTREE is not a bare word (#9626) ───────────────────────── // // The genericity refusal reads the hint as the author wrote it. Both