Skip to content

test(cli): teach the host-anchoring sweep one more hop — an import alias to a literal in a sibling module - #12582

Draft
os-litant wants to merge 1 commit into
mainfrom
claude/issue-12533-resolver-import-alias-hop
Draft

test(cli): teach the host-anchoring sweep one more hop — an import alias to a literal in a sibling module#12582
os-litant wants to merge 1 commit into
mainfrom
claude/issue-12533-resolver-import-alias-hop

Conversation

@os-litant

@os-litant os-litant commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes #12533

serve-cluster-host-resolution.test.ts's resolveIdentifier() learns one more hop: an import alias followed to a string literal in a sibling module of the same package. Nothing else in the file moves.

⚠️ Body rewritten once after creation: GitHub's body sanitizer silently ate every angle-bracket placeholder, backticks included, so the placeholders are spelled out here instead. AGENTS.md's "GitHub mutates body BYTES" clause, observed again.

What was measured first

On origin/main @ 9bed0b0fe, every anchor the card and its claim comment handed down was re-derived rather than trusted:

claim measured
resolveIdentifier at :278 ✅ exact
UNRESOLVABLE_HOST_LOADS at :486 ✅ exact
serve.ts:548 still a literal static readonly ORGANIZATIONS_RUNTIME_PKG = '@objectstack/organizations';
tenancy-posture-hints.ts:89"⛔ Do NOT close the gap…" ✅ exact
serve-organizations-message-spelling.test.ts:52"deliberately stays a string LITERAL" ✅ exact

The card's premise holds: before this PR the resolver knew const X = '@objectstack/service-i18n' and const X = Serve.MEMBER, and knew no spelling that reaches outside serve.ts.

⛔ Scope: the hop only — NOT the refactor it enables

The card proposes the hop and the refactor that consumes it (single-sourcing ORGANIZATIONS_RUNTIME_PKG out of serve.ts, plus deleting serve-organizations-message-spelling.test.ts's site 8). Only the hop is here.

PR #12532 put that duplication in place deliberately, with the reasoning written at both ends, and the card itself names the trap: #12532 shipped shape A "with the reasoning at both sites so the next author does not 'finish the job' and blind the sweep." Doing the refactor in this diff is that move with a safety net stapled to it — ⛔ a capability and the reversal it licenses do not land together. So serve.ts, tenancy-posture-hints.ts and site 8 are all untouched here; site 8's subject still exists, so it is not a pin without a subject.

⚠️ NO LIVE SITE USES THIS HOP YET — do not read the branch as dead code

This is the unusual part of the change and the reason it is stated in the body, in the resolver's docblock and again on the suite:

The hop is the capability. Its consumer is a follow-up.

Do not delete the branch as unreachable. The follow-up is #12579 — the decision about whether to consume it, which also has to rewrite the two ⛔ notes whose stated reason this hop changes. Deleting the branch before that is decided puts the tree straight back in the state where single-sourcing a spelling silently empties the sweep, which is how this file lost the @objectstack/organizations load once already.

The hop, and the property it had to keep

The resolver's docblock commits to one thing: resolving further "strictly WIDENS what the sweep judges; it can never excuse a load." The hop is built so that it cannot:

  • it is consulted only where the resolver already returned undefined, at the three points that need a literal — the call-site identifier, const X = ALIAS, and static readonly MEMBER = ALIAS;
  • it returns a literal or undefined, never a different literal, so nothing that resolves today resolves differently;
  • a const X = IDENTIFIER becomes a candidate only when IDENTIFIER is a name this module actually imports. Admitting every identifier would let an unresolvable nearer binding shadow a farther const X = '@objectstack/service-i18n' and take a site out of the judged population — the one direction this resolver is forbidden to move in;
  • an alias it cannot follow is UNRESOLVED, and unresolved-and-undeclared is a loud failure naming file, line, callee and specifier.

The fence — one hop, not a module resolver

⛔ Refused, each with a case: a bare (cross-package) specifier, a relative path that escapes the package even when the file is there and readable, a re-export, a namespace import, a type import, a missing sibling, and an export that is not a string literal. Comments in the sibling are blanked before it is read, so a commented-out declaration is prose rather than a spelling.

Anti-vacuity: how a hop with no live site was proven

The strongest anchor available without a consumer: the source is synthetic, but the sibling followed is the real packages/cli/src/utils/tenancy-posture-hints.ts, read through serve.ts's own scan context — the module a single-sourcing refactor would actually import from. If that export is renamed or stops being a literal, it goes red here, naming the hop.

Ten cases, each of which fails if the hop is removed or loosened:

case asserts
alias → real sibling (tenancy-posture-hints.ts) the hop, against the module the follow-up would use
alias named directly at the call site importFromHost(SHARED_PKG) with no local rebinding
static readonly MEMBER = ALIAS ⭐ the exact rewrite that emptied the sweep before
alias → non-literal REFUSES
cross-package (bare) alias REFUSES
relative alias escaping the package REFUSES — the file exists and is readable
re-export / namespace / type import / missing sibling REFUSES, four ways
commented-out export in the sibling REFUSES (sibling is read as code)
const X = '@objectstack/service-i18n', Serve.MEMBER, binding-below-call ⭐ unchanged
nearer unresolvable alias + farther literal UNRESOLVED, never the farther literal

⚠️ The live sweep is unchanged in BOTH directions

The card's own headline is that an unresolved specifier drops out rather than in, so "additive" had to be measured, not argued. The swept population was dumped from the base copy of this file and from this copy, both scanning the same serve.ts:

43 load sites · 4 app-declarable · 8 unresolved   (identical, both copies)
sha256(population) 7802769253…93ccd2   ← byte-for-byte equal

APP_DECLARABLE_LOADS on both: service-cluster, service-cluster-, service-i18n, organizations. Nothing gained, nothing lost. That is expected and it is the point — serve.ts contains zero const X = IDENTIFIER; and zero static readonly X = IDENTIFIER; forms today, so there is nothing for the hop to reach yet.

⛔ Not touched

serve.ts (held by #12543 this round) · serve-organizations-message-spelling.test.ts site 8 · tenancy-posture-hints.ts · UNRESOLVABLE_HOST_LOADS and UNRESOLVABLE_BARE_IMPORTS (those declarations belong to #12540 / #12162) · every named assertion in the vacuity guard — that named half is what caught the loss this card is filed for, and it is extended by nothing and weakened by nothing here.

Changeset

skip-changeset, measured against the repo's own rule rather than assumed: pr-automation.yml's changeset-check has no path-based exemption — the label is the only mechanism, and lint.yml:2057 states the prescription ("such a PR releases nothing, so by the workflow's own prescription it takes the label"). This diff is one *.test.ts file in packages/cli; nothing is published and no package version moves.

Verification

All heavy work went through this container's shared verify lock (OS_VERIFY_LOCK_SLOT=issue-12533); the VERDICT line is the authority, and every exit code below was captured before any pipe.

The suite, plus a base-vs-head population probe, at 2f17ecdc6:

pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 \
  src/commands/serve-cluster-host-resolution.test.ts src/commands/zz-probe-base.test.ts src/commands/zz-probe-head.test.ts

 Test Files  3 passed (3)
      Tests  88 passed (88)
os-verify-lock: VERDICT command-exit 0 · held the lock 133s (2m13s) · waited 216s (3m36s)

⚠️ Declared narrowing. The full packages/cli vitest suite does not finish inside this container's ~10-minute foreground window, so the run is narrowed to the edited file and its two probe copies. The dependency closure was likewise narrowed to pnpm --filter '@objectstack/types...' build@objectstack/types/node is this file's only workspace import and packages/cli's vitest config does not alias it, so it resolves through exports to dist/. Everything else is CI's.

The two probes are untracked copies generated inside the locked run and removed by its trap … EXIT INT TERM; git status after the round showed only the intended modification.

Gate families derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed — the script takes its own change set from the merge base). ⚠️ It reported the checkout 4 commits behind origin/main with 3 of the files it derives from changed in that range, so the family list is a well-formed answer about a slightly older tree; it is reported as measured rather than reconciled. The 9 packages/spec families it printed under "THE LAYOUT MOVED" are #12514, already tracked — not refiled.

turbo.json's @objectstack/cli#test already declares $TURBO_DEFAULT$, and the one new read this change adds (packages/cli/src/utils/tenancy-posture-hints.ts) is inside packages/cli — so no inputs entry is owed and check:cross-package-test-inputs has nothing new to see. packages/cli/tsconfig.json is include: ["src"] with no exclude, so *.test.ts really is inside the typecheck program rather than merely adjacent to it.

⛔ Not measured locally, and named rather than skipped: check:i18n / check:i18n-coverage (need the CLI built) and check:type-check-coverage / check:type-check-debt (need the whole workspace built — the ratchet refuses outright on an unbuilt worktree, and that refusal is NOT MEASURED, never a pass). CI owns those.


Generated by Claude Code

…ias to a literal in a sibling module

`serve-cluster-host-resolution.test.ts`'s `resolveIdentifier()` knew two
spellings, both confined to `serve.ts` itself: `const X = '<literal>'` and
`const X = Serve.MEMBER`. So single-sourcing a package spelling into a module
`serve.ts` shares with another reader turned that load from "app-declarable,
host-anchored, checked" into "unknowable" — and an unresolved specifier drops
OUT of the judged population rather than into it, silently.

Add a third hop: follow an import alias to a string literal in a sibling module
of the SAME package, consulted at the three points the resolver would otherwise
return `undefined` (the call-site identifier, `const X = <alias>`, and
`static readonly MEMBER = <alias>`).

It can only widen. It runs where the resolver already gave up and returns a
literal or `undefined`, never a different literal; a `const X = <identifier>`
becomes a candidate only when the name is one this module imports, so an
unresolvable nearer binding can never shadow a farther literal and take a site
out of the swept population.

The fence is the package, not the filesystem: a bare specifier, a relative path
that escapes the package, a re-export, a namespace import, a type import, a
missing sibling and a non-literal export are each refused, with a case for each.

No live site uses the hop yet — that is deliberate, and the consumer is a
follow-up. Measured either side of this change, the swept population is
byte-for-byte identical: 43 load sites, 4 app-declarable, 8 unresolved.

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 3f41a21527c611620295632e692bcefd56bd67edpackageMentionDocs.

@os-litant os-litant added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed tests labels Aug 26, 2026 — with Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants