Skip to content

test(cli): the serve host-resolution sweep reports what it cannot resolve - #12535

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-12162-host-resolution-sweep-loud
Aug 26, 2026
Merged

test(cli): the serve host-resolution sweep reports what it cannot resolve#12535
os-litant merged 2 commits into
mainfrom
claude/issue-12162-host-resolution-sweep-loud

Conversation

@os-litant

@os-litantos-litant commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes#12162

The sweep in packages/cli/src/commands/serve-cluster-host-resolution.test.ts asks whether every app-declarable optional load in serve.ts is host-anchored. A specifier it could not resolve carried no package name, so it fell out of the judged population rather than into it: the sweep did not report the load as unknowable, it reported nothing at all and went on passing over the remainder. Silence was the guard's own failure mode.

Per the dispatch ruling the answer is fail loudly, not one more spelling. An unresolved specifier is now a failure naming the file, the line, the callee and the specifier text, for every callee.

The finding that changes the card

The card credits the hand-maintained four-package list with catching the @objectstack/organizations loss during #11614. Measured on current main, that list would not catch it again — and was already green for the wrong reason.

resolveIdentifier searched the whole file for the first const binding of the identifier. The load at serve.ts:3217 is importFromHost(pkg) inside loadOptionalServicePlugin, where pkg is a function parameter (declared at :3212). The resolver bound it to a const pkg = Serve.ORGANIZATIONS_RUNTIME_PKG at :4377 — 1160 lines below the call, in an unrelated string helper. So the sweep reported an organizations load that does not exist at that line.

Replaying the #11614 shape on today's tree (const organizationsPkg written as let organizationsPkg, the first spelling the card lists as vanishing) with the old resolver:

APP_DECLARABLE_LOADS.length = 4 floor >= 4 green
named present ... @objectstack/organizations:true named list GREEN

The phantom at :3217 stood in for the real load at :2841. Every guard stayed green. ⇒ A whole-file first-match resolver can bind a name to a declaration that is not in scope at all, and the backstop the card credits was already defeated. That is more general than the gap this card was filed for.

The search is now confined to the source above the call and takes the nearest preceding binding — a const only exists below itself, the same temporal-dead-zone fact #10769 pinned for importFromHost. Two app-declarable loads that the phantom was hiding (@objectstack/service-ai, @objectstack/service-ai-studio, passed as literals one frame above :3217) are now declared out of the sweep explicitly instead of accidentally.

The premise was measured, and it is FALSE

Ruling: the swept region contains no dynamic load whose specifier is legitimately unresolvable. Measured against origin/main3dafd8c9c: 8 of 43 load sites are unresolvable, and every one legitimately so — a served-artifact path, a loop over two CLI-declared packages, two CAPABILITY_PROVIDERS members, the host importer's own fallback base, the app's config-plugin specifier, and a generic helper's parameter. A bare hard failure would be 8 false positives.

So "fail loudly" is expressed as a declared boundary rather than an absent one, and the full census is written into the file (line, callee, argument, reason) so the next reader inherits it rather than the number 8. Two properties are what separate this from an allowlist, and both are enforced:

  • Per callee. A bare import() can never inherit a host-anchored load's excuse. Rewriting importFromHost(pkg) to import(pkg) finds no entry and goes red.
  • Staleness fails. A declaration matching no live site is itself a failure. An allowlist's defect is that it goes stale silently over a shrinking population; this one reds when it stops matching.

Three scanner repairs the loud path needed in order to be true, each removing a false positive rather than adding an excuse:

repaireffect
function importFromHost( was read as a load siteremoved a phantom, permanently unresolvable member (44 → 43 sites)
the whole argument list was read as the specifierimportFromHost(pluginSpecifier, root) was unresolvable for a reason unrelated to its specifier
resolveIdentifier searched the whole filethe :3217 mis-resolution above

A blank specifier (a template whose prefix is empty yields the empty string, which is falsy everywhere downstream) now counts as no specifier, so it is reported rather than dropped.

Proof the loud failure fires

Planted an unknown spelling in serve.ts, ran the sweep, restored, and proved the restore by blob hash rather than exit code:

MUTATION-ON-DISK: injected=1 (want 1) removed-text-remaining=0 (want 0)
× FAILS on any load whose specifier it cannot resolve — it never drops one
+ "packages/cli/src/commands/serve.ts:2841 importFromHost(organizationsPkg)",
Tests 3 failed | 19 passed (22)
RESTORE A: blob=3b93962e6bd93461dc1be57f6d6e1b1b653cda05 want=3b93962e6bd93461dc1be57f6d6e1b1b653cda05
RESTORE A: git diff HEAD empty OK

serve.ts is unchanged in this PR — the diff is one test file. No build was involved in that ablation and none was needed: the sweep reads serve.ts and package.json from source with readFileSync at test time, so nothing resolves through a dist/.

The failure path is also exercised permanently, over synthetic sources, by nine new tests — a let, a member of something other than Serve., a binding below its use, a same-named binding in another scope, a blank template prefix, a two-argument host load, and a comma inside a literal. A guard whose red path is never run is the same silence one layer up, which is this card's whole subject.

The count floor at 20 — the answer, measured

The card's sharpest general point is that a guard which COUNTS cannot detect a specific member disappearing. True, and it is no longer asked to: that job now belongs to the loud check, by name, per site. But the floor is not redundant, and the split is the inverse of what the card assumed. Three ablations:

breakageloud checkcount floornamed 4-package list
specifier becomes unresolvable (constlet)RED, names the sitedoes not firered
identifier path resolves wronglygreengreen — 31 to 23, did NOT fireRED
literal path resolves wronglygreenRED — 31 to 9green

So the floor covers the literal half of "wrong but present" and the named list covers the identifier half. Both are kept; the floor's comment and failure message are rewritten to say what it actually guards, because its old justification ("every load now looks unknowable, which would empty the sweep below without failing it") is no longer true of this file.

Verification

At dbb6f3343 (the branch head):

  • pnpm --filter @objectstack/cli exec vitest run src/commands/serve-cluster-host-resolution.test.tsTest Files 1 passed (1) · Tests 22 passed (22) (13 before).
  • pnpm --filter @objectstack/cli exec tsc --noEmit — exit 0, 0 error TS. --listFiles confirms the edited test file is in tsc's program, so this is not a vacuous reading.
  • Full packages/cli suite: Tests 2119 passed | 5 skipped. Four test/*.e2e.test.ts files failed only because packages/cli/dist was absent (serve exited 2 before "Server is ready"); after building the CLI they are Test Files 4 passed (4) · Tests 8 passed (8).
  • npx eslint . --no-inline-config --format json over the WHOLE repo: 5204 files linted, 0 errors, 0 warnings. Not a narrowed run.
  • All 15 gate families derived by scripts/pm/dispatch-gates.mjs for this diff: green, including check:nul-bytes, check:test-source-alias, check:type-source-resolution, check:cross-package-test-inputs and check:i18n-coverage (OK (12 config(s), 602 baselined untranslated string(s), none new)).

No changeset: the diff is one test file and publishes nothing. Nothing in PR #12149 changes — that repair is correct on its own terms and is why this gap was visible at all.


Generated by Claude Code

…olve
An unresolvable specifier carried no package name, so it fell OUT of the
judged population rather than into it: the sweep did not report the load
as unknowable, it reported nothing and kept passing over the remainder.
That is how the @objectstack/organizations load left this sweep during
#11614, and teaching the resolver one more spelling only moves the
boundary rather than making a crossing audible.
An unresolved specifier is now a failure naming the file, the line, the
callee and the specifier text, for every callee, unless the site is
declared out of the sweep with its reason. Declarations are kept per
callee so a bare import() can never inherit a host-anchored load's
excuse, and a declaration matching no live site fails too.
Three scanner repairs the loud path needed to be true:
- `function importFromHost(` was read as a load site, adding a phantom,
permanently unresolvable member to the population.
- The whole argument list was read as the specifier, so the two-argument
`importFromHost(pluginSpecifier, root)` was unresolvable for a reason
unrelated to its specifier.
- `resolveIdentifier` searched the whole file for the FIRST `const <name> =`,
so the parameter `pkg` in `loadOptionalServicePlugin` resolved against a
binding 1160 lines below it. The search is now confined to the source
above the call and takes the nearest preceding binding.
A blank specifier now counts as no specifier, and the sweep's own RED
path is exercised over synthetic sources so it is observed on every run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 33c5fd3d2360ffc2a7ff79df869bf65027197889packageMentionDocs.

@os-litantos-litant added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed tests labels Aug 26, 2026 — with Claude
Whitespace only; no assertion, table entry or message text changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@os-litant
os-litant marked this pull request as ready for review August 26, 2026 09:26
@os-litant
os-litant added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit c0f5e8fAug 26, 2026
37 checks passed
@os-litant
os-litant deleted the claude/issue-12162-host-resolution-sweep-loud branch August 26, 2026 09:40
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 gatetests

Projects

None yet

1 participant

@os-litant