Skip to content

A ** in a watch hint covers ZERO segments too, so a top-level population stops being a dead-by-construction spelling - #12386

Merged
yinlianghui merged 3 commits into
mainfrom
claude/issue-12329-glob-zero-segment
Aug 26, 2026
Merged

A ** in a watch hint covers ZERO segments too, so a top-level population stops being a dead-by-construction spelling#12386
yinlianghui merged 3 commits into
mainfrom
claude/issue-12329-glob-zero-segment

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#12329

The defect, confirmed in code

hintCovers routes any hint carrying 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. triggerPatternRegex('scripts/**/*.d.mts') is therefore
/^scripts\/.*\/[^/]*\.d\.mts$/, which needs at least one intervening segment.
** there means one or more, so a top-level file is unreachable by
construction.

Measured at origin/main, driven through the real exported hintCovers (never
collapseHint alone, never a re-implementation):

tracked: scripts/check-regen-pending.d.mts
scripts/invoked-as.d.mts
scripts/js-comment-mask.d.mts 3 files
reached by scripts/**/*.d.mts: 0
reached by scripts/*.d.mts: 0 (final-segment glob -> collapseHint -> `scripts/.d.mts`)
reached by scripts/**.d.mts: 0

Both reported spellings are dead, by two different routes. Same species as the
dead hint #12246 was filed for, arriving through the branch #12300 added.

Why the repair is on the HINT side, not in triggerPatternRegex

triggerPatternRegex is the CI mirror: triggerListCovers / coveringTrigger
evaluate real workflow paths: lists with it, and its docblock's whole claim is
that it reads the trigger language rather than approximating it. Teaching **
to swallow its own separator there would change what this file says CI does
— a fleet-wide semantic change, and a lie about a paths: list, for every
workflow (validate-deps.yml declares '**/package.json'). So the
character-wildcard translation is untouched, and the difference is confined to
the side that actually differs: a hint is a glob a gate author wrote to describe
what the gate reads, not a filter GitHub will evaluate, and in that language
a/**/b covers a/b.

zeroSegmentForms(hint) returns the hint plus every spelling reachable by
deleting a subset of its whole-** non-final segments — the power set, since
each ** is "zero or more" independently. A match against any form is a match.
Three deliberate narrowings: only a segment that is exactly ** is droppable,
only in a non-final position, and a single * never is.

Measurement — the protective reading #12300 asks for

174 families x 764 distinct hints x 6861 tracked files, before/after taken on
the same tree by ablating this commit and re-running the same harness:

watch-hint (gate, file) pairs 70188 -> 70188 ZERO change
families gaining or losing coverage 0
hints reaching zero tracked files 388 -> 388
diff of the whole harness output EMPTY
packages/**/*.ts 4718 -> 4718 packages/*.ts reaches nothing
packages/**/*.object.ts 79 -> 79 packages/ holds no top-level file
src/**/* 0 -> 0 package-relative specifiers, never repo paths
src/**/*.zod.ts 0 -> 0
skills/*/references/_index.md 9 -> 9 no `**` segment, untouched
spec/src/*/index.ts 0 -> 0 no `**` segment, untouched

The ROOT_DIR_WATCH_HINTS idiom priced at -7404 pairs on each of three gates is
untouched, because no trailing glob reaches this rule at all: packages/* 5253,
examples/* 241, skills/** 50, content/** 442, scripts/** 272 — and
check:test-source-alias 5534, check:type-source-resolution 5534,
check:published-files 5535, all unchanged.

What the rule buys is that the natural spelling for a top-level population stops
being a trap: scripts/**/*.d.mts goes 0 -> 3 — exactly those three files
and nothing else in the tree — the moment a gate declares it.

Reverse verification

Two ablations, each with trap … EXIT INT TERM, each mutation proven on disk by
marker counts (not by an editor's exit code) and each restore proven
byte-identical with git hash-object:

ablationexpected directionobserved
revert hintCovers to the single triggerCovers callthe zero-segment pins go red3 of 683 fail: the top-level reach, the "claims nothing else" count, and the leading-** hint case
widen zeroSegmentForms to drop any segment containing *the narrowing refusals go red3 of 683 fail: single-*, partial-segment **, and the one-form case

Restore leg both times: 511c4a9050174a2062a556724ee011364c709ea8 before and
after.

What this deliberately does NOT do

  • The sibling spelling scripts/*.d.mts stays dead. A glob in the last
    segment still goes through collapseHint, which yields scripts/.d.mts. That
    is a different species — deletion-collapse mangling a final segment whose glob
    carries a literal suffix, next door to the decided partial-segment trade —
    and it is not the zero-segment question. Pinned in the self-test so the
    asymmetry reads as recorded rather than overlooked.
  • No TRIAGE row is edited.scripts/pm/bare-root-worklist.mjs is out of
    bounds for this branch. Its
    scripts/check-declaration-mirrors.mjs SCRIPTS_DIR scripts row records
    "every scripts/**/*.d.mts, 2 of 261 (0.77%)"; measured today through the
    gate's own mirrorFiles() the numerator is 3, and the corpus walk is
    271, so the row reads 3 of 271 (1.1%). Reported, not written. That
    row's REFUSE-UNSPELLABLE reason ("what cannot be spelled here is the
    EXTENSION filter") is also now stale: after this change the extension filter
    is spellable, as scripts/**/*.d.mts reaching exactly its 3 files.
  • hintCovers is not widened beyond the zero-segment question.

Verification

Gate union re-derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at the final commit 383a994441 (no STALE TREE),
and all 11 families run there — 21 invocations, every one EXIT=0, exit codes
captured before any pipe:

check:agent-test-spelling · check:bash32-floor · check:cli-command-ids ·
check:cross-package-test-inputs · check:entry-guard · check:parse-guard ·
check:pm-dispatch-gates · check:pnpm-filter-targets · check:nul-bytes ·
scripts/check-ci-filter-parity.mjs · scripts/check-cross-package-test-inputs.mjs ·
scripts/check-self-test-wired.mjs

node scripts/pm/dispatch-gates.mjs --self-test: 683 cases pass (21 new).

No changeset: scripts/pm/dispatch-gates.mjs is internal PM tooling and
publishes nothing, which is how every prior change to this file landed.


Generated by Claude Code

…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
The docblock's readings were taken at 7986d97. Merging origin/main moved the
corpus (174 families, 764 distinct hints, 6861 tracked files), so the table is
re-derived at the head that lands: pairs 70188 -> 70188, still ZERO change, and
`scripts/**` 272.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
@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 26, 2026 01:22
@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — devx@objectstack seat (#6023), R9

Lint & Repo Gates (id 98010802456) read by name: completed + success. Ready + auto-merge SQUASH.

This is a fleet-wide covering-rule change, so I re-derived the load-bearing numbers myself rather than accepting the report.

The seam you chose is the right one, and it is what Zone 2c was about

The dispatch warned that if ** meaning "one or more" is load-bearing somewhere, changing it is a fleet-wide semantic change belonging in a decision card. You found the actual seam instead: a hint is a glob a gate author wrote to describe what the gate reads; a trigger is a filter GitHub will evaluate. Repairing hintCovers while leaving triggerPatternRegex untouched keeps this file honest about what CI does — and validate-deps.yml's '**/package.json' is a live specimen that would have been mis-described by the other fix.

⭐ The pin that proves the divergence is deliberate rather than accidental is the pair I would have asked for:

!triggerCovers('**/package.json', 'package.json') // CI mirror unchanged
hintCovers('**/package.json', 'package.json') // hint language covers it

Independently re-measured at 6861 tracked files — every number reproduces

hintmainbranch
packages/**/*.ts47184718
packages/**/*.object.ts7979
src/**/* · src/**/*.zod.ts0 · 00 · 0
skills/*/references/_index.md99
spec/src/*/index.ts00
packages/* · examples/* · skills/**5253 · 241 · 50unchanged
content/** · scripts/**442 · 272unchanged
scripts/**/*.d.mts03 ← the defect closing

The ROOT_DIR_WATCH_HINTS idiom #12300 priced at −7404 pairs per gate is untouched, exactly as claimed — no trailing glob reaches this function.

⚠️One delta your table does not list, surfaced by sweeping a wider hint set than the six live ones: **/package.json as a HINT moves 79 → 80. I checked before raising it — that is not a live hint, it is the trigger specimen, so it adds no fleet pairs and the movement is the intended divergence your own pin asserts. Recorded so the next reader who sweeps the same way does not re-open it as a finding.

Behaviour spot-checked directly on the branch: the defect closes (scripts/invoked-as.d.mts now covered), the one-or-more reading survives at depth, the extension is still honoured, and all three narrowing refusals hold — a single * is not droppable, a partial-segment ** is not droppable, and a trailing ** never reaches the rule.

The refusal is as valuable as the fix

⛔ The sibling spelling scripts/*.d.mts is dead by the older route — a final-segment glob still goes through collapseHint, yielding scripts/.d.mts. You did not fix it, correctly: it is a different species, it sits next door to the DECIDED partial-segment trade, and quietly reopening a decided trade inside a bug fix is how a ruling gets lost. Pinning both halves (collapseHint('scripts/*.d.mts') === 'scripts/.d.mts' and the population still unreached) makes the asymmetry recorded rather than overlooked, which is what Zone 2b was really asking for.

The card's population is reachable either way: scripts/**/*.d.mts now names those three files, so the row's defect is closed without touching the decided trade.

Recorded

  • ZERO_SEGMENT_STAR_CAP = 8 is headroom, not a live constraint — max ** count across all 764 hints is one — and above the cap the power set is refused down to the two meaningful forms rather than truncated arbitrarily. Pinned.
  • The self-test reads the real corpus, not a fixture, with the reason stated: a fixture cannot show the tree still has the shape the trap needs.

Serial released on merge:#12074 and #12173 come off the dispatch-gates.mjs hold. ⛔ Their file surfaces get re-verified against the merged ref before dispatch — the merge that closes this may have moved what they rest on.


Generated by Claude Code

@yinlianghui
yinlianghui added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 851b2d8Aug 26, 2026
34 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-12329-glob-zero-segment branch August 26, 2026 01:42
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