Skip to content

Add workspace source resolution for accurate coverage reporting - #741

Draft
sroussey wants to merge 14 commits into
mainfrom
claude/coverage-dist-bundle-fix-ew0vj8
Draft

Add workspace source resolution for accurate coverage reporting#741
sroussey wants to merge 14 commits into
mainfrom
claude/coverage-dist-bundle-fix-ew0vj8

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

Summary

Adds a Vite plugin that resolves workspace package imports from built bundles to source files during testing, enabling accurate v8 coverage attribution and eliminating duplicate module identities from mixed import graphs.

Changes

  • scripts/lib/workspaceSource.ts — New module providing:

    • listWorkspacePackages(): Scans workspace groups (packages/, providers/, examples/) to enumerate all packages
    • distToSource(): Maps dist entry points back to their source counterparts (e.g., packages/ai/dist/node.jspackages/ai/src/node.ts)
    • workspaceSourcePlugin(): Vite resolver plugin that intercepts workspace specifier resolution and rewrites dist paths to source paths after normal resolution completes
  • scripts/workspaceSource.test.ts — Comprehensive test suite ensuring:

    • All workspace packages are discovered
    • Every published runtime entry has a source counterpart (fails if any entry lacks a source twin)
    • Dist-to-source mapping works correctly
    • Non-existent entries and source files are left unmapped
  • vitest.config.ts — Integration:

    • Attaches workspaceSourcePlugin to each test project when WORKGLOW_TEST_TARGET !== "dist"
    • Explicitly configures coverage denominator to packages/*/src/**/*.{ts,tsx} and providers/*/src/**/*.{ts,tsx} rather than relying on vitest's default (which omits untested modules)
    • Excludes dist, test harness, test files, and type declarations from coverage reporting
  • .gitignore — Adds coverage output directories (coverage/, .nyc_output/)

  • .claude/CLAUDE.md — Documents the coverage strategy and WORKGLOW_TEST_TARGET environment variable

Implementation Details

The plugin works by letting normal Vite resolution run first (preserving conditional exports behavior for node/browser/bun targets), then rewriting only the resolved path. This approach requires no per-package configuration and automatically covers all packages and subpath exports.

The test suite uses stubSpecsFor() from sourceStubs.ts to enumerate the same entry points that use-source stubs from, ensuring the two mechanisms cannot drift into disagreement about what a package exports.

WORKGLOW_TEST_TARGET=dist restores the old behavior for verifying bundle integrity; the Bun runner's native exports resolution provides an additional guard via the nightly parity workflow.

https://claude.ai/code/session_016khjNuSErZBHzW3aUw2SP4

@sroussey
sroussey force-pushed the claude/coverage-dist-bundle-fix-ew0vj8 branch from 8d27385 to ced2825 Compare August 11, 2026 03:08
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 70.57% 29327 / 41554
🔵 Statements 69.43% 31383 / 45196
🔵 Functions 70.37% 5963 / 8473
🔵 Branches 60.64% 16110 / 26566
File CoverageNo changed files found.
Generated in workflow #3093 for commit 05f3b38 by the Vitest Coverage Report Action

`packages/test` reaches everything it exercises by package specifier, which
`exports` resolves to `dist/node.js`. Under v8 coverage that bundle is the
file instrumented, so executed lines were attributed to `packages/ai/dist/*`
and `packages/ai/src/**` read as barely covered — a package scored worse the
more of its behavior lived behind its public entry point.

Measured on the `ai-model` section: every executed line landed on a dist
bundle (task-graph/dist 29.7%, ai/dist 23.8%, util/dist 14.0%) and
`packages/ai/src` was absent from the report entirely.

The fix is a resolver, not a per-package config. `workspaceSourcePlugin` lets
normal resolution run first — so conditional exports still pick the
node/browser/bun target — and rewrites only the result, `<pkg>/dist/<entry>.js`
to `<pkg>/src/<entry>.ts`. Every package, every subpath export, and every
package added later is covered with no list to maintain. Unlike `use-source`
it writes nothing into `dist`, so it cannot clobber a build or leave a tree
that needs `use-dist` afterwards. `WORKGLOW_TEST_TARGET=dist` opts out; the
Bun runner still resolves `exports` natively, so the nightly parity workflow
exercises the bundles either way.

The coverage denominator is now stated explicitly rather than left to
vitest's default of "files loaded during the run" — that default omits the
modules no test imports at all, which are exactly the ones a coverage report
exists to surface, and makes a package's file list depend on which section CI
happened to run.

`scripts/workspaceSource.test.ts` fails if any published runtime entry lacks a
source counterpart: such an entry keeps resolving to its bundle, and the only
symptom is one package's coverage collapsing back onto `dist/*`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016khjNuSErZBHzW3aUw2SP4
@sroussey
sroussey force-pushed the claude/coverage-dist-bundle-fix-ew0vj8 branch from ced2825 to 97a0b75 Compare August 13, 2026 03:53
claude and others added 8 commits August 13, 2026 03:54
Resolving @workglow/* to src is what makes the coverage numbers mean anything,
but the plugin is attached to every project unconditionally — not only to
coverage runs — so with the default in force no vitest job resolves a specifier
through `exports` at all. There is also no `bun test` job in the blocking
workflow. So after the source-resolution change, nothing that can block a merge
loads a built bundle: a `bun build` entry that silently dropped a re-export
would reach main and surface only in the nightly Bun parity run, which is
explicitly informational, runs on a cron, and excludes six sections.

Adds test-vitest-dist: reuses the existing build-output artifact and runs the
unit tier with WORKGLOW_TEST_TARGET=dist. It is in cleanup's needs list, since
cleanup deletes the artifact it downloads.

Scoping the plugin to coverage runs instead would not have worked: scripts/test.ts
adds --coverage whenever CI is set, so in CI every run is a coverage run and
would still resolve to src.

Also skips --coverage for a dist-targeted run. The denominator names package
sources, so such a run reported all ~1286 of them at 0% — not a measurement of
anything, and it is what lets the new job reuse test:vitest:unit unchanged and
produce no fragment for merge-vitest-coverage.

The CLAUDE.md and vitest.config.ts notes claimed bundle integrity was covered by
the nightly parity run; both now say what actually guards it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H797qbH356jjznKgUax63o
The coverage-flag test spawned the runner with `{...process.env, CI: "1"}` and
let WORKGLOW_TEST_TARGET come from the ambient environment. The new
test-vitest-dist job exports that variable for its whole step, so inside that
job the source-target case inherited `dist` and became a second copy of the
dist case — asserting `--coverage` is present while the runner correctly
omitted it. It failed in the one job it was added to support.

Both cases now state the target explicitly, so the assertions hold whatever the
runner is invoked under.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H797qbH356jjznKgUax63o
`examples` is a first-class workspace group in both workspaceSource.ts and
testDiscovery.ts, and all three example packages — @workglow/cli, @workglow/eval
and @workglow/web — are published with none marked private. They carry 24 test
files between them. The denominator listed only packages/* and providers/*, so
their source was rewritten to src, executed by their own tests, and then left
out of the measurement entirely: a coverage run from examples/cli reported
`All files 0%` with no file rows at all.

Pins coverage.root to the config's own directory rather than making the globs
absolute. coverage.root is the documented base for include/exclude, and this
config is invoked from package directories too
(vitest run --config ../../vitest.config.ts), where a repo-relative glob would
otherwise match nothing. It also does not depend on whether vitest accepts
absolute glob patterns.

Excludes examples/*/src/test/**: those dirs hold the example suites plus the
occasional non-`.test.` helper the filename rules cannot catch, and counting a
test helper is what the adjacent excludes already exist to prevent.

Guards the invariant that broke, in workspaceSource.test.ts: every entry in
WORKSPACE_GROUPS must be a prefix of some coverage.include glob, read from the
actual config so the two cannot drift apart again. A missing group is invisible
in a coverage report — it shows a shorter file list, not an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H797qbH356jjznKgUax63o
The source-redirect plugin rewrites the RESULT of resolution, so resolution
itself still goes through the package's `exports`, which point at ./dist/*.
With no built entry there, Vite's resolution fails first, `this.resolve` yields
nothing, the plugin returns null, and the run dies with a generic
`Cannot find package '@workglow/ai/worker' imported from …` that blames the
manifest and names neither the plugin nor anything to do about it.

Keeps the workspace list as WorkspacePackage rather than bare names, so the
owning directory is available at the point resolution fails, and throws a
message naming the specifier, the owner, the importer and the remedy. Throwing
rather than warning is right: an unresolvable @workglow/* specifier already
fails the run, so this replaces a misleading message with an actionable one.

The remedy branches on whether the owner's dist holds any built entries, since
"never built" and "a new exports subpath was added without rebuilding" call for
different actions and the second reads as wrong advice to someone looking at a
populated dist. An empty dist directory — what `bun run clean` and
`use-dist --no-build` both leave behind — counts as never built.

ownerOf and unresolvedWorkspaceMessage are separated out as pure functions
because resolveId needs Vite's plugin context to drive and cannot be unit
tested; the message was additionally verified end to end by emptying dist and
running a suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H797qbH356jjznKgUax63o
…st-job

ci: keep a blocking job that exercises the built bundles
…-examples

fix(test): count examples/* source in the coverage denominator
…ew0vj8' into tmp-resolve-752

# Conflicts:
#	scripts/workspaceSource.test.ts
…gnostic

fix(test): explain an unresolvable workspace specifier
sroussey and others added 4 commits August 13, 2026 10:35
…pecifier causes (#759)

Two guards on the dist-targeted test path.

`WORKGLOW_TEST_TARGET` had two independent readers, each treating
anything that was not literally "dist" as source, silently. A workflow
edited to "dist " or Dist turned test-vitest-dist into a byte-identical
rerun of test-vitest-unit: green, a full runner slot spent, no bundle
coverage. `resolveTestTarget` is now the single reader — unset and empty
mean source, everything else throws naming the value, with no
lowercasing and no prefix matching.

`unresolvedWorkspaceMessage` offered only "never built" or "stale dist",
but resolution fails from the IMPORTER's node_modules: under isolated
linking an undeclared workspace dependency, or a subpath absent from the
owner's exports, fails while the owner's dist is fully populated. The
causes are now ranked, so the built/stale pair is offered last and
labelled as the remaining likely cause.


Claude-Session: https://claude.ai/code/session_01RomTUtZSTgUbFCYqFs4pcu

Co-authored-by: Claude <noreply@anthropic.com>
`resolveId` — the one function the workspace-source plugin exists for — had
no test, and nothing asserted the plugin reaches the generated projects.
`workspaceSource.test.ts` covered only the pure helpers, and both CI modes
pass under either resolution: `test-vitest-unit` and `test-vitest-dist` run
the same files and the same assertions, differing only in which files load,
so no suite distinguished "resolved to src" from "resolved to dist".

The failure that leaves is silent. Hoisting `plugins:
[workspaceSourcePlugin(__dirname)]` from the per-project object to the root
`defineConfig` — a natural "one instance instead of N" cleanup, and the
exact mistake the comment there warns about — makes every project resolve
`@workglow/*` through `exports` to dist again. All tests pass,
merge-vitest-coverage succeeds, and the only symptom is entry-point
behavior reading as uncovered. The same silence covers a regression inside
`resolveId`.

Extract the hook body into `resolveWorkspaceSourceId(packages, context,
source, importer, options)`, which takes the plugin context as a parameter
so a recording stub can stand in for Vite; `workspaceSourcePlugin` becomes
a one-line adapter. `WorkspaceResolveContext` declares `resolve` with
method syntax on purpose — parameter bivariance is what lets Vite's real
`PluginContext` satisfy it — and is generic in the result so the adapter's
return type stays `ResolvedId`, which is what Vite's `resolveId` hook is
declared to return. Both together are what keep the call site cast-free
(checked against the installed vite typings, where the non-generic form
needs one).

New tests: the rewrite, a non-workspace specifier short-circuiting before
`this.resolve`, an external resolution left alone, dist output with no
source twin left on the built file, the unresolved-specifier diagnostic
(a branch with zero execution anywhere before this), and `skipSelf: true`
plus verbatim forwarding of the hook's own options. Plus a "plugin
attachment" block that re-imports the real `vitest.config.ts` and asserts
every project carries `workglow:workspace-source` under the default target
and none does under `dist`.

Both directions stub `WORKGLOW_TEST_TARGET` explicitly and unstub
afterwards: `test-vitest-dist` runs this file with that variable ambient,
so a test reading the ambient value would pass in one CI job and fail in
the other.
`test-vitest-dist` is the one blocking job that resolves `@workglow/*`
through `exports`, but it runs the UNIT tier only — while the source
rewrite applies to EVERY vitest job. The integration/rag/provider suites
previously loaded the bundles and now load src, so a bundle reachable only
from an `.integration.test.ts` file lost its blocking check.

Two entries lost every check: `@workglow/openrouter/ai-runtime` and
`@workglow/huggingface-inference/ai-runtime`, imported only from
provider-api integration files, whose section the nightly Bun parity run
also excludes. Concretely: a `bun build` change dropping
`registerOpenRouterInline` from `providers/openrouter/dist/ai-runtime.js`
leaves the file in place, satisfies the dist-must-exist requirement, passes
all of CI, and breaks consumers only after publish.

`PublishedEntryImports.test.ts` makes the check total instead of
tier-shaped: it enumerates every workspace manifest's `exports`, resolves
each subpath under the Node conditions only (`node`/`import`/`default`,
walked in declaration order the way Node does, so `types`/`browser`/`bun`
are stepped over rather than entered), and dynamically imports each
resulting specifier, asserting the module is non-empty. Under
`WORKGLOW_TEST_TARGET=dist` that one unit-tier file loads every published
bundle; under the default target it costs nothing, since it loads the same
source the rest of the suite already does.

Adding `workglow` to `packages/test`'s devDependencies is the larger half:
it brings the meta-package's own entries and, transitively, the provider
bundles those re-export.

The enumeration is local rather than shared with `scripts/lib/sourceStubs`:
`stubSpecsFor` returns dist targets rather than import specifiers, and
`packages/test` is a `composite` project rooted at `./src`, so importing
from `scripts/` would put those files in its program and break
`build-types`.

Anti-vacuity assertions (over 60 entries across over 20 packages, every
target `./dist/**.js`) keep a mis-typed walk from passing as a short list,
and both exemption maps are staleness-checked against the enumeration. Two
exemptions, each with its reason: `@workglow/cli` (uncheckable — an example
app `packages/test` does not depend on, so under isolated linking the
specifier does not resolve from here at all) and `workglow/auto-bootstrap`
(imported, but exempt from the non-empty assertion: it registers providers
as a side effect and exports nothing by design). New packages default to
checked.
…hxoj5s-coverage-guard

test: prove the dist→src rewrite is in effect, and make the dist job total
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants