Skip to content

Commit 783111d

Browse files
yinlianghuiclaude
andauthored
fix(pm): match a hint whose glob sits in a non-final segment (#12300)
`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. Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1c66fe4 commit 783111d

1 file changed

Lines changed: 186 additions & 6 deletions

File tree

‎scripts/pm/dispatch-gates.mjs‎

Lines changed: 186 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,17 +1386,141 @@ export function runnableInvocation({ check, filter, direct }) {
13861386
*/
13871387
/**
13881388
* A hint with its globs collapsed and its trailing separators dropped — the
1389-
* form `hintCovers` compares against, extracted so the reachability sweep can
1390-
* describe a dead hint in the SAME terms the comparison judged it by. The
1391-
* transformation is carried verbatim from where it was written inline; a
1392-
* second, separately-maintained copy of it is exactly the drift this file
1393-
* refuses everywhere else.
1389+
* form `hintCovers` compares against for every hint whose globs are TRAILING,
1390+
* extracted so the reachability sweep can describe a dead hint in the SAME
1391+
* terms the comparison judged it by. The transformation is carried verbatim
1392+
* from where it was written inline; a second, separately-maintained copy of it
1393+
* is exactly the drift this file refuses everywhere else.
1394+
*
1395+
* One strip, not two. The trailing-separator cleanup used to be written
1396+
* `.replace(/\/+$/, '').replace(/\/$/, '')`, and the second call was
1397+
* unreachable: `/\/+$/` is greedy and anchored, so after it runs no trailing
1398+
* separator survives for the second to find. Measured over all 754 distinct
1399+
* hints in the fleet — and over every probe string the shape admits (`a///`,
1400+
* `a/`, `a//`, `a/**\/`, `**\/`, `/`) — it changed the answer for zero of them.
1401+
* Deleted here rather than left as decoration, because a redundant strip reads
1402+
* as defence against a case the first one misses and there is no such case:
1403+
* what the anchored strips genuinely cannot touch is a separator left in the
1404+
* MIDDLE, which is the defect the branch below fixes rather than one more
1405+
* trailing pass would.
13941406
*/
13951407
export function collapseHint(hint) {
1396-
return hint.replace(/\*\*?/g, '').replace(/\/+$/, '').replace(/\/$/, '');
1408+
return hint.replace(/\*\*?/g, '').replace(/\/+$/, '');
13971409
}
13981410

1411+
/**
1412+
* ## A glob in a NON-FINAL segment is not collapsible, and must not be collapsed
1413+
*
1414+
* Collapse-by-deletion is sound for a glob in the LAST segment, which is every
1415+
* shape the idioms above use: `packages/**` → `packages`, `packages/spec/src/**`
1416+
* → `packages/spec/src`, `packages/client*` → `packages/client`. Each names a
1417+
* subtree root the tree really has, and `ROOT_DIR_WATCH_HINTS` depends on
1418+
* exactly that reduction (`scripts/check-published-files.mjs:215-248` declares
1419+
* the workspace globs VERBATIM so "the glob collapse reduces each back to the
1420+
* root it names", justified there at 91.3%).
1421+
*
1422+
* A glob in a non-final segment is the one case where deletion does not reduce
1423+
* — it MANGLES. `skills/*\/references/_index.md` collapses to
1424+
* `skills//references/_index.md`, a double separator no tree can hold, so the
1425+
* hint matches nothing BY CONSTRUCTION while looking like an ordinary literal.
1426+
* `check:skill-refs` was the live specimen: `packages/spec/scripts/
1427+
* build-skill-references.ts` generates nine `_index.md` files, `git ls-files
1428+
* 'skills/*\/references/_index.md'` returns those nine, and the derivation
1429+
* reached zero of them.
1430+
*
1431+
* It also produced the worst row this output can print. `unreachableClass`
1432+
* calls a family "THE LAYOUT MOVED … a real miss, worth triaging" when a dead
1433+
* hint's `deepest` differs from its collapsed form, and the mangled form makes
1434+
* that true for a gate whose layout did not move at all — with the three
1435+
* "reasons" printed beside it drawn from OTHER inert hints, TypeScript
1436+
* `paths`-mapped module specifiers that were never repo paths. Both halves of
1437+
* the row were wrong: the classification and its evidence.
1438+
*
1439+
* No spelling of `collapseHint` can fix this, which is why the branch is here
1440+
* rather than there, and the impossibility is structural rather than a matter
1441+
* of a cleverer string. For a family to be classed "by construction" its dead
1442+
* hint needs `deepest(P) === P`, i.e. P must be a tracked prefix — but any
1443+
* tracked prefix P makes the three comparisons below reach every file beneath
1444+
* it, so the hint goes LIVE and never enters `dead` to be classified at all.
1445+
* The two requirements are mutually exclusive. Re-checked on this tree against
1446+
* every candidate P the specimen admits: `skills` is a tracked prefix and
1447+
* reaches 50 files (live, never classified); `skills/*`,
1448+
* `skills/*\/references`, `skills/*\/references/_index.md`, `skills/references`
1449+
* and the mangled `skills//references/_index.md` are none of them tracked
1450+
* prefixes and all reach 0.
1451+
*
1452+
* ## The rule, and why it reuses `triggerCovers`
1453+
*
1454+
* When — and ONLY when — a glob sits in a non-final segment, the hint is
1455+
* matched as the filter pattern it visibly is, through `triggerCovers`. That is
1456+
* not a second matching language: `triggerCovers` is `triggerPatternRegex` plus
1457+
* the literal-prefix directory reach, both of which already live in this file
1458+
* for this exact job, and the docblock above refuses a second one on principle.
1459+
* It also lines the two directions up with the ones the collapse gives: the
1460+
* regex answers "does this pattern match the file" (`plain`-equality and
1461+
* subtree descent), and the literal prefix answers "is the input a DIRECTORY
1462+
* the pattern reaches into" (reverse containment).
1463+
*
1464+
* ## Measured, both directions, on 170 families × 754 distinct hints × 6816
1465+
* ## tracked files
1466+
*
1467+
* Exactly SIX distinct hints in the whole fleet carry a glob in a non-final
1468+
* segment, so the blast radius is enumerable rather than estimated:
1469+
*
1470+
* packages/**\/*.ts 0 → 4693 check:cross-package-test-inputs (+2)
1471+
* packages/**\/*.object.ts 0 → 79 the same three families
1472+
* skills/*\/references/_index.md 0 → 9 check:skill-refs
1473+
* src/**\/* 0 → 0 check:type-source-resolution
1474+
* spec/src/*\/index.ts 0 → 0 check:type-source-resolution
1475+
* src/**\/*.zod.ts 0 → 0 check:published-files
1476+
*
1477+
* watch-hint (gate, file) pairs 72482 → 86807
1478+
* families gaining coverage 4 rows / 3 gates, listed above; ZERO losing
1479+
* (check, hint) newly live 7; newly inert 0
1480+
* unreachable families 12 → 11, and "layout moved" 1 → 0
1481+
* `skills/*\/references/_index.md` claims the 9 real files and 0 others
1482+
*
1483+
* The narrower rule is deliberate. Matching ALL whole-segment globs this way
1484+
* was measured and REFUSED: it takes `check:test-source-alias` 7511 → 107,
1485+
* `check:type-source-resolution` 8682 → 1278 and `check:published-files` 7407 →
1486+
* 3 (−7404 each), because it stops collapsing the trailing `**` the
1487+
* `ROOT_DIR_WATCH_HINTS` idiom is built on. That is a regression, not a
1488+
* correction, and this branch keeps the idiom bit-for-bit: `packages/*` 5228,
1489+
* `examples/*` 241, `skills/**` 50, `content/**` 442, all unchanged. So is the
1490+
* DECIDED partial-segment trade — `packages/client*` still refuses
1491+
* `packages/client-react/src/index.ts` and still covers `packages/client`,
1492+
* because a partial-segment glob in the LAST segment is not this case.
1493+
*
1494+
* ## What the +14325 pairs are, and why they are not a widening
1495+
*
1496+
* All of the residual cost is two hints: `packages/**\/*.ts` and
1497+
* `packages/**\/*.object.ts`, declared in `scripts/cross-package-test-inputs.mjs`
1498+
* and inherited by three family rows. They reach 0 today ONLY because of this
1499+
* defect, so the question is not whether to widen a gate but whether a
1500+
* declaration that finally means what it says is wanted — and it is, checked at
1501+
* the declaration site rather than assumed: `packages/**\/*.ts` is held by
1502+
* `packages/core/src/security/operation-private-keys.pin.test.ts`, whose scan
1503+
* surface is spelled `git ls-files` over "every `.ts`/`.tsx` file under
1504+
* `packages/`", and `packages/**\/*.object.ts` by the two repo-wide
1505+
* `*.object.ts` walkers in `packages/spec`. Those tests really do read every
1506+
* matching file, so every recovered pair is a TRUE lead, not a fabricated one —
1507+
* which is the provenance criterion the docblock above prices, never volume.
1508+
*
1509+
* Its derivation cost was measured rather than feared, because "22 leads is the
1510+
* same as none" is the failure this file exists to avoid. Reading each tracked
1511+
* file as a one-file card surface: `check:cross-package-test-inputs` is named by
1512+
* 3323 cards today and 5561 after — 2238 additional cards, 32.8% of the tree.
1513+
* It is NOT "nearly every card touching `packages/**`": of the 4693
1514+
* `packages/**\/*.ts` files, 2455 already name the family through some other
1515+
* hint and 2238 do not. Mean matched families per card goes 12.05 → 13.51 for a
1516+
* `packages/**\/*.ts` card and 11.99 → 12.89 fleet-wide — about one extra
1517+
* lead, against the +139084-pair explosion that took one card from 7 families
1518+
* to 34 and is the number this file calls unaffordable. So the two hints stay
1519+
* as declared; narrowing them at their declaration site was considered and is
1520+
* refused, because the narrowing would be the false statement.
1521+
*/
13991522
export function hintCovers(hint, inputPath) {
1523+
if (globInNonFinalSegment(hint)) return triggerCovers(hint, inputPath);
14001524
const plain = collapseHint(hint);
14011525
if (plain.length < 2) return false;
14021526
// `hint`, not `plain`: glob collapse destroys the separator this refusal is
@@ -1409,6 +1533,20 @@ export function hintCovers(hint, inputPath) {
14091533
);
14101534
}
14111535

1536+
/**
1537+
* Does a glob sit anywhere but this hint's LAST segment? The one question that
1538+
* decides which of the two rules above judges a hint, kept as its own named
1539+
* predicate so the branch reads as the case it is rather than as an inline
1540+
* condition. Segment-wise on purpose: `packages/client*` is a glob in the last
1541+
* segment and stays with the collapse (the DECIDED trade), while
1542+
* `packages/**\/*.ts` is not.
1543+
*/
1544+
export function globInNonFinalSegment(hint) {
1545+
const segments = hint.split('/');
1546+
for (let i = 0; i < segments.length - 1; i++) if (segments[i].includes('*')) return true;
1547+
return false;
1548+
}
1549+
14121550
/**
14131551
* Translate ONE GitHub filter pattern into an anchored regex, following the
14141552
* documented filter-pattern semantics rather than approximating them:
@@ -4661,6 +4799,48 @@ function selfTest() {
46614799
t('the same glob still covers the package it names', hintCovers('packages/client*', 'packages/client/src/index.ts'));
46624800
t('a segment-boundary glob is untouched by the trade', hintCovers('packages/client/**', 'packages/client/src/index.ts'));
46634801

4802+
// ── A glob in a NON-FINAL segment is matched, not collapsed (#12246) ──────
4803+
//
4804+
// Collapse-by-deletion mangles this one shape into a double separator no tree
4805+
// can hold, so the hint matched nothing BY CONSTRUCTION and its family was
4806+
// then misfiled as "THE LAYOUT MOVED". Both halves are pinned: what the rule
4807+
// now reaches, and — the larger half — everything it deliberately does NOT
4808+
// disturb, because the refused alternative (matching ALL whole-segment globs)
4809+
// breaks the ROOT_DIR_WATCH_HINTS idiom by −7404 pairs on each of three gates.
4810+
t('the predicate reads the LAST segment, so a trailing glob is not this case', !globInNonFinalSegment('packages/**'));
4811+
t('nor is a partial-segment glob in the last segment', !globInNonFinalSegment('packages/client*'));
4812+
t('a glob in a middle segment IS', globInNonFinalSegment('skills/*/references/_index.md'));
4813+
t('and a `**` in a middle segment IS', globInNonFinalSegment('packages/**/*.ts'));
4814+
// The live specimen: `packages/spec/scripts/build-skill-references.ts` emits
4815+
// these nine files and the derivation reached zero of them. If the skills
4816+
// layout ever changes, re-point this case at whatever mid-segment glob the
4817+
// fleet then declares rather than deleting it.
4818+
t('a mid-segment glob reaches the file it names', hintCovers('skills/*/references/_index.md', 'skills/objectstack-formula/references/_index.md'));
4819+
t('and does NOT claim the rest of the subtree it passes through', !hintCovers('skills/*/references/_index.md', 'skills/objectstack-formula/SKILL.md'));
4820+
t('a `**` crosses separators, a single `*` does not', hintCovers('packages/**/*.ts', 'packages/spec/src/data/filter.zod.ts'));
4821+
t('so a single `*` segment matches exactly one segment', !hintCovers('skills/*/references/_index.md', 'skills/a/b/references/_index.md'));
4822+
t('the extension the glob names is honoured', !hintCovers('packages/**/*.object.ts', 'packages/spec/src/index.ts'));
4823+
// Reverse containment, the direction a coarse card surface needs: the literal
4824+
// prefix before the first wildcard still reaches back to a directory surface,
4825+
// exactly as the collapsed form used to.
4826+
t('a directory surface above the glob still derives the gate', hintCovers('skills/*/references/_index.md', 'skills'));
4827+
t('but an unrelated root does not', !hintCovers('skills/*/references/_index.md', 'packages'));
4828+
// ⛔ The refused alternative, pinned as the loss it would be. Each of these
4829+
// is a trailing glob and MUST keep going through the collapse.
4830+
t('a trailing `**` still collapses to the root it names', hintCovers('packages/**', 'packages/spec/src/index.ts'));
4831+
t('the ROOT_DIR_WATCH_HINTS idiom is untouched', hintCovers('skills/**', 'skills/objectstack-formula/SKILL.md'));
4832+
t('and so is a trailing single `*`', hintCovers('examples/*', 'examples/app-showcase/src/x.ts'));
4833+
t('the DECIDED partial-segment trade still refuses the sibling', !hintCovers('packages/client*', 'packages/client-react/src/index.ts'));
4834+
4835+
// The trailing-separator strip is ONE call, not two: `/\/+$/` is greedy and
4836+
// anchored, so nothing survives for a second `/\/$/` to remove. Measured at
4837+
// zero of 754 hints; pinned on the probes that could tell them apart, so a
4838+
// future reader does not restore the redundant call as defence-in-depth.
4839+
t('the greedy trailing strip removes every trailing separator', collapseHint('a///') === 'a');
4840+
t('including the one a trailing `**` leaves behind', collapseHint('a/**/') === 'a');
4841+
t('and a hint that is nothing but separators collapses to empty', collapseHint('**/') === '');
4842+
t('what it cannot touch is a separator left in the MIDDLE', collapseHint('skills/*/references/_index.md') === 'skills//references/_index.md');
4843+
46644844
// ── A declared SUBTREE is not a bare word (#9626) ─────────────────────────
46654845
//
46664846
// The genericity refusal reads the hint as the author wrote it. Both

0 commit comments

Comments
 (0)