Skip to content

finding: under vitest a linked workspace package's import() is re-anchored at the vitest root, so an in-process test cannot measure which package a bare specifier resolves from — and its anti-vacuity control is vacuous too #11412

Description

@os-zhuang

Filed unassigned from the domain:cli lane while implementing #11157 (PR #11405). Recording only — not graded here, and no domain:* applied.

The mechanism

@objectstack/types is a linked workspace package, so Vite processes it as source rather than externalising it, and rewrites the import() written inside packages/types/dist/node.mjs to use its own resolver — which resolves from the vitest root, i.e. the package under test.

Measured while implementing #11157:

createHostImporter(appRoot)('chalk') with no caller base at all RESOLVES under vitest and THROWS under Node.

Node ESM anchors a bare specifier at the module that physically contains the import(). Vite does not preserve that anchor for a linked workspace dep. Under vitest the two bases are the same base.

Why this is worse than an ordinary false green

Any in-process assertion about which base is in use is green both before and after a change to that base — and so is the anti-vacuity control written beside it. The vacuity check is itself vacuous. A dev doing everything right — writing the pin, then ablating to prove it fails without the fix — gets a green ablation and concludes the pin is sound.

That is a different failure from #7849 (a stale dist deciding verdicts) and from #8249 / #8180 (the type axis resolving through dist). Those are wrong-or-outdated answers. This one erases the question.

The measured instance

packages/cli/src/commands/serve-config-plugin-host-resolution.test.ts carries a chalk assertion that reads as a statement about the fallback resolution base. Under #11157's ablation — the fix removed, everything else identical — it stayed green, while the pins that spawn a real Node child went red. So that assertion was never measuring the base; it is a behaviour statement that happens to hold.

⚠️ Not a defect introduced by #11157, and not left silent by it: PR #11405 records the measurement in the headers of both affected test files and moves its own resolution pins into a spawned Node process, with a CONTROL case that asserts the no-base importer fails and names packages/types as the origin it failed from. This card is about the class, not that file.

Not measured here

  • How many other in-process tests make a claim of this shape. The repo has many tests asserting resolution behaviour; which of them are vacuous under vitest for this reason is uncounted, and counting it is the substance of whatever this card becomes.
  • Whether the same collapse applies to workspace deps that are not linked (i.e. resolved through dist via exports) — fix(rest,runtime,metadata-protocol): make the destructive-409 remedy true on both remaining /meta doors — compound-name PUT threads ?force, dispatcher states its own face #11392's ablation C suggests those behave differently again: there a stale dist produced a false green that a rebuild corrected, which means the base was preserved and only the content was old.
  • Whether a vitest config option (server.deps.external, resolve.alias) would restore the Node anchor for these packages, and at what cost to the source-resolution invariant that check:test-source-alias exists to enforce — those two goals may be in tension, which is exactly why this is filed rather than fixed.

Why it matters beyond one file

The repo's anti-vacuity discipline rests on "show the pin failing without the behaviour it pins". This mechanism makes that demonstration produce the wrong answer for one specific class of claim, and produce it silently, in the direction of false confidence. A recorded platform fact would let the next author reach for a spawned child immediately instead of discovering it by ablation.

Routing note for triage: the mechanism is vitest/Vite configuration and test infrastructure; the measured instance is in packages/cli.

Related: #11157 / PR #11405 (where it was measured) · #7849 (stale dist decides verdicts — different failure) · #8249 · #8180 · #11392 (a dist false green that a rebuild did correct, i.e. the contrasting case)

Activity

  1. added theissue type on Aug 24, 2026
  2. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    ContributorAuthor

    Triage (finding first-touch): promoted — finding → pm:queue, domain:devx, type Task. Rationale: the mechanism is measured and reproducible, the failure direction is false confidence in the repo's own anti-vacuity discipline (an ablation control that is itself vacuous), and the deliverable is concrete: ① record the platform fact where test authors will find it (the references/testing conventions the repo already keeps); ② census the in-process tests asserting resolution-base claims (the card's own "uncounted" list) and mark or migrate the vacuous ones to spawned-child pins per PR #11405's pattern; ③ report (not decide) on the server.deps.external tension with check:test-source-alias — if restoring the Node anchor conflicts with the source-resolution invariant, that trade-off goes to a decision card, ⛔ not into a config change.

    Scoped as devx: the mechanism lives in vitest/Vite test infrastructure; the measured instance's home (packages/cli) is evidence, not the fix surface.


    Generated by Claude Code

  3. self-assigned this
    on Aug 24, 2026
  4. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    Claim: domain:devx PM seat, session session_015ahemw8RcTgqtxrj15PEZx, branch claude/issue-11412-vitest-resolution-vacuity-census.

    Dispatching at opus. Tier derived live at dd4113ec0, not recalled: no path-derived mandate.

    Premise verified on today's main:

    PR #11405 landed          a4cb7817f  fix(cli): serve's host importer states its own resolution base (#11405)
    measured instance         packages/cli/src/commands/serve-config-plugin-host-resolution.test.ts   PRESENT
      ⚠️ that file's own child_process refs = 0; the comment at :162 points elsewhere
         ⇒ the spawned pins live in a DIFFERENT file — ⛔ not evidence the card is wrong
    CTRL  cli test files using child_process   30     ← the probe works
    haystack  test files mentioning import( or resolve( : 339
    

    Opus, and the reason is the card's own: this is the one defect class where the anti-vacuity ritual returns the wrong answer. A dev doing everything right — write the pin, ablate to prove it fails without the fix — gets a green ablation and concludes the pin is sound. Any seat working this card is working inside the mechanism it is measuring, and a sonnet-floor run that trusts an in-process control here would produce a census that is itself vacuous.

    ⛔ Dispatched as a census, not a fix — the card is explicit that counting is the substance.


    Generated by Claude Code

  5. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    Claim: devx lane PM seat, session e2eac1a7-8000-5c95-9749-38aec2ace6fc, branch claude/issue-11412-vitest-resolution-vacuity-census — re-dispatch.

    The first dev on this card died mid-run to an API transport error, not to anything about the work. Confirmed by content, not assumed: git ls-remote shows refs/heads/claude/issue-11412-vitest-resolution-vacuity-census still at 945ffbea8, which is the branch point — nothing was pushed, so there is no partial work to salvage or collide with, and the fresh dev starts from a clean branch of the same name.

    The one measurement that survived in the transcript, carried forward so it does not have to be re-derived: childEnv() strips only TEST / VITEST*, so NODE_PATH rides along into the spawned child — which is exactly the question the census turns on, since a spawned-child remedy that inherits NODE_PATH has not actually escaped the vitest root. Treat that as a lead to verify, not as an established finding; it was mid-verification when the run died.


    Generated by Claude Code

  6. claude commented on Aug 24, 2026

    @claude
    Contributor
    {
     "issue": 11412,
     "status": "done",
     "branch": "claude/issue-11412-vitest-resolution-vacuity-census",
     "pr": "https://github.com/objectstack-ai/objectstack/pull/11771",
     "premise_still_valid": true,
     "summary": "The card is RIGHT on both of its assertions, and both were confirmed by measurement rather than argument. Claim 1 (an in-process test cannot measure which package a bare specifier resolves from): in-process createHostImporter(appRoot)('chalk') with NO base RESOLVES, while the identical call in a real Node child THROWS naming packages/types. Claim 2 (the anti-vacuity control is vacuous too), the half the PM most wanted: ablating the whole #11157 repair out of serve.ts left the in-process 'keeps this CLI as the resolver' assertion GREEN while the spawned-child pins of the same claim went RED (baseline 18/18 green, ablated 14/18). The CENSUS RESOLVES TO A POPULATION OF ONE, so a census artifact is NOT the right deliverable and none is shipped: 2706 tracked test files -> 61 mention a resolution token -> 48 in-process -> 28 actually call a resolver -> 1 is vacuous, and it is the instance the card already names. What ships instead is (a) a new spawned-child PRECONDITION pin holding the platform fact executably with controls proven to fire, (b) an in-place correction of that one assertion, whose existing comment claimed 'take the base away and this line goes red - measured' and is FALSIFIED by the ablation, and (c) a cross-reference from check-test-source-alias.mjs's header. TWO THINGS THE CARD DID NOT HAVE. First, a SECOND base-erasing mechanism that survives the spawned-child remedy: a vitest worker carries pnpm's hoisted store in NODE_PATH, childEnv() strips only TEST/VITEST* so it rides into every spawned child, and CJS createRequire honours it while ESM import() ignores it. The PM's carried-forward lead is therefore CONFIRMED BUT HALF-TRUE, and the surviving half is the load-bearing one: the #11405 spawned CONTROL is sound ONLY because createHostImporter's fallback leg is ESM; had it been CJS the inherited NODE_PATH would have kept it green through the very ablation it exists to fail. Also note NODE_PATH is not 'the vitest root' - it is a FALLBACK consulted only after the node_modules walk misses, so it can only turn a MISS into a HIT. Second, on deliverable (3): server.deps.external DOES restore the Node anchor, check:test-source-alias stays green with it, and the 11 affected cli tests pass identically (103/103 both ways) - so the tension the card assumed MAY NOT EXIST. No config change was made; that is the decision the card reserved.",
     "tests": "All readings at fc6eadda1, the final commit (HEAD confirmed by git rev-parse in the same shell as the run). GROUND TRUTH, established before any in-process reading and with the anchor PROVEN not assumed: `cd <base> && node --input-type=module -e ...` anchors a bare specifier at <base>, and the probe prints import.meta.url as file://<base>/[eval1] to show it. Controls both ways: @objectstack/spec resolves from BOTH packages/cli and packages/types (probe can say HIT); @os-fixture/... resolves from NEITHER (probe can say MISS); chalk resolves from packages/cli only (the discriminator). ABLATION of the #11157 fix (fallbackImport removed from importFromHost in serve.ts): mutation proven on disk by greppable counts - deleted marker 1 -> 0, injected tag 0 -> 1, git diff --stat '1 file changed, 1 insertion(+), 6 deletions(-)' - and restored via a trap on EXIT/INT/TERM, with `git status --porcelain` empty afterwards. Baseline 18/18 passed; ablated 4 failed / 14 passed. GREEN despite the ablation: 'keeps this CLI as the resolver for a package the app does not declare' (the vacuous one). RED: the two spawned-child resolution pins in serve-host-fallback-base.e2e.test.ts. No rebuild was needed for this ablation and that is stated deliberately: the target is packages/cli/src/commands/serve.ts, reached relatively (source) by the in-process legs and via tsx (source) by the e2e, so no dist mediates it; @objectstack/types IS reached through dist and was rebuilt beforehand (pnpm --filter '@objectstack/cli^...' build, turbo '55 successful, 55 total'). THE NEW PIN'S OWN CONTROLS WERE PROVEN TO FIRE - three mutations, each red recorded, each restored by trap: (a) running the child at the CLI's base instead of the types package's reddens the anchor control plus both NODE_PATH base cases (3 failed / 6 passed); (b) giving the NOWHERE specifier a name that really resolves reddens the 'CAN say MISS' control plus the in-process negative pair (2 failed / 7 passed); (c) stripping NODE_PATH from both children reddens the childEnv control plus 'CJS honours NODE_PATH' (2 failed / 7 passed). Unmutated the pin is 9 passed (9). TARGETED SUITE on the final tree: 13 files (the 11 packages/cli tests importing @objectstack/types, the new pin, the relative-refusal pin) - 118 passed (118), via scripts/pm/os-verify-lock.sh, VERDICT command-exit 0. TYPECHECK: pnpm --filter @objectstack/cli typecheck exit 0. NEGATIVE RESULT REPORTED RATHER THAN HIDDEN: I then checked the new file is actually IN the tsc program via `tsc --listFiles | grep -c` and it is NOT (count 0) - packages/cli/tsconfig.json includes 'src' only, so packages/cli/test/** sits outside tsc for the new file AND every sibling e2e there. Pre-existing, not introduced here, and check:type-check-coverage (which owns that structural question) is green. GATES all exit 0, derived from the real changeset with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (3 paths, three-dot semantics), not from the dispatch list: check:cli-test-child-env, check:cross-package-test-inputs, check:test-source-alias, check:agent-test-spelling, check:nul-bytes, check:entry-guard, check:parse-guard, check:pnpm-filter-targets, check:published-files, check:slot-lookup, check:type-source-resolution, check:query-options-erasure, check:type-check-coverage, check-plugin-teardown-shape.mjs, check-cross-package-test-inputs.mjs. Each read from the gate's own printed verdict line, with output redirected before the exit code was captured (never `| tail` then $?). DECLARED NARROWINGS, both measured rather than asserted. (1) ESLint narrowed to the 3 changed files: all 3 confirmed IN eslint's own population by asking eslint per file (none reported as ignored); 3 files / 0 errors / 0 warnings read from --format json; and the repo's single eslint.config.mjs 'never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file' - quoted from the config itself - so this diff cannot move the verdict on an untouched file. (2) The full packages/cli suite (~1498 tests, ~495s) was NOT run: both edits to existing files are mechanically comment-only (non-comment lines added 0, removed 0, over +18/-4 and +27/-0 diffs), so the only executable change is the new file, which was run. check:type-check-debt --re-measure needs the whole workspace closure built and is a repo-level scan left to CI. EVERY ZERO IS PAIRED: the '0 non-comment lines' pair with non-zero total diff counts, so the counter was reading lines; the 'population 1 of 28' pairs with a MEASURED false negative in my own selector - its first token list MISSED the known instance (that file reaches the resolver through the Serve.importConfigPlugin wrapper, not require.resolve), so a census script built on it would have reported ZERO and read as reassuring; it was widened and re-run, and this is recorded in the PR body rather than quietly fixed.",
     "open_questions": [
      {
       "question": "The triage asked for the platform fact to be recorded 'where test authors will find it'. I put the authoritative detail in the new pin's header plus a cross-reference in scripts/check-test-source-alias.mjs, and deliberately did NOT add a pointer to AGENTS.md - AGENTS.md is a governed surface, so touching it would take this PR off the normal merge path for one sentence. Is that the right trade?",
       "options": [
        "A - leave as is: detail in the pin header, repo-wide discoverability via the check-test-source-alias.mjs header (the gate someone chasing a test-resolution question already lands on). PR stays ungoverned.",
        "B - add a short pointer to AGENTS.md's Build & Test section. Maximum discoverability, but the PR then requires a maintainer hand-merge under Prime Directive #14.",
        "C - split: land this PR as is, and carry the AGENTS.md line as its own tiny governed PR."
       ],
       "recommendation": "A, with C as the cheap upgrade if you want the repo-wide line. The repo's own stated convention is that the script/file header is the authority on detail and AGENTS.md states the rule once; here there is no enforced RULE to state - the hazard is recorded, not gated - so AGENTS.md would be carrying a pointer rather than a directive. If you disagree, C keeps this PR's merge path clean."
      }
     ],
     "out_of_scope_findings": [
      "filed as #11773: childEnv() forwards NODE_PATH to spawned children, so a spawned resolution pin routing through CJS is as vacuous as the in-process one it replaced - the #11405 CONTROL escapes only because its fallback leg is ESM",
      "filed as #11775: server.deps.external restores the Node anchor with check:test-source-alias still green and no test cost measured, so the tension #11412 assumed between the two may not exist - includes the realpath-pattern trap that makes a natural-looking regex match nothing silently"
     ]
    }

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions