Skip to content

Commit 851b2d8

Browse files
yinlianghuiclaude
andauthored
A ** in a watch hint covers ZERO segments too, so a top-level population stops being a dead-by-construction spelling (#12386)
* fix(pm): a `**` in a watch hint covers ZERO segments too, so a top-level population stops being a trap `hintCovers` judges any hint with a glob in a non-final segment through `triggerCovers`, i.e. through GitHub's filter-pattern language, where `**` is a CHARACTER wildcard and the `/` written after it is a literal that must still appear. `scripts/**/*.d.mts` therefore compiles to `^scripts/.*/[^/]*\.d\.mts$` and needs at least one intervening segment: the three tracked `scripts/*.d.mts` mirrors are unreachable BY CONSTRUCTION by the natural spelling for them — the dead-hint species #12246 was filed for, arriving through the branch that fixed it. Repaired on the HINT side only. `triggerPatternRegex` is the CI mirror and `triggerListCovers`/`coveringTrigger` evaluate real workflow `paths:` lists with it (`validate-deps.yml` declares `'**/package.json'`), so teaching `**` to swallow its own separator there would make this file lie about CI. A hint is a glob a gate author wrote to describe what the gate reads, and in that language `a/**/b` covers `a/b`. A hint's forms are itself plus every spelling reachable by deleting a subset of its whole-`**` non-final segments — the power set, since each `**` is "zero or more" independently. Only a segment that is exactly `**` is droppable, only in a non-final position, and a single `*` never is. Measured on 173 families x 763 distinct hints x 6859 tracked files, driven through the real `hintCovers`: watch-hint (gate, file) pairs 70172 -> 70172 (ZERO change) families gaining or losing coverage 0 hints reaching zero tracked files 388 -> 388 packages/**/*.ts 4718, packages/**/*.object.ts 79, skills/*/references/ _index.md 9, src/**/* 0, src/**/*.zod.ts 0, spec/src/*/index.ts 0 — all held Zero is the expected reading: `packages/` holds no file at its top level and the two `src/**` hints are package-relative module specifiers. What the rule buys is that `scripts/**/*.d.mts` goes 0 -> 3 the moment a gate declares it. The ROOT_DIR_WATCH_HINTS idiom #12300 priced at -7404 pairs is untouched — `packages/*` 5253, `examples/*` 241, `skills/**` 50, `content/**` 442, `scripts/**` 271, and check:test-source-alias 5534, check:type-source- resolution 5534, check:published-files 5535, all unchanged — because no trailing glob reaches this rule at all. 21 self-test cases pin both directions, including the refusals that keep it narrow and the trigger-side assertion that the CI mirror still says what CI says. Part of #12329 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6 * docs(pm): re-take the zero-segment measurement at the merged head The docblock's readings were taken at 7986d97. Merging origin/main moved the corpus (174 families, 764 distinct hints, 6861 tracked files), so the table is re-derived at the head that lands: pairs 70188 -> 70188, still ZERO change, and `scripts/**` 272. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2a845da commit 851b2d8

1 file changed

Lines changed: 171 additions & 1 deletion

File tree

scripts/pm/dispatch-gates.mjs

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1617,7 +1617,8 @@ export function collapseHint(hint) {
16171617
* refused, because the narrowing would be the false statement.
16181618
*/
16191619
export function hintCovers(hint, inputPath) {
1620-
if (globInNonFinalSegment(hint)) return triggerCovers(hint, inputPath);
1620+
if (globInNonFinalSegment(hint))
1621+
return zeroSegmentForms(hint).some((form) => triggerCovers(form, inputPath));
16211622
const plain = collapseHint(hint);
16221623
if (plain.length < 2) return false;
16231624
// `hint`, not `plain`: glob collapse destroys the separator this refusal is
@@ -1644,6 +1645,132 @@ export function globInNonFinalSegment(hint) {
16441645
return false;
16451646
}
16461647

1648+
/**
1649+
* How many `**` segments one hint may carry before the enumeration below stops
1650+
* being exhaustive. The forms are a power set, so the bound is what keeps a
1651+
* pathological hint from costing 2^n comparisons per file. Measured over all
1652+
* 764 distinct hints in the fleet, the maximum any hint carries is ONE; the cap
1653+
* is headroom, not a live constraint, and the self-test pins what a hint above
1654+
* it still gets.
1655+
*/
1656+
export const ZERO_SEGMENT_STAR_CAP = 8;
1657+
1658+
/**
1659+
* ## `a/**\/b` must reach `a/b` — the spellings a hint author writes, not the
1660+
* ## ones CI's trigger language happens to share
1661+
*
1662+
* Every spelling of a hint that carries a glob in a non-final segment is judged
1663+
* by `triggerCovers`, which is `triggerPatternRegex` and therefore GitHub's
1664+
* filter-pattern language verbatim: there `**` is a CHARACTER wildcard ("zero
1665+
* or more of any character, `/` included"), so the `/` written after it is a
1666+
* literal that must still appear. The consequence is segment-wise arithmetic no
1667+
* hint author expects — `scripts/**\/*.d.mts` compiles to
1668+
* `^scripts/.*\/[^/]*\.d\.mts$`, which needs at least one intervening
1669+
* segment, so `**` there means ONE OR MORE and a top-level file is unreachable
1670+
* BY CONSTRUCTION:
1671+
*
1672+
* scripts/check-regen-pending.d.mts ← 3 tracked files, the natural
1673+
* scripts/invoked-as.d.mts spelling for them reaches 0 of them
1674+
* scripts/js-comment-mask.d.mts
1675+
*
1676+
* That is the same dead-hint species #12246 was filed for, arriving through the
1677+
* branch that fixed it: a hint that matches nothing while looking like an
1678+
* ordinary literal, which `unreachableClass` then files as "THE LAYOUT MOVED …
1679+
* a real miss, worth triaging" — the wrong-classification-plus-wrong-evidence
1680+
* row this output calls the worst one it can print.
1681+
*
1682+
* ## Why the repair is HERE and not in `triggerPatternRegex`
1683+
*
1684+
* `triggerPatternRegex` is the CI mirror. `triggerListCovers`/`coveringTrigger`
1685+
* evaluate real workflow `paths:` lists with it, and its docblock's whole claim
1686+
* is that it reads the trigger language rather than approximating it. Teaching
1687+
* `**` to swallow its own separator THERE would change what this file says CI
1688+
* does — a fleet-wide semantic change, and a lie about a `paths:` list, for
1689+
* every workflow (`validate-deps.yml`'s `'**\/package.json'` is the live
1690+
* specimen). So the character-wildcard translation stays exactly as it is, and
1691+
* the difference is confined to the side that actually differs: a HINT is a
1692+
* glob a gate author wrote to describe what the gate reads, not a filter GitHub
1693+
* will evaluate, and in that language `a/**\/b` covers `a/b`.
1694+
*
1695+
* ## The rule
1696+
*
1697+
* A hint's forms are itself plus every spelling reachable by deleting some
1698+
* subset of its whole-`**` non-final segments — the power set, because each
1699+
* `**` means "zero or more" independently of the others. A match against ANY
1700+
* form is a match. Deliberately narrow in three ways:
1701+
*
1702+
* - only a segment that is EXACTLY `**` is droppable. `packages/client*` and
1703+
* `*.d.mts` are partial-segment globs and keep the meaning they have;
1704+
* - only NON-FINAL segments, so nothing that reaches the collapse is touched;
1705+
* - a single `*` is never droppable — `a/*\/b` means exactly one segment in
1706+
* every glob language, `skills/*\/references/_index.md` included.
1707+
*
1708+
* ## Measured, both directions, on 174 families × 764 distinct hints × 6861
1709+
* ## tracked files
1710+
*
1711+
* The blast radius is enumerable rather than estimated: four of the six live
1712+
* hints with a glob in a non-final segment carry a whole-`**` segment, and the
1713+
* form this rule adds for each reaches nothing the tree has.
1714+
*
1715+
* packages/**\/*.ts + packages/*.ts 4718 → 4718
1716+
* packages/**\/*.object.ts + packages/*.object.ts 79 → 79
1717+
* src/**\/* + src/* 0 → 0
1718+
* src/**\/*.zod.ts + src/*.zod.ts 0 → 0
1719+
* skills/*\/references/_index.md no `**` segment 9 → 9
1720+
* spec/src/*\/index.ts no `**` segment 0 → 0
1721+
*
1722+
* watch-hint (gate, file) pairs 70188 → 70188 (ZERO change)
1723+
* families gaining or losing coverage 0
1724+
* (check, hint) newly live 0; newly inert 0
1725+
* hints reaching zero tracked files 388 → 388
1726+
*
1727+
* Zero is the expected reading, not a disappointing one: `packages/` holds no
1728+
* file at its top level, and the two `src/**` hints are package-relative module
1729+
* specifiers that were never repo paths. What the rule buys is that the natural
1730+
* spelling for a top-level population STOPS BEING A TRAP — `scripts/**\/*.d.mts`
1731+
* goes 0 → 3 the moment a gate declares it, instead of being recorded as an
1732+
* unspellable population.
1733+
*
1734+
* The `ROOT_DIR_WATCH_HINTS` idiom #12300 measured at −7404 pairs on each of
1735+
* three gates if widened wrongly is untouched, because no trailing glob reaches
1736+
* this function at all: `packages/*` 5253, `examples/*` 241, `skills/**` 50,
1737+
* `content/**` 442, `scripts/**` 272, all unchanged, and the three gates hold
1738+
* at check:test-source-alias 5534, check:type-source-resolution 5534,
1739+
* check:published-files 5535.
1740+
*
1741+
* ## What this deliberately does NOT fix
1742+
*
1743+
* The sibling spelling `scripts/*.d.mts` is dead too, by the OLDER route: a
1744+
* glob in the LAST segment still goes through `collapseHint`, which deletes the
1745+
* `*` and yields `scripts/.d.mts` — a path no tree holds. That is a different
1746+
* species (deletion-collapse mangling a final segment whose glob carries a
1747+
* literal SUFFIX, next door to the DECIDED partial-segment trade), it is not
1748+
* the zero-segment question, and it is left exactly as it was. Pinned below so
1749+
* the asymmetry reads as recorded rather than overlooked.
1750+
*/
1751+
export function zeroSegmentForms(hint) {
1752+
const segments = hint.split('/');
1753+
const droppable = [];
1754+
for (let i = 0; i < segments.length - 1; i++) if (segments[i] === '**') droppable.push(i);
1755+
if (droppable.length === 0) return [hint];
1756+
// Above the cap the power set is refused rather than truncated arbitrarily:
1757+
// the two forms that carry meaning are the hint as written (every `**` at one
1758+
// or more) and the hint fully reduced (every `**` at zero).
1759+
const dropSets =
1760+
droppable.length > ZERO_SEGMENT_STAR_CAP
1761+
? [[], droppable]
1762+
: Array.from({ length: 1 << droppable.length }, (_, mask) =>
1763+
droppable.filter((_, k) => (mask >> k) & 1),
1764+
);
1765+
const forms = [];
1766+
for (const drop of dropSets) {
1767+
const dropped = new Set(drop);
1768+
const form = segments.filter((_, i) => !dropped.has(i)).join('/');
1769+
if (form.length > 0 && !forms.includes(form)) forms.push(form);
1770+
}
1771+
return forms;
1772+
}
1773+
16471774
/**
16481775
* Translate ONE GitHub filter pattern into an anchored regex, following the
16491776
* documented filter-pattern semantics rather than approximating them:
@@ -5006,6 +5133,49 @@ function selfTest() {
50065133
t('and so is a trailing single `*`', hintCovers('examples/*', 'examples/app-showcase/src/x.ts'));
50075134
t('the DECIDED partial-segment trade still refuses the sibling', !hintCovers('packages/client*', 'packages/client-react/src/index.ts'));
50085135

5136+
// ── `**` covers ZERO segments too (#12329) ───────────────────────────────
5137+
//
5138+
// The branch above judges these hints with `triggerCovers`, i.e. with
5139+
// GitHub's filter-pattern language, where `**` is a CHARACTER wildcard and
5140+
// the `/` written after it is a literal that must still appear. That makes
5141+
// `**` mean ONE OR MORE segments, so the natural spelling for a top-level
5142+
// population reaches none of it. Read from the real corpus, not a fixture: a
5143+
// fixture cannot show that the tree still has the shape the trap needs.
5144+
const topLevelMirrors = trackedFiles().filter((f) => /^scripts\/[^/]+\.d\.mts$/.test(f));
5145+
t('the tree really does hold top-level `.d.mts` files under a root', topLevelMirrors.length >= 3);
5146+
t('a `**` root reaches the top-level files under it', topLevelMirrors.every((f) => hintCovers('scripts/**/*.d.mts', f)));
5147+
t('and claims nothing else in the whole tree', trackedFiles().filter((f) => hintCovers('scripts/**/*.d.mts', f)).length === topLevelMirrors.length);
5148+
t('the ONE-OR-MORE reading it used to have is still there', hintCovers('scripts/**/*.d.mts', 'scripts/pm/x.d.mts'));
5149+
t('at any depth', hintCovers('scripts/**/*.d.mts', 'scripts/a/b/x.d.mts'));
5150+
t('the extension the glob names is still honoured at the top level', !hintCovers('scripts/**/*.d.mts', 'scripts/invoked-as.mjs'));
5151+
t('and a directory surface above it still derives the gate', hintCovers('scripts/**/*.d.mts', 'scripts'));
5152+
// The forms are itself first, then the reductions — the original spelling is
5153+
// never lost, which is what keeps the one-or-more cases above passing.
5154+
t('the forms of a `**` hint are the hint and its zero-segment reduction', zeroSegmentForms('scripts/**/*.d.mts').join(' ') === 'scripts/**/*.d.mts scripts/*.d.mts');
5155+
t('each `**` drops independently, so two of them give the power set', zeroSegmentForms('a/**/b/**/c').join(' ') === 'a/**/b/**/c a/b/**/c a/**/b/c a/b/c');
5156+
t('a hint with no `**` segment has exactly one form', zeroSegmentForms('skills/*/references/_index.md').join(' ') === 'skills/*/references/_index.md');
5157+
t('and so does a hint with no glob at all', zeroSegmentForms('packages/spec/src/index.ts').join(' ') === 'packages/spec/src/index.ts');
5158+
t('a hint above the cap keeps its written and fully-reduced forms only', zeroSegmentForms('a/**/**/**/**/**/**/**/**/**/z').length === 2);
5159+
// ⛔ Deliberately NOT droppable — three refusals that keep this narrow.
5160+
t('a single `*` segment is not a zero-segment wildcard', !hintCovers('skills/*/references/_index.md', 'skills/references/_index.md'));
5161+
t('nor is a `**` that is only PART of a segment', zeroSegmentForms('packages/a**/b.ts').join(' ') === 'packages/a**/b.ts');
5162+
t('and a trailing `**` never reaches this rule at all', hintCovers('packages/**', 'packages/spec/src/index.ts') && !globInNonFinalSegment('packages/**'));
5163+
// The CI mirror is untouched, which is the whole reason the repair lives in
5164+
// `hintCovers` and not in `triggerPatternRegex`: a hint is a glob a gate
5165+
// author wrote, a trigger is a filter GitHub will evaluate, and this file
5166+
// must keep saying what GitHub does. `validate-deps.yml` declares
5167+
// `'**/package.json'` and is the live specimen.
5168+
t('the trigger language still reads `**` as the character wildcard GitHub documents', !triggerCovers('**/package.json', 'package.json'));
5169+
t('while the same spelling as a HINT covers the root file', hintCovers('**/package.json', 'package.json'));
5170+
// ⛔ The sibling spelling is dead by the OLDER route and is NOT repaired
5171+
// here: a glob in the LAST segment still goes through `collapseHint`, which
5172+
// yields `scripts/.d.mts`. A different species (deletion-collapse mangling a
5173+
// final segment whose glob carries a literal SUFFIX), next door to the
5174+
// DECIDED partial-segment trade. Pinned so the asymmetry reads as recorded
5175+
// rather than overlooked — see zeroSegmentForms' docblock.
5176+
t('the final-segment spelling of the same population is still dead', collapseHint('scripts/*.d.mts') === 'scripts/.d.mts');
5177+
t('and still reaches none of the files it names', !topLevelMirrors.some((f) => hintCovers('scripts/*.d.mts', f)));
5178+
50095179
// The trailing-separator strip is ONE call, not two: `/\/+$/` is greedy and
50105180
// anchored, so nothing survives for a second `/\/$/` to remove. Measured at
50115181
// zero of 754 hints; pinned on the probes that could tell them apart, so a

0 commit comments

Comments
 (0)