Skip to content

fix(pm): match a hint whose glob sits in a non-final segment - #12300

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-12246-hint-covers-mid-segment-glob
Aug 25, 2026
Merged

fix(pm): match a hint whose glob sits in a non-final segment#12300
yinlianghui merged 1 commit into
mainfrom
claude/issue-12246-hint-covers-mid-segment-glob

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#12246

collapseHint collapses globs by deletion. That is sound for a glob in the last
segment — packages/** reduces to packages, which is the reduction the
ROOT_DIR_WATCH_HINTS idiom is built on and which scripts/check-published-files.mjs:215-248
justifies at 91.3%. It mangles a glob in a middle segment:

collapseHint('skills/*/references/_index.md')// -> 'skills//references/_index.md'

A double separator is not a path any tree can hold, so the hint matched nothing by
construction
. git ls-files 'skills/*/references/_index.md' returns 9 tracked files that
packages/spec/scripts/build-skill-references.ts really generates, and the derivation reached
zero of them.

It also produced the worst row this output can print. unreachableClass calls a family
"THE LAYOUT MOVED ... a real miss, worth triaging" when a dead hint's deepest differs from
its collapsed form, and the mangled form made that true for a gate whose layout did not move —
with the three "reasons" beside it drawn from other inert hints, TypeScript paths-mapped
module specifiers that were never repo paths. Classification and evidence, both wrong, in the
same row.

Why the fix is in hintCovers and not in collapseHint

A previous round on this card was dispatched with collapseHint alone as its surface and
showed, as a check rather than as prose, that no spelling of collapseHint can fix either
half. The impossibility is structural and it re-derives on this base: unreachableClass says
"by construction" only when deepest(P) === P, i.e. P must be a tracked prefix — but any
tracked prefix P makes hintCovers reach every file beneath it, so the hint goes live and
never enters dead to be classified at all. The two requirements are mutually exclusive, so
no string P exists. Re-checked here against every candidate P the specimen admits:

Ptracked prefixreachoutcome
skillsyes50live, never classified
skills/*no0dead, still "layout moved"
skills/*/referencesno0dead, still "layout moved"
skills/*/references/_index.mdno0dead, still "layout moved"
skills//references/_index.mdno0dead, still "layout moved"

The change

When — and only when — a glob sits in a non-final segment, hintCovers matches the hint
as the filter pattern it visibly is, through triggerCovers. That is not a second matching
language: triggerCovers is triggerPatternRegex plus the literal-prefix directory reach,
both of which already live in this file for this exact job, and hintCovers' docblock refuses
a second one on principle. It also lines the two directions up with the ones the collapse
gives — the regex answers "does this pattern match the file", the literal prefix answers "is
the input a directory the pattern reaches into".

Everything else keeps the collapse, untouched.

Also deletes collapseHint's second trailing strip. .replace(/\/+$/, '').replace(/\/$/, '')
— the second call is unreachable, because /\/+$/ is greedy and anchored, so nothing survives
for it to find. Measured at 0 of 754 hints and on every probe the shape admits
(a///, a/, a//, a/**/, **/, /). Deleted rather than left as decoration: a
redundant strip reads as defence against a case the first one misses, and there is no such
case — what the anchored strips genuinely cannot touch is a separator left in the middle.

Measured both directions, on 170 families x 754 distinct hints x 6816 tracked files

Read from the two real implementations — the tool at origin/main in a comparison worktree and
the committed tool here — never from a re-implementation of either.

BASEafter
watch-hint (gate, file) pairs7248286807
(check, hint) live / inert735 / 640742 / 633
unreachable families1211
families classed "layout moved"1 (check:skill-refs)0

Families gaining coverage: 4 rows / 3 gates. Families losing: 0.
(check, hint) newly live: 7. Newly inert: 0.

Exactly six distinct hints in the whole fleet carry a glob in a non-final segment, so the
blast radius is enumerable rather than estimated:

hintcollapses toreaches, BASEreaches, afterdeclared by
packages/**/*.tspackages//.ts04693check:cross-package-test-inputs (+2 rows)
packages/**/*.object.tspackages//.object.ts079the same three rows
skills/*/references/_index.mdskills//references/_index.md09check:skill-refs
src/**/*src00check:type-source-resolution
spec/src/*/index.tsspec/src//index.ts00check:type-source-resolution
src/**/*.zod.tssrc//.zod.ts00check:published-files

skills/*/references/_index.md claims the 9 real files and 0 others under skills/.

The narrower rule is deliberate

Matching all whole-segment globs this way was measured and refused: it stops collapsing
the trailing ** the ROOT_DIR_WATCH_HINTS idiom is built on, at −7404 pairs each on
check:test-source-alias (7511 → 107), check:type-source-resolution (8682 → 1278) and
check:published-files (7407 → 3). That is a regression, not a correction.

This branch keeps the idiom bit-for-bit — packages/* 5228, examples/* 241, skills/** 50,
packages/** 5228, content/** 442, all unchanged — and so is the DECIDED partial-segment
trade: packages/client* still refuses packages/client-react/src/index.ts and still covers
packages/client, because a partial-segment glob in the last segment is not this case. Both
directions are pinned.

The +14325 pairs are a declaration finally meaning what it says, not a widening

All of the residual cost is two hints, packages/**/*.ts and packages/**/*.object.ts,
declared in scripts/cross-package-test-inputs.mjs and inherited by three family rows. They
reach 0 today only because of this defect, so the question was not whether to widen a gate
but whether the declaration is honest — checked at the declaration site rather than assumed:

  • packages/**/*.ts is held by packages/core/src/security/operation-private-keys.pin.test.ts,
    whose scan surface is spelled git ls-files over "every .ts/.tsx file under packages/";
  • packages/**/*.object.ts is held by the two repo-wide *.object.ts walkers in
    packages/spec.

Those tests really do read every matching file, so every recovered pair is a true lead, not
a fabricated one — which is the provenance criterion hintCovers' docblock prices, never
volume.

The derivation cost was measured rather than feared, because "22 leads is the same as none" is
the failure this file exists to avoid. Reading each tracked file as a one-file card surface,
check:cross-package-test-inputs is named by 3323 cards today and 5561 after — 2238
additional cards, 32.8% of the tree. It is not "nearly every card touching packages/**":
of the 4693 packages/**/*.ts files, 2455 already name the family through some other hint
and 2238 do not. Mean matched families per card goes 12.05 → 13.51 for a packages/**/*.ts
card and 11.99 → 12.89 fleet-wide — about one extra lead, against the +139084-pair explosion
that took one card from 7 families to 34 and is the number this file calls unaffordable.

So the two hints stay as declared. Narrowing them at their declaration site was considered and
is refused, because the narrowing would be the false statement.

Does this change the union it is judged by?

No — and this is checked rather than assumed, following the precedent PR #12247 set on this
same file. Deriving the gate list for scripts/pm/dispatch-gates.mjs with the BASE tool and
with the committed tool, against trees whose git ls-files are byte-identical (md5 match),
gives the same 10 families with the same provenance labels. The entire observable diff
between the two runs is the defect leaving:

- Unreachable — the 12 famil(ies) …
- 1 where THE LAYOUT MOVED under a gate that still spells the old path — a real miss, worth triaging:
- - pnpm --filter @objectstack/spec run check:skill-refs [lint.yml] dead: 'lib/file-description' — …
+ Unreachable — the 11 famil(ies) …

Gates, at the final commit d66f710ce8

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (exit 0
captured before any pipe; no STALE TREE warning), then run through
scripts/pm/os-verify-lock.shVERDICT command-exit 0 · held the lock 66s · waited 377s.
Each exit code was captured before any pipe; each line below is the gate's own verdict.

  • pnpm check:pm-dispatch-gates✓ dispatch-gates self-test: 633 cases pass. (614 on an
    unchanged tree, +19 new pins)
  • node scripts/pm/bare-root-worklist.mjs --self-testOK self-test: 46 live row(s), 39 unreachable as spelled, 39 recorded verdict(s) — none stale, none missing.Identical to the
    control reading on the unchanged tree.
    No TRIAGE row was touched.
  • pnpm check:nul-bytes✓ check-nul-bytes --self-test: 75 assertions over a temp git repo
  • pnpm check:cross-package-test-inputsOK: 16 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
  • node scripts/check-ci-filter-parity.mjsOK: all 96 declared cross-package glob(s) (81 unique) are covered by 'core' or 'crosspkg' …
  • pnpm check:entry-guard✓ check:entry-guard: 165 scripts/ file(s) — every entry guard goes through invoked-as.mjs
  • pnpm check:parse-guard✓ check:parse-guard: 164 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
  • pnpm check:agent-test-spelling✓ check-agent-test-spelling: 0 violations
  • pnpm check:cli-command-ids✓ check-cli-command-ids: 276 command-id literal(s) across 99 file(s) … all resolve
  • pnpm check:pnpm-filter-targets✓ check-pnpm-filter-targets --self-test: 40 assertions
  • node scripts/check-self-test-wired.mjs✓ check-self-test-wired: every one of the 135 script(s) CI runs that ship a '--self-test' has that self-test run by CI.

No narrowing was taken: the derived union was run in full.

Deliberately not done

Changeset

None — root scripts/ only, nothing published. Carries the skip-changeset label.


Generated by Claude Code

`collapseHint` collapses globs by deletion, which is sound for a trailing glob
(`packages/**` -> `packages`, the reduction `ROOT_DIR_WATCH_HINTS` is built on)
but MANGLES a glob in a middle segment: `skills/*/references/_index.md` became
`skills//references/_index.md`, a double separator no tree can hold. The hint
named nine tracked files that `packages/spec/scripts/build-skill-references.ts`
really generates and reached none of them, and `unreachableClass` then filed the
family under "THE LAYOUT MOVED ... a real miss, worth triaging" with three
TypeScript module specifiers as its evidence -- classification and evidence both
wrong in the same row.
No spelling of `collapseHint` can fix it, and the impossibility is structural:
"by construction" needs `deepest(P) === P`, i.e. a tracked prefix, but any
tracked prefix makes the hint live so it never enters `dead` to be classified.
So `hintCovers` now matches that one shape through `triggerCovers` -- the file's
own filter-pattern language, not a second one -- and everything else keeps the
collapse untouched.
Measured both ways over 170 families x 754 hints x 6816 files: pairs
72482 -> 86807, 4 family rows gaining and ZERO losing, 7 (check,hint) newly live
and 0 newly inert, unreachable families 12 -> 11 and "layout moved" 1 -> 0.
Matching ALL whole-segment globs was measured and refused (-7404 pairs each on
check:test-source-alias, check:type-source-resolution, check:published-files).
Also deletes `collapseHint`'s second trailing strip, which was unreachable:
`/\/+$/` is greedy and anchored, so it changed the answer for 0 of 754 hints.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
@yinlianghuiyinlianghui added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 25, 2026 — with Claude
@yinlianghui
yinlianghui marked this pull request as ready for review August 25, 2026 17:19
@yinlianghui
yinlianghui added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 783111dAug 25, 2026
34 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-12246-hint-covers-mid-segment-glob branch August 25, 2026 17:40
yinlianghui pushed a commit that referenced this pull request Aug 25, 2026
…nputs against its own deletion criterion
The convention-trigger entry this card asked for has been in the table since
#10542. What the re-measurement found instead is that the entry now READS
redundant against its own stated deletion criterion and is not: since PR #12300
taught hintCovers to read a glob in a non-final segment, the hint route names
the gate for 2758 of the 2771 tracked test files (99.5%, against 0-3.3% for its
five siblings in this kind) -- through a hint INHERITED from the declaration
table the gate imports, where it is one package's declared turbo inputs glob
rather than the gate's own population.
Records the measurement at the criterion it qualifies, and pins every
load-bearing half of it in --self-test: the residue class (no test file outside
the packages tree, no .tsx inside it, nothing under apps), the live specimens,
the inheritance provenance, and that the KIND still names the gate for all of
them. Both routes are kept -- two routes to one gate is redundancy, not a bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
yinlianghui pushed a commit that referenced this pull request Aug 25, 2026
…mechanism
The row refused `packages` on the grounds that `packages/**/src/**`, its true
population, "collapseHint reduces to `packages`" -- an over-claim the function
never performed. collapseHint leaves `packages//src`, and since #12300
hintCovers does not route this shape through the collapse at all: a glob in a
non-final segment is MATCHED. So the narrow spelling is a live hint reaching all
1812 population files, not a claim that degenerates to the bare root.
REFUSE-UNSPELLABLE survives, on the reason that was always doing the work: the
file-KIND filter. The live spelling covers 4290 tracked files to reach 1812, and
2465 of the 2478 it over-names are the test files the gate skips -- 42% true
where the bare root is 35%. The segment buys seven points, not precision, so
both spellings are false about the same non-test filter. The check:authz-resolver
comparison is kept and sharpened: there the bare root is TRUE and refused for
width; here it is FALSE, and so is every narrower spelling the idiom offers.
Every number in the row is re-derived from the gate's own collectFiles() walk in
one reading, so the row holds one tree -- never a denominator refreshed alone.
The self-test now pins the DIRECTION that reason depends on (the mid-segment
spelling is live, narrows, matches the segment, and still over-names), so a
future change of the #12300 shape lands as a failure beside the row it
invalidates instead of rotting the prose silently. Not a prose scanner: that
would need a parser over English and would check collapseHint, the wrong
function -- it stays green through exactly the change that falsifies these
reasons.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
yinlianghui pushed a commit that referenced this pull request Aug 25, 2026
…vel population stops being a trap
`hintCovers` judges any hint with a glob in a non-final segment through
`triggerCovers`, i.e. through GitHub's filter-pattern language, where `**` is a
CHARACTER wildcard and the `/` written after it is a literal that must still
appear. `scripts/**/*.d.mts` therefore compiles to
`^scripts/.*/[^/]*\.d\.mts$` and needs at least one intervening segment: the
three tracked `scripts/*.d.mts` mirrors are unreachable BY CONSTRUCTION by the
natural spelling for them — the dead-hint species #12246 was filed for,
arriving through the branch that fixed it.
Repaired on the HINT side only. `triggerPatternRegex` is the CI mirror and
`triggerListCovers`/`coveringTrigger` evaluate real workflow `paths:` lists
with it (`validate-deps.yml` declares `'**/package.json'`), so teaching `**` to
swallow its own separator there would make this file lie about CI. A hint is a
glob a gate author wrote to describe what the gate reads, and in that language
`a/**/b` covers `a/b`.
A hint's forms are itself plus every spelling reachable by deleting a subset of
its whole-`**` non-final segments — the power set, since each `**` is "zero or
more" independently. Only a segment that is exactly `**` is droppable, only in
a non-final position, and a single `*` never is.
Measured on 173 families x 763 distinct hints x 6859 tracked files, driven
through the real `hintCovers`:
watch-hint (gate, file) pairs 70172 -> 70172 (ZERO change)
families gaining or losing coverage 0
hints reaching zero tracked files 388 -> 388
packages/**/*.ts 4718, packages/**/*.object.ts 79, skills/*/references/
_index.md 9, src/**/* 0, src/**/*.zod.ts 0, spec/src/*/index.ts 0 — all held
Zero is the expected reading: `packages/` holds no file at its top level and
the two `src/**` hints are package-relative module specifiers. What the rule
buys is that `scripts/**/*.d.mts` goes 0 -> 3 the moment a gate declares it.
The ROOT_DIR_WATCH_HINTS idiom #12300 priced at -7404 pairs is untouched —
`packages/*` 5253, `examples/*` 241, `skills/**` 50, `content/**` 442,
`scripts/**` 271, and check:test-source-alias 5534, check:type-source-
resolution 5534, check:published-files 5535, all unchanged — because no
trailing glob reaches this rule at all.
21 self-test cases pin both directions, including the refusals that keep it
narrow and the trigger-side assertion that the CI mirror still says what CI
says.
Part of #12329
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
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

@yinlianghui@claude