From 376b6f50598ded5657747076287f0381db62ac25 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:27:25 +0000 Subject: [PATCH 1/2] fix(pm): route the artifact-roster classifier through the file's own resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `artifactOnlySilence` decided "every declared literal is a tracked FILE" with `trackedFiles.has(collapseHint(h))` — a private, weaker copy of a question this file already has an owner for. `hintCovers` follows a dropped extension through MODULE_SPECIFIER_EXTENSIONS and `extensionlessModuleTarget` names the file such a specifier resolves to; this predicate followed neither, so a family whose roster is extensionless import targets failed the every-literal test and printed as an ordinary silence — a different fact, and one that reads as a clearance. The repair is the single owner, not a list of the families it got wrong: `declaredFileTarget` is now the one answer to "the tracked FILE this declared literal names", composed from the membership test and the resolver, with patterns refused up front. `artifactOnlySilence` asks it and holds no rule of its own, and takes the `watchHintTree` bundle rather than a bare file set — refusing the bare set loudly, since that is exactly the input that mis-categorises silently. Measured over the whole fleet at 16c3601d2 (192 families, 754 distinct hints, 7605 tracked files): artifact-roster families 30 -> 39 (+9, ZERO lost), zero existing rosters whose membership or directory moved, and literals where the covering rule and the classifier disagree 40 -> 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC --- scripts/pm/dispatch-gates.mjs | 291 ++++++++++++++++++++++++++++++++-- 1 file changed, 274 insertions(+), 17 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index e8ca41e7cd..2055ef5d05 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -3589,7 +3589,7 @@ export function commonDirectory(paths) { * * ## The test, and why it is FILES and not a shape heuristic * - * Every declared literal must collapse to a path the tree tracks as a FILE. A + * Every declared literal must NAME a path the tree tracks as a FILE. A * gate that names one directory has declared a population, whatever else it * names; a gate that names only files has declared ARTIFACTS — a baseline it * maintains, an allowlist of current members, a sibling tool it reads — and @@ -3598,6 +3598,53 @@ export function commonDirectory(paths) { * its baseline artifact" case the residue prose already describes, and several * under a common root is the enumeration above. * + * ## Why it asks `declaredFileTarget` and holds no answer of its own (#13520) + * + * "NAME" above used to read "collapse to", and the difference was a whole + * category. The test was `trackedFiles.has(collapseHint(h))` — a private, + * weaker copy of a question this file already has an owner for. `hintCovers` + * resolves a module specifier through `MODULE_SPECIFIER_EXTENSIONS` (#12514) + * and `extensionlessModuleTarget` names the file it resolves to (#12299); this + * predicate followed neither, so a family whose whole roster is extensionless + * import targets — `packages/spec/scripts/lib/dist-freshness`, while the tree + * holds `…/dist-freshness.ts` — failed the every-literal test and printed as + * an ORDINARY silence, which this file's header calls a different fact. + * + * ⚠️ Note the failure direction, which is why it survived: it threw nothing and + * printed no error. It returned a coherent, plausible, WRONG category, and the + * `--residue` block printed both halves of the contradiction on one line — the + * dead-hint sweep (`hintCovers`) marking every literal as reaching the tree, + * and this predicate declining to call the family a roster. + * + * The repair is not a list of the families it got wrong. It is that this + * predicate no longer decides the question at all: `declaredFileTarget` is the + * one owner of "the tracked FILE this declared literal names", and every + * spelling that owner learns is learned here in the same edit, for every + * family at once. The self-test holds the two EQUAL over the live fleet, so a + * future widening of the covering rule that forgets this reader reds instead of + * silently re-categorising families — the class, not the gate names. + * + * ## Blast radius, measured over the WHOLE fleet before widening (#13520) + * + * On 192 discovered families × 754 distinct hints × 7605 tracked files, at + * `16c3601d2`: + * + * artifact-roster families 30 -> 39 (+9, and ZERO lost) + * rosters whose membership or dir moved 0 + * literals where the covering rule and this predicate disagree 40 -> 0 + * + * All nine gained are `packages/spec` families whose rosters are `.ts` module + * specifiers; no family loses the verdict it had, and no existing roster's + * `artifacts` or `dir` changes, because resolution is the identity on a literal + * that already spells its file. The card that filed this named six; the sweep + * it asked for found nine. + * + * The widening cannot reach a declared POPULATION: `extensionlessModuleTarget` + * refuses any hint the tree has as a prefix, so a directory literal never + * resolves, and a pattern is refused up front (measured: 18 pattern-judged + * hints in the fleet, 0 of which collapse to a tracked file — the refusal is + * structural rather than lucky). + * * Deliberately NOT inferred: whether the author meant the roster as the * population. Intent is not in the tree — the same refusal `unreachableFamilies` * makes — so this reports the SHAPE and hands the reader the discriminator. @@ -3614,12 +3661,26 @@ export function commonDirectory(paths) { * * @param {{hints?: string[]}} entry * @param {string[]} paths the card's file surface - * @param {Set} trackedFiles every file git tracks, repo-relative - */ -export function artifactOnlySilence(entry, paths, trackedFiles) { - const artifacts = [...new Set(entry.hints ?? [])].map(collapseHint); - if (artifacts.length === 0) return null; - if (!artifacts.every((a) => trackedFiles.has(a))) return null; + * @param {{files: Set, prefixes: Set}} tree the `watchHintTree` bundle + */ +export function artifactOnlySilence(entry, paths, tree) { + // A bare file set is exactly what this predicate used to take, and it is the + // shape that loses every literal naming its file through a dropped + // extension. Refused loudly rather than read as an empty answer (#4690's + // rule, applied to a caller instead of to a corpus): a wrong CATEGORY is the + // failure this parameter was widened to stop, and it prints as a plausible + // sentence when it happens. + if (!(tree?.files instanceof Set) || !(tree?.prefixes instanceof Set)) { + throw new TypeError( + 'artifactOnlySilence needs the watch-hint TREE bundle ({files, prefixes}) from watchHintTree(), ' + + 'not a bare file set — the pair is meaningless apart, and files alone silently mis-categorises ' + + 'every family whose roster is spelled as extensionless module specifiers.', + ); + } + const declared = [...new Set(entry.hints ?? [])]; + if (declared.length === 0) return null; + const artifacts = declared.map((h) => declaredFileTarget(h, tree)); + if (!artifacts.every(Boolean)) return null; const dir = commonDirectory(artifacts); const coversYourPath = Boolean(dir) && paths.some((p) => p === dir || p.startsWith(`${dir}/`)); return { artifacts, dir, coversYourPath }; @@ -3652,7 +3713,11 @@ export function artifactOnlySilence(entry, paths, trackedFiles) { */ export function artifactOnlyNote({ artifacts, dir, coversYourPath }) { const what = - `⚠ artifact roster: all ${artifacts.length} declared literal(s) are tracked FILES` + + // "NAME", not "are" (#13520). A literal may name its file through a + // dropped extension, so the roster is a list of files the literals RESOLVE + // to, and a sentence saying the literals ARE those files stopped being true + // the moment the classifier learned to follow the resolution. + `⚠ artifact roster: all ${artifacts.length} declared literal(s) name tracked FILES` + (dir ? `, under ${dir}` : '') + ' — artifacts this gate names (a baseline, an allowlist of current members), not a population it declares.'; if (!coversYourPath) { @@ -4560,6 +4625,66 @@ export function extensionlessModuleTarget(hint, files, prefixes) { return null; } +/** + * The tracked FILE a declared literal NAMES, or `null` when it names anything + * else — a directory, a pattern, a path this tree does not have. The one owner + * of that question, for every reader in this file that has it (#13520). + * + * ## Why it exists: the question had three answers and they disagreed + * + * Three places in this file ask some form of "does this literal name a tracked + * file", and until this function they answered it three different ways: + * + * - `hintCovers` — the covering rule, which follows a dropped extension + * through `MODULE_SPECIFIER_EXTENSIONS` (#12514); + * - `extensionlessModuleTarget` — which NAMES the file such a specifier + * resolves to, and is what the residue printer says "the tree HAS this + * file" with (#12299); + * - `artifactOnlySilence` — which asked `trackedFiles.has(collapseHint(h))` + * and so followed nothing. + * + * The third is a private copy of a rule that lives elsewhere, and a copy that + * drifts is the defect this file refuses everywhere else — `extractWatchHints` + * was refused the same widening on exactly this ground ("a SECOND answer to a + * question `extensionlessModuleTarget` already owns — the drift this file + * refuses everywhere else"). Measured over the fleet, the copy disagreed with + * the covering rule about 40 of 754 declared literals, silently, in the + * direction that prints a coherent wrong category rather than an error. + * + * ## What it composes, and why the composition is total + * + * A literal names a tracked file in exactly two ways, and they are mutually + * exclusive by construction rather than by luck: it IS the file, or it is that + * file's module specifier with the extension dropped. + * `extensionlessModuleTarget` refuses any hint the tree has as a prefix, and a + * tracked file is a tracked prefix, so the second branch can never re-answer + * the first. That exclusion is already pinned; this function is where the two + * halves are joined so no third caller has to join them again. + * + * ## Why a PATTERN is refused before either branch + * + * A glob is a declared population — the opposite of an artifact — and a + * mangled collapse must never be able to smuggle one in through the file + * branch. `globInNonFinalSegment` and `globCarriesLiteralSuffix` both splice + * strings that were never adjacent (`skills/*\/references/_index.md` → + * `skills//references/_index.md`, `.changeset/*.md` → `.changeset/.md`), and + * a splice that happened to land on a tracked file would enter here as an + * artifact. Measured on this tree: 18 pattern-judged hints in the fleet, none + * of which collapses to a tracked file — so the refusal costs nothing today and + * makes the exclusion structural, the standard this file holds its other + * boundaries to. + * + * @param {string} hint one declared literal, as the family spells it + * @param {{files: Set, prefixes: Set}} tree the `watchHintTree` bundle + */ +export function declaredFileTarget(hint, tree) { + if (judgedAsPattern(hint)) return null; + const plain = collapseHint(hint); + if (!plain) return null; + if (tree.files.has(plain)) return plain; + return extensionlessModuleTarget(hint, tree.files, tree.prefixes); +} + /** * Does this hint reach ANY tracked file? * @@ -6940,7 +7065,12 @@ function derive(paths, { showResidue = false, mode = 'human' } = {}) { // corpus to judge a single-segment directory literal — one listing, so the // hints and the sweep that grades them cannot be taken from different trees. const swept = trackedFiles(); - const { byCheck, workflows, workflowEntries } = discoverFamilies({ tree: watchHintTree(swept) }); + // Held in a name rather than built inline: the artifact-roster split needs + // the SAME bundle the discovery was handed. Built twice it would be two + // readings of one listing, which is the drift `watchHintTree`'s own docblock + // refuses ("the pair is meaningless apart"). + const tree = watchHintTree(swept); + const { byCheck, workflows, workflowEntries } = discoverFamilies({ tree }); // ONE per-hint sweep feeds both readers of dead literals: the unreachable // listing (whole-family grain) and the residue annotations (per-hint grain, // #13312) — so the two cannot disagree about which literals are dead. @@ -6950,18 +7080,13 @@ function derive(paths, { showResidue = false, mode = 'human' } = {}) { const matched = new Map(); const undetermined = []; const silent = []; - // The corpus the reachability sweep already read, as a membership test: the - // artifact-roster split asks whether a declared literal is a tracked FILE, - // and re-reading the tree for it would be a second answer to a question this - // run has already asked once. - const trackedSet = new Set(swept); for (const [check, entry] of byCheck) { const { verdict, hits } = classifyEntry(entry, paths); if (verdict === 'matched') matched.set(check, { entry, hits }); else if (verdict === 'undetermined') undetermined.push([check, entry]); else silent.push([check, entry]); } - const rosters = silent.map(([, entry]) => artifactOnlySilence(entry, paths, trackedSet)).filter(Boolean); + const rosters = silent.map(([, entry]) => artifactOnlySilence(entry, paths, tree)).filter(Boolean); // ONE structured answer, rendered three ways below. The human block, the // `--commands` list and the `--json` document are readings of these same @@ -7068,7 +7193,7 @@ function derive(paths, { showResidue = false, mode = 'human' } = {}) { // said the same words in this listing as one that really does not read // your file. The note says which, and raises its voice only where the // roster was taken from a directory the card edits. - const roster = withHints ? artifactOnlySilence(entry, paths, trackedSet) : null; + const roster = withHints ? artifactOnlySilence(entry, paths, tree) : null; if (roster) for (const line of artifactOnlyNote(roster)) console.log(line); } }; @@ -9553,7 +9678,7 @@ function selfTest() { t('files with nothing above the repo root share no directory', commonDirectory(['README.md', 'AGENTS.md']) === ''); t('one file is its own directory', commonDirectory(['scripts/pm/a.mjs']) === 'scripts/pm'); - const rosterTree = new Set(['scripts/a.mjs', 'scripts/b.mjs', 'scripts/pm/c.mjs']); + const rosterTree = watchHintTree(['scripts/a.mjs', 'scripts/b.mjs', 'scripts/pm/c.mjs']); const rosterFam = (hints) => ({ hints, files: [], workflows: new Set(['lint.yml']) }); const roster = artifactOnlySilence(rosterFam(['scripts/a.mjs', 'scripts/b.mjs']), [unwrittenScript], rosterTree); t('a population of nothing but tracked FILES is an artifact roster', roster?.artifacts.length === 2 && roster.dir === 'scripts'); @@ -9587,6 +9712,138 @@ function selfTest() { .includes('ordinary one'), ); + // ── The classifier returned a plausible WRONG CATEGORY (#13520) ─────────── + // + // ⚠️ Every case below asserts the CATEGORY, never "it did not crash" and + // never "it still classifies". This defect threw nothing and printed no + // error — it answered `null` where the answer is a roster, so a case that + // only checked for an answer would have been GREEN against the bug. Each + // assertion therefore names the bucket AND its contents: which tracked files, + // under which directory, and for the negatives, `null` exactly. + const extlessTree = watchHintTree([ + 'packages/spec/scripts/lib/dist-freshness.ts', + 'packages/spec/scripts/lib/sharded-artifacts.ts', + 'packages/spec/scripts/lib/notes.md', + 'packages/spec/src/index.ts', + ]); + const extlessRoster = artifactOnlySilence( + rosterFam(['packages/spec/scripts/lib/dist-freshness', 'packages/spec/scripts/lib/sharded-artifacts']), + ['packages/spec/scripts/lib/dist-freshness.ts'], + extlessTree, + ); + t( + 'a roster spelled as extensionless module specifiers is an artifact ROSTER, not an ordinary silence', + extlessRoster !== null, + JSON.stringify(extlessRoster), + ); + t( + '…and the CATEGORY is pinned by its contents: the tracked files those specifiers name', + JSON.stringify(extlessRoster?.artifacts) === + JSON.stringify(['packages/spec/scripts/lib/dist-freshness.ts', 'packages/spec/scripts/lib/sharded-artifacts.ts']), + JSON.stringify(extlessRoster?.artifacts), + ); + t( + '…under the directory those files really sit in, not the one the specifier stops short at', + extlessRoster?.dir === 'packages/spec/scripts/lib', + String(extlessRoster?.dir), + ); + t( + '…and for a card in that directory it is the INVERTED silence, which is the whole point of the split', + extlessRoster?.coversYourPath === true, + ); + t( + 'a roster mixing both spellings of the same claim resolves to the same category', + JSON.stringify( + artifactOnlySilence( + rosterFam(['packages/spec/scripts/lib/dist-freshness', 'packages/spec/scripts/lib/notes.md']), + [], + extlessTree, + )?.artifacts, + ) === JSON.stringify(['packages/spec/scripts/lib/dist-freshness.ts', 'packages/spec/scripts/lib/notes.md']), + ); + // The negatives, which are what stop the widening from becoming a second + // defect pointing the other way: a POPULATION must still refuse the roster + // category however resolvable its siblings are. + t( + 'a declared DIRECTORY beside resolvable specifiers is still a population, not a roster', + artifactOnlySilence( + rosterFam(['packages/spec/scripts/lib/dist-freshness', 'packages/spec/scripts/lib']), + [], + extlessTree, + ) === null, + ); + t( + 'a specifier that resolves to nothing is not an artifact — that is still the unreachable species', + artifactOnlySilence(rosterFam(['packages/spec/scripts/lib/gone']), [], extlessTree) === null, + ); + t( + 'a PATTERN whose collapse would land on a tracked file is refused before the file branch', + declaredFileTarget('packages/spec/scripts/lib/dist-freshness*.ts', extlessTree) === null && + extlessTree.files.has(collapseHint('packages/spec/scripts/lib/dist-freshness*.ts')), + ); + t( + '…non-vacuously: that hint really is judged as a pattern, and the same literal without the glob resolves', + judgedAsPattern('packages/spec/scripts/lib/dist-freshness*.ts') && + declaredFileTarget('packages/spec/scripts/lib/dist-freshness', extlessTree) === + 'packages/spec/scripts/lib/dist-freshness.ts', + ); + // The bare file set is the OLD parameter, and it is the one input that + // reproduces the defect exactly. It must not be readable as an empty answer. + let rosterRefusedBareSet = false; + try { + artifactOnlySilence(rosterFam(['scripts/a.mjs']), [], new Set(['scripts/a.mjs'])); + } catch { + rosterRefusedBareSet = true; + } + t('a bare file set is REFUSED, never answered — it is the shape that mis-categorises silently', rosterRefusedBareSet); + + // ⭐ THE CLASS GUARD, and the reason this card is not "nine gate names added + // to a table". The defect was one predicate holding a private, weaker copy + // of the covering rule; the copy is gone, and this holds the two EQUAL over + // the LIVE fleet, per declared literal: + // + // for every literal `hintCovers` judges as a LITERAL and the tree does not + // have as a DIRECTORY — `declaredFileTarget` names a file exactly when the + // covering rule reaches exactly one tracked file, and it is that file. + // + // Non-tautological on purpose: the two sides are computed by different code + // (a membership test plus the extension resolver, against a full sweep of the + // corpus through `hintCovers`). Measured while writing this: BEFORE the + // repair the two disagreed about 40 of 754 declared literals; after it, 0. + // The day someone teaches `hintCovers` a further spelling — the way #12514 + // taught it the extension list — and forgets this reader, this reds, for + // whatever family happens to carry it. No gate name appears in it. + const classFiles = trackedFiles(); + const classTree = watchHintTree(classFiles); + const classHints = new Set(); + for (const [, entry] of discoverFamilies({ tree: classTree }).byCheck) { + for (const h of entry.hints ?? []) classHints.add(h); + } + const isTrackedDirHint = (h) => { + const plain = collapseHint(h); + return classTree.prefixes.has(plain) && !classTree.files.has(plain); + }; + const coveringRuleFile = (h) => { + if (judgedAsPattern(h) || isTrackedDirHint(h)) return null; + const reached = classFiles.filter((f) => hintCovers(h, f)); + return reached.length === 1 ? reached[0] : null; + }; + const classDisagreements = [...classHints].filter((h) => coveringRuleFile(h) !== declaredFileTarget(h, classTree)); + t( + `the roster classifier and the covering rule name the SAME file for every declared literal in the fleet (${classHints.size} literals)`, + classDisagreements.length === 0, + classDisagreements + .slice(0, 5) + .map((h) => `${h}: rule=${coveringRuleFile(h)} classifier=${declaredFileTarget(h, classTree)}`) + .join(' · '), + ); + // Non-vacuity for the case above — an empty population would assert nothing, + // and the fleet really does carry the shape this card was filed for. + t( + '…non-vacuously: the fleet really contains literals that name their file only through a dropped extension', + [...classHints].some((h) => declaredFileTarget(h, classTree) && !classTree.files.has(collapseHint(h))), + ); + // ── A trailing sentence period is not part of the path (#8534, half two) ── // // Coupled to the rule above: the raw-prefix comparison reached the real file From 4eaee4a0e38cff0d4f8dfa87e35a2d33f5de59f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:45:59 +0000 Subject: [PATCH 2/2] test(pm): state the class guard over the CLASSIFIER, not over the resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first spelling of the fleet-wide agreement case compared `declaredFileTarget` against `hintCovers`. The ablation that put the old per-literal rule back inside `artifactOnlySilence` left the resolver untouched, so that case was GREEN against the exact bug it exists to catch — a guard on the owner does not hold the caller to it. Restated at family grain over the classifier's own output: 5 of 1060 cases red under the ablation before, 6 after, and the sixth is the one that generalises past the fixture. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC --- scripts/pm/dispatch-gates.mjs | 77 +++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 2055ef5d05..e307514375 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -9798,50 +9798,73 @@ function selfTest() { t('a bare file set is REFUSED, never answered — it is the shape that mis-categorises silently', rosterRefusedBareSet); // ⭐ THE CLASS GUARD, and the reason this card is not "nine gate names added - // to a table". The defect was one predicate holding a private, weaker copy - // of the covering rule; the copy is gone, and this holds the two EQUAL over - // the LIVE fleet, per declared literal: + // to a table". The defect was ONE PREDICATE holding a private, weaker copy of + // the covering rule; the copy is gone, and this holds the two instruments + // EQUAL over the LIVE fleet, at FAMILY grain: // - // for every literal `hintCovers` judges as a LITERAL and the tree does not - // have as a DIRECTORY — `declaredFileTarget` names a file exactly when the - // covering rule reaches exactly one tracked file, and it is that file. + // for every discovered family — `artifactOnlySilence` returns a roster + // exactly when every declared literal of that family names exactly one + // tracked file under `hintCovers`, and the roster IS those files. // - // Non-tautological on purpose: the two sides are computed by different code - // (a membership test plus the extension resolver, against a full sweep of the - // corpus through `hintCovers`). Measured while writing this: BEFORE the - // repair the two disagreed about 40 of 754 declared literals; after it, 0. - // The day someone teaches `hintCovers` a further spelling — the way #12514 - // taught it the extension list — and forgets this reader, this reds, for - // whatever family happens to carry it. No gate name appears in it. + // ⚠️ Family grain, not literal grain, and the difference was measured rather + // than reasoned. Written against `declaredFileTarget` this case was GREEN + // against the very bug it exists to catch: the ablation that put the old rule + // back inside `artifactOnlySilence` left the resolver untouched, so a guard + // comparing resolver to covering rule saw nothing wrong. A guard on the OWNER + // does not hold the CALLER to it. Stated over the classifier's own output it + // reds, because the classifier is what the reader is shown. + // + // Non-tautological in both directions: the right side is computed by sweeping + // the whole tracked corpus through `hintCovers`, which shares no code with the + // membership test and resolver the classifier composes. Measured while + // writing this — before the repair the two disagreed about 40 of 754 declared + // literals and 9 of 192 families; after it, 0 and 0. The day someone teaches + // `hintCovers` a further spelling (the way #12514 taught it the extension + // list) and forgets this reader, or reintroduces a private test in the + // classifier, this reds for whatever family happens to carry it. No gate name + // appears in it, which is the whole point. const classFiles = trackedFiles(); const classTree = watchHintTree(classFiles); - const classHints = new Set(); - for (const [, entry] of discoverFamilies({ tree: classTree }).byCheck) { - for (const h of entry.hints ?? []) classHints.add(h); - } + const classFams = [...discoverFamilies({ tree: classTree }).byCheck]; const isTrackedDirHint = (h) => { const plain = collapseHint(h); return classTree.prefixes.has(plain) && !classTree.files.has(plain); }; + // The covering rule's own answer to "this literal names exactly one tracked + // FILE": swept, not resolved. A pattern and a directory are declared + // POPULATIONS and are excluded before the sweep — `apps/*/package.json` + // reaches exactly one file on this tree only because the repo has one app. const coveringRuleFile = (h) => { if (judgedAsPattern(h) || isTrackedDirHint(h)) return null; const reached = classFiles.filter((f) => hintCovers(h, f)); return reached.length === 1 ? reached[0] : null; }; - const classDisagreements = [...classHints].filter((h) => coveringRuleFile(h) !== declaredFileTarget(h, classTree)); + const ruleRoster = (entry) => { + const declaredHints = [...new Set(entry.hints ?? [])]; + if (declaredHints.length === 0) return null; + const named = declaredHints.map(coveringRuleFile); + return named.every(Boolean) ? named : null; + }; + const classSplit = classFams.map(([check, entry]) => [ + check, + JSON.stringify(artifactOnlySilence(entry, [], classTree)?.artifacts ?? null), + JSON.stringify(ruleRoster(entry)), + ]); + const classDisagreements = classSplit.filter(([, mine, rule]) => mine !== rule); t( - `the roster classifier and the covering rule name the SAME file for every declared literal in the fleet (${classHints.size} literals)`, + `the roster classifier and the covering rule agree about every family in the fleet (${classFams.length} families)`, classDisagreements.length === 0, - classDisagreements - .slice(0, 5) - .map((h) => `${h}: rule=${coveringRuleFile(h)} classifier=${declaredFileTarget(h, classTree)}`) - .join(' · '), + classDisagreements.slice(0, 5).map(([c, mine, rule]) => `${c}: classifier=${mine} rule=${rule}`).join(' · '), ); - // Non-vacuity for the case above — an empty population would assert nothing, - // and the fleet really does carry the shape this card was filed for. + // Non-vacuity for the case above — an agreement over an empty or all-null + // population asserts nothing, and the fleet really does carry both the shape + // this card was filed for and rosters that predate it. t( - '…non-vacuously: the fleet really contains literals that name their file only through a dropped extension', - [...classHints].some((h) => declaredFileTarget(h, classTree) && !classTree.files.has(collapseHint(h))), + '…non-vacuously: the fleet carries families whose roster is named only through a dropped extension', + classFams.some(([, entry]) => { + const r = artifactOnlySilence(entry, [], classTree); + return r && r.artifacts.some((a, i) => a !== collapseHint([...new Set(entry.hints ?? [])][i])); + }), ); // ── A trailing sentence period is not part of the path (#8534, half two) ──