Skip to content

fix(ci): read a cross-package declaration initialiser that spans lines - #11486

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-11093-multiline-resolve-roster
Aug 23, 2026
Merged

fix(ci): read a cross-package declaration initialiser that spans lines#11486
os-steve merged 1 commit into
mainfrom
claude/issue-11093-multiline-resolve-roster

Conversation

@claude

@claudeclaudeBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes#11093

What was actually wrong — the split is POSITION, not spelling

The card reports the asymmetry as "multi-line new URL() is recognised, multi-line
resolve() is not". Re-derived on origin/main at 7f30b6be with positive controls, that
framing is falsified: every recognised call was invisible when it spanned lines in
declaration position, and every one of them was already visible in read-argument
position.

spellingdeclaration position (before)read-argument position (before)
resolve() on one lineflagged, namedflagged, named
resolve() across linesnot seenflagged
join() across linesnot seenflagged
new URL() across linesnot seenflagged (the existing self-test case)

A read argument is read by balancedArgs(), which has always been line-agnostic. A
declaration initialiser was matched by a regex carrying a hard two-line window
([^;\n]+(?:\n\s*[^;\n]*)??), so a call a formatter broke over three or more lines matched
nothing at all: no binding, no depth, no name, and therefore no flag. Prettier breaks any
argument list past the print width, so this was the default spelling for a long relative
literal, not an exotic one.

The second half, which is the silent one

declarationInitialiser() alone would have restored the depth (and with it the escape flag)
and still left the name lost. splitTopLevel yields a formatter's trailing comma as an
EMPTY final argument, and an empty argument was read as "one I cannot fold", which nulls the
segments in pathExpression(). A read flagged as escaping whose path holds no glob is the
#10566 failure — reported against the glob, not against the scan. withoutTrailingComma()
closes that half; the two are ablated separately below.

Changes

  • scanBalanced() — the single quote-aware scanner. balancedArgs() becomes a thin wrapper
    over it (semantics unchanged: it still returns the argument text or null), and the new
    declaration reader shares it, per this file's own "one balancer" rule. [ and { are
    deliberately not counted; the docblock says why.
  • declarationInitialiser() — reads a const X = … initialiser to a ; or newline at paren
    depth 0. Because the head match now advances past the = rather than past the whole
    statement, a declaration nested inside another one's initialiser is reached as well.
  • withoutTrailingComma() + ,? in the three seed patterns — a formatter's trailing comma is
    punctuation, not an unreadable argument.
  • Failure text (Zone 1 ruling 3): the unheld-glob remedy is now three numbered dispositions
    with deleting last. "The read is REAL and this scan does not SEE it" leads. Rendered
    output is in the evidence section below.
  • RECOGNISED_PATH_SPELLINGS and the file header record the shape and the measurement.

Out of scope by Zone 1 ruling 4: no turbo.json inputs and no package globs are touched.
AGENTS.md's excerpt of the spellings list is left as it stands — it is an excerpt, and it is
not falsified by this change.

Evidence

All gate results below quote each gate's own printed verdict line; exit codes were
captured before any pipe. Local union re-run at the final commit 943ea6c6, clean tree.

Premise re-derivation on 7f30b6be, end to end, with a positive control. A probe test in
packages/spec reading two real files out of package, one spelling each, then the roster read
straight out of findEscapingPackages():

BEFORE CONTROL (single-line) scripts/invoked-as.mjs on roster: true
BEFORE SPANNED (multi-line) scripts/js-comment-mask.mjs on roster: false
AFTER CONTROL (single-line) scripts/invoked-as.mjs on roster: true
AFTER SPANNED (multi-line) scripts/js-comment-mask.mjs on roster: true

Zone 2 — does any existing glob's verdict move? No, measured rather than asserted. A full
snapshot of the gate's answer (every escaping package, its tests, every rostered path and
directory, and every globHolderVerdict() unheld/stray verdict) is byte-identical before
and after: diff -u produced 0 lines. The same snapshot pipeline moved by exactly one line
when the probe above was present, which is what makes the zero-diff a measurement rather than
a silent instrument.

Non-vacuity, two ablation legs. Each mutation was proven on disk by grepping the text it
injected and the text it removed — not by an editor's exit code — and each script carried a
trap … EXIT INT TERM restore; the tree was confirmed clean and marker-free after both.

  • Leg 1, restore the two-line window: 10/116 self-test case(s) failed — every new case that
    claims a line-spanning read is seen.
  • Leg 2, neuter withoutTrailingComma() only: 3/116 self-test case(s) failed, and they are
    exactly the NAME assertions. The flag assertions stay green, which is the point: the name
    half fails silently on its own.

The two remaining new cases are guards, not proofs, and pass in both directions by design: the
in-package negative, and "a declaration FOLLOWING a multi-line one is still read".

Rendered failure text (produced by injecting one unheld glob into @objectstack/core,
restored by trap):

 - @objectstack/core declares glob(s) nothing holds any more — no path its escaping tests
name lands inside them, and no `heldBy` witness reads outside the package
any more:
content/docs/zz-unheld-probe/**
Three dispositions, in the order to try them. Deleting is LAST because a
glob can read as unheld for two opposite reasons, and only one of them means
the declaration is wrong:
1. The read is REAL and this scan does not SEE it. …
2. The read is real and this scan cannot NAME it even though it sees it — …
3. The read is GONE. Delete the glob (and its turbo.json input).

Gates. Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(6 families) and treated as a floor. All green at 943ea6c6:

gateverdict line
check:cross-package-test-inputs (self-test)All 116 self-test cases passed.
check:cross-package-test-inputs (verify)OK: 14 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
check:entry-guard✓ check:entry-guard: 138 scripts/ file(s) — every entry guard goes through invoked-as.mjs
check:parse-guardexit 0
check:pnpm-filter-targets✓ check:pnpm-filter-targets: 120/148 --filter occurrence(s) … resolve
check-ci-filter-parity.mjsOK: all 89 declared cross-package glob(s) (77 unique) are covered by core or crosspkg …
check:nul-bytescheck-nul-bytes: OK (scanned 6435 text file(s) … no raw ASCII control bytes)

Added beyond the derivation, because this diff rewrites remedy text inside a gate script and
edits a published declaration list — neither is reachable from the path derivation:

gateverdict line
check:ratchet-remedy-authorityOK check-ratchet-remedy-authority: 123 scripts swept … Control corpus: 21 hand-classified scripts, set-equality audited both ways. (its control expects this file excluded; still excluded)
check:declaration-mirrorsexit 0
check:console-injectionexit 0

Declared narrowing — ESLint. The repo-wide pnpm lint was not run; it is CI-owned.
The narrowing is measured, not asserted, on three counts: (1) the population comes from
ESLint's own config resolution, not from a guess about which files count — the changed path was
handed to ESLint and came back as a lint result rather than as an ignore warning; (2) the file
count is read from --format json: 1 file, 0 errors, 0 warnings; (3) invariance for
untouched files — this repo runs one eslint.config.mjs which never enables type-aware
linting
(no parserOptions.project, no typed @typescript-eslint rules) for any file, a
property eslint.config.mjs itself records as measured with a positive control. With no
cross-file program, a diff confined to one file cannot move any untouched file's verdict.

No changeset: this edits a CI-internal script and releases nothing — the skip-changeset case
lint.yml names in its own words. The label is applied on this PR.


Generated by Claude Code

…-package scan
`scanPathExpressions` matched a `const X = …` initialiser with a regex carrying
a hard two-line window, so a call a formatter broke past the print width matched
nothing at all -- no binding, no depth, no name, no flag. A read ARGUMENT never
carried that window, which is why the asymmetry read as `new URL()` seen /
`resolve()` unseen; measured, the split is POSITION, not spelling.
`declarationInitialiser()` reads the statement to a depth-0 terminator, sharing
the one quote-aware scanner with `balancedArgs`. `withoutTrailingComma()`
restores the NAME half, which a formatter's trailing comma cost on its own by
arriving as an empty final argument.
The unheld-glob failure text now orders its remedies so deleting a correct
declaration is last, not first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 23, 2026
@os-steve
os-steve marked this pull request as ready for review August 23, 2026 23:18
@os-steve
os-steve added this pull request to the merge queueAug 23, 2026
Merged via the queue into main with commit 81462d0Aug 23, 2026
32 checks passed
@os-steve
os-steve deleted the claude/issue-11093-multiline-resolve-roster branch August 23, 2026 23:36
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

1 participant

@os-steve