Skip to content

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened - #13783

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob
Aug 31, 2026
Merged

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened#13783
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13448

hintCovers could not follow a final-segment basename glob, and the residue
printer then stated a specific wrong cause for the deadness it produced. Both
halves land here, in one change, as the triage ruling requires.

The defect, one line each

Half 1 — coverage.collapseHint collapses a glob by deleting it. That is a
sound reduction when nothing but glob characters follow (packages/** ->
packages, packages/client* -> packages/client). When a literal follows the
glob it is a splice: .changeset/*.md -> .changeset/.md, a path no tree can
hold. The hint matched nothing BY CONSTRUCTION while reading as an ordinary literal.
zeroSegmentForms' docblock recorded this exact species one branch over
(scripts/*.d.mts) and left it as "a different species"; it is the same defect,
splicing inside a segment rather than across a separator.

Half 2 — the false reason.deepestTrackedPrefix, unreachableClass and
unreachableReason all reasoned from collapseHintunconditionally, including
for hints hintCovers had already stopped judging that way. A pattern-judged hint
can never equal its own collapsed splice (.changeset is not .changeset/.md), so
deepest !== collapseHint(hint) was always true for the whole shape class, and
"the tree stops at .changeset; the layout moved under it" was the only
reachable sentence. The layout never moved. A reader acting on that string goes
hunting a directory rename that never happened.

The fix

scripts/pm/dispatch-gates.mjs — three new exported predicates, four call sites:

  • globCarriesLiteralSuffix(hint) — a * in the final segment with a literal
    behind it. What decides it is a literal behind the glob, never one in front:
    scripts/check-*.mjs yes, scripts/check-* no.
  • judgedAsPattern(hint) = globInNonFinalSegment(hint) || globCarriesLiteralSuffix(hint)
    — the two splice shapes asked as one question, because two call sites need the
    same answer and a second copy of it is the drift this file refuses everywhere else.
    hintCovers routes on it into the existingzeroSegmentForms/triggerCovers
    branch: not a second matching language, the one already there for this exact job.
  • comparedForm(hint) — the form hintCoversactually judged the hint by:
    collapseHint for a collapse-judged hint, the literal prefix for a
    pattern-judged one. deepestTrackedPrefix walks it, unreachableClass compares
    against it, and unreachableReason gets a branch that speaks in it.

The new reason is checkable by the reader, which is the bar a triage lead has to
clear:

BEFORE '.changeset/*.md' — the tree stops at .changeset; the layout moved under it
AFTER '.changeset/*.md' — the tree HAS .changeset; this hint is a GLOB PATTERN and
nothing under that root matches it — check with `git ls-files '.changeset/*.md'`

...and the exception stays exactly that narrow — a pattern whose own literal prefix
really is gone still reads as a move:

'packages/gone-away/*.ts' — a glob pattern whose literal prefix packages/gone-away is
gone; the tree stops at packages, so the layout moved under it

A2.1 — the changeset count, re-measured at my own ref

The number moves with every merged PR, which is exactly why it must not be quoted
from anyone:

refreading
the card as filed~400
triage, 10:28Z537
PM, 10:55Z546
my branch point c42bc8ee6546
this PR's head 4301f7846 + the fix548
git ls-files '.changeset/*.md' | wc -l # 548
git ls-tree -r --name-only HEAD | grep -c '^\.changeset/.*\.md$' # 548, same answer both readings

Four readings on four days over one directory is itself part of the cost: a silent
zero over a population that grows with every merge is the one you notice last.

A2.2 — per-shape census, before/after, rather than "widen until it looks glob-ish"

Every distinct hint in the fleet, classified by glob shape and measured against the
tracked corpus. 191 families x 749 distinct hints x 7588 tracked files at
4301f7846
, before = the unfixed tool run from an origin/main worktree over the
identical corpus:

hintsreach 0 files (before -> after)(hint, file) pairs (before -> after)shape
661208 -> 2087411 -> 7411literal, no glob
571 -> 111420 -> 11420final segment exactly ** (trailing)
173 -> 38497 -> 8497glob in a NON-FINAL segment (already pattern-judged)
132 -> 27947 -> 7947final segment exactly * (trailing)
11 -> 00 -> 548final-segment * with a literal SUFFIX — the defect
0final-segment partial glob TRAILING (packages/client*, the DECIDED trade)
0?, + or a character class, anywhere in the fleet

Exactly one hint in the whole fleet carries the missed shape.**/ prefixes,
mid-segment wildcards and whole-** segments were already routed through
triggerCovers and are untouched. There are zero hints carrying ?, + or a
character class, so those are deliberately not admitted: collapseHint never
deleted them, so they are an ordinary literal that fails to match — the missing-lead
direction this file errs in — not a mangle. Admitting them would be a fabricated-lead
widening bought for no live instance; the self-test reds on their arrival instead.

Blast radius, both directions:

(hint, file) pairs 35275 -> 35823 (+548, ZERO lost)
(gate, file) pairs 140716 -> 143456 (+2740, ZERO lost)
hints reaching 0 files 215 -> 214
distinct hints whose reach changes at all: 1
cards LOSING any family: 0

The ROOT_DIR_WATCH_HINTS idiom is bit-for-bit unchanged — packages/* 5631,
examples/* 243, skills/** 50, content/** 442, scripts/** 299, packages/**
5631, all identical before and after — and the DECIDED packages/client*
partial-segment trade is a TRAILING glob, so it is not this case and stays pinned in
both directions.

A2.3 — which cards gain which families: ZERO, and that is the finding

The card and the triage ruling both expected the matched column to move —
"Correcting hintCovers makes those families match more cards — a live change
to which gates a dispatch brief names, not a display change"
, and triage's
⇒ **这不是显示缺陷,是覆盖缺陷。** 今天派发简报**漏点**了本该跑的 changeset 门族.

Measured over every tracked file read as a one-file card surface: 0 cards gain a
family, 0 cards lose one.

The reason is only visible in the census: all five owners of .changeset/*.md also
declare the bare literal .changeset
, which hintCovers has always matched
against every file beneath it. A card touching a changeset was already being told all
five families — through the sibling literal, not through the glob.

familyalso declares .changesetindex of .changesetindex of .changeset/*.md
check:changeset-gate-self-testsyes01
scripts/check-adr-0087-registration.mjsyes48
scripts/check-changeset-no-major.mjsyes01
scripts/check-empty-changeset.mjsyes01
scripts/pm/release-rehearsal-clone.mjs --self-testyes21

One thing does move, and it is a printed key, not a verdict: 548 (family, card)
pairs re-attribute
from .changeset to .changeset/*.md, all of them in
release-rehearsal-clone.mjs --self-test, the one owner that spells the glob before
the bare literal. Same family, same gate source provenance, strictly more precise
key — the pattern that actually matches rather than the directory it sits in. Every
other owner keeps the exact key it printed. Disclosed rather than buried because
coveringKey's docblock prices re-attribution explicitly.

So the live cost of this defect was never an under-named brief. It was the false
reason — 5 residue notes asserting a directory rename -> 0 — plus a single point of
failure nobody could see: the moment any of those five gates spells its population as
the glob alone, its coverage vanishes silently. That is not hypothetical.
scripts/*.d.mts is the second, already-live instance (0 -> 4 tracked files), and it
was pinned in this file's own self-test as "still dead" until this change.

⚠️ This does not falsify the card's premise. The hint really did measure dead
against 548 tracked changesets, and the residue really did name a false cause; both
are repaired here. What the measurement falsifies is the blast-radius claim the p1
rationale rested on. Worth re-reading the priority against that, which is a call for
triage and not for me.

A2.4 — unreachableReason is a reason SET, and its reasons did not line up

Not a single hard-coded string: four branches (extensionless-module target / no
tracked first segment / tree-has-it-but-too-generic / layout moved). The mismatch is
one level up — hintCovers has two comparison modes and the reason set covered
one.
Every branch derived from collapseHint, so for the pattern-judged shapes the
renderer was reasoning about a string the comparison never looked at, and one branch
was therefore unreachable while another was unconditional.

That is why the repair is comparedForm and not a reworded branch: reword it and the
next hint of this shape mints the same falsehood. A2.4's own test — "a reader
following the stated reason now finds the real cause" — is pinned rather than
asserted: the new sentence hands the reader the exact git ls-files invocation that
reproduces the emptiness, and the self-test asserts the string contains it.

Tests

31 new self-test cases, in two blocks beside the ones they extend. The two pins that
previously asserted the defect are flipped rather than deleted, so the change of
mind is legible: 'the final-segment spelling of the same population is still dead'
becomes 'the collapse ... is still a splice' + '...so the hint is judged as a pattern instead' + '...and now reaches every one of the files it names'.

Read from the real corpus, not a fixture — a fixture cannot show that the tree
still holds the population the trap needs, and this one grows daily. Both directions
are pinned: the reach, the refusals (packages/client* sibling still refused, the
ROOT_DIR_WATCH_HINTS idiom untouched, ?/+/[…] still collapse-judged), and a
fleet-wide ratchet that reds if a ?, + or character-class hint ever arrives.

Verification

Exit codes captured before any pipe; each verdict quoted from the line the gate
itself printed. Run on a shared, contended box (a docs dev server, two sibling agents'
gate runs), so heavy runs went through scripts/pm/os-verify-lock.sh.

The family was re-derived after the final commit, from the tool's own answer and
not from a hand-written list:

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack # 14 commands

The whole union then ran at head d56daafea, with git status --short empty at
the start and HEAD unchanged at the end (both recorded in the run log), so the green
below is about the tree this PR actually lands.

checkexitverdict
check:pm-dispatch-gates (the edited script's own suite)0GREEN — ✓ dispatch-gates self-test: 1048 cases pass.
scripts/pm/bare-root-worklist.mjs --self-test (the only importer of hintCovers/collapseHint)0GREEN — OK self-test: 51 live row(s), 43 unreachable as spelled … every one of 9 distinct spelling(s) pinned LIVE, PRECISE and COMPLETE … in hintCovers' own terms
check:nul-bytes0GREEN — OK (scanned 7581 text file(s) … no raw ASCII control bytes)
check:watch-hint-literal0GREEN — 34 declaration(s) across 4 rostered name(s) … no unrostered spelling of the idiom in the tree
check:entry-guard0GREEN — 189 scripts/ file(s) — every entry guard goes through invoked-as.mjs
check:agent-test-spelling0GREEN
check:bash32-floor0GREEN — 153 cases pass + 24 tracked shell file(s)
check:cli-command-ids0GREEN — 39 cases pass + 304 command-id literal(s) across 109 file(s)
check:cross-package-test-inputs0GREEN — All 117 self-test cases passed. + 24 package(s) read outside themselves, all declared
check:parse-guard0GREEN
check:pnpm-filter-targets0GREEN — 54 + 40 assertions, 140/179 --filter occurrence(s) resolve
node scripts/check-ci-filter-parity.mjs0GREEN — all 129 declared cross-package glob(s) (92 unique) are covered
node scripts/check-cross-package-test-inputs.mjs0GREEN
node scripts/check-self-test-wired.mjs0GREEN — every one of the 154 script(s) CI runs that ship a --self-test has that self-test run by CI
node scripts/check-shard-attestation.mjs0GREEN — 2 aggregate gate(s) count 3 declared leg(s)
node scripts/check-test-completeness.mjs3NOT MEASURED — see below
pnpm lint (eslint . --no-inline-config, repo-wide)0GREEN
eslint --format json scripts/pm/dispatch-gates.mjs0GREEN — 1 file, errorCount: 0, warningCount: 0

check-test-completeness is NOT MEASURED, not red. Exit 3 is its documented
prerequisite branch, and the gate says so in its own words, naming this exact arrival:

check-test-completeness: PREREQUISITE NOT MET — this gate grades a saved `turbo run test` log, and no log was named.
⚠ Arrived here from the gate family `scripts/pm/dispatch-gates.mjs` derives?
That list names this script with NO argument, which is this branch. There is no
local log to hand it, so the local reading for this gate is NOT MEASURED.
⛔ It is not a red, and there is nothing here to fix.

CI passes it the teed log, so CI's reading is a real one and this PR does not change it.

Repo-wide pnpm lint was run in full rather than narrowed, so no narrowing
argument is owed. For completeness, the invariance that would have backed one is real
and stated by the config itself (eslint.config.mjs lines 327-328: "never enables
type-aware linting (no parserOptions.project, no typed @typescript-eslint rules)
for ANY file"), so a one-file .mjs edit cannot move an untouched file's verdict.

Heavy runs went through scripts/pm/os-verify-lock.sh on a shared, contended box (a
docs dev server plus two sibling agents' gate runs). Wall-clock figures are therefore
shared-box seconds, not idle-box ones: check:pm-dispatch-gates held the lock 5m43s
after waiting 2m06s.

Reverse verification

Both halves withheld in one leg, from the committed state, so the restore has a
real reference point. The two pin sets are disjoint by name, so attribution is
unambiguous without a second leg.

The mutation is proved on disk before anything is measured — exact-string
replacement, each of the four anchors required to hit exactly once, then counted
again after. A zero-hit edit exits 0 and leaves an unmutated file producing healthy
output, which reads exactly like a successful ablation:

HEAD d56daafea
HEAD blob for the target 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
pre-mutation worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
=== MUTATION (exact-string, each anchor must hit exactly once) ===
hits=1 ' if (judgedAsPattern(hint))\n return zeroSegmentForms' -> globInNonFinalSegment
hits=1 " const segments = comparedForm(hint).split('/');" -> collapseHint
hits=1 'deepest !== comparedForm(hint)' -> collapseHint
hits=1 ' const form = comparedForm(hint);\n if (judgedAsPattern(hint)) {'
-> collapseHint / if (false)
=== MUTATION OBSERVED ON DISK (counts, not an exit code) ===
1 INJECTED if (globInNonFinalSegment(hint)) 0 REMOVED if (judgedAsPattern(hint))
1 INJECTED deepest !== collapseHint(hint) 0 REMOVED deepest !== comparedForm(hint)
1 INJECTED const form = collapseHint(hint); 0 REMOVED const form = comparedForm(hint);
1 INJECTED const segments = collapseHint(hint) 0 REMOVED const segments = comparedForm(hint)
1 INJECTED if (false) {
mutated worktree blob 7773d8e28bb6f7a8203fb8f42d1bccac27e49636 (differs from HEAD's)
scripts/pm/dispatch-gates.mjs | 10 +++++-----

RED, in the expected direction:

GATE_EXIT_WITH_FIX_WITHHELD=1
✗ dispatch-gates self-test: 9 of 1048 case(s) failed.
half 1 — coverage
✗ ...and now reaches every one of the files it names (scripts/*.d.mts)
✗ ...and claims nothing else in the whole tree (scripts/*.d.mts)
✗ the live specimen reaches every changeset it names (.changeset/*.md)
✗ and claims nothing else in the whole tree (.changeset/*.md)
✗ so it is nobody's dead literal any more
✗ the live specimen is not a dead literal on this tree at all
half 2 — the reason (hand-built `dead` rows, so independent of half 1)
✗ a dead glob pattern whose root is right there is NOT a layout move
✗ ...and never asserts a directory rename that never happened
✗ ...while the reason it does give is one the reader can check for themselves

Restore proved by OBSERVED STATE, not by an exit code:

post-restore worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
HEAD blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
blob equality: MATCH
git diff HEAD (printed nothing)
git status --short (printed nothing)

The restore leg is git checkout HEAD -- ABSOLUTE_PATH, never a bare
git checkout -- PATH (that reads from the index, which the mutation may have
touched) and never a relative path in the trap (a cwd that is no longer the repo root
silently leaves the tree mutated). An empty hash is treated as FAILURE, not as
"nothing to compare".

Does the fix change its own derived family? No.

The tool being fixed is the tool that derives this PR's own gate list, so it was run
from both sides on the same input path — an origin/main worktree (unfixed) and
this branch (fixed):

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands scripts/pm/dispatch-gates.mjs

14 commands from each, diff empty. Expected, and worth stating rather than assuming:
the only coverage that moves is .changeset/*.md, and this PR's diff contains no
.changeset/ path.

Scope

One file: scripts/pm/dispatch-gates.mjs. ⛔ scripts/check-doc-anchors.mjs is
untouched — that is #13449's landing site, dispatched in the same wave to a different
dev. No governed surface, no packages/spec/src/**, no content/docs/releases/.

scripts/ publishes nothing, so there is no changeset. Check Changeset in
pr-automation.yml carries no path filter and no docs-only exemption — it enforces on
every PR and exempts only on the skip-changeset label — so the label is applied here
and read back rather than waited for.


Generated by Claude Code

…aming a move that never happened
`collapseHint` collapses a glob by deleting it. That is a sound reduction when
nothing but glob characters follow (`packages/**` -> `packages`), and a SPLICE
when a literal does: `.changeset/*.md` -> `.changeset/.md`, a path no tree can
hold. So the hint matched nothing BY CONSTRUCTION while reading as an ordinary
literal, against 548 tracked changesets. `zeroSegmentForms` recorded this exact
species one branch over and left it; it is the same defect, splicing inside a
segment rather than across a separator.
The second half is why it was worth a card. `deepestTrackedPrefix`,
`unreachableClass` and `unreachableReason` all reasoned from `collapseHint`
UNCONDITIONALLY, including for the hints `hintCovers` had already stopped
judging that way. A pattern-judged hint can never equal its own collapsed splice
(`.changeset` is not `.changeset/.md`), so "the tree stops at X; the layout
moved under it" was the only reachable sentence for the whole shape class -- a
specific wrong cause, printed under the heading that tells a reader to go chase
it. Repairing the matcher alone would have retired today's five instances and
left the derivation that mints them intact.
- `globCarriesLiteralSuffix` -- a `*` in the final segment with a literal behind
it. A literal in FRONT of the glob is not this case.
- `judgedAsPattern` -- the two splice shapes as one question, because two call
sites need the same answer and a second copy of it is the drift this file
refuses everywhere else.
- `comparedForm` -- the form `hintCovers` ACTUALLY judged the hint by. The three
reason branches now mean what they say for both comparison modes.
Measured at 4301f78 over 191 families x 749 distinct hints x 7588 tracked
files: one hint in the whole fleet carries the shape; (hint, file) pairs
35275 -> 35823 and (gate, file) pairs 140716 -> 143456, ZERO lost; residue notes
asserting a layout move for it 5 -> 0. ZERO cards gain a family and zero lose
one -- all five owners also declare the bare `.changeset`, so the brief was
already naming them through the sibling literal. 548 (family, card) pairs
re-attribute to a more precise key in the one owner that spells the glob first.
`?`, `+` and character classes are deliberately NOT admitted (zero live
instances) and the self-test reds on their arrival.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 12:16
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit eb717a1Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13448-hintcovers-basename-glob branch August 31, 2026 12:37
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened by claude[bot] · Pull Request #13783 · objectstack-ai/objectstack · GitHub
Skip to content

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened - #13783

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob
Aug 31, 2026
Merged

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened#13783
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13448

hintCovers could not follow a final-segment basename glob, and the residue
printer then stated a specific wrong cause for the deadness it produced. Both
halves land here, in one change, as the triage ruling requires.

The defect, one line each

Half 1 — coverage.collapseHint collapses a glob by deleting it. That is a
sound reduction when nothing but glob characters follow (packages/** ->
packages, packages/client* -> packages/client). When a literal follows the
glob it is a splice: .changeset/*.md -> .changeset/.md, a path no tree can
hold. The hint matched nothing BY CONSTRUCTION while reading as an ordinary literal.
zeroSegmentForms' docblock recorded this exact species one branch over
(scripts/*.d.mts) and left it as "a different species"; it is the same defect,
splicing inside a segment rather than across a separator.

Half 2 — the false reason.deepestTrackedPrefix, unreachableClass and
unreachableReason all reasoned from collapseHintunconditionally, including
for hints hintCovers had already stopped judging that way. A pattern-judged hint
can never equal its own collapsed splice (.changeset is not .changeset/.md), so
deepest !== collapseHint(hint) was always true for the whole shape class, and
"the tree stops at .changeset; the layout moved under it" was the only
reachable sentence. The layout never moved. A reader acting on that string goes
hunting a directory rename that never happened.

The fix

scripts/pm/dispatch-gates.mjs — three new exported predicates, four call sites:

  • globCarriesLiteralSuffix(hint) — a * in the final segment with a literal
    behind it. What decides it is a literal behind the glob, never one in front:
    scripts/check-*.mjs yes, scripts/check-* no.
  • judgedAsPattern(hint) = globInNonFinalSegment(hint) || globCarriesLiteralSuffix(hint)
    — the two splice shapes asked as one question, because two call sites need the
    same answer and a second copy of it is the drift this file refuses everywhere else.
    hintCovers routes on it into the existingzeroSegmentForms/triggerCovers
    branch: not a second matching language, the one already there for this exact job.
  • comparedForm(hint) — the form hintCoversactually judged the hint by:
    collapseHint for a collapse-judged hint, the literal prefix for a
    pattern-judged one. deepestTrackedPrefix walks it, unreachableClass compares
    against it, and unreachableReason gets a branch that speaks in it.

The new reason is checkable by the reader, which is the bar a triage lead has to
clear:

BEFORE '.changeset/*.md' — the tree stops at .changeset; the layout moved under it
AFTER '.changeset/*.md' — the tree HAS .changeset; this hint is a GLOB PATTERN and
nothing under that root matches it — check with `git ls-files '.changeset/*.md'`

...and the exception stays exactly that narrow — a pattern whose own literal prefix
really is gone still reads as a move:

'packages/gone-away/*.ts' — a glob pattern whose literal prefix packages/gone-away is
gone; the tree stops at packages, so the layout moved under it

A2.1 — the changeset count, re-measured at my own ref

The number moves with every merged PR, which is exactly why it must not be quoted
from anyone:

refreading
the card as filed~400
triage, 10:28Z537
PM, 10:55Z546
my branch point c42bc8ee6546
this PR's head 4301f7846 + the fix548
git ls-files '.changeset/*.md' | wc -l # 548
git ls-tree -r --name-only HEAD | grep -c '^\.changeset/.*\.md$' # 548, same answer both readings

Four readings on four days over one directory is itself part of the cost: a silent
zero over a population that grows with every merge is the one you notice last.

A2.2 — per-shape census, before/after, rather than "widen until it looks glob-ish"

Every distinct hint in the fleet, classified by glob shape and measured against the
tracked corpus. 191 families x 749 distinct hints x 7588 tracked files at
4301f7846
, before = the unfixed tool run from an origin/main worktree over the
identical corpus:

hintsreach 0 files (before -> after)(hint, file) pairs (before -> after)shape
661208 -> 2087411 -> 7411literal, no glob
571 -> 111420 -> 11420final segment exactly ** (trailing)
173 -> 38497 -> 8497glob in a NON-FINAL segment (already pattern-judged)
132 -> 27947 -> 7947final segment exactly * (trailing)
11 -> 00 -> 548final-segment * with a literal SUFFIX — the defect
0final-segment partial glob TRAILING (packages/client*, the DECIDED trade)
0?, + or a character class, anywhere in the fleet

Exactly one hint in the whole fleet carries the missed shape.**/ prefixes,
mid-segment wildcards and whole-** segments were already routed through
triggerCovers and are untouched. There are zero hints carrying ?, + or a
character class, so those are deliberately not admitted: collapseHint never
deleted them, so they are an ordinary literal that fails to match — the missing-lead
direction this file errs in — not a mangle. Admitting them would be a fabricated-lead
widening bought for no live instance; the self-test reds on their arrival instead.

Blast radius, both directions:

(hint, file) pairs 35275 -> 35823 (+548, ZERO lost)
(gate, file) pairs 140716 -> 143456 (+2740, ZERO lost)
hints reaching 0 files 215 -> 214
distinct hints whose reach changes at all: 1
cards LOSING any family: 0

The ROOT_DIR_WATCH_HINTS idiom is bit-for-bit unchanged — packages/* 5631,
examples/* 243, skills/** 50, content/** 442, scripts/** 299, packages/**
5631, all identical before and after — and the DECIDED packages/client*
partial-segment trade is a TRAILING glob, so it is not this case and stays pinned in
both directions.

A2.3 — which cards gain which families: ZERO, and that is the finding

The card and the triage ruling both expected the matched column to move —
"Correcting hintCovers makes those families match more cards — a live change
to which gates a dispatch brief names, not a display change"
, and triage's
⇒ **这不是显示缺陷,是覆盖缺陷。** 今天派发简报**漏点**了本该跑的 changeset 门族.

Measured over every tracked file read as a one-file card surface: 0 cards gain a
family, 0 cards lose one.

The reason is only visible in the census: all five owners of .changeset/*.md also
declare the bare literal .changeset
, which hintCovers has always matched
against every file beneath it. A card touching a changeset was already being told all
five families — through the sibling literal, not through the glob.

familyalso declares .changesetindex of .changesetindex of .changeset/*.md
check:changeset-gate-self-testsyes01
scripts/check-adr-0087-registration.mjsyes48
scripts/check-changeset-no-major.mjsyes01
scripts/check-empty-changeset.mjsyes01
scripts/pm/release-rehearsal-clone.mjs --self-testyes21

One thing does move, and it is a printed key, not a verdict: 548 (family, card)
pairs re-attribute
from .changeset to .changeset/*.md, all of them in
release-rehearsal-clone.mjs --self-test, the one owner that spells the glob before
the bare literal. Same family, same gate source provenance, strictly more precise
key — the pattern that actually matches rather than the directory it sits in. Every
other owner keeps the exact key it printed. Disclosed rather than buried because
coveringKey's docblock prices re-attribution explicitly.

So the live cost of this defect was never an under-named brief. It was the false
reason — 5 residue notes asserting a directory rename -> 0 — plus a single point of
failure nobody could see: the moment any of those five gates spells its population as
the glob alone, its coverage vanishes silently. That is not hypothetical.
scripts/*.d.mts is the second, already-live instance (0 -> 4 tracked files), and it
was pinned in this file's own self-test as "still dead" until this change.

⚠️ This does not falsify the card's premise. The hint really did measure dead
against 548 tracked changesets, and the residue really did name a false cause; both
are repaired here. What the measurement falsifies is the blast-radius claim the p1
rationale rested on. Worth re-reading the priority against that, which is a call for
triage and not for me.

A2.4 — unreachableReason is a reason SET, and its reasons did not line up

Not a single hard-coded string: four branches (extensionless-module target / no
tracked first segment / tree-has-it-but-too-generic / layout moved). The mismatch is
one level up — hintCovers has two comparison modes and the reason set covered
one.
Every branch derived from collapseHint, so for the pattern-judged shapes the
renderer was reasoning about a string the comparison never looked at, and one branch
was therefore unreachable while another was unconditional.

That is why the repair is comparedForm and not a reworded branch: reword it and the
next hint of this shape mints the same falsehood. A2.4's own test — "a reader
following the stated reason now finds the real cause" — is pinned rather than
asserted: the new sentence hands the reader the exact git ls-files invocation that
reproduces the emptiness, and the self-test asserts the string contains it.

Tests

31 new self-test cases, in two blocks beside the ones they extend. The two pins that
previously asserted the defect are flipped rather than deleted, so the change of
mind is legible: 'the final-segment spelling of the same population is still dead'
becomes 'the collapse ... is still a splice' + '...so the hint is judged as a pattern instead' + '...and now reaches every one of the files it names'.

Read from the real corpus, not a fixture — a fixture cannot show that the tree
still holds the population the trap needs, and this one grows daily. Both directions
are pinned: the reach, the refusals (packages/client* sibling still refused, the
ROOT_DIR_WATCH_HINTS idiom untouched, ?/+/[…] still collapse-judged), and a
fleet-wide ratchet that reds if a ?, + or character-class hint ever arrives.

Verification

Exit codes captured before any pipe; each verdict quoted from the line the gate
itself printed. Run on a shared, contended box (a docs dev server, two sibling agents'
gate runs), so heavy runs went through scripts/pm/os-verify-lock.sh.

The family was re-derived after the final commit, from the tool's own answer and
not from a hand-written list:

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack # 14 commands

The whole union then ran at head d56daafea, with git status --short empty at
the start and HEAD unchanged at the end (both recorded in the run log), so the green
below is about the tree this PR actually lands.

checkexitverdict
check:pm-dispatch-gates (the edited script's own suite)0GREEN — ✓ dispatch-gates self-test: 1048 cases pass.
scripts/pm/bare-root-worklist.mjs --self-test (the only importer of hintCovers/collapseHint)0GREEN — OK self-test: 51 live row(s), 43 unreachable as spelled … every one of 9 distinct spelling(s) pinned LIVE, PRECISE and COMPLETE … in hintCovers' own terms
check:nul-bytes0GREEN — OK (scanned 7581 text file(s) … no raw ASCII control bytes)
check:watch-hint-literal0GREEN — 34 declaration(s) across 4 rostered name(s) … no unrostered spelling of the idiom in the tree
check:entry-guard0GREEN — 189 scripts/ file(s) — every entry guard goes through invoked-as.mjs
check:agent-test-spelling0GREEN
check:bash32-floor0GREEN — 153 cases pass + 24 tracked shell file(s)
check:cli-command-ids0GREEN — 39 cases pass + 304 command-id literal(s) across 109 file(s)
check:cross-package-test-inputs0GREEN — All 117 self-test cases passed. + 24 package(s) read outside themselves, all declared
check:parse-guard0GREEN
check:pnpm-filter-targets0GREEN — 54 + 40 assertions, 140/179 --filter occurrence(s) resolve
node scripts/check-ci-filter-parity.mjs0GREEN — all 129 declared cross-package glob(s) (92 unique) are covered
node scripts/check-cross-package-test-inputs.mjs0GREEN
node scripts/check-self-test-wired.mjs0GREEN — every one of the 154 script(s) CI runs that ship a --self-test has that self-test run by CI
node scripts/check-shard-attestation.mjs0GREEN — 2 aggregate gate(s) count 3 declared leg(s)
node scripts/check-test-completeness.mjs3NOT MEASURED — see below
pnpm lint (eslint . --no-inline-config, repo-wide)0GREEN
eslint --format json scripts/pm/dispatch-gates.mjs0GREEN — 1 file, errorCount: 0, warningCount: 0

check-test-completeness is NOT MEASURED, not red. Exit 3 is its documented
prerequisite branch, and the gate says so in its own words, naming this exact arrival:

check-test-completeness: PREREQUISITE NOT MET — this gate grades a saved `turbo run test` log, and no log was named.
⚠ Arrived here from the gate family `scripts/pm/dispatch-gates.mjs` derives?
That list names this script with NO argument, which is this branch. There is no
local log to hand it, so the local reading for this gate is NOT MEASURED.
⛔ It is not a red, and there is nothing here to fix.

CI passes it the teed log, so CI's reading is a real one and this PR does not change it.

Repo-wide pnpm lint was run in full rather than narrowed, so no narrowing
argument is owed. For completeness, the invariance that would have backed one is real
and stated by the config itself (eslint.config.mjs lines 327-328: "never enables
type-aware linting (no parserOptions.project, no typed @typescript-eslint rules)
for ANY file"), so a one-file .mjs edit cannot move an untouched file's verdict.

Heavy runs went through scripts/pm/os-verify-lock.sh on a shared, contended box (a
docs dev server plus two sibling agents' gate runs). Wall-clock figures are therefore
shared-box seconds, not idle-box ones: check:pm-dispatch-gates held the lock 5m43s
after waiting 2m06s.

Reverse verification

Both halves withheld in one leg, from the committed state, so the restore has a
real reference point. The two pin sets are disjoint by name, so attribution is
unambiguous without a second leg.

The mutation is proved on disk before anything is measured — exact-string
replacement, each of the four anchors required to hit exactly once, then counted
again after. A zero-hit edit exits 0 and leaves an unmutated file producing healthy
output, which reads exactly like a successful ablation:

HEAD d56daafea
HEAD blob for the target 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
pre-mutation worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
=== MUTATION (exact-string, each anchor must hit exactly once) ===
hits=1 ' if (judgedAsPattern(hint))\n return zeroSegmentForms' -> globInNonFinalSegment
hits=1 " const segments = comparedForm(hint).split('/');" -> collapseHint
hits=1 'deepest !== comparedForm(hint)' -> collapseHint
hits=1 ' const form = comparedForm(hint);\n if (judgedAsPattern(hint)) {'
-> collapseHint / if (false)
=== MUTATION OBSERVED ON DISK (counts, not an exit code) ===
1 INJECTED if (globInNonFinalSegment(hint)) 0 REMOVED if (judgedAsPattern(hint))
1 INJECTED deepest !== collapseHint(hint) 0 REMOVED deepest !== comparedForm(hint)
1 INJECTED const form = collapseHint(hint); 0 REMOVED const form = comparedForm(hint);
1 INJECTED const segments = collapseHint(hint) 0 REMOVED const segments = comparedForm(hint)
1 INJECTED if (false) {
mutated worktree blob 7773d8e28bb6f7a8203fb8f42d1bccac27e49636 (differs from HEAD's)
scripts/pm/dispatch-gates.mjs | 10 +++++-----

RED, in the expected direction:

GATE_EXIT_WITH_FIX_WITHHELD=1
✗ dispatch-gates self-test: 9 of 1048 case(s) failed.
half 1 — coverage
✗ ...and now reaches every one of the files it names (scripts/*.d.mts)
✗ ...and claims nothing else in the whole tree (scripts/*.d.mts)
✗ the live specimen reaches every changeset it names (.changeset/*.md)
✗ and claims nothing else in the whole tree (.changeset/*.md)
✗ so it is nobody's dead literal any more
✗ the live specimen is not a dead literal on this tree at all
half 2 — the reason (hand-built `dead` rows, so independent of half 1)
✗ a dead glob pattern whose root is right there is NOT a layout move
✗ ...and never asserts a directory rename that never happened
✗ ...while the reason it does give is one the reader can check for themselves

Restore proved by OBSERVED STATE, not by an exit code:

post-restore worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
HEAD blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
blob equality: MATCH
git diff HEAD (printed nothing)
git status --short (printed nothing)

The restore leg is git checkout HEAD -- ABSOLUTE_PATH, never a bare
git checkout -- PATH (that reads from the index, which the mutation may have
touched) and never a relative path in the trap (a cwd that is no longer the repo root
silently leaves the tree mutated). An empty hash is treated as FAILURE, not as
"nothing to compare".

Does the fix change its own derived family? No.

The tool being fixed is the tool that derives this PR's own gate list, so it was run
from both sides on the same input path — an origin/main worktree (unfixed) and
this branch (fixed):

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands scripts/pm/dispatch-gates.mjs

14 commands from each, diff empty. Expected, and worth stating rather than assuming:
the only coverage that moves is .changeset/*.md, and this PR's diff contains no
.changeset/ path.

Scope

One file: scripts/pm/dispatch-gates.mjs. ⛔ scripts/check-doc-anchors.mjs is
untouched — that is #13449's landing site, dispatched in the same wave to a different
dev. No governed surface, no packages/spec/src/**, no content/docs/releases/.

scripts/ publishes nothing, so there is no changeset. Check Changeset in
pr-automation.yml carries no path filter and no docs-only exemption — it enforces on
every PR and exempts only on the skip-changeset label — so the label is applied here
and read back rather than waited for.


Generated by Claude Code

…aming a move that never happened
`collapseHint` collapses a glob by deleting it. That is a sound reduction when
nothing but glob characters follow (`packages/**` -> `packages`), and a SPLICE
when a literal does: `.changeset/*.md` -> `.changeset/.md`, a path no tree can
hold. So the hint matched nothing BY CONSTRUCTION while reading as an ordinary
literal, against 548 tracked changesets. `zeroSegmentForms` recorded this exact
species one branch over and left it; it is the same defect, splicing inside a
segment rather than across a separator.
The second half is why it was worth a card. `deepestTrackedPrefix`,
`unreachableClass` and `unreachableReason` all reasoned from `collapseHint`
UNCONDITIONALLY, including for the hints `hintCovers` had already stopped
judging that way. A pattern-judged hint can never equal its own collapsed splice
(`.changeset` is not `.changeset/.md`), so "the tree stops at X; the layout
moved under it" was the only reachable sentence for the whole shape class -- a
specific wrong cause, printed under the heading that tells a reader to go chase
it. Repairing the matcher alone would have retired today's five instances and
left the derivation that mints them intact.
- `globCarriesLiteralSuffix` -- a `*` in the final segment with a literal behind
it. A literal in FRONT of the glob is not this case.
- `judgedAsPattern` -- the two splice shapes as one question, because two call
sites need the same answer and a second copy of it is the drift this file
refuses everywhere else.
- `comparedForm` -- the form `hintCovers` ACTUALLY judged the hint by. The three
reason branches now mean what they say for both comparison modes.
Measured at 4301f78 over 191 families x 749 distinct hints x 7588 tracked
files: one hint in the whole fleet carries the shape; (hint, file) pairs
35275 -> 35823 and (gate, file) pairs 140716 -> 143456, ZERO lost; residue notes
asserting a layout move for it 5 -> 0. ZERO cards gain a family and zero lose
one -- all five owners also declare the bare `.changeset`, so the brief was
already naming them through the sibling literal. 548 (family, card) pairs
re-attribute to a more precise key in the one owner that spells the glob first.
`?`, `+` and character classes are deliberately NOT admitted (zero live
instances) and the self-test reds on their arrival.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 12:16
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit eb717a1Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13448-hintcovers-basename-glob branch August 31, 2026 12:37
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened by claude[bot] · Pull Request #13783 · objectstack-ai/objectstack · GitHub
Skip to content

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened - #13783

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob
Aug 31, 2026
Merged

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened#13783
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13448

hintCovers could not follow a final-segment basename glob, and the residue
printer then stated a specific wrong cause for the deadness it produced. Both
halves land here, in one change, as the triage ruling requires.

The defect, one line each

Half 1 — coverage.collapseHint collapses a glob by deleting it. That is a
sound reduction when nothing but glob characters follow (packages/** ->
packages, packages/client* -> packages/client). When a literal follows the
glob it is a splice: .changeset/*.md -> .changeset/.md, a path no tree can
hold. The hint matched nothing BY CONSTRUCTION while reading as an ordinary literal.
zeroSegmentForms' docblock recorded this exact species one branch over
(scripts/*.d.mts) and left it as "a different species"; it is the same defect,
splicing inside a segment rather than across a separator.

Half 2 — the false reason.deepestTrackedPrefix, unreachableClass and
unreachableReason all reasoned from collapseHintunconditionally, including
for hints hintCovers had already stopped judging that way. A pattern-judged hint
can never equal its own collapsed splice (.changeset is not .changeset/.md), so
deepest !== collapseHint(hint) was always true for the whole shape class, and
"the tree stops at .changeset; the layout moved under it" was the only
reachable sentence. The layout never moved. A reader acting on that string goes
hunting a directory rename that never happened.

The fix

scripts/pm/dispatch-gates.mjs — three new exported predicates, four call sites:

  • globCarriesLiteralSuffix(hint) — a * in the final segment with a literal
    behind it. What decides it is a literal behind the glob, never one in front:
    scripts/check-*.mjs yes, scripts/check-* no.
  • judgedAsPattern(hint) = globInNonFinalSegment(hint) || globCarriesLiteralSuffix(hint)
    — the two splice shapes asked as one question, because two call sites need the
    same answer and a second copy of it is the drift this file refuses everywhere else.
    hintCovers routes on it into the existingzeroSegmentForms/triggerCovers
    branch: not a second matching language, the one already there for this exact job.
  • comparedForm(hint) — the form hintCoversactually judged the hint by:
    collapseHint for a collapse-judged hint, the literal prefix for a
    pattern-judged one. deepestTrackedPrefix walks it, unreachableClass compares
    against it, and unreachableReason gets a branch that speaks in it.

The new reason is checkable by the reader, which is the bar a triage lead has to
clear:

BEFORE '.changeset/*.md' — the tree stops at .changeset; the layout moved under it
AFTER '.changeset/*.md' — the tree HAS .changeset; this hint is a GLOB PATTERN and
nothing under that root matches it — check with `git ls-files '.changeset/*.md'`

...and the exception stays exactly that narrow — a pattern whose own literal prefix
really is gone still reads as a move:

'packages/gone-away/*.ts' — a glob pattern whose literal prefix packages/gone-away is
gone; the tree stops at packages, so the layout moved under it

A2.1 — the changeset count, re-measured at my own ref

The number moves with every merged PR, which is exactly why it must not be quoted
from anyone:

refreading
the card as filed~400
triage, 10:28Z537
PM, 10:55Z546
my branch point c42bc8ee6546
this PR's head 4301f7846 + the fix548
git ls-files '.changeset/*.md' | wc -l # 548
git ls-tree -r --name-only HEAD | grep -c '^\.changeset/.*\.md$' # 548, same answer both readings

Four readings on four days over one directory is itself part of the cost: a silent
zero over a population that grows with every merge is the one you notice last.

A2.2 — per-shape census, before/after, rather than "widen until it looks glob-ish"

Every distinct hint in the fleet, classified by glob shape and measured against the
tracked corpus. 191 families x 749 distinct hints x 7588 tracked files at
4301f7846
, before = the unfixed tool run from an origin/main worktree over the
identical corpus:

hintsreach 0 files (before -> after)(hint, file) pairs (before -> after)shape
661208 -> 2087411 -> 7411literal, no glob
571 -> 111420 -> 11420final segment exactly ** (trailing)
173 -> 38497 -> 8497glob in a NON-FINAL segment (already pattern-judged)
132 -> 27947 -> 7947final segment exactly * (trailing)
11 -> 00 -> 548final-segment * with a literal SUFFIX — the defect
0final-segment partial glob TRAILING (packages/client*, the DECIDED trade)
0?, + or a character class, anywhere in the fleet

Exactly one hint in the whole fleet carries the missed shape.**/ prefixes,
mid-segment wildcards and whole-** segments were already routed through
triggerCovers and are untouched. There are zero hints carrying ?, + or a
character class, so those are deliberately not admitted: collapseHint never
deleted them, so they are an ordinary literal that fails to match — the missing-lead
direction this file errs in — not a mangle. Admitting them would be a fabricated-lead
widening bought for no live instance; the self-test reds on their arrival instead.

Blast radius, both directions:

(hint, file) pairs 35275 -> 35823 (+548, ZERO lost)
(gate, file) pairs 140716 -> 143456 (+2740, ZERO lost)
hints reaching 0 files 215 -> 214
distinct hints whose reach changes at all: 1
cards LOSING any family: 0

The ROOT_DIR_WATCH_HINTS idiom is bit-for-bit unchanged — packages/* 5631,
examples/* 243, skills/** 50, content/** 442, scripts/** 299, packages/**
5631, all identical before and after — and the DECIDED packages/client*
partial-segment trade is a TRAILING glob, so it is not this case and stays pinned in
both directions.

A2.3 — which cards gain which families: ZERO, and that is the finding

The card and the triage ruling both expected the matched column to move —
"Correcting hintCovers makes those families match more cards — a live change
to which gates a dispatch brief names, not a display change"
, and triage's
⇒ **这不是显示缺陷,是覆盖缺陷。** 今天派发简报**漏点**了本该跑的 changeset 门族.

Measured over every tracked file read as a one-file card surface: 0 cards gain a
family, 0 cards lose one.

The reason is only visible in the census: all five owners of .changeset/*.md also
declare the bare literal .changeset
, which hintCovers has always matched
against every file beneath it. A card touching a changeset was already being told all
five families — through the sibling literal, not through the glob.

familyalso declares .changesetindex of .changesetindex of .changeset/*.md
check:changeset-gate-self-testsyes01
scripts/check-adr-0087-registration.mjsyes48
scripts/check-changeset-no-major.mjsyes01
scripts/check-empty-changeset.mjsyes01
scripts/pm/release-rehearsal-clone.mjs --self-testyes21

One thing does move, and it is a printed key, not a verdict: 548 (family, card)
pairs re-attribute
from .changeset to .changeset/*.md, all of them in
release-rehearsal-clone.mjs --self-test, the one owner that spells the glob before
the bare literal. Same family, same gate source provenance, strictly more precise
key — the pattern that actually matches rather than the directory it sits in. Every
other owner keeps the exact key it printed. Disclosed rather than buried because
coveringKey's docblock prices re-attribution explicitly.

So the live cost of this defect was never an under-named brief. It was the false
reason — 5 residue notes asserting a directory rename -> 0 — plus a single point of
failure nobody could see: the moment any of those five gates spells its population as
the glob alone, its coverage vanishes silently. That is not hypothetical.
scripts/*.d.mts is the second, already-live instance (0 -> 4 tracked files), and it
was pinned in this file's own self-test as "still dead" until this change.

⚠️ This does not falsify the card's premise. The hint really did measure dead
against 548 tracked changesets, and the residue really did name a false cause; both
are repaired here. What the measurement falsifies is the blast-radius claim the p1
rationale rested on. Worth re-reading the priority against that, which is a call for
triage and not for me.

A2.4 — unreachableReason is a reason SET, and its reasons did not line up

Not a single hard-coded string: four branches (extensionless-module target / no
tracked first segment / tree-has-it-but-too-generic / layout moved). The mismatch is
one level up — hintCovers has two comparison modes and the reason set covered
one.
Every branch derived from collapseHint, so for the pattern-judged shapes the
renderer was reasoning about a string the comparison never looked at, and one branch
was therefore unreachable while another was unconditional.

That is why the repair is comparedForm and not a reworded branch: reword it and the
next hint of this shape mints the same falsehood. A2.4's own test — "a reader
following the stated reason now finds the real cause" — is pinned rather than
asserted: the new sentence hands the reader the exact git ls-files invocation that
reproduces the emptiness, and the self-test asserts the string contains it.

Tests

31 new self-test cases, in two blocks beside the ones they extend. The two pins that
previously asserted the defect are flipped rather than deleted, so the change of
mind is legible: 'the final-segment spelling of the same population is still dead'
becomes 'the collapse ... is still a splice' + '...so the hint is judged as a pattern instead' + '...and now reaches every one of the files it names'.

Read from the real corpus, not a fixture — a fixture cannot show that the tree
still holds the population the trap needs, and this one grows daily. Both directions
are pinned: the reach, the refusals (packages/client* sibling still refused, the
ROOT_DIR_WATCH_HINTS idiom untouched, ?/+/[…] still collapse-judged), and a
fleet-wide ratchet that reds if a ?, + or character-class hint ever arrives.

Verification

Exit codes captured before any pipe; each verdict quoted from the line the gate
itself printed. Run on a shared, contended box (a docs dev server, two sibling agents'
gate runs), so heavy runs went through scripts/pm/os-verify-lock.sh.

The family was re-derived after the final commit, from the tool's own answer and
not from a hand-written list:

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack # 14 commands

The whole union then ran at head d56daafea, with git status --short empty at
the start and HEAD unchanged at the end (both recorded in the run log), so the green
below is about the tree this PR actually lands.

checkexitverdict
check:pm-dispatch-gates (the edited script's own suite)0GREEN — ✓ dispatch-gates self-test: 1048 cases pass.
scripts/pm/bare-root-worklist.mjs --self-test (the only importer of hintCovers/collapseHint)0GREEN — OK self-test: 51 live row(s), 43 unreachable as spelled … every one of 9 distinct spelling(s) pinned LIVE, PRECISE and COMPLETE … in hintCovers' own terms
check:nul-bytes0GREEN — OK (scanned 7581 text file(s) … no raw ASCII control bytes)
check:watch-hint-literal0GREEN — 34 declaration(s) across 4 rostered name(s) … no unrostered spelling of the idiom in the tree
check:entry-guard0GREEN — 189 scripts/ file(s) — every entry guard goes through invoked-as.mjs
check:agent-test-spelling0GREEN
check:bash32-floor0GREEN — 153 cases pass + 24 tracked shell file(s)
check:cli-command-ids0GREEN — 39 cases pass + 304 command-id literal(s) across 109 file(s)
check:cross-package-test-inputs0GREEN — All 117 self-test cases passed. + 24 package(s) read outside themselves, all declared
check:parse-guard0GREEN
check:pnpm-filter-targets0GREEN — 54 + 40 assertions, 140/179 --filter occurrence(s) resolve
node scripts/check-ci-filter-parity.mjs0GREEN — all 129 declared cross-package glob(s) (92 unique) are covered
node scripts/check-cross-package-test-inputs.mjs0GREEN
node scripts/check-self-test-wired.mjs0GREEN — every one of the 154 script(s) CI runs that ship a --self-test has that self-test run by CI
node scripts/check-shard-attestation.mjs0GREEN — 2 aggregate gate(s) count 3 declared leg(s)
node scripts/check-test-completeness.mjs3NOT MEASURED — see below
pnpm lint (eslint . --no-inline-config, repo-wide)0GREEN
eslint --format json scripts/pm/dispatch-gates.mjs0GREEN — 1 file, errorCount: 0, warningCount: 0

check-test-completeness is NOT MEASURED, not red. Exit 3 is its documented
prerequisite branch, and the gate says so in its own words, naming this exact arrival:

check-test-completeness: PREREQUISITE NOT MET — this gate grades a saved `turbo run test` log, and no log was named.
⚠ Arrived here from the gate family `scripts/pm/dispatch-gates.mjs` derives?
That list names this script with NO argument, which is this branch. There is no
local log to hand it, so the local reading for this gate is NOT MEASURED.
⛔ It is not a red, and there is nothing here to fix.

CI passes it the teed log, so CI's reading is a real one and this PR does not change it.

Repo-wide pnpm lint was run in full rather than narrowed, so no narrowing
argument is owed. For completeness, the invariance that would have backed one is real
and stated by the config itself (eslint.config.mjs lines 327-328: "never enables
type-aware linting (no parserOptions.project, no typed @typescript-eslint rules)
for ANY file"), so a one-file .mjs edit cannot move an untouched file's verdict.

Heavy runs went through scripts/pm/os-verify-lock.sh on a shared, contended box (a
docs dev server plus two sibling agents' gate runs). Wall-clock figures are therefore
shared-box seconds, not idle-box ones: check:pm-dispatch-gates held the lock 5m43s
after waiting 2m06s.

Reverse verification

Both halves withheld in one leg, from the committed state, so the restore has a
real reference point. The two pin sets are disjoint by name, so attribution is
unambiguous without a second leg.

The mutation is proved on disk before anything is measured — exact-string
replacement, each of the four anchors required to hit exactly once, then counted
again after. A zero-hit edit exits 0 and leaves an unmutated file producing healthy
output, which reads exactly like a successful ablation:

HEAD d56daafea
HEAD blob for the target 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
pre-mutation worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
=== MUTATION (exact-string, each anchor must hit exactly once) ===
hits=1 ' if (judgedAsPattern(hint))\n return zeroSegmentForms' -> globInNonFinalSegment
hits=1 " const segments = comparedForm(hint).split('/');" -> collapseHint
hits=1 'deepest !== comparedForm(hint)' -> collapseHint
hits=1 ' const form = comparedForm(hint);\n if (judgedAsPattern(hint)) {'
-> collapseHint / if (false)
=== MUTATION OBSERVED ON DISK (counts, not an exit code) ===
1 INJECTED if (globInNonFinalSegment(hint)) 0 REMOVED if (judgedAsPattern(hint))
1 INJECTED deepest !== collapseHint(hint) 0 REMOVED deepest !== comparedForm(hint)
1 INJECTED const form = collapseHint(hint); 0 REMOVED const form = comparedForm(hint);
1 INJECTED const segments = collapseHint(hint) 0 REMOVED const segments = comparedForm(hint)
1 INJECTED if (false) {
mutated worktree blob 7773d8e28bb6f7a8203fb8f42d1bccac27e49636 (differs from HEAD's)
scripts/pm/dispatch-gates.mjs | 10 +++++-----

RED, in the expected direction:

GATE_EXIT_WITH_FIX_WITHHELD=1
✗ dispatch-gates self-test: 9 of 1048 case(s) failed.
half 1 — coverage
✗ ...and now reaches every one of the files it names (scripts/*.d.mts)
✗ ...and claims nothing else in the whole tree (scripts/*.d.mts)
✗ the live specimen reaches every changeset it names (.changeset/*.md)
✗ and claims nothing else in the whole tree (.changeset/*.md)
✗ so it is nobody's dead literal any more
✗ the live specimen is not a dead literal on this tree at all
half 2 — the reason (hand-built `dead` rows, so independent of half 1)
✗ a dead glob pattern whose root is right there is NOT a layout move
✗ ...and never asserts a directory rename that never happened
✗ ...while the reason it does give is one the reader can check for themselves

Restore proved by OBSERVED STATE, not by an exit code:

post-restore worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
HEAD blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
blob equality: MATCH
git diff HEAD (printed nothing)
git status --short (printed nothing)

The restore leg is git checkout HEAD -- ABSOLUTE_PATH, never a bare
git checkout -- PATH (that reads from the index, which the mutation may have
touched) and never a relative path in the trap (a cwd that is no longer the repo root
silently leaves the tree mutated). An empty hash is treated as FAILURE, not as
"nothing to compare".

Does the fix change its own derived family? No.

The tool being fixed is the tool that derives this PR's own gate list, so it was run
from both sides on the same input path — an origin/main worktree (unfixed) and
this branch (fixed):

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands scripts/pm/dispatch-gates.mjs

14 commands from each, diff empty. Expected, and worth stating rather than assuming:
the only coverage that moves is .changeset/*.md, and this PR's diff contains no
.changeset/ path.

Scope

One file: scripts/pm/dispatch-gates.mjs. ⛔ scripts/check-doc-anchors.mjs is
untouched — that is #13449's landing site, dispatched in the same wave to a different
dev. No governed surface, no packages/spec/src/**, no content/docs/releases/.

scripts/ publishes nothing, so there is no changeset. Check Changeset in
pr-automation.yml carries no path filter and no docs-only exemption — it enforces on
every PR and exempts only on the skip-changeset label — so the label is applied here
and read back rather than waited for.


Generated by Claude Code

…aming a move that never happened
`collapseHint` collapses a glob by deleting it. That is a sound reduction when
nothing but glob characters follow (`packages/**` -> `packages`), and a SPLICE
when a literal does: `.changeset/*.md` -> `.changeset/.md`, a path no tree can
hold. So the hint matched nothing BY CONSTRUCTION while reading as an ordinary
literal, against 548 tracked changesets. `zeroSegmentForms` recorded this exact
species one branch over and left it; it is the same defect, splicing inside a
segment rather than across a separator.
The second half is why it was worth a card. `deepestTrackedPrefix`,
`unreachableClass` and `unreachableReason` all reasoned from `collapseHint`
UNCONDITIONALLY, including for the hints `hintCovers` had already stopped
judging that way. A pattern-judged hint can never equal its own collapsed splice
(`.changeset` is not `.changeset/.md`), so "the tree stops at X; the layout
moved under it" was the only reachable sentence for the whole shape class -- a
specific wrong cause, printed under the heading that tells a reader to go chase
it. Repairing the matcher alone would have retired today's five instances and
left the derivation that mints them intact.
- `globCarriesLiteralSuffix` -- a `*` in the final segment with a literal behind
it. A literal in FRONT of the glob is not this case.
- `judgedAsPattern` -- the two splice shapes as one question, because two call
sites need the same answer and a second copy of it is the drift this file
refuses everywhere else.
- `comparedForm` -- the form `hintCovers` ACTUALLY judged the hint by. The three
reason branches now mean what they say for both comparison modes.
Measured at 4301f78 over 191 families x 749 distinct hints x 7588 tracked
files: one hint in the whole fleet carries the shape; (hint, file) pairs
35275 -> 35823 and (gate, file) pairs 140716 -> 143456, ZERO lost; residue notes
asserting a layout move for it 5 -> 0. ZERO cards gain a family and zero lose
one -- all five owners also declare the bare `.changeset`, so the brief was
already naming them through the sibling literal. 548 (family, card) pairs
re-attribute to a more precise key in the one owner that spells the glob first.
`?`, `+` and character classes are deliberately NOT admitted (zero live
instances) and the self-test reds on their arrival.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 12:16
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit eb717a1Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13448-hintcovers-basename-glob branch August 31, 2026 12:37
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened by claude[bot] · Pull Request #13783 · objectstack-ai/objectstack · GitHub
Skip to content

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened - #13783

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob
Aug 31, 2026
Merged

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened#13783
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13448

hintCovers could not follow a final-segment basename glob, and the residue
printer then stated a specific wrong cause for the deadness it produced. Both
halves land here, in one change, as the triage ruling requires.

The defect, one line each

Half 1 — coverage.collapseHint collapses a glob by deleting it. That is a
sound reduction when nothing but glob characters follow (packages/** ->
packages, packages/client* -> packages/client). When a literal follows the
glob it is a splice: .changeset/*.md -> .changeset/.md, a path no tree can
hold. The hint matched nothing BY CONSTRUCTION while reading as an ordinary literal.
zeroSegmentForms' docblock recorded this exact species one branch over
(scripts/*.d.mts) and left it as "a different species"; it is the same defect,
splicing inside a segment rather than across a separator.

Half 2 — the false reason.deepestTrackedPrefix, unreachableClass and
unreachableReason all reasoned from collapseHintunconditionally, including
for hints hintCovers had already stopped judging that way. A pattern-judged hint
can never equal its own collapsed splice (.changeset is not .changeset/.md), so
deepest !== collapseHint(hint) was always true for the whole shape class, and
"the tree stops at .changeset; the layout moved under it" was the only
reachable sentence. The layout never moved. A reader acting on that string goes
hunting a directory rename that never happened.

The fix

scripts/pm/dispatch-gates.mjs — three new exported predicates, four call sites:

  • globCarriesLiteralSuffix(hint) — a * in the final segment with a literal
    behind it. What decides it is a literal behind the glob, never one in front:
    scripts/check-*.mjs yes, scripts/check-* no.
  • judgedAsPattern(hint) = globInNonFinalSegment(hint) || globCarriesLiteralSuffix(hint)
    — the two splice shapes asked as one question, because two call sites need the
    same answer and a second copy of it is the drift this file refuses everywhere else.
    hintCovers routes on it into the existingzeroSegmentForms/triggerCovers
    branch: not a second matching language, the one already there for this exact job.
  • comparedForm(hint) — the form hintCoversactually judged the hint by:
    collapseHint for a collapse-judged hint, the literal prefix for a
    pattern-judged one. deepestTrackedPrefix walks it, unreachableClass compares
    against it, and unreachableReason gets a branch that speaks in it.

The new reason is checkable by the reader, which is the bar a triage lead has to
clear:

BEFORE '.changeset/*.md' — the tree stops at .changeset; the layout moved under it
AFTER '.changeset/*.md' — the tree HAS .changeset; this hint is a GLOB PATTERN and
nothing under that root matches it — check with `git ls-files '.changeset/*.md'`

...and the exception stays exactly that narrow — a pattern whose own literal prefix
really is gone still reads as a move:

'packages/gone-away/*.ts' — a glob pattern whose literal prefix packages/gone-away is
gone; the tree stops at packages, so the layout moved under it

A2.1 — the changeset count, re-measured at my own ref

The number moves with every merged PR, which is exactly why it must not be quoted
from anyone:

refreading
the card as filed~400
triage, 10:28Z537
PM, 10:55Z546
my branch point c42bc8ee6546
this PR's head 4301f7846 + the fix548
git ls-files '.changeset/*.md' | wc -l # 548
git ls-tree -r --name-only HEAD | grep -c '^\.changeset/.*\.md$' # 548, same answer both readings

Four readings on four days over one directory is itself part of the cost: a silent
zero over a population that grows with every merge is the one you notice last.

A2.2 — per-shape census, before/after, rather than "widen until it looks glob-ish"

Every distinct hint in the fleet, classified by glob shape and measured against the
tracked corpus. 191 families x 749 distinct hints x 7588 tracked files at
4301f7846
, before = the unfixed tool run from an origin/main worktree over the
identical corpus:

hintsreach 0 files (before -> after)(hint, file) pairs (before -> after)shape
661208 -> 2087411 -> 7411literal, no glob
571 -> 111420 -> 11420final segment exactly ** (trailing)
173 -> 38497 -> 8497glob in a NON-FINAL segment (already pattern-judged)
132 -> 27947 -> 7947final segment exactly * (trailing)
11 -> 00 -> 548final-segment * with a literal SUFFIX — the defect
0final-segment partial glob TRAILING (packages/client*, the DECIDED trade)
0?, + or a character class, anywhere in the fleet

Exactly one hint in the whole fleet carries the missed shape.**/ prefixes,
mid-segment wildcards and whole-** segments were already routed through
triggerCovers and are untouched. There are zero hints carrying ?, + or a
character class, so those are deliberately not admitted: collapseHint never
deleted them, so they are an ordinary literal that fails to match — the missing-lead
direction this file errs in — not a mangle. Admitting them would be a fabricated-lead
widening bought for no live instance; the self-test reds on their arrival instead.

Blast radius, both directions:

(hint, file) pairs 35275 -> 35823 (+548, ZERO lost)
(gate, file) pairs 140716 -> 143456 (+2740, ZERO lost)
hints reaching 0 files 215 -> 214
distinct hints whose reach changes at all: 1
cards LOSING any family: 0

The ROOT_DIR_WATCH_HINTS idiom is bit-for-bit unchanged — packages/* 5631,
examples/* 243, skills/** 50, content/** 442, scripts/** 299, packages/**
5631, all identical before and after — and the DECIDED packages/client*
partial-segment trade is a TRAILING glob, so it is not this case and stays pinned in
both directions.

A2.3 — which cards gain which families: ZERO, and that is the finding

The card and the triage ruling both expected the matched column to move —
"Correcting hintCovers makes those families match more cards — a live change
to which gates a dispatch brief names, not a display change"
, and triage's
⇒ **这不是显示缺陷,是覆盖缺陷。** 今天派发简报**漏点**了本该跑的 changeset 门族.

Measured over every tracked file read as a one-file card surface: 0 cards gain a
family, 0 cards lose one.

The reason is only visible in the census: all five owners of .changeset/*.md also
declare the bare literal .changeset
, which hintCovers has always matched
against every file beneath it. A card touching a changeset was already being told all
five families — through the sibling literal, not through the glob.

familyalso declares .changesetindex of .changesetindex of .changeset/*.md
check:changeset-gate-self-testsyes01
scripts/check-adr-0087-registration.mjsyes48
scripts/check-changeset-no-major.mjsyes01
scripts/check-empty-changeset.mjsyes01
scripts/pm/release-rehearsal-clone.mjs --self-testyes21

One thing does move, and it is a printed key, not a verdict: 548 (family, card)
pairs re-attribute
from .changeset to .changeset/*.md, all of them in
release-rehearsal-clone.mjs --self-test, the one owner that spells the glob before
the bare literal. Same family, same gate source provenance, strictly more precise
key — the pattern that actually matches rather than the directory it sits in. Every
other owner keeps the exact key it printed. Disclosed rather than buried because
coveringKey's docblock prices re-attribution explicitly.

So the live cost of this defect was never an under-named brief. It was the false
reason — 5 residue notes asserting a directory rename -> 0 — plus a single point of
failure nobody could see: the moment any of those five gates spells its population as
the glob alone, its coverage vanishes silently. That is not hypothetical.
scripts/*.d.mts is the second, already-live instance (0 -> 4 tracked files), and it
was pinned in this file's own self-test as "still dead" until this change.

⚠️ This does not falsify the card's premise. The hint really did measure dead
against 548 tracked changesets, and the residue really did name a false cause; both
are repaired here. What the measurement falsifies is the blast-radius claim the p1
rationale rested on. Worth re-reading the priority against that, which is a call for
triage and not for me.

A2.4 — unreachableReason is a reason SET, and its reasons did not line up

Not a single hard-coded string: four branches (extensionless-module target / no
tracked first segment / tree-has-it-but-too-generic / layout moved). The mismatch is
one level up — hintCovers has two comparison modes and the reason set covered
one.
Every branch derived from collapseHint, so for the pattern-judged shapes the
renderer was reasoning about a string the comparison never looked at, and one branch
was therefore unreachable while another was unconditional.

That is why the repair is comparedForm and not a reworded branch: reword it and the
next hint of this shape mints the same falsehood. A2.4's own test — "a reader
following the stated reason now finds the real cause" — is pinned rather than
asserted: the new sentence hands the reader the exact git ls-files invocation that
reproduces the emptiness, and the self-test asserts the string contains it.

Tests

31 new self-test cases, in two blocks beside the ones they extend. The two pins that
previously asserted the defect are flipped rather than deleted, so the change of
mind is legible: 'the final-segment spelling of the same population is still dead'
becomes 'the collapse ... is still a splice' + '...so the hint is judged as a pattern instead' + '...and now reaches every one of the files it names'.

Read from the real corpus, not a fixture — a fixture cannot show that the tree
still holds the population the trap needs, and this one grows daily. Both directions
are pinned: the reach, the refusals (packages/client* sibling still refused, the
ROOT_DIR_WATCH_HINTS idiom untouched, ?/+/[…] still collapse-judged), and a
fleet-wide ratchet that reds if a ?, + or character-class hint ever arrives.

Verification

Exit codes captured before any pipe; each verdict quoted from the line the gate
itself printed. Run on a shared, contended box (a docs dev server, two sibling agents'
gate runs), so heavy runs went through scripts/pm/os-verify-lock.sh.

The family was re-derived after the final commit, from the tool's own answer and
not from a hand-written list:

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack # 14 commands

The whole union then ran at head d56daafea, with git status --short empty at
the start and HEAD unchanged at the end (both recorded in the run log), so the green
below is about the tree this PR actually lands.

checkexitverdict
check:pm-dispatch-gates (the edited script's own suite)0GREEN — ✓ dispatch-gates self-test: 1048 cases pass.
scripts/pm/bare-root-worklist.mjs --self-test (the only importer of hintCovers/collapseHint)0GREEN — OK self-test: 51 live row(s), 43 unreachable as spelled … every one of 9 distinct spelling(s) pinned LIVE, PRECISE and COMPLETE … in hintCovers' own terms
check:nul-bytes0GREEN — OK (scanned 7581 text file(s) … no raw ASCII control bytes)
check:watch-hint-literal0GREEN — 34 declaration(s) across 4 rostered name(s) … no unrostered spelling of the idiom in the tree
check:entry-guard0GREEN — 189 scripts/ file(s) — every entry guard goes through invoked-as.mjs
check:agent-test-spelling0GREEN
check:bash32-floor0GREEN — 153 cases pass + 24 tracked shell file(s)
check:cli-command-ids0GREEN — 39 cases pass + 304 command-id literal(s) across 109 file(s)
check:cross-package-test-inputs0GREEN — All 117 self-test cases passed. + 24 package(s) read outside themselves, all declared
check:parse-guard0GREEN
check:pnpm-filter-targets0GREEN — 54 + 40 assertions, 140/179 --filter occurrence(s) resolve
node scripts/check-ci-filter-parity.mjs0GREEN — all 129 declared cross-package glob(s) (92 unique) are covered
node scripts/check-cross-package-test-inputs.mjs0GREEN
node scripts/check-self-test-wired.mjs0GREEN — every one of the 154 script(s) CI runs that ship a --self-test has that self-test run by CI
node scripts/check-shard-attestation.mjs0GREEN — 2 aggregate gate(s) count 3 declared leg(s)
node scripts/check-test-completeness.mjs3NOT MEASURED — see below
pnpm lint (eslint . --no-inline-config, repo-wide)0GREEN
eslint --format json scripts/pm/dispatch-gates.mjs0GREEN — 1 file, errorCount: 0, warningCount: 0

check-test-completeness is NOT MEASURED, not red. Exit 3 is its documented
prerequisite branch, and the gate says so in its own words, naming this exact arrival:

check-test-completeness: PREREQUISITE NOT MET — this gate grades a saved `turbo run test` log, and no log was named.
⚠ Arrived here from the gate family `scripts/pm/dispatch-gates.mjs` derives?
That list names this script with NO argument, which is this branch. There is no
local log to hand it, so the local reading for this gate is NOT MEASURED.
⛔ It is not a red, and there is nothing here to fix.

CI passes it the teed log, so CI's reading is a real one and this PR does not change it.

Repo-wide pnpm lint was run in full rather than narrowed, so no narrowing
argument is owed. For completeness, the invariance that would have backed one is real
and stated by the config itself (eslint.config.mjs lines 327-328: "never enables
type-aware linting (no parserOptions.project, no typed @typescript-eslint rules)
for ANY file"), so a one-file .mjs edit cannot move an untouched file's verdict.

Heavy runs went through scripts/pm/os-verify-lock.sh on a shared, contended box (a
docs dev server plus two sibling agents' gate runs). Wall-clock figures are therefore
shared-box seconds, not idle-box ones: check:pm-dispatch-gates held the lock 5m43s
after waiting 2m06s.

Reverse verification

Both halves withheld in one leg, from the committed state, so the restore has a
real reference point. The two pin sets are disjoint by name, so attribution is
unambiguous without a second leg.

The mutation is proved on disk before anything is measured — exact-string
replacement, each of the four anchors required to hit exactly once, then counted
again after. A zero-hit edit exits 0 and leaves an unmutated file producing healthy
output, which reads exactly like a successful ablation:

HEAD d56daafea
HEAD blob for the target 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
pre-mutation worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
=== MUTATION (exact-string, each anchor must hit exactly once) ===
hits=1 ' if (judgedAsPattern(hint))\n return zeroSegmentForms' -> globInNonFinalSegment
hits=1 " const segments = comparedForm(hint).split('/');" -> collapseHint
hits=1 'deepest !== comparedForm(hint)' -> collapseHint
hits=1 ' const form = comparedForm(hint);\n if (judgedAsPattern(hint)) {'
-> collapseHint / if (false)
=== MUTATION OBSERVED ON DISK (counts, not an exit code) ===
1 INJECTED if (globInNonFinalSegment(hint)) 0 REMOVED if (judgedAsPattern(hint))
1 INJECTED deepest !== collapseHint(hint) 0 REMOVED deepest !== comparedForm(hint)
1 INJECTED const form = collapseHint(hint); 0 REMOVED const form = comparedForm(hint);
1 INJECTED const segments = collapseHint(hint) 0 REMOVED const segments = comparedForm(hint)
1 INJECTED if (false) {
mutated worktree blob 7773d8e28bb6f7a8203fb8f42d1bccac27e49636 (differs from HEAD's)
scripts/pm/dispatch-gates.mjs | 10 +++++-----

RED, in the expected direction:

GATE_EXIT_WITH_FIX_WITHHELD=1
✗ dispatch-gates self-test: 9 of 1048 case(s) failed.
half 1 — coverage
✗ ...and now reaches every one of the files it names (scripts/*.d.mts)
✗ ...and claims nothing else in the whole tree (scripts/*.d.mts)
✗ the live specimen reaches every changeset it names (.changeset/*.md)
✗ and claims nothing else in the whole tree (.changeset/*.md)
✗ so it is nobody's dead literal any more
✗ the live specimen is not a dead literal on this tree at all
half 2 — the reason (hand-built `dead` rows, so independent of half 1)
✗ a dead glob pattern whose root is right there is NOT a layout move
✗ ...and never asserts a directory rename that never happened
✗ ...while the reason it does give is one the reader can check for themselves

Restore proved by OBSERVED STATE, not by an exit code:

post-restore worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
HEAD blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
blob equality: MATCH
git diff HEAD (printed nothing)
git status --short (printed nothing)

The restore leg is git checkout HEAD -- ABSOLUTE_PATH, never a bare
git checkout -- PATH (that reads from the index, which the mutation may have
touched) and never a relative path in the trap (a cwd that is no longer the repo root
silently leaves the tree mutated). An empty hash is treated as FAILURE, not as
"nothing to compare".

Does the fix change its own derived family? No.

The tool being fixed is the tool that derives this PR's own gate list, so it was run
from both sides on the same input path — an origin/main worktree (unfixed) and
this branch (fixed):

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands scripts/pm/dispatch-gates.mjs

14 commands from each, diff empty. Expected, and worth stating rather than assuming:
the only coverage that moves is .changeset/*.md, and this PR's diff contains no
.changeset/ path.

Scope

One file: scripts/pm/dispatch-gates.mjs. ⛔ scripts/check-doc-anchors.mjs is
untouched — that is #13449's landing site, dispatched in the same wave to a different
dev. No governed surface, no packages/spec/src/**, no content/docs/releases/.

scripts/ publishes nothing, so there is no changeset. Check Changeset in
pr-automation.yml carries no path filter and no docs-only exemption — it enforces on
every PR and exempts only on the skip-changeset label — so the label is applied here
and read back rather than waited for.


Generated by Claude Code

…aming a move that never happened
`collapseHint` collapses a glob by deleting it. That is a sound reduction when
nothing but glob characters follow (`packages/**` -> `packages`), and a SPLICE
when a literal does: `.changeset/*.md` -> `.changeset/.md`, a path no tree can
hold. So the hint matched nothing BY CONSTRUCTION while reading as an ordinary
literal, against 548 tracked changesets. `zeroSegmentForms` recorded this exact
species one branch over and left it; it is the same defect, splicing inside a
segment rather than across a separator.
The second half is why it was worth a card. `deepestTrackedPrefix`,
`unreachableClass` and `unreachableReason` all reasoned from `collapseHint`
UNCONDITIONALLY, including for the hints `hintCovers` had already stopped
judging that way. A pattern-judged hint can never equal its own collapsed splice
(`.changeset` is not `.changeset/.md`), so "the tree stops at X; the layout
moved under it" was the only reachable sentence for the whole shape class -- a
specific wrong cause, printed under the heading that tells a reader to go chase
it. Repairing the matcher alone would have retired today's five instances and
left the derivation that mints them intact.
- `globCarriesLiteralSuffix` -- a `*` in the final segment with a literal behind
it. A literal in FRONT of the glob is not this case.
- `judgedAsPattern` -- the two splice shapes as one question, because two call
sites need the same answer and a second copy of it is the drift this file
refuses everywhere else.
- `comparedForm` -- the form `hintCovers` ACTUALLY judged the hint by. The three
reason branches now mean what they say for both comparison modes.
Measured at 4301f78 over 191 families x 749 distinct hints x 7588 tracked
files: one hint in the whole fleet carries the shape; (hint, file) pairs
35275 -> 35823 and (gate, file) pairs 140716 -> 143456, ZERO lost; residue notes
asserting a layout move for it 5 -> 0. ZERO cards gain a family and zero lose
one -- all five owners also declare the bare `.changeset`, so the brief was
already naming them through the sibling literal. 548 (family, card) pairs
re-attribute to a more precise key in the one owner that spells the glob first.
`?`, `+` and character classes are deliberately NOT admitted (zero live
instances) and the self-test reds on their arrival.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 12:16
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit eb717a1Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13448-hintcovers-basename-glob branch August 31, 2026 12:37
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened by claude[bot] · Pull Request #13783 · objectstack-ai/objectstack · GitHub
Skip to content

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened - #13783

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob
Aug 31, 2026
Merged

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened#13783
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13448

hintCovers could not follow a final-segment basename glob, and the residue
printer then stated a specific wrong cause for the deadness it produced. Both
halves land here, in one change, as the triage ruling requires.

The defect, one line each

Half 1 — coverage.collapseHint collapses a glob by deleting it. That is a
sound reduction when nothing but glob characters follow (packages/** ->
packages, packages/client* -> packages/client). When a literal follows the
glob it is a splice: .changeset/*.md -> .changeset/.md, a path no tree can
hold. The hint matched nothing BY CONSTRUCTION while reading as an ordinary literal.
zeroSegmentForms' docblock recorded this exact species one branch over
(scripts/*.d.mts) and left it as "a different species"; it is the same defect,
splicing inside a segment rather than across a separator.

Half 2 — the false reason.deepestTrackedPrefix, unreachableClass and
unreachableReason all reasoned from collapseHintunconditionally, including
for hints hintCovers had already stopped judging that way. A pattern-judged hint
can never equal its own collapsed splice (.changeset is not .changeset/.md), so
deepest !== collapseHint(hint) was always true for the whole shape class, and
"the tree stops at .changeset; the layout moved under it" was the only
reachable sentence. The layout never moved. A reader acting on that string goes
hunting a directory rename that never happened.

The fix

scripts/pm/dispatch-gates.mjs — three new exported predicates, four call sites:

  • globCarriesLiteralSuffix(hint) — a * in the final segment with a literal
    behind it. What decides it is a literal behind the glob, never one in front:
    scripts/check-*.mjs yes, scripts/check-* no.
  • judgedAsPattern(hint) = globInNonFinalSegment(hint) || globCarriesLiteralSuffix(hint)
    — the two splice shapes asked as one question, because two call sites need the
    same answer and a second copy of it is the drift this file refuses everywhere else.
    hintCovers routes on it into the existingzeroSegmentForms/triggerCovers
    branch: not a second matching language, the one already there for this exact job.
  • comparedForm(hint) — the form hintCoversactually judged the hint by:
    collapseHint for a collapse-judged hint, the literal prefix for a
    pattern-judged one. deepestTrackedPrefix walks it, unreachableClass compares
    against it, and unreachableReason gets a branch that speaks in it.

The new reason is checkable by the reader, which is the bar a triage lead has to
clear:

BEFORE '.changeset/*.md' — the tree stops at .changeset; the layout moved under it
AFTER '.changeset/*.md' — the tree HAS .changeset; this hint is a GLOB PATTERN and
nothing under that root matches it — check with `git ls-files '.changeset/*.md'`

...and the exception stays exactly that narrow — a pattern whose own literal prefix
really is gone still reads as a move:

'packages/gone-away/*.ts' — a glob pattern whose literal prefix packages/gone-away is
gone; the tree stops at packages, so the layout moved under it

A2.1 — the changeset count, re-measured at my own ref

The number moves with every merged PR, which is exactly why it must not be quoted
from anyone:

refreading
the card as filed~400
triage, 10:28Z537
PM, 10:55Z546
my branch point c42bc8ee6546
this PR's head 4301f7846 + the fix548
git ls-files '.changeset/*.md' | wc -l # 548
git ls-tree -r --name-only HEAD | grep -c '^\.changeset/.*\.md$' # 548, same answer both readings

Four readings on four days over one directory is itself part of the cost: a silent
zero over a population that grows with every merge is the one you notice last.

A2.2 — per-shape census, before/after, rather than "widen until it looks glob-ish"

Every distinct hint in the fleet, classified by glob shape and measured against the
tracked corpus. 191 families x 749 distinct hints x 7588 tracked files at
4301f7846
, before = the unfixed tool run from an origin/main worktree over the
identical corpus:

hintsreach 0 files (before -> after)(hint, file) pairs (before -> after)shape
661208 -> 2087411 -> 7411literal, no glob
571 -> 111420 -> 11420final segment exactly ** (trailing)
173 -> 38497 -> 8497glob in a NON-FINAL segment (already pattern-judged)
132 -> 27947 -> 7947final segment exactly * (trailing)
11 -> 00 -> 548final-segment * with a literal SUFFIX — the defect
0final-segment partial glob TRAILING (packages/client*, the DECIDED trade)
0?, + or a character class, anywhere in the fleet

Exactly one hint in the whole fleet carries the missed shape.**/ prefixes,
mid-segment wildcards and whole-** segments were already routed through
triggerCovers and are untouched. There are zero hints carrying ?, + or a
character class, so those are deliberately not admitted: collapseHint never
deleted them, so they are an ordinary literal that fails to match — the missing-lead
direction this file errs in — not a mangle. Admitting them would be a fabricated-lead
widening bought for no live instance; the self-test reds on their arrival instead.

Blast radius, both directions:

(hint, file) pairs 35275 -> 35823 (+548, ZERO lost)
(gate, file) pairs 140716 -> 143456 (+2740, ZERO lost)
hints reaching 0 files 215 -> 214
distinct hints whose reach changes at all: 1
cards LOSING any family: 0

The ROOT_DIR_WATCH_HINTS idiom is bit-for-bit unchanged — packages/* 5631,
examples/* 243, skills/** 50, content/** 442, scripts/** 299, packages/**
5631, all identical before and after — and the DECIDED packages/client*
partial-segment trade is a TRAILING glob, so it is not this case and stays pinned in
both directions.

A2.3 — which cards gain which families: ZERO, and that is the finding

The card and the triage ruling both expected the matched column to move —
"Correcting hintCovers makes those families match more cards — a live change
to which gates a dispatch brief names, not a display change"
, and triage's
⇒ **这不是显示缺陷,是覆盖缺陷。** 今天派发简报**漏点**了本该跑的 changeset 门族.

Measured over every tracked file read as a one-file card surface: 0 cards gain a
family, 0 cards lose one.

The reason is only visible in the census: all five owners of .changeset/*.md also
declare the bare literal .changeset
, which hintCovers has always matched
against every file beneath it. A card touching a changeset was already being told all
five families — through the sibling literal, not through the glob.

familyalso declares .changesetindex of .changesetindex of .changeset/*.md
check:changeset-gate-self-testsyes01
scripts/check-adr-0087-registration.mjsyes48
scripts/check-changeset-no-major.mjsyes01
scripts/check-empty-changeset.mjsyes01
scripts/pm/release-rehearsal-clone.mjs --self-testyes21

One thing does move, and it is a printed key, not a verdict: 548 (family, card)
pairs re-attribute
from .changeset to .changeset/*.md, all of them in
release-rehearsal-clone.mjs --self-test, the one owner that spells the glob before
the bare literal. Same family, same gate source provenance, strictly more precise
key — the pattern that actually matches rather than the directory it sits in. Every
other owner keeps the exact key it printed. Disclosed rather than buried because
coveringKey's docblock prices re-attribution explicitly.

So the live cost of this defect was never an under-named brief. It was the false
reason — 5 residue notes asserting a directory rename -> 0 — plus a single point of
failure nobody could see: the moment any of those five gates spells its population as
the glob alone, its coverage vanishes silently. That is not hypothetical.
scripts/*.d.mts is the second, already-live instance (0 -> 4 tracked files), and it
was pinned in this file's own self-test as "still dead" until this change.

⚠️ This does not falsify the card's premise. The hint really did measure dead
against 548 tracked changesets, and the residue really did name a false cause; both
are repaired here. What the measurement falsifies is the blast-radius claim the p1
rationale rested on. Worth re-reading the priority against that, which is a call for
triage and not for me.

A2.4 — unreachableReason is a reason SET, and its reasons did not line up

Not a single hard-coded string: four branches (extensionless-module target / no
tracked first segment / tree-has-it-but-too-generic / layout moved). The mismatch is
one level up — hintCovers has two comparison modes and the reason set covered
one.
Every branch derived from collapseHint, so for the pattern-judged shapes the
renderer was reasoning about a string the comparison never looked at, and one branch
was therefore unreachable while another was unconditional.

That is why the repair is comparedForm and not a reworded branch: reword it and the
next hint of this shape mints the same falsehood. A2.4's own test — "a reader
following the stated reason now finds the real cause" — is pinned rather than
asserted: the new sentence hands the reader the exact git ls-files invocation that
reproduces the emptiness, and the self-test asserts the string contains it.

Tests

31 new self-test cases, in two blocks beside the ones they extend. The two pins that
previously asserted the defect are flipped rather than deleted, so the change of
mind is legible: 'the final-segment spelling of the same population is still dead'
becomes 'the collapse ... is still a splice' + '...so the hint is judged as a pattern instead' + '...and now reaches every one of the files it names'.

Read from the real corpus, not a fixture — a fixture cannot show that the tree
still holds the population the trap needs, and this one grows daily. Both directions
are pinned: the reach, the refusals (packages/client* sibling still refused, the
ROOT_DIR_WATCH_HINTS idiom untouched, ?/+/[…] still collapse-judged), and a
fleet-wide ratchet that reds if a ?, + or character-class hint ever arrives.

Verification

Exit codes captured before any pipe; each verdict quoted from the line the gate
itself printed. Run on a shared, contended box (a docs dev server, two sibling agents'
gate runs), so heavy runs went through scripts/pm/os-verify-lock.sh.

The family was re-derived after the final commit, from the tool's own answer and
not from a hand-written list:

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack # 14 commands

The whole union then ran at head d56daafea, with git status --short empty at
the start and HEAD unchanged at the end (both recorded in the run log), so the green
below is about the tree this PR actually lands.

checkexitverdict
check:pm-dispatch-gates (the edited script's own suite)0GREEN — ✓ dispatch-gates self-test: 1048 cases pass.
scripts/pm/bare-root-worklist.mjs --self-test (the only importer of hintCovers/collapseHint)0GREEN — OK self-test: 51 live row(s), 43 unreachable as spelled … every one of 9 distinct spelling(s) pinned LIVE, PRECISE and COMPLETE … in hintCovers' own terms
check:nul-bytes0GREEN — OK (scanned 7581 text file(s) … no raw ASCII control bytes)
check:watch-hint-literal0GREEN — 34 declaration(s) across 4 rostered name(s) … no unrostered spelling of the idiom in the tree
check:entry-guard0GREEN — 189 scripts/ file(s) — every entry guard goes through invoked-as.mjs
check:agent-test-spelling0GREEN
check:bash32-floor0GREEN — 153 cases pass + 24 tracked shell file(s)
check:cli-command-ids0GREEN — 39 cases pass + 304 command-id literal(s) across 109 file(s)
check:cross-package-test-inputs0GREEN — All 117 self-test cases passed. + 24 package(s) read outside themselves, all declared
check:parse-guard0GREEN
check:pnpm-filter-targets0GREEN — 54 + 40 assertions, 140/179 --filter occurrence(s) resolve
node scripts/check-ci-filter-parity.mjs0GREEN — all 129 declared cross-package glob(s) (92 unique) are covered
node scripts/check-cross-package-test-inputs.mjs0GREEN
node scripts/check-self-test-wired.mjs0GREEN — every one of the 154 script(s) CI runs that ship a --self-test has that self-test run by CI
node scripts/check-shard-attestation.mjs0GREEN — 2 aggregate gate(s) count 3 declared leg(s)
node scripts/check-test-completeness.mjs3NOT MEASURED — see below
pnpm lint (eslint . --no-inline-config, repo-wide)0GREEN
eslint --format json scripts/pm/dispatch-gates.mjs0GREEN — 1 file, errorCount: 0, warningCount: 0

check-test-completeness is NOT MEASURED, not red. Exit 3 is its documented
prerequisite branch, and the gate says so in its own words, naming this exact arrival:

check-test-completeness: PREREQUISITE NOT MET — this gate grades a saved `turbo run test` log, and no log was named.
⚠ Arrived here from the gate family `scripts/pm/dispatch-gates.mjs` derives?
That list names this script with NO argument, which is this branch. There is no
local log to hand it, so the local reading for this gate is NOT MEASURED.
⛔ It is not a red, and there is nothing here to fix.

CI passes it the teed log, so CI's reading is a real one and this PR does not change it.

Repo-wide pnpm lint was run in full rather than narrowed, so no narrowing
argument is owed. For completeness, the invariance that would have backed one is real
and stated by the config itself (eslint.config.mjs lines 327-328: "never enables
type-aware linting (no parserOptions.project, no typed @typescript-eslint rules)
for ANY file"), so a one-file .mjs edit cannot move an untouched file's verdict.

Heavy runs went through scripts/pm/os-verify-lock.sh on a shared, contended box (a
docs dev server plus two sibling agents' gate runs). Wall-clock figures are therefore
shared-box seconds, not idle-box ones: check:pm-dispatch-gates held the lock 5m43s
after waiting 2m06s.

Reverse verification

Both halves withheld in one leg, from the committed state, so the restore has a
real reference point. The two pin sets are disjoint by name, so attribution is
unambiguous without a second leg.

The mutation is proved on disk before anything is measured — exact-string
replacement, each of the four anchors required to hit exactly once, then counted
again after. A zero-hit edit exits 0 and leaves an unmutated file producing healthy
output, which reads exactly like a successful ablation:

HEAD d56daafea
HEAD blob for the target 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
pre-mutation worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
=== MUTATION (exact-string, each anchor must hit exactly once) ===
hits=1 ' if (judgedAsPattern(hint))\n return zeroSegmentForms' -> globInNonFinalSegment
hits=1 " const segments = comparedForm(hint).split('/');" -> collapseHint
hits=1 'deepest !== comparedForm(hint)' -> collapseHint
hits=1 ' const form = comparedForm(hint);\n if (judgedAsPattern(hint)) {'
-> collapseHint / if (false)
=== MUTATION OBSERVED ON DISK (counts, not an exit code) ===
1 INJECTED if (globInNonFinalSegment(hint)) 0 REMOVED if (judgedAsPattern(hint))
1 INJECTED deepest !== collapseHint(hint) 0 REMOVED deepest !== comparedForm(hint)
1 INJECTED const form = collapseHint(hint); 0 REMOVED const form = comparedForm(hint);
1 INJECTED const segments = collapseHint(hint) 0 REMOVED const segments = comparedForm(hint)
1 INJECTED if (false) {
mutated worktree blob 7773d8e28bb6f7a8203fb8f42d1bccac27e49636 (differs from HEAD's)
scripts/pm/dispatch-gates.mjs | 10 +++++-----

RED, in the expected direction:

GATE_EXIT_WITH_FIX_WITHHELD=1
✗ dispatch-gates self-test: 9 of 1048 case(s) failed.
half 1 — coverage
✗ ...and now reaches every one of the files it names (scripts/*.d.mts)
✗ ...and claims nothing else in the whole tree (scripts/*.d.mts)
✗ the live specimen reaches every changeset it names (.changeset/*.md)
✗ and claims nothing else in the whole tree (.changeset/*.md)
✗ so it is nobody's dead literal any more
✗ the live specimen is not a dead literal on this tree at all
half 2 — the reason (hand-built `dead` rows, so independent of half 1)
✗ a dead glob pattern whose root is right there is NOT a layout move
✗ ...and never asserts a directory rename that never happened
✗ ...while the reason it does give is one the reader can check for themselves

Restore proved by OBSERVED STATE, not by an exit code:

post-restore worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
HEAD blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
blob equality: MATCH
git diff HEAD (printed nothing)
git status --short (printed nothing)

The restore leg is git checkout HEAD -- ABSOLUTE_PATH, never a bare
git checkout -- PATH (that reads from the index, which the mutation may have
touched) and never a relative path in the trap (a cwd that is no longer the repo root
silently leaves the tree mutated). An empty hash is treated as FAILURE, not as
"nothing to compare".

Does the fix change its own derived family? No.

The tool being fixed is the tool that derives this PR's own gate list, so it was run
from both sides on the same input path — an origin/main worktree (unfixed) and
this branch (fixed):

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands scripts/pm/dispatch-gates.mjs

14 commands from each, diff empty. Expected, and worth stating rather than assuming:
the only coverage that moves is .changeset/*.md, and this PR's diff contains no
.changeset/ path.

Scope

One file: scripts/pm/dispatch-gates.mjs. ⛔ scripts/check-doc-anchors.mjs is
untouched — that is #13449's landing site, dispatched in the same wave to a different
dev. No governed surface, no packages/spec/src/**, no content/docs/releases/.

scripts/ publishes nothing, so there is no changeset. Check Changeset in
pr-automation.yml carries no path filter and no docs-only exemption — it enforces on
every PR and exempts only on the skip-changeset label — so the label is applied here
and read back rather than waited for.


Generated by Claude Code

…aming a move that never happened
`collapseHint` collapses a glob by deleting it. That is a sound reduction when
nothing but glob characters follow (`packages/**` -> `packages`), and a SPLICE
when a literal does: `.changeset/*.md` -> `.changeset/.md`, a path no tree can
hold. So the hint matched nothing BY CONSTRUCTION while reading as an ordinary
literal, against 548 tracked changesets. `zeroSegmentForms` recorded this exact
species one branch over and left it; it is the same defect, splicing inside a
segment rather than across a separator.
The second half is why it was worth a card. `deepestTrackedPrefix`,
`unreachableClass` and `unreachableReason` all reasoned from `collapseHint`
UNCONDITIONALLY, including for the hints `hintCovers` had already stopped
judging that way. A pattern-judged hint can never equal its own collapsed splice
(`.changeset` is not `.changeset/.md`), so "the tree stops at X; the layout
moved under it" was the only reachable sentence for the whole shape class -- a
specific wrong cause, printed under the heading that tells a reader to go chase
it. Repairing the matcher alone would have retired today's five instances and
left the derivation that mints them intact.
- `globCarriesLiteralSuffix` -- a `*` in the final segment with a literal behind
it. A literal in FRONT of the glob is not this case.
- `judgedAsPattern` -- the two splice shapes as one question, because two call
sites need the same answer and a second copy of it is the drift this file
refuses everywhere else.
- `comparedForm` -- the form `hintCovers` ACTUALLY judged the hint by. The three
reason branches now mean what they say for both comparison modes.
Measured at 4301f78 over 191 families x 749 distinct hints x 7588 tracked
files: one hint in the whole fleet carries the shape; (hint, file) pairs
35275 -> 35823 and (gate, file) pairs 140716 -> 143456, ZERO lost; residue notes
asserting a layout move for it 5 -> 0. ZERO cards gain a family and zero lose
one -- all five owners also declare the bare `.changeset`, so the brief was
already naming them through the sibling literal. 548 (family, card) pairs
re-attribute to a more precise key in the one owner that spells the glob first.
`?`, `+` and character classes are deliberately NOT admitted (zero live
instances) and the self-test reds on their arrival.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 12:16
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit eb717a1Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13448-hintcovers-basename-glob branch August 31, 2026 12:37
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened by claude[bot] · Pull Request #13783 · objectstack-ai/objectstack · GitHub
Skip to content

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened - #13783

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob
Aug 31, 2026
Merged

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened#13783
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13448

hintCovers could not follow a final-segment basename glob, and the residue
printer then stated a specific wrong cause for the deadness it produced. Both
halves land here, in one change, as the triage ruling requires.

The defect, one line each

Half 1 — coverage.collapseHint collapses a glob by deleting it. That is a
sound reduction when nothing but glob characters follow (packages/** ->
packages, packages/client* -> packages/client). When a literal follows the
glob it is a splice: .changeset/*.md -> .changeset/.md, a path no tree can
hold. The hint matched nothing BY CONSTRUCTION while reading as an ordinary literal.
zeroSegmentForms' docblock recorded this exact species one branch over
(scripts/*.d.mts) and left it as "a different species"; it is the same defect,
splicing inside a segment rather than across a separator.

Half 2 — the false reason.deepestTrackedPrefix, unreachableClass and
unreachableReason all reasoned from collapseHintunconditionally, including
for hints hintCovers had already stopped judging that way. A pattern-judged hint
can never equal its own collapsed splice (.changeset is not .changeset/.md), so
deepest !== collapseHint(hint) was always true for the whole shape class, and
"the tree stops at .changeset; the layout moved under it" was the only
reachable sentence. The layout never moved. A reader acting on that string goes
hunting a directory rename that never happened.

The fix

scripts/pm/dispatch-gates.mjs — three new exported predicates, four call sites:

  • globCarriesLiteralSuffix(hint) — a * in the final segment with a literal
    behind it. What decides it is a literal behind the glob, never one in front:
    scripts/check-*.mjs yes, scripts/check-* no.
  • judgedAsPattern(hint) = globInNonFinalSegment(hint) || globCarriesLiteralSuffix(hint)
    — the two splice shapes asked as one question, because two call sites need the
    same answer and a second copy of it is the drift this file refuses everywhere else.
    hintCovers routes on it into the existingzeroSegmentForms/triggerCovers
    branch: not a second matching language, the one already there for this exact job.
  • comparedForm(hint) — the form hintCoversactually judged the hint by:
    collapseHint for a collapse-judged hint, the literal prefix for a
    pattern-judged one. deepestTrackedPrefix walks it, unreachableClass compares
    against it, and unreachableReason gets a branch that speaks in it.

The new reason is checkable by the reader, which is the bar a triage lead has to
clear:

BEFORE '.changeset/*.md' — the tree stops at .changeset; the layout moved under it
AFTER '.changeset/*.md' — the tree HAS .changeset; this hint is a GLOB PATTERN and
nothing under that root matches it — check with `git ls-files '.changeset/*.md'`

...and the exception stays exactly that narrow — a pattern whose own literal prefix
really is gone still reads as a move:

'packages/gone-away/*.ts' — a glob pattern whose literal prefix packages/gone-away is
gone; the tree stops at packages, so the layout moved under it

A2.1 — the changeset count, re-measured at my own ref

The number moves with every merged PR, which is exactly why it must not be quoted
from anyone:

refreading
the card as filed~400
triage, 10:28Z537
PM, 10:55Z546
my branch point c42bc8ee6546
this PR's head 4301f7846 + the fix548
git ls-files '.changeset/*.md' | wc -l # 548
git ls-tree -r --name-only HEAD | grep -c '^\.changeset/.*\.md$' # 548, same answer both readings

Four readings on four days over one directory is itself part of the cost: a silent
zero over a population that grows with every merge is the one you notice last.

A2.2 — per-shape census, before/after, rather than "widen until it looks glob-ish"

Every distinct hint in the fleet, classified by glob shape and measured against the
tracked corpus. 191 families x 749 distinct hints x 7588 tracked files at
4301f7846
, before = the unfixed tool run from an origin/main worktree over the
identical corpus:

hintsreach 0 files (before -> after)(hint, file) pairs (before -> after)shape
661208 -> 2087411 -> 7411literal, no glob
571 -> 111420 -> 11420final segment exactly ** (trailing)
173 -> 38497 -> 8497glob in a NON-FINAL segment (already pattern-judged)
132 -> 27947 -> 7947final segment exactly * (trailing)
11 -> 00 -> 548final-segment * with a literal SUFFIX — the defect
0final-segment partial glob TRAILING (packages/client*, the DECIDED trade)
0?, + or a character class, anywhere in the fleet

Exactly one hint in the whole fleet carries the missed shape.**/ prefixes,
mid-segment wildcards and whole-** segments were already routed through
triggerCovers and are untouched. There are zero hints carrying ?, + or a
character class, so those are deliberately not admitted: collapseHint never
deleted them, so they are an ordinary literal that fails to match — the missing-lead
direction this file errs in — not a mangle. Admitting them would be a fabricated-lead
widening bought for no live instance; the self-test reds on their arrival instead.

Blast radius, both directions:

(hint, file) pairs 35275 -> 35823 (+548, ZERO lost)
(gate, file) pairs 140716 -> 143456 (+2740, ZERO lost)
hints reaching 0 files 215 -> 214
distinct hints whose reach changes at all: 1
cards LOSING any family: 0

The ROOT_DIR_WATCH_HINTS idiom is bit-for-bit unchanged — packages/* 5631,
examples/* 243, skills/** 50, content/** 442, scripts/** 299, packages/**
5631, all identical before and after — and the DECIDED packages/client*
partial-segment trade is a TRAILING glob, so it is not this case and stays pinned in
both directions.

A2.3 — which cards gain which families: ZERO, and that is the finding

The card and the triage ruling both expected the matched column to move —
"Correcting hintCovers makes those families match more cards — a live change
to which gates a dispatch brief names, not a display change"
, and triage's
⇒ **这不是显示缺陷,是覆盖缺陷。** 今天派发简报**漏点**了本该跑的 changeset 门族.

Measured over every tracked file read as a one-file card surface: 0 cards gain a
family, 0 cards lose one.

The reason is only visible in the census: all five owners of .changeset/*.md also
declare the bare literal .changeset
, which hintCovers has always matched
against every file beneath it. A card touching a changeset was already being told all
five families — through the sibling literal, not through the glob.

familyalso declares .changesetindex of .changesetindex of .changeset/*.md
check:changeset-gate-self-testsyes01
scripts/check-adr-0087-registration.mjsyes48
scripts/check-changeset-no-major.mjsyes01
scripts/check-empty-changeset.mjsyes01
scripts/pm/release-rehearsal-clone.mjs --self-testyes21

One thing does move, and it is a printed key, not a verdict: 548 (family, card)
pairs re-attribute
from .changeset to .changeset/*.md, all of them in
release-rehearsal-clone.mjs --self-test, the one owner that spells the glob before
the bare literal. Same family, same gate source provenance, strictly more precise
key — the pattern that actually matches rather than the directory it sits in. Every
other owner keeps the exact key it printed. Disclosed rather than buried because
coveringKey's docblock prices re-attribution explicitly.

So the live cost of this defect was never an under-named brief. It was the false
reason — 5 residue notes asserting a directory rename -> 0 — plus a single point of
failure nobody could see: the moment any of those five gates spells its population as
the glob alone, its coverage vanishes silently. That is not hypothetical.
scripts/*.d.mts is the second, already-live instance (0 -> 4 tracked files), and it
was pinned in this file's own self-test as "still dead" until this change.

⚠️ This does not falsify the card's premise. The hint really did measure dead
against 548 tracked changesets, and the residue really did name a false cause; both
are repaired here. What the measurement falsifies is the blast-radius claim the p1
rationale rested on. Worth re-reading the priority against that, which is a call for
triage and not for me.

A2.4 — unreachableReason is a reason SET, and its reasons did not line up

Not a single hard-coded string: four branches (extensionless-module target / no
tracked first segment / tree-has-it-but-too-generic / layout moved). The mismatch is
one level up — hintCovers has two comparison modes and the reason set covered
one.
Every branch derived from collapseHint, so for the pattern-judged shapes the
renderer was reasoning about a string the comparison never looked at, and one branch
was therefore unreachable while another was unconditional.

That is why the repair is comparedForm and not a reworded branch: reword it and the
next hint of this shape mints the same falsehood. A2.4's own test — "a reader
following the stated reason now finds the real cause" — is pinned rather than
asserted: the new sentence hands the reader the exact git ls-files invocation that
reproduces the emptiness, and the self-test asserts the string contains it.

Tests

31 new self-test cases, in two blocks beside the ones they extend. The two pins that
previously asserted the defect are flipped rather than deleted, so the change of
mind is legible: 'the final-segment spelling of the same population is still dead'
becomes 'the collapse ... is still a splice' + '...so the hint is judged as a pattern instead' + '...and now reaches every one of the files it names'.

Read from the real corpus, not a fixture — a fixture cannot show that the tree
still holds the population the trap needs, and this one grows daily. Both directions
are pinned: the reach, the refusals (packages/client* sibling still refused, the
ROOT_DIR_WATCH_HINTS idiom untouched, ?/+/[…] still collapse-judged), and a
fleet-wide ratchet that reds if a ?, + or character-class hint ever arrives.

Verification

Exit codes captured before any pipe; each verdict quoted from the line the gate
itself printed. Run on a shared, contended box (a docs dev server, two sibling agents'
gate runs), so heavy runs went through scripts/pm/os-verify-lock.sh.

The family was re-derived after the final commit, from the tool's own answer and
not from a hand-written list:

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack # 14 commands

The whole union then ran at head d56daafea, with git status --short empty at
the start and HEAD unchanged at the end (both recorded in the run log), so the green
below is about the tree this PR actually lands.

checkexitverdict
check:pm-dispatch-gates (the edited script's own suite)0GREEN — ✓ dispatch-gates self-test: 1048 cases pass.
scripts/pm/bare-root-worklist.mjs --self-test (the only importer of hintCovers/collapseHint)0GREEN — OK self-test: 51 live row(s), 43 unreachable as spelled … every one of 9 distinct spelling(s) pinned LIVE, PRECISE and COMPLETE … in hintCovers' own terms
check:nul-bytes0GREEN — OK (scanned 7581 text file(s) … no raw ASCII control bytes)
check:watch-hint-literal0GREEN — 34 declaration(s) across 4 rostered name(s) … no unrostered spelling of the idiom in the tree
check:entry-guard0GREEN — 189 scripts/ file(s) — every entry guard goes through invoked-as.mjs
check:agent-test-spelling0GREEN
check:bash32-floor0GREEN — 153 cases pass + 24 tracked shell file(s)
check:cli-command-ids0GREEN — 39 cases pass + 304 command-id literal(s) across 109 file(s)
check:cross-package-test-inputs0GREEN — All 117 self-test cases passed. + 24 package(s) read outside themselves, all declared
check:parse-guard0GREEN
check:pnpm-filter-targets0GREEN — 54 + 40 assertions, 140/179 --filter occurrence(s) resolve
node scripts/check-ci-filter-parity.mjs0GREEN — all 129 declared cross-package glob(s) (92 unique) are covered
node scripts/check-cross-package-test-inputs.mjs0GREEN
node scripts/check-self-test-wired.mjs0GREEN — every one of the 154 script(s) CI runs that ship a --self-test has that self-test run by CI
node scripts/check-shard-attestation.mjs0GREEN — 2 aggregate gate(s) count 3 declared leg(s)
node scripts/check-test-completeness.mjs3NOT MEASURED — see below
pnpm lint (eslint . --no-inline-config, repo-wide)0GREEN
eslint --format json scripts/pm/dispatch-gates.mjs0GREEN — 1 file, errorCount: 0, warningCount: 0

check-test-completeness is NOT MEASURED, not red. Exit 3 is its documented
prerequisite branch, and the gate says so in its own words, naming this exact arrival:

check-test-completeness: PREREQUISITE NOT MET — this gate grades a saved `turbo run test` log, and no log was named.
⚠ Arrived here from the gate family `scripts/pm/dispatch-gates.mjs` derives?
That list names this script with NO argument, which is this branch. There is no
local log to hand it, so the local reading for this gate is NOT MEASURED.
⛔ It is not a red, and there is nothing here to fix.

CI passes it the teed log, so CI's reading is a real one and this PR does not change it.

Repo-wide pnpm lint was run in full rather than narrowed, so no narrowing
argument is owed. For completeness, the invariance that would have backed one is real
and stated by the config itself (eslint.config.mjs lines 327-328: "never enables
type-aware linting (no parserOptions.project, no typed @typescript-eslint rules)
for ANY file"), so a one-file .mjs edit cannot move an untouched file's verdict.

Heavy runs went through scripts/pm/os-verify-lock.sh on a shared, contended box (a
docs dev server plus two sibling agents' gate runs). Wall-clock figures are therefore
shared-box seconds, not idle-box ones: check:pm-dispatch-gates held the lock 5m43s
after waiting 2m06s.

Reverse verification

Both halves withheld in one leg, from the committed state, so the restore has a
real reference point. The two pin sets are disjoint by name, so attribution is
unambiguous without a second leg.

The mutation is proved on disk before anything is measured — exact-string
replacement, each of the four anchors required to hit exactly once, then counted
again after. A zero-hit edit exits 0 and leaves an unmutated file producing healthy
output, which reads exactly like a successful ablation:

HEAD d56daafea
HEAD blob for the target 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
pre-mutation worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
=== MUTATION (exact-string, each anchor must hit exactly once) ===
hits=1 ' if (judgedAsPattern(hint))\n return zeroSegmentForms' -> globInNonFinalSegment
hits=1 " const segments = comparedForm(hint).split('/');" -> collapseHint
hits=1 'deepest !== comparedForm(hint)' -> collapseHint
hits=1 ' const form = comparedForm(hint);\n if (judgedAsPattern(hint)) {'
-> collapseHint / if (false)
=== MUTATION OBSERVED ON DISK (counts, not an exit code) ===
1 INJECTED if (globInNonFinalSegment(hint)) 0 REMOVED if (judgedAsPattern(hint))
1 INJECTED deepest !== collapseHint(hint) 0 REMOVED deepest !== comparedForm(hint)
1 INJECTED const form = collapseHint(hint); 0 REMOVED const form = comparedForm(hint);
1 INJECTED const segments = collapseHint(hint) 0 REMOVED const segments = comparedForm(hint)
1 INJECTED if (false) {
mutated worktree blob 7773d8e28bb6f7a8203fb8f42d1bccac27e49636 (differs from HEAD's)
scripts/pm/dispatch-gates.mjs | 10 +++++-----

RED, in the expected direction:

GATE_EXIT_WITH_FIX_WITHHELD=1
✗ dispatch-gates self-test: 9 of 1048 case(s) failed.
half 1 — coverage
✗ ...and now reaches every one of the files it names (scripts/*.d.mts)
✗ ...and claims nothing else in the whole tree (scripts/*.d.mts)
✗ the live specimen reaches every changeset it names (.changeset/*.md)
✗ and claims nothing else in the whole tree (.changeset/*.md)
✗ so it is nobody's dead literal any more
✗ the live specimen is not a dead literal on this tree at all
half 2 — the reason (hand-built `dead` rows, so independent of half 1)
✗ a dead glob pattern whose root is right there is NOT a layout move
✗ ...and never asserts a directory rename that never happened
✗ ...while the reason it does give is one the reader can check for themselves

Restore proved by OBSERVED STATE, not by an exit code:

post-restore worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
HEAD blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
blob equality: MATCH
git diff HEAD (printed nothing)
git status --short (printed nothing)

The restore leg is git checkout HEAD -- ABSOLUTE_PATH, never a bare
git checkout -- PATH (that reads from the index, which the mutation may have
touched) and never a relative path in the trap (a cwd that is no longer the repo root
silently leaves the tree mutated). An empty hash is treated as FAILURE, not as
"nothing to compare".

Does the fix change its own derived family? No.

The tool being fixed is the tool that derives this PR's own gate list, so it was run
from both sides on the same input path — an origin/main worktree (unfixed) and
this branch (fixed):

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands scripts/pm/dispatch-gates.mjs

14 commands from each, diff empty. Expected, and worth stating rather than assuming:
the only coverage that moves is .changeset/*.md, and this PR's diff contains no
.changeset/ path.

Scope

One file: scripts/pm/dispatch-gates.mjs. ⛔ scripts/check-doc-anchors.mjs is
untouched — that is #13449's landing site, dispatched in the same wave to a different
dev. No governed surface, no packages/spec/src/**, no content/docs/releases/.

scripts/ publishes nothing, so there is no changeset. Check Changeset in
pr-automation.yml carries no path filter and no docs-only exemption — it enforces on
every PR and exempts only on the skip-changeset label — so the label is applied here
and read back rather than waited for.


Generated by Claude Code

…aming a move that never happened
`collapseHint` collapses a glob by deleting it. That is a sound reduction when
nothing but glob characters follow (`packages/**` -> `packages`), and a SPLICE
when a literal does: `.changeset/*.md` -> `.changeset/.md`, a path no tree can
hold. So the hint matched nothing BY CONSTRUCTION while reading as an ordinary
literal, against 548 tracked changesets. `zeroSegmentForms` recorded this exact
species one branch over and left it; it is the same defect, splicing inside a
segment rather than across a separator.
The second half is why it was worth a card. `deepestTrackedPrefix`,
`unreachableClass` and `unreachableReason` all reasoned from `collapseHint`
UNCONDITIONALLY, including for the hints `hintCovers` had already stopped
judging that way. A pattern-judged hint can never equal its own collapsed splice
(`.changeset` is not `.changeset/.md`), so "the tree stops at X; the layout
moved under it" was the only reachable sentence for the whole shape class -- a
specific wrong cause, printed under the heading that tells a reader to go chase
it. Repairing the matcher alone would have retired today's five instances and
left the derivation that mints them intact.
- `globCarriesLiteralSuffix` -- a `*` in the final segment with a literal behind
it. A literal in FRONT of the glob is not this case.
- `judgedAsPattern` -- the two splice shapes as one question, because two call
sites need the same answer and a second copy of it is the drift this file
refuses everywhere else.
- `comparedForm` -- the form `hintCovers` ACTUALLY judged the hint by. The three
reason branches now mean what they say for both comparison modes.
Measured at 4301f78 over 191 families x 749 distinct hints x 7588 tracked
files: one hint in the whole fleet carries the shape; (hint, file) pairs
35275 -> 35823 and (gate, file) pairs 140716 -> 143456, ZERO lost; residue notes
asserting a layout move for it 5 -> 0. ZERO cards gain a family and zero lose
one -- all five owners also declare the bare `.changeset`, so the brief was
already naming them through the sibling literal. 548 (family, card) pairs
re-attribute to a more precise key in the one owner that spells the glob first.
`?`, `+` and character classes are deliberately NOT admitted (zero live
instances) and the self-test reds on their arrival.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 12:16
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit eb717a1Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13448-hintcovers-basename-glob branch August 31, 2026 12:37
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened by claude[bot] · Pull Request #13783 · objectstack-ai/objectstack · GitHub
Skip to content

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened - #13783

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob
Aug 31, 2026
Merged

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened#13783
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13448

hintCovers could not follow a final-segment basename glob, and the residue
printer then stated a specific wrong cause for the deadness it produced. Both
halves land here, in one change, as the triage ruling requires.

The defect, one line each

Half 1 — coverage.collapseHint collapses a glob by deleting it. That is a
sound reduction when nothing but glob characters follow (packages/** ->
packages, packages/client* -> packages/client). When a literal follows the
glob it is a splice: .changeset/*.md -> .changeset/.md, a path no tree can
hold. The hint matched nothing BY CONSTRUCTION while reading as an ordinary literal.
zeroSegmentForms' docblock recorded this exact species one branch over
(scripts/*.d.mts) and left it as "a different species"; it is the same defect,
splicing inside a segment rather than across a separator.

Half 2 — the false reason.deepestTrackedPrefix, unreachableClass and
unreachableReason all reasoned from collapseHintunconditionally, including
for hints hintCovers had already stopped judging that way. A pattern-judged hint
can never equal its own collapsed splice (.changeset is not .changeset/.md), so
deepest !== collapseHint(hint) was always true for the whole shape class, and
"the tree stops at .changeset; the layout moved under it" was the only
reachable sentence. The layout never moved. A reader acting on that string goes
hunting a directory rename that never happened.

The fix

scripts/pm/dispatch-gates.mjs — three new exported predicates, four call sites:

  • globCarriesLiteralSuffix(hint) — a * in the final segment with a literal
    behind it. What decides it is a literal behind the glob, never one in front:
    scripts/check-*.mjs yes, scripts/check-* no.
  • judgedAsPattern(hint) = globInNonFinalSegment(hint) || globCarriesLiteralSuffix(hint)
    — the two splice shapes asked as one question, because two call sites need the
    same answer and a second copy of it is the drift this file refuses everywhere else.
    hintCovers routes on it into the existingzeroSegmentForms/triggerCovers
    branch: not a second matching language, the one already there for this exact job.
  • comparedForm(hint) — the form hintCoversactually judged the hint by:
    collapseHint for a collapse-judged hint, the literal prefix for a
    pattern-judged one. deepestTrackedPrefix walks it, unreachableClass compares
    against it, and unreachableReason gets a branch that speaks in it.

The new reason is checkable by the reader, which is the bar a triage lead has to
clear:

BEFORE '.changeset/*.md' — the tree stops at .changeset; the layout moved under it
AFTER '.changeset/*.md' — the tree HAS .changeset; this hint is a GLOB PATTERN and
nothing under that root matches it — check with `git ls-files '.changeset/*.md'`

...and the exception stays exactly that narrow — a pattern whose own literal prefix
really is gone still reads as a move:

'packages/gone-away/*.ts' — a glob pattern whose literal prefix packages/gone-away is
gone; the tree stops at packages, so the layout moved under it

A2.1 — the changeset count, re-measured at my own ref

The number moves with every merged PR, which is exactly why it must not be quoted
from anyone:

refreading
the card as filed~400
triage, 10:28Z537
PM, 10:55Z546
my branch point c42bc8ee6546
this PR's head 4301f7846 + the fix548
git ls-files '.changeset/*.md' | wc -l # 548
git ls-tree -r --name-only HEAD | grep -c '^\.changeset/.*\.md$' # 548, same answer both readings

Four readings on four days over one directory is itself part of the cost: a silent
zero over a population that grows with every merge is the one you notice last.

A2.2 — per-shape census, before/after, rather than "widen until it looks glob-ish"

Every distinct hint in the fleet, classified by glob shape and measured against the
tracked corpus. 191 families x 749 distinct hints x 7588 tracked files at
4301f7846
, before = the unfixed tool run from an origin/main worktree over the
identical corpus:

hintsreach 0 files (before -> after)(hint, file) pairs (before -> after)shape
661208 -> 2087411 -> 7411literal, no glob
571 -> 111420 -> 11420final segment exactly ** (trailing)
173 -> 38497 -> 8497glob in a NON-FINAL segment (already pattern-judged)
132 -> 27947 -> 7947final segment exactly * (trailing)
11 -> 00 -> 548final-segment * with a literal SUFFIX — the defect
0final-segment partial glob TRAILING (packages/client*, the DECIDED trade)
0?, + or a character class, anywhere in the fleet

Exactly one hint in the whole fleet carries the missed shape.**/ prefixes,
mid-segment wildcards and whole-** segments were already routed through
triggerCovers and are untouched. There are zero hints carrying ?, + or a
character class, so those are deliberately not admitted: collapseHint never
deleted them, so they are an ordinary literal that fails to match — the missing-lead
direction this file errs in — not a mangle. Admitting them would be a fabricated-lead
widening bought for no live instance; the self-test reds on their arrival instead.

Blast radius, both directions:

(hint, file) pairs 35275 -> 35823 (+548, ZERO lost)
(gate, file) pairs 140716 -> 143456 (+2740, ZERO lost)
hints reaching 0 files 215 -> 214
distinct hints whose reach changes at all: 1
cards LOSING any family: 0

The ROOT_DIR_WATCH_HINTS idiom is bit-for-bit unchanged — packages/* 5631,
examples/* 243, skills/** 50, content/** 442, scripts/** 299, packages/**
5631, all identical before and after — and the DECIDED packages/client*
partial-segment trade is a TRAILING glob, so it is not this case and stays pinned in
both directions.

A2.3 — which cards gain which families: ZERO, and that is the finding

The card and the triage ruling both expected the matched column to move —
"Correcting hintCovers makes those families match more cards — a live change
to which gates a dispatch brief names, not a display change"
, and triage's
⇒ **这不是显示缺陷,是覆盖缺陷。** 今天派发简报**漏点**了本该跑的 changeset 门族.

Measured over every tracked file read as a one-file card surface: 0 cards gain a
family, 0 cards lose one.

The reason is only visible in the census: all five owners of .changeset/*.md also
declare the bare literal .changeset
, which hintCovers has always matched
against every file beneath it. A card touching a changeset was already being told all
five families — through the sibling literal, not through the glob.

familyalso declares .changesetindex of .changesetindex of .changeset/*.md
check:changeset-gate-self-testsyes01
scripts/check-adr-0087-registration.mjsyes48
scripts/check-changeset-no-major.mjsyes01
scripts/check-empty-changeset.mjsyes01
scripts/pm/release-rehearsal-clone.mjs --self-testyes21

One thing does move, and it is a printed key, not a verdict: 548 (family, card)
pairs re-attribute
from .changeset to .changeset/*.md, all of them in
release-rehearsal-clone.mjs --self-test, the one owner that spells the glob before
the bare literal. Same family, same gate source provenance, strictly more precise
key — the pattern that actually matches rather than the directory it sits in. Every
other owner keeps the exact key it printed. Disclosed rather than buried because
coveringKey's docblock prices re-attribution explicitly.

So the live cost of this defect was never an under-named brief. It was the false
reason — 5 residue notes asserting a directory rename -> 0 — plus a single point of
failure nobody could see: the moment any of those five gates spells its population as
the glob alone, its coverage vanishes silently. That is not hypothetical.
scripts/*.d.mts is the second, already-live instance (0 -> 4 tracked files), and it
was pinned in this file's own self-test as "still dead" until this change.

⚠️ This does not falsify the card's premise. The hint really did measure dead
against 548 tracked changesets, and the residue really did name a false cause; both
are repaired here. What the measurement falsifies is the blast-radius claim the p1
rationale rested on. Worth re-reading the priority against that, which is a call for
triage and not for me.

A2.4 — unreachableReason is a reason SET, and its reasons did not line up

Not a single hard-coded string: four branches (extensionless-module target / no
tracked first segment / tree-has-it-but-too-generic / layout moved). The mismatch is
one level up — hintCovers has two comparison modes and the reason set covered
one.
Every branch derived from collapseHint, so for the pattern-judged shapes the
renderer was reasoning about a string the comparison never looked at, and one branch
was therefore unreachable while another was unconditional.

That is why the repair is comparedForm and not a reworded branch: reword it and the
next hint of this shape mints the same falsehood. A2.4's own test — "a reader
following the stated reason now finds the real cause" — is pinned rather than
asserted: the new sentence hands the reader the exact git ls-files invocation that
reproduces the emptiness, and the self-test asserts the string contains it.

Tests

31 new self-test cases, in two blocks beside the ones they extend. The two pins that
previously asserted the defect are flipped rather than deleted, so the change of
mind is legible: 'the final-segment spelling of the same population is still dead'
becomes 'the collapse ... is still a splice' + '...so the hint is judged as a pattern instead' + '...and now reaches every one of the files it names'.

Read from the real corpus, not a fixture — a fixture cannot show that the tree
still holds the population the trap needs, and this one grows daily. Both directions
are pinned: the reach, the refusals (packages/client* sibling still refused, the
ROOT_DIR_WATCH_HINTS idiom untouched, ?/+/[…] still collapse-judged), and a
fleet-wide ratchet that reds if a ?, + or character-class hint ever arrives.

Verification

Exit codes captured before any pipe; each verdict quoted from the line the gate
itself printed. Run on a shared, contended box (a docs dev server, two sibling agents'
gate runs), so heavy runs went through scripts/pm/os-verify-lock.sh.

The family was re-derived after the final commit, from the tool's own answer and
not from a hand-written list:

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack # 14 commands

The whole union then ran at head d56daafea, with git status --short empty at
the start and HEAD unchanged at the end (both recorded in the run log), so the green
below is about the tree this PR actually lands.

checkexitverdict
check:pm-dispatch-gates (the edited script's own suite)0GREEN — ✓ dispatch-gates self-test: 1048 cases pass.
scripts/pm/bare-root-worklist.mjs --self-test (the only importer of hintCovers/collapseHint)0GREEN — OK self-test: 51 live row(s), 43 unreachable as spelled … every one of 9 distinct spelling(s) pinned LIVE, PRECISE and COMPLETE … in hintCovers' own terms
check:nul-bytes0GREEN — OK (scanned 7581 text file(s) … no raw ASCII control bytes)
check:watch-hint-literal0GREEN — 34 declaration(s) across 4 rostered name(s) … no unrostered spelling of the idiom in the tree
check:entry-guard0GREEN — 189 scripts/ file(s) — every entry guard goes through invoked-as.mjs
check:agent-test-spelling0GREEN
check:bash32-floor0GREEN — 153 cases pass + 24 tracked shell file(s)
check:cli-command-ids0GREEN — 39 cases pass + 304 command-id literal(s) across 109 file(s)
check:cross-package-test-inputs0GREEN — All 117 self-test cases passed. + 24 package(s) read outside themselves, all declared
check:parse-guard0GREEN
check:pnpm-filter-targets0GREEN — 54 + 40 assertions, 140/179 --filter occurrence(s) resolve
node scripts/check-ci-filter-parity.mjs0GREEN — all 129 declared cross-package glob(s) (92 unique) are covered
node scripts/check-cross-package-test-inputs.mjs0GREEN
node scripts/check-self-test-wired.mjs0GREEN — every one of the 154 script(s) CI runs that ship a --self-test has that self-test run by CI
node scripts/check-shard-attestation.mjs0GREEN — 2 aggregate gate(s) count 3 declared leg(s)
node scripts/check-test-completeness.mjs3NOT MEASURED — see below
pnpm lint (eslint . --no-inline-config, repo-wide)0GREEN
eslint --format json scripts/pm/dispatch-gates.mjs0GREEN — 1 file, errorCount: 0, warningCount: 0

check-test-completeness is NOT MEASURED, not red. Exit 3 is its documented
prerequisite branch, and the gate says so in its own words, naming this exact arrival:

check-test-completeness: PREREQUISITE NOT MET — this gate grades a saved `turbo run test` log, and no log was named.
⚠ Arrived here from the gate family `scripts/pm/dispatch-gates.mjs` derives?
That list names this script with NO argument, which is this branch. There is no
local log to hand it, so the local reading for this gate is NOT MEASURED.
⛔ It is not a red, and there is nothing here to fix.

CI passes it the teed log, so CI's reading is a real one and this PR does not change it.

Repo-wide pnpm lint was run in full rather than narrowed, so no narrowing
argument is owed. For completeness, the invariance that would have backed one is real
and stated by the config itself (eslint.config.mjs lines 327-328: "never enables
type-aware linting (no parserOptions.project, no typed @typescript-eslint rules)
for ANY file"), so a one-file .mjs edit cannot move an untouched file's verdict.

Heavy runs went through scripts/pm/os-verify-lock.sh on a shared, contended box (a
docs dev server plus two sibling agents' gate runs). Wall-clock figures are therefore
shared-box seconds, not idle-box ones: check:pm-dispatch-gates held the lock 5m43s
after waiting 2m06s.

Reverse verification

Both halves withheld in one leg, from the committed state, so the restore has a
real reference point. The two pin sets are disjoint by name, so attribution is
unambiguous without a second leg.

The mutation is proved on disk before anything is measured — exact-string
replacement, each of the four anchors required to hit exactly once, then counted
again after. A zero-hit edit exits 0 and leaves an unmutated file producing healthy
output, which reads exactly like a successful ablation:

HEAD d56daafea
HEAD blob for the target 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
pre-mutation worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
=== MUTATION (exact-string, each anchor must hit exactly once) ===
hits=1 ' if (judgedAsPattern(hint))\n return zeroSegmentForms' -> globInNonFinalSegment
hits=1 " const segments = comparedForm(hint).split('/');" -> collapseHint
hits=1 'deepest !== comparedForm(hint)' -> collapseHint
hits=1 ' const form = comparedForm(hint);\n if (judgedAsPattern(hint)) {'
-> collapseHint / if (false)
=== MUTATION OBSERVED ON DISK (counts, not an exit code) ===
1 INJECTED if (globInNonFinalSegment(hint)) 0 REMOVED if (judgedAsPattern(hint))
1 INJECTED deepest !== collapseHint(hint) 0 REMOVED deepest !== comparedForm(hint)
1 INJECTED const form = collapseHint(hint); 0 REMOVED const form = comparedForm(hint);
1 INJECTED const segments = collapseHint(hint) 0 REMOVED const segments = comparedForm(hint)
1 INJECTED if (false) {
mutated worktree blob 7773d8e28bb6f7a8203fb8f42d1bccac27e49636 (differs from HEAD's)
scripts/pm/dispatch-gates.mjs | 10 +++++-----

RED, in the expected direction:

GATE_EXIT_WITH_FIX_WITHHELD=1
✗ dispatch-gates self-test: 9 of 1048 case(s) failed.
half 1 — coverage
✗ ...and now reaches every one of the files it names (scripts/*.d.mts)
✗ ...and claims nothing else in the whole tree (scripts/*.d.mts)
✗ the live specimen reaches every changeset it names (.changeset/*.md)
✗ and claims nothing else in the whole tree (.changeset/*.md)
✗ so it is nobody's dead literal any more
✗ the live specimen is not a dead literal on this tree at all
half 2 — the reason (hand-built `dead` rows, so independent of half 1)
✗ a dead glob pattern whose root is right there is NOT a layout move
✗ ...and never asserts a directory rename that never happened
✗ ...while the reason it does give is one the reader can check for themselves

Restore proved by OBSERVED STATE, not by an exit code:

post-restore worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
HEAD blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
blob equality: MATCH
git diff HEAD (printed nothing)
git status --short (printed nothing)

The restore leg is git checkout HEAD -- ABSOLUTE_PATH, never a bare
git checkout -- PATH (that reads from the index, which the mutation may have
touched) and never a relative path in the trap (a cwd that is no longer the repo root
silently leaves the tree mutated). An empty hash is treated as FAILURE, not as
"nothing to compare".

Does the fix change its own derived family? No.

The tool being fixed is the tool that derives this PR's own gate list, so it was run
from both sides on the same input path — an origin/main worktree (unfixed) and
this branch (fixed):

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands scripts/pm/dispatch-gates.mjs

14 commands from each, diff empty. Expected, and worth stating rather than assuming:
the only coverage that moves is .changeset/*.md, and this PR's diff contains no
.changeset/ path.

Scope

One file: scripts/pm/dispatch-gates.mjs. ⛔ scripts/check-doc-anchors.mjs is
untouched — that is #13449's landing site, dispatched in the same wave to a different
dev. No governed surface, no packages/spec/src/**, no content/docs/releases/.

scripts/ publishes nothing, so there is no changeset. Check Changeset in
pr-automation.yml carries no path filter and no docs-only exemption — it enforces on
every PR and exempts only on the skip-changeset label — so the label is applied here
and read back rather than waited for.


Generated by Claude Code

…aming a move that never happened
`collapseHint` collapses a glob by deleting it. That is a sound reduction when
nothing but glob characters follow (`packages/**` -> `packages`), and a SPLICE
when a literal does: `.changeset/*.md` -> `.changeset/.md`, a path no tree can
hold. So the hint matched nothing BY CONSTRUCTION while reading as an ordinary
literal, against 548 tracked changesets. `zeroSegmentForms` recorded this exact
species one branch over and left it; it is the same defect, splicing inside a
segment rather than across a separator.
The second half is why it was worth a card. `deepestTrackedPrefix`,
`unreachableClass` and `unreachableReason` all reasoned from `collapseHint`
UNCONDITIONALLY, including for the hints `hintCovers` had already stopped
judging that way. A pattern-judged hint can never equal its own collapsed splice
(`.changeset` is not `.changeset/.md`), so "the tree stops at X; the layout
moved under it" was the only reachable sentence for the whole shape class -- a
specific wrong cause, printed under the heading that tells a reader to go chase
it. Repairing the matcher alone would have retired today's five instances and
left the derivation that mints them intact.
- `globCarriesLiteralSuffix` -- a `*` in the final segment with a literal behind
it. A literal in FRONT of the glob is not this case.
- `judgedAsPattern` -- the two splice shapes as one question, because two call
sites need the same answer and a second copy of it is the drift this file
refuses everywhere else.
- `comparedForm` -- the form `hintCovers` ACTUALLY judged the hint by. The three
reason branches now mean what they say for both comparison modes.
Measured at 4301f78 over 191 families x 749 distinct hints x 7588 tracked
files: one hint in the whole fleet carries the shape; (hint, file) pairs
35275 -> 35823 and (gate, file) pairs 140716 -> 143456, ZERO lost; residue notes
asserting a layout move for it 5 -> 0. ZERO cards gain a family and zero lose
one -- all five owners also declare the bare `.changeset`, so the brief was
already naming them through the sibling literal. 548 (family, card) pairs
re-attribute to a more precise key in the one owner that spells the glob first.
`?`, `+` and character classes are deliberately NOT admitted (zero live
instances) and the self-test reds on their arrival.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 12:16
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit eb717a1Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13448-hintcovers-basename-glob branch August 31, 2026 12:37
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened by claude[bot] · Pull Request #13783 · objectstack-ai/objectstack · GitHub
Skip to content

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened - #13783

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob
Aug 31, 2026
Merged

fix(pm): follow a final-segment basename glob in hintCovers, and stop the residue naming a move that never happened#13783
os-project-manager merged 1 commit into
mainfrom
claude/issue-13448-hintcovers-basename-glob

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13448

hintCovers could not follow a final-segment basename glob, and the residue
printer then stated a specific wrong cause for the deadness it produced. Both
halves land here, in one change, as the triage ruling requires.

The defect, one line each

Half 1 — coverage.collapseHint collapses a glob by deleting it. That is a
sound reduction when nothing but glob characters follow (packages/** ->
packages, packages/client* -> packages/client). When a literal follows the
glob it is a splice: .changeset/*.md -> .changeset/.md, a path no tree can
hold. The hint matched nothing BY CONSTRUCTION while reading as an ordinary literal.
zeroSegmentForms' docblock recorded this exact species one branch over
(scripts/*.d.mts) and left it as "a different species"; it is the same defect,
splicing inside a segment rather than across a separator.

Half 2 — the false reason.deepestTrackedPrefix, unreachableClass and
unreachableReason all reasoned from collapseHintunconditionally, including
for hints hintCovers had already stopped judging that way. A pattern-judged hint
can never equal its own collapsed splice (.changeset is not .changeset/.md), so
deepest !== collapseHint(hint) was always true for the whole shape class, and
"the tree stops at .changeset; the layout moved under it" was the only
reachable sentence. The layout never moved. A reader acting on that string goes
hunting a directory rename that never happened.

The fix

scripts/pm/dispatch-gates.mjs — three new exported predicates, four call sites:

  • globCarriesLiteralSuffix(hint) — a * in the final segment with a literal
    behind it. What decides it is a literal behind the glob, never one in front:
    scripts/check-*.mjs yes, scripts/check-* no.
  • judgedAsPattern(hint) = globInNonFinalSegment(hint) || globCarriesLiteralSuffix(hint)
    — the two splice shapes asked as one question, because two call sites need the
    same answer and a second copy of it is the drift this file refuses everywhere else.
    hintCovers routes on it into the existingzeroSegmentForms/triggerCovers
    branch: not a second matching language, the one already there for this exact job.
  • comparedForm(hint) — the form hintCoversactually judged the hint by:
    collapseHint for a collapse-judged hint, the literal prefix for a
    pattern-judged one. deepestTrackedPrefix walks it, unreachableClass compares
    against it, and unreachableReason gets a branch that speaks in it.

The new reason is checkable by the reader, which is the bar a triage lead has to
clear:

BEFORE '.changeset/*.md' — the tree stops at .changeset; the layout moved under it
AFTER '.changeset/*.md' — the tree HAS .changeset; this hint is a GLOB PATTERN and
nothing under that root matches it — check with `git ls-files '.changeset/*.md'`

...and the exception stays exactly that narrow — a pattern whose own literal prefix
really is gone still reads as a move:

'packages/gone-away/*.ts' — a glob pattern whose literal prefix packages/gone-away is
gone; the tree stops at packages, so the layout moved under it

A2.1 — the changeset count, re-measured at my own ref

The number moves with every merged PR, which is exactly why it must not be quoted
from anyone:

refreading
the card as filed~400
triage, 10:28Z537
PM, 10:55Z546
my branch point c42bc8ee6546
this PR's head 4301f7846 + the fix548
git ls-files '.changeset/*.md' | wc -l # 548
git ls-tree -r --name-only HEAD | grep -c '^\.changeset/.*\.md$' # 548, same answer both readings

Four readings on four days over one directory is itself part of the cost: a silent
zero over a population that grows with every merge is the one you notice last.

A2.2 — per-shape census, before/after, rather than "widen until it looks glob-ish"

Every distinct hint in the fleet, classified by glob shape and measured against the
tracked corpus. 191 families x 749 distinct hints x 7588 tracked files at
4301f7846
, before = the unfixed tool run from an origin/main worktree over the
identical corpus:

hintsreach 0 files (before -> after)(hint, file) pairs (before -> after)shape
661208 -> 2087411 -> 7411literal, no glob
571 -> 111420 -> 11420final segment exactly ** (trailing)
173 -> 38497 -> 8497glob in a NON-FINAL segment (already pattern-judged)
132 -> 27947 -> 7947final segment exactly * (trailing)
11 -> 00 -> 548final-segment * with a literal SUFFIX — the defect
0final-segment partial glob TRAILING (packages/client*, the DECIDED trade)
0?, + or a character class, anywhere in the fleet

Exactly one hint in the whole fleet carries the missed shape.**/ prefixes,
mid-segment wildcards and whole-** segments were already routed through
triggerCovers and are untouched. There are zero hints carrying ?, + or a
character class, so those are deliberately not admitted: collapseHint never
deleted them, so they are an ordinary literal that fails to match — the missing-lead
direction this file errs in — not a mangle. Admitting them would be a fabricated-lead
widening bought for no live instance; the self-test reds on their arrival instead.

Blast radius, both directions:

(hint, file) pairs 35275 -> 35823 (+548, ZERO lost)
(gate, file) pairs 140716 -> 143456 (+2740, ZERO lost)
hints reaching 0 files 215 -> 214
distinct hints whose reach changes at all: 1
cards LOSING any family: 0

The ROOT_DIR_WATCH_HINTS idiom is bit-for-bit unchanged — packages/* 5631,
examples/* 243, skills/** 50, content/** 442, scripts/** 299, packages/**
5631, all identical before and after — and the DECIDED packages/client*
partial-segment trade is a TRAILING glob, so it is not this case and stays pinned in
both directions.

A2.3 — which cards gain which families: ZERO, and that is the finding

The card and the triage ruling both expected the matched column to move —
"Correcting hintCovers makes those families match more cards — a live change
to which gates a dispatch brief names, not a display change"
, and triage's
⇒ **这不是显示缺陷,是覆盖缺陷。** 今天派发简报**漏点**了本该跑的 changeset 门族.

Measured over every tracked file read as a one-file card surface: 0 cards gain a
family, 0 cards lose one.

The reason is only visible in the census: all five owners of .changeset/*.md also
declare the bare literal .changeset
, which hintCovers has always matched
against every file beneath it. A card touching a changeset was already being told all
five families — through the sibling literal, not through the glob.

familyalso declares .changesetindex of .changesetindex of .changeset/*.md
check:changeset-gate-self-testsyes01
scripts/check-adr-0087-registration.mjsyes48
scripts/check-changeset-no-major.mjsyes01
scripts/check-empty-changeset.mjsyes01
scripts/pm/release-rehearsal-clone.mjs --self-testyes21

One thing does move, and it is a printed key, not a verdict: 548 (family, card)
pairs re-attribute
from .changeset to .changeset/*.md, all of them in
release-rehearsal-clone.mjs --self-test, the one owner that spells the glob before
the bare literal. Same family, same gate source provenance, strictly more precise
key — the pattern that actually matches rather than the directory it sits in. Every
other owner keeps the exact key it printed. Disclosed rather than buried because
coveringKey's docblock prices re-attribution explicitly.

So the live cost of this defect was never an under-named brief. It was the false
reason — 5 residue notes asserting a directory rename -> 0 — plus a single point of
failure nobody could see: the moment any of those five gates spells its population as
the glob alone, its coverage vanishes silently. That is not hypothetical.
scripts/*.d.mts is the second, already-live instance (0 -> 4 tracked files), and it
was pinned in this file's own self-test as "still dead" until this change.

⚠️ This does not falsify the card's premise. The hint really did measure dead
against 548 tracked changesets, and the residue really did name a false cause; both
are repaired here. What the measurement falsifies is the blast-radius claim the p1
rationale rested on. Worth re-reading the priority against that, which is a call for
triage and not for me.

A2.4 — unreachableReason is a reason SET, and its reasons did not line up

Not a single hard-coded string: four branches (extensionless-module target / no
tracked first segment / tree-has-it-but-too-generic / layout moved). The mismatch is
one level up — hintCovers has two comparison modes and the reason set covered
one.
Every branch derived from collapseHint, so for the pattern-judged shapes the
renderer was reasoning about a string the comparison never looked at, and one branch
was therefore unreachable while another was unconditional.

That is why the repair is comparedForm and not a reworded branch: reword it and the
next hint of this shape mints the same falsehood. A2.4's own test — "a reader
following the stated reason now finds the real cause" — is pinned rather than
asserted: the new sentence hands the reader the exact git ls-files invocation that
reproduces the emptiness, and the self-test asserts the string contains it.

Tests

31 new self-test cases, in two blocks beside the ones they extend. The two pins that
previously asserted the defect are flipped rather than deleted, so the change of
mind is legible: 'the final-segment spelling of the same population is still dead'
becomes 'the collapse ... is still a splice' + '...so the hint is judged as a pattern instead' + '...and now reaches every one of the files it names'.

Read from the real corpus, not a fixture — a fixture cannot show that the tree
still holds the population the trap needs, and this one grows daily. Both directions
are pinned: the reach, the refusals (packages/client* sibling still refused, the
ROOT_DIR_WATCH_HINTS idiom untouched, ?/+/[…] still collapse-judged), and a
fleet-wide ratchet that reds if a ?, + or character-class hint ever arrives.

Verification

Exit codes captured before any pipe; each verdict quoted from the line the gate
itself printed. Run on a shared, contended box (a docs dev server, two sibling agents'
gate runs), so heavy runs went through scripts/pm/os-verify-lock.sh.

The family was re-derived after the final commit, from the tool's own answer and
not from a hand-written list:

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack # 14 commands

The whole union then ran at head d56daafea, with git status --short empty at
the start and HEAD unchanged at the end (both recorded in the run log), so the green
below is about the tree this PR actually lands.

checkexitverdict
check:pm-dispatch-gates (the edited script's own suite)0GREEN — ✓ dispatch-gates self-test: 1048 cases pass.
scripts/pm/bare-root-worklist.mjs --self-test (the only importer of hintCovers/collapseHint)0GREEN — OK self-test: 51 live row(s), 43 unreachable as spelled … every one of 9 distinct spelling(s) pinned LIVE, PRECISE and COMPLETE … in hintCovers' own terms
check:nul-bytes0GREEN — OK (scanned 7581 text file(s) … no raw ASCII control bytes)
check:watch-hint-literal0GREEN — 34 declaration(s) across 4 rostered name(s) … no unrostered spelling of the idiom in the tree
check:entry-guard0GREEN — 189 scripts/ file(s) — every entry guard goes through invoked-as.mjs
check:agent-test-spelling0GREEN
check:bash32-floor0GREEN — 153 cases pass + 24 tracked shell file(s)
check:cli-command-ids0GREEN — 39 cases pass + 304 command-id literal(s) across 109 file(s)
check:cross-package-test-inputs0GREEN — All 117 self-test cases passed. + 24 package(s) read outside themselves, all declared
check:parse-guard0GREEN
check:pnpm-filter-targets0GREEN — 54 + 40 assertions, 140/179 --filter occurrence(s) resolve
node scripts/check-ci-filter-parity.mjs0GREEN — all 129 declared cross-package glob(s) (92 unique) are covered
node scripts/check-cross-package-test-inputs.mjs0GREEN
node scripts/check-self-test-wired.mjs0GREEN — every one of the 154 script(s) CI runs that ship a --self-test has that self-test run by CI
node scripts/check-shard-attestation.mjs0GREEN — 2 aggregate gate(s) count 3 declared leg(s)
node scripts/check-test-completeness.mjs3NOT MEASURED — see below
pnpm lint (eslint . --no-inline-config, repo-wide)0GREEN
eslint --format json scripts/pm/dispatch-gates.mjs0GREEN — 1 file, errorCount: 0, warningCount: 0

check-test-completeness is NOT MEASURED, not red. Exit 3 is its documented
prerequisite branch, and the gate says so in its own words, naming this exact arrival:

check-test-completeness: PREREQUISITE NOT MET — this gate grades a saved `turbo run test` log, and no log was named.
⚠ Arrived here from the gate family `scripts/pm/dispatch-gates.mjs` derives?
That list names this script with NO argument, which is this branch. There is no
local log to hand it, so the local reading for this gate is NOT MEASURED.
⛔ It is not a red, and there is nothing here to fix.

CI passes it the teed log, so CI's reading is a real one and this PR does not change it.

Repo-wide pnpm lint was run in full rather than narrowed, so no narrowing
argument is owed. For completeness, the invariance that would have backed one is real
and stated by the config itself (eslint.config.mjs lines 327-328: "never enables
type-aware linting (no parserOptions.project, no typed @typescript-eslint rules)
for ANY file"), so a one-file .mjs edit cannot move an untouched file's verdict.

Heavy runs went through scripts/pm/os-verify-lock.sh on a shared, contended box (a
docs dev server plus two sibling agents' gate runs). Wall-clock figures are therefore
shared-box seconds, not idle-box ones: check:pm-dispatch-gates held the lock 5m43s
after waiting 2m06s.

Reverse verification

Both halves withheld in one leg, from the committed state, so the restore has a
real reference point. The two pin sets are disjoint by name, so attribution is
unambiguous without a second leg.

The mutation is proved on disk before anything is measured — exact-string
replacement, each of the four anchors required to hit exactly once, then counted
again after. A zero-hit edit exits 0 and leaves an unmutated file producing healthy
output, which reads exactly like a successful ablation:

HEAD d56daafea
HEAD blob for the target 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
pre-mutation worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
=== MUTATION (exact-string, each anchor must hit exactly once) ===
hits=1 ' if (judgedAsPattern(hint))\n return zeroSegmentForms' -> globInNonFinalSegment
hits=1 " const segments = comparedForm(hint).split('/');" -> collapseHint
hits=1 'deepest !== comparedForm(hint)' -> collapseHint
hits=1 ' const form = comparedForm(hint);\n if (judgedAsPattern(hint)) {'
-> collapseHint / if (false)
=== MUTATION OBSERVED ON DISK (counts, not an exit code) ===
1 INJECTED if (globInNonFinalSegment(hint)) 0 REMOVED if (judgedAsPattern(hint))
1 INJECTED deepest !== collapseHint(hint) 0 REMOVED deepest !== comparedForm(hint)
1 INJECTED const form = collapseHint(hint); 0 REMOVED const form = comparedForm(hint);
1 INJECTED const segments = collapseHint(hint) 0 REMOVED const segments = comparedForm(hint)
1 INJECTED if (false) {
mutated worktree blob 7773d8e28bb6f7a8203fb8f42d1bccac27e49636 (differs from HEAD's)
scripts/pm/dispatch-gates.mjs | 10 +++++-----

RED, in the expected direction:

GATE_EXIT_WITH_FIX_WITHHELD=1
✗ dispatch-gates self-test: 9 of 1048 case(s) failed.
half 1 — coverage
✗ ...and now reaches every one of the files it names (scripts/*.d.mts)
✗ ...and claims nothing else in the whole tree (scripts/*.d.mts)
✗ the live specimen reaches every changeset it names (.changeset/*.md)
✗ and claims nothing else in the whole tree (.changeset/*.md)
✗ so it is nobody's dead literal any more
✗ the live specimen is not a dead literal on this tree at all
half 2 — the reason (hand-built `dead` rows, so independent of half 1)
✗ a dead glob pattern whose root is right there is NOT a layout move
✗ ...and never asserts a directory rename that never happened
✗ ...while the reason it does give is one the reader can check for themselves

Restore proved by OBSERVED STATE, not by an exit code:

post-restore worktree blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
HEAD blob 316ce5a99bc57f8191fec67c7a5a5fe602dbf51d
blob equality: MATCH
git diff HEAD (printed nothing)
git status --short (printed nothing)

The restore leg is git checkout HEAD -- ABSOLUTE_PATH, never a bare
git checkout -- PATH (that reads from the index, which the mutation may have
touched) and never a relative path in the trap (a cwd that is no longer the repo root
silently leaves the tree mutated). An empty hash is treated as FAILURE, not as
"nothing to compare".

Does the fix change its own derived family? No.

The tool being fixed is the tool that derives this PR's own gate list, so it was run
from both sides on the same input path — an origin/main worktree (unfixed) and
this branch (fixed):

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands scripts/pm/dispatch-gates.mjs

14 commands from each, diff empty. Expected, and worth stating rather than assuming:
the only coverage that moves is .changeset/*.md, and this PR's diff contains no
.changeset/ path.

Scope

One file: scripts/pm/dispatch-gates.mjs. ⛔ scripts/check-doc-anchors.mjs is
untouched — that is #13449's landing site, dispatched in the same wave to a different
dev. No governed surface, no packages/spec/src/**, no content/docs/releases/.

scripts/ publishes nothing, so there is no changeset. Check Changeset in
pr-automation.yml carries no path filter and no docs-only exemption — it enforces on
every PR and exempts only on the skip-changeset label — so the label is applied here
and read back rather than waited for.


Generated by Claude Code

…aming a move that never happened
`collapseHint` collapses a glob by deleting it. That is a sound reduction when
nothing but glob characters follow (`packages/**` -> `packages`), and a SPLICE
when a literal does: `.changeset/*.md` -> `.changeset/.md`, a path no tree can
hold. So the hint matched nothing BY CONSTRUCTION while reading as an ordinary
literal, against 548 tracked changesets. `zeroSegmentForms` recorded this exact
species one branch over and left it; it is the same defect, splicing inside a
segment rather than across a separator.
The second half is why it was worth a card. `deepestTrackedPrefix`,
`unreachableClass` and `unreachableReason` all reasoned from `collapseHint`
UNCONDITIONALLY, including for the hints `hintCovers` had already stopped
judging that way. A pattern-judged hint can never equal its own collapsed splice
(`.changeset` is not `.changeset/.md`), so "the tree stops at X; the layout
moved under it" was the only reachable sentence for the whole shape class -- a
specific wrong cause, printed under the heading that tells a reader to go chase
it. Repairing the matcher alone would have retired today's five instances and
left the derivation that mints them intact.
- `globCarriesLiteralSuffix` -- a `*` in the final segment with a literal behind
it. A literal in FRONT of the glob is not this case.
- `judgedAsPattern` -- the two splice shapes as one question, because two call
sites need the same answer and a second copy of it is the drift this file
refuses everywhere else.
- `comparedForm` -- the form `hintCovers` ACTUALLY judged the hint by. The three
reason branches now mean what they say for both comparison modes.
Measured at 4301f78 over 191 families x 749 distinct hints x 7588 tracked
files: one hint in the whole fleet carries the shape; (hint, file) pairs
35275 -> 35823 and (gate, file) pairs 140716 -> 143456, ZERO lost; residue notes
asserting a layout move for it 5 -> 0. ZERO cards gain a family and zero lose
one -- all five owners also declare the bare `.changeset`, so the brief was
already naming them through the sibling literal. 548 (family, card) pairs
re-attribute to a more precise key in the one owner that spells the glob first.
`?`, `+` and character classes are deliberately NOT admitted (zero live
instances) and the self-test reds on their arrival.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review August 31, 2026 12:16
@os-project-manager
os-project-manager added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit eb717a1Aug 31, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13448-hintcovers-basename-glob branch August 31, 2026 12:37
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-project-manager@claude