Skip to content

dogfood: vendor scan walks scripts/, sends devDependencies to jspm #1399

Description

@vivek7405

Line anchors in this body were verified against HEAD 4a335549. Re-check them with git log -1 --format=%h before editing.

Problem

scanBareImports (packages/server/src/vendor.js:100) finds the bare specifiers that become the vendor importmap by doing a filesystem walk of the app directory. It is not rooted in anything. Every .js / .ts / .mjs / .mts file under appDir that survives a list of hardcoded name exclusions is read and regex-scanned, whether or not a browser could ever load it.

The exclusions are enumerated in the docblock at vendor.js:81-99 and implemented in walk() at vendor.js:176-228. They are node_modules, .webjs, public, any directory starting with _, any dot-prefixed directory, test/, tests/, *.server.{js,ts,mjs,mts}, route.*, middleware.*, *.config.*, and any file whose first non-whitespace content is 'use server'. There is no scripts/ exclusion, and more importantly there is no reachability criterion at all.

Two consequences, both measured on website/ at HEAD.

1. Build scripts are scanned. website/scripts/generate-og.mjs:19 and website/scripts/generate-og-why.mjs:16 both open with import { chromium } from 'playwright'; (verified, both line anchors in the original report are correct). Neither file is imported by any page, layout, component, or any other app module. The only references to scripts/ anywhere in website/ are npm-script invocations in package.json and two prose comments. So playwright reaches the vendor resolver and is sent to jspm, which fails every time:

[webjs] could not vendor 'playwright@1.60.0' via jspm (status 401): Error: No
'./lib/cjs/bidiMapper/BidiMapper' exports subpath defined in
https://ga.jspm.io/npm:chromium-bidi@17.0.2/ resolving
chromium-bidi/lib/cjs/bidiMapper/BidiMapper imported from
https://ga.jspm.io/npm:playwright-core@1.60.0/index.mjs.

2. Code samples are scanned. The scan strips comments (stripComments, vendor.js:145-149) but does not mask template literals or plain strings, so an import written as example text inside a template literal is indistinguishable from a real one. website/lib/samples.ts:25 holds import { eq } from 'drizzle-orm'; inside the exported ACTION_SAMPLE template literal, a docs code sample. drizzle-orm therefore also reaches jspm on every cold analysis of website.

This second half is new information relative to the original report and it changes the fix. The module graph's own scanner already closed this class (module-graph.js scans over redactStringsAndTemplates(src, true), the fully blanked mask, per #753), so WebJs today runs two vendor-specifier scanners that disagree. The modulepreload hints come from the graph's bareImports(graph) (#754) and the importmap comes from scanBareImports, which is why website currently serves a drizzle-orm importmap entry that gets no matching modulepreload hint.

Measured, at HEAD, with no network call

A read-only script called the real functions against the three in-repo apps. PIN is scanBareImports(appDir) as pinAll and webjs doctor call it, RUNTIME is scanBareImports(appDir, skipFiles) as dev.js:1143 calls it, GRAPH is what a reachability-rooted scan would produce (browser-bound files only, server files excluded, specifiers read from bareImports(graph)).

website/

set result
PIN (today) @/lib/stats, @sentry/node, @webjsdev/core/client-router, @webjsdev/core/context, @webjsdev/core/directives, @webjsdev/core/server, @webjsdev/core/task, @webjsdev/core/testing, analytics, dayjs, drizzle-orm, drizzle-orm/bun-sqlite, drizzle-orm/node-postgres, drizzle-orm/node-sqlite, express, fastify, pg, pino, playwright, react, tailwindcss, three, ws, zod (24 specifiers, all but @webjsdev/core* phantom)
RUNTIME (today) @webjsdev/core/directives, drizzle-orm, playwright
GRAPH (proposed) @webjsdev/core, @webjsdev/core/directives

Both survivors in the GRAPH row are in the BUILTIN set, so vendorImportMapEntries produces an empty install list, and jspmGenerate returns {} without a network call (vendor.js:577). The jspm round trip for website does not shrink, it disappears.

examples/blog/

set result
PIN (today) @webjsdev/core/directives, dayjs
RUNTIME (today) @webjsdev/core/directives
GRAPH, browser-bound only (the pin-side root) @webjsdev/core, @webjsdev/core/directives, dayjs
GRAPH, minus the elision skip set (the runtime-side root) @webjsdev/core, @webjsdev/core/directives

dayjs is reachable only through the elided components/vendor-badge.ts, which is the #170 / #197 property. It must stay out of the runtime set and stay in the pin-side superset. Both hold.

gallery/ PIN and RUNTIME are already identical and contain only @webjsdev/core* subpaths, so nothing changes there.

buildModuleGraph cost, measured: 199ms on website, 74ms on gallery, 68ms on examples/blog.

Corrections to the original report

  • The docblock anchor is vendor.js:81-99, not 85-99, and the exclusion list it enumerates is missing *.config.* and the dot-directory rule that walk() actually applies. It is already out of date before this change touches it.
  • The report frames this as scripts/ only. It is not. drizzle-orm from a docs code sample is the same defect, and 21 of the 24 specifiers pinAll currently finds in website are phantom.
  • The 1296-1464ms figure is carried over from the original report and was not re-measured here (that needs a booted dev server plus a live jspm call, and hammering jspm was out of bounds). What is verified instead is the stronger structural claim above: the install list for website goes to zero, so the call is not made at all.
  • The report's claim that the 401 fallback already isolates an unresolvable install is accurate. jspmGenerate (vendor.js:576-650) runs one unified call, and on a permanent 401 it probes each install alone via jspmProbeOne (vendor.js:519), drops the unresolvable ones, and re-runs the unified call over the resolvable subset. jspmResolveOne (vendor.js:493) sets the retry flag only on a transient failure. So this is a wasted-work bug, not a correctness bug, exactly as stated.

Design / approach

Settled: option 2, root the scan in the module graph. Option 1 is rejected.

Why option 2, concretely

The scan already has a correct root available. dev.js:949 computes state.browserBoundFiles = computeBrowserBoundFiles(state.routeTable, state.moduleGraph, components, appDir), which is the dev server's authorization gate: the set of files reachable from a browser-bound entry, and therefore the exact set of files the browser can ever fetch (dev.js:2874-2900, the gate applied at dev.js:2183). Anything outside it 404s. A specifier found only outside it can never legitimately appear in a served importmap, so the gate is the right root by definition.

Note that the three sets dev.js:1143 passes today are not that root. state.elidableComponents, state.inertRouteModules and state.importOnlyRouteModules are elision verdicts (which browser-reachable modules get dropped from the boot), a strictly narrower question than reachability. They are still needed, as a second filter on the runtime path only, but they were never a substitute for rooting the walk.

Option 2 also subsumes the second half of the problem for free. Rooting in the graph means reading the specifiers from bareImports(graph) (module-graph.js:173), the per-file bare-specifier map #754 already populates, whose scan runs over the fully blanked mask and therefore cannot see an import written as text inside a template literal. That deletes the duplicate weaker scanner in vendor.js rather than leaving two scanners that disagree.

Prior art

Vite, ~/Documents/Projects/frameworks/vite/packages/vite/src/node/optimizer/scan.ts. computeEntries (L209-255) resolves entries from explicit optimizeDeps.entries, else build.rollupOptions.input, else an **/*.html glob, and prepareRolldownScanner then crawls from those entries. The hardcoded convention-name ignores (**/__tests__/**, **/coverage/**, globEntries L333-337) are applied only when there are no explicit entries, that is, only on the degraded path where Vite cannot root the crawl. This settles the question directly. Adding another convention name is what a dependency scanner does when it has no entry graph. WebJs has one (the route table plus the component scan), so it should crawl.

#754, in this repo. reachedVendorSpecifiers (ssr.js:2134-2164) is already a graph-rooted vendor-specifier walk. It roots at the boot's actually-shipped module set, walks transitiveDeps with the elidable components skipped, drops server files, and unions each reached file's bareImports(graph) entry. The issue cites it as prior art for option 2 and the citation is correct. The plan below is the app-wide analogue of that function, so the importmap and the modulepreload hints finally come from one scanner.

#197 and #446 define the parity invariant this must not break. #197: a committed pin was served verbatim while the live path pruned elided-only deps, so pinned and unpinned served different maps. The fix was prunePinToReachable (vendor.js:1869), called at dev.js:1154, which intersects the pin with the runtime's own reachable set at serve time. #446: per-package isolated resolution merged last-write-wins produced an incoherent graph, fixed by resolving the whole install set in one call and persisting the flattened transitives so pin and runtime agree on the same specifier-to-URL set.

How parity survives, and why the CLI does not need the elision analysis

The invariant is that a pinned app and an unpinned app serve the same importmap. It is upheld today not by pin and runtime computing the same set, but by the runtime intersecting the pin down to its own reachable set via prunePinToReachable. That only works while the pin is a superset of the runtime set. So the plan keeps a deliberate asymmetry:

  • Pin side (pinAll at vendor.js:1286, and webjs doctor's liveImports at packages/cli/lib/doctor.js:752): root at the browser-bound set, no elision analysis. Cheaper (no analyzeElision call) and a superset by construction.
  • Runtime side (dev.js:1143): root at the browser-bound set minus the existing elision skip set, walked so the skipped modules are not traversed into either.

Runtime is a subset of pin by construction, because the runtime roots are a subset of the pin roots and the runtime walk additionally prunes. Verified on examples/blog above: pin gives {core, core/directives, dayjs}, runtime gives {core, core/directives}, and prunePinToReachable intersects to the runtime answer, which is the #170 expectation of no dayjs entry.

So the answer to "can pinAll be given the same reachability data" is yes, and it does not need a booted server. elision-report.js:66-73 and check.js:1536-1538 already build buildModuleGraph + scanComponents + buildRouteTable out of process for exactly this purpose. pinAll does the same three calls, then reachableFromEntries. Cost is the 68-199ms measured above, paid by a CLI command that then makes network calls anyway. webjs doctor will build a second module graph in one run (it already builds one for the elision checks). That is accepted and left unmemoized, matching the elision-report docblock's stated position that out-of-process consumers run the analysis once and exit.

Option 1, rejected

Adding a scripts/ name to the exclusion list in walk() would fix playwright and nothing else. It leaves drizzle-orm and the other 20 phantom specifiers pinAll finds in website, it leaves the two disagreeing scanners in place, and it adds a fourth hardcoded convention name to a list that is already stale in its own docblock. It also has an open question with no good answer, the one the issue flags: an app that legitimately serves browser code from a directory named scripts/ would silently lose its importmap entries, and there is no signal available to a name-matching walk that could distinguish the two cases. Option 2 makes that question disappear, because the criterion becomes whether a page or component actually imports the file.

The one shape option 2 drops that today's walk finds

A bare import written inside an inline <script type="module"> in a page template. Today's walk finds it by accident, because it does not mask template literals, and that same accident is what admits every docs code sample. The two cannot be separated. Given WebJs has no users yet, this ships as a clean break rather than a shim. It is called out under Out of scope with its workaround.

Implementation plan

All source is plain .js with JSDoc. Do not add a .ts file under packages/.

Step 1. Add a reachability-rooted bare-specifier walk to module-graph.js

packages/server/src/module-graph.js already exports bareImports(graph) (L173) and reachableFromEntries(graph, entryFiles, appDir) (L337). Add one export beside them that composes the two. Put it directly after reachableFromEntries ends at L375.

It must follow static and dynamic edges, like reachableFromEntries and unlike transitiveDeps. This is load-bearing and is the one place the modulepreload analogue in ssr.js deliberately differs: a lazily import('./chart.ts')-ed module must not be preloaded, but its chart.js specifier absolutely must be in the importmap, or the dynamic import fails to resolve when it finally runs.

/**
 * Bare npm vendor specifiers reachable from `entryFiles`, the importmap
 * analogue of ssr.js's per-page `reachedVendorSpecifiers` (#754).
 *
 * Walks the SAME edges the authorization gate walks (static + dynamic, #751,
 * stopping at `.server.*` boundaries) so the answer is exactly "what a browser
 * could load from these entries", then unions each reached file's recorded
 * bare edges. Dynamic edges are followed here even though `transitiveDeps`
 * (preload) ignores them: a lazily imported module is not preloaded, but its
 * bare specifier still has to resolve when the import finally runs, so it
 * belongs in the importmap.
 *
 * `.server.*` files are reached (the browser fetches a stub at that URL) but
 * their bare imports are NOT collected: the source never ships, so a DB driver
 * imported by a server file must never enter the importmap.
 *
 * `skip` files are neither collected nor traversed into, so a specifier
 * reachable ONLY through an elided component drops out along with the subtree
 * behind it (#197 / #170). Omit `skip` for the un-pruned superset the pin path
 * needs.
 *
 * @param {ModuleGraph} graph
 * @param {string[]} entryFiles  absolute paths
 * @param {string} appDir
 * @param {Set<string>} [skip]  absolute paths to exclude and not traverse
 * @returns {Set<string>}
 */
export function reachableBareSpecifiers(graph, entryFiles, appDir, skip) {
  /** @type {Set<string>} */
  const specs = new Set();
  const bare = BARE_EDGES.get(graph);
  if (!bare || !bare.size) return specs;
  const dynamic = DYNAMIC_EDGES.get(graph);
  /** @type {Set<string>} */
  const visited = new Set();
  /** @type {string[]} */
  const queue = [];
  for (const entry of entryFiles) {
    if (!entry || !entry.startsWith(appDir)) continue;
    if (skip && skip.has(entry)) continue;
    visited.add(entry);
    queue.push(entry);
  }
  while (queue.length) {
    const file = /** @type {string} */ (queue.shift());
    if (!SERVER_FILE_RE.test(file)) {
      const fileBare = bare.get(file);
      if (fileBare) for (const spec of fileBare) specs.add(spec);
      const staticDeps = graph.get(file);
      const dynDeps = dynamic && dynamic.get(file);
      for (const set of [staticDeps, dynDeps]) {
        if (!set) continue;
        for (const dep of set) {
          if (visited.has(dep)) continue;
          if (!dep.startsWith(appDir)) continue;
          if (skip && skip.has(dep)) continue;
          visited.add(dep);
          queue.push(dep);
        }
      }
    }
  }
  return specs;
}

SERVER_FILE_RE, BARE_EDGES and DYNAMIC_EDGES are already module-private in this file, so nothing else needs exporting.

Step 2. Extract the browser-bound entry set into a shared module

computeBrowserBoundFiles at packages/server/src/dev.js:2874 builds the entry set and then calls reachableFromEntries. Only the entry set half is needed out of process, so split it out. Create packages/server/src/browser-entries.js:

/**
 * The browser-bound ENTRY files of an app: every module the client boot can
 * import directly. Pages, layouts, and the error / loading / forbidden /
 * unauthorized / not-found boundaries (which always ship), the two root-only
 * boundaries, `instrumentation-client`, and every discovered component (a
 * `static lazy` component is fetched by the lazy loader, not imported by a
 * page, so the component scan is an entry source in its own right).
 *
 * Split out of dev.js's `computeBrowserBoundFiles` so the CLI-side vendor
 * paths (`pinAll`, `webjs doctor`) can root a scan at the same entries the
 * dev server's authorization gate uses, without booting a server.
 *
 * @param {Awaited<ReturnType<typeof import('./router.js').buildRouteTable>>} routeTable
 * @param {Awaited<ReturnType<typeof import('./component-scanner.js').scanComponents>>} components
 * @returns {Set<string>}
 */
export function browserEntryFiles(routeTable, components) {
  /** @type {Set<string>} */
  const entries = new Set();
  for (const page of routeTable.pages) {
    if (page.file) entries.add(page.file);
    for (const f of page.layouts || []) entries.add(f);
    for (const f of page.errors || []) entries.add(f);
    for (const f of page.loadings || []) entries.add(f);
    for (const f of page.forbiddens || []) entries.add(f);
    for (const f of page.unauthorizeds || []) entries.add(f);
  }
  if (routeTable.notFound) entries.add(routeTable.notFound);
  if (routeTable.notFounds) {
    for (const f of routeTable.notFounds.values()) entries.add(f);
  }
  if (routeTable.globalError) entries.add(routeTable.globalError);
  if (routeTable.globalNotFound) entries.add(routeTable.globalNotFound);
  if (routeTable.instrumentationClient) entries.add(routeTable.instrumentationClient);
  for (const c of components) entries.add(c.file);
  return entries;
}

The body is moved verbatim from dev.js:2875-2898, including the existing comments about instrumentation-client and lazy components. Do not paraphrase them.

Then dev.js:2874 becomes a two-line wrapper. Keep the existing docblock above it (dev.js:2850-2873) and add a line saying the entry set now comes from browser-entries.js:

function computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir) {
  return reachableFromEntries(moduleGraph, [...browserEntryFiles(routeTable, components)], appDir);
}

Add import { browserEntryFiles } from './browser-entries.js'; to the dev.js import block near L52.

Step 3. Keep the entry set on state so the vendor scan can reuse it

packages/server/src/dev.js:949 reads:

            state.browserBoundFiles = computeBrowserBoundFiles(state.routeTable, state.moduleGraph, components, appDir);

Change it to compute the entries once and keep them, so the vendor closure at L1143 does not need the components local (which is scoped to ensureReady):

            state.browserEntryFiles = browserEntryFiles(state.routeTable, components);
            state.browserBoundFiles = reachableFromEntries(state.moduleGraph, [...state.browserEntryFiles], appDir);

reachableFromEntries is already imported at dev.js:52. Initialise browserEntryFiles: new Set() in the state object literal alongside elidableComponents: new Set() at dev.js:830.

Step 4. Rewrite the two vendor entry points

packages/server/src/vendor.js. Replace the whole of scanBareImports (L100-112), the walk() helper (L176-228), isServerOnlyFile (L156-161) and CONFIG_FILE_RE (L170) with the graph-rooted implementation, and rewrite the docblock at L82-99. IMPORT_RE, DYNAMIC_IMPORT_RE, BLOCK_COMMENT_RE, LINE_COMMENT_RE and stripComments (L142-149) also go: the graph's scanner replaces them. extractPackageName, BUILTIN and FRAMEWORK_SERVER_ONLY all stay.

Add imports at the top of vendor.js (currently L43-49):

import { buildModuleGraph, reachableBareSpecifiers } from './module-graph.js';
import { browserEntryFiles } from './browser-entries.js';
import { scanComponents } from './component-scanner.js';
import { buildRouteTable } from './router.js';

No cycle: router.js imports only fs-walk.js, component-scanner.js imports fs-walk.js / @webjsdev/core / js-scan.js, and module-graph.js imports js-scan.js. None of them import vendor.js.

Two exports replace the one:

/**
 * Bare npm specifiers that could reach the browser, filtered of the framework
 * packages that must never be vendored.
 *
 * The scan is ROOTED IN THE MODULE GRAPH, not in a filesystem walk. Only files
 * reachable from a browser-bound entry (page / layout / error / loading /
 * not-found / forbidden / unauthorized / instrumentation-client / any
 * component) contribute, which is the same authorization gate the dev server
 * uses to decide what it will serve at all. A file nothing imports contributes
 * nothing, so a build script under `scripts/`, a tooling config, a test helper
 * and an unreferenced module all drop out by reachability rather than by name
 * (there is no exclusion LIST any more, which is the point: the old one was
 * open-ended and went stale).
 *
 * Excluded on top of unreachability:
 *   - `.server.{js,ts,mjs,mts}` files. They are REACHED (the browser fetches an
 *     RPC or throw-at-load stub at that URL) but their source never ships, so
 *     their bare imports must not enter the importmap.
 *   - `@webjsdev/core` and its subpaths (BUILTIN, served locally).
 *   - `@webjsdev/cli` / `@webjsdev/server` / `@webjsdev/mcp` (#713).
 *   - `import type` statements and any `import` written inside a comment,
 *     string, template literal or regex body. Both come free from the module
 *     graph's blanked-mask scanner (#753 / #805); this function no longer has
 *     a scanner of its own.
 *
 * This is the OUT-OF-PROCESS entry point (`pinAll`, `webjs doctor`). It builds
 * its own graph, route table and component scan, roughly 70-200ms on the
 * in-repo apps, and applies NO elision pruning, so the result is a SUPERSET of
 * what the running server serves. That superset relation is what lets
 * `prunePinToReachable` intersect a committed pin down to the runtime answer
 * (#197). The dev server calls `reachedBareImports` instead, with the graph it
 * already has and its elision skip set.
 *
 * @param {string} appDir
 * @returns {Promise<Set<string>>}
 */
export async function scanBareImports(appDir) {
  let graph, components, routeTable;
  try {
    graph = await buildModuleGraph(appDir);
    components = await scanComponents(appDir);
    routeTable = await buildRouteTable(appDir);
  } catch {
    // An app the analysis cannot process yields no vendor specifiers rather
    // than a throw, matching how check.js and elision-report.js degrade. The
    // dev server surfaces the real problem.
    return new Set();
  }
  return reachedBareImports(graph, [...browserEntryFiles(routeTable, components)], appDir);
}

/**
 * The in-process form: the caller already has a module graph, the entry set,
 * and (on the runtime path) the elision skip set. Used by the dev server so a
 * vendor resolve does not rebuild analysis it just built.
 *
 * @param {import('./module-graph.js').ModuleGraph} graph
 * @param {string[]} entryFiles
 * @param {string} appDir
 * @param {Set<string>} [skip]  elided / inert / import-only modules
 * @returns {Set<string>}
 */
export function reachedBareImports(graph, entryFiles, appDir, skip) {
  const found = reachableBareSpecifiers(graph, entryFiles, appDir, skip);
  for (const b of BUILTIN) found.delete(b);
  for (const spec of found) {
    const p = extractPackageName(spec);
    if (p && (BUILTIN.has(p) || FRAMEWORK_SERVER_ONLY.has(p))) found.delete(spec);
  }
  return found;
}

Note the one behaviour fix folded in: the old filter loop (vendor.js:105-110) deleted a specifier only when extractPackageName matched FRAMEWORK_SERVER_ONLY, so @webjsdev/core/directives survived scanBareImports and was dropped later inside vendorImportMapEntries (vendor.js:687). Adding BUILTIN.has(p) to the same loop drops core subpaths at the same place as everything else. vendorImportMapEntries keeps its own guard as defence in depth. This is a same-file tweak in the function being rewritten, so it belongs in this PR rather than anywhere else.

Step 5. Point the dev server's vendor closure at the graph

packages/server/src/dev.js:1143 reads:

        const scan = () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules, ...state.importOnlyRouteModules.keys()]));

Change it to:

        const scan = async () => {
          const skip = new Set([...state.elidableComponents, ...state.inertRouteModules, ...state.importOnlyRouteModules.keys()]);
          return reachedBareImports(state.moduleGraph, [...state.browserEntryFiles], appDir, skip);
        };

It stays async because resolveVendorImports and the prunePinToReachable branch at dev.js:1149-1155 both await the thunk. The skip set is byte-for-byte the one built today, so the elision semantics are unchanged; what changes is that it now prunes a graph walk instead of filtering a filesystem walk, which additionally drops the subtree reachable only through a skipped module (the #780 reasoning, applied to vendors).

Update the vendor.js import at dev.js:51 to bring in reachedBareImports alongside (or instead of) scanBareImports, which dev.js no longer calls.

These three dev.js edits are at L51-52, L830, L949, L1143 and L2874. None of them is inside the two regions #1397 will edit (L1552-1567, hoisting the /public/* serve ahead of await ensureReady(), and L2107-2140, the existing /public/* branch), so the two PRs merge cleanly. Do not touch either region. #1397's measured restart window includes the jspm resolve this issue removes, so landing this materially improves #1397 without replacing it.

Step 6. Callers that need no change, and the type declaration that does

  • pinAll at packages/server/src/vendor.js:1286, const bare = await scanBareImports(appDir);, is unchanged. Same name, same one argument, now graph-rooted.
  • packages/cli/lib/doctor.js:752, const resolved = await mod.resolveVendorImports(appDir, () => mod.scanBareImports(appDir));, is unchanged for the same reason.
  • packages/server/index.js:20 re-exports scanBareImports. Add reachedBareImports beside it only if a test imports it through the package index; the in-repo tests import from ../../src/vendor.js directly, so prefer not to widen the public surface.
  • packages/server/index.d.ts:340 reads export declare function scanBareImports(dir: string, skipFiles?: Set<string>): Promise<Set<string>>;. Drop the second parameter: export declare function scanBareImports(dir: string): Promise<Set<string>>;.

Tests

Unit, packages/server/test/vendor/vendor.test.js (rewrite the scanBareImports block, L72-380)

Every existing fixture in that block writes loose files into a bare temp dir with no app/, so under a reachability-rooted scan they would all return an empty set. They must be rebuilt as minimal apps: a package.json, an app/page.ts that imports the fixture module, and the fixture module itself. Follow the existing harness exactly (mkdir under tmpdir(), a webjs-test-vendor-<purpose>-${Date.now()} directory name, rm(dir, { recursive: true, force: true }) at the end, node:test plus node:assert/strict).

Keep and rebuild these four, whose subject is vendor-level behaviour that still lives in vendor.js.

  • finds bare specifiers in source files (L74). Reachable page plus a reachable module, static and dynamic import, .server.ts excluded, relative excluded, @webjsdev/core excluded.
  • skips server-only @webjsdev pkgs (#713) (L162).
  • preserves full specifiers including subpaths (L220).
  • skips import type statements (TS erases them) (L202). Same assertion, now proving the graph scanner's dogfood: check false-positives on import type from a .server.ts file #805 behaviour flows through.

Replace these four with one test, because their subject is no longer a name list:

  • skips route.ts and middleware.ts (L123), skips test/ and tests/ directories (L185), skips dot-prefixed dirs (L348), skips *.config.* files at any depth (L366).

Replacement, scanBareImports: an unreachable file contributes nothing, whatever it is called. One fixture app with app/page.ts importing components/widget.ts (which imports real-dep), plus, all with their own distinct bare specifier: scripts/generate-og.mjs, web-test-runner.config.js, test/helper.ts, .github/tool.js, app/api/x/route.ts, middleware.ts, orphan.ts (a plain unreferenced module at the app root, which no name-based exclusion would ever catch). Assert real-dep is present and every one of the others is absent.

Move these to packages/server/test/module-graph/bare-imports.test.js, where the scanner now lives, rather than deleting them: handles CRLF line endings (L282), handles UTF-8 BOM at file start (L292), does not crash on unterminated string literal (L302), handles a multi-MB file (L334), skips import strings inside comments (L242). Reframe each as an assertion on bareImports(graph).get(file).

Add these four, which are the new behaviour:

  1. scanBareImports: a bare specifier imported only from scripts/ never reaches the result. The literal repro. Fixture app plus scripts/generate-og.mjs containing import { chromium } from 'playwright';. Assert !found.has('playwright'). Counterfactual: revert Step 4 (restore the filesystem walk()) and this fails, because the old walk has no scripts/ exclusion. Confirm by hand before shipping.
  2. scanBareImports: an import written inside a template literal is not a vendor specifier. app/page.ts exporting const SAMPLE = `import { eq } from 'drizzle-orm';` and importing a real zod. Assert zod present, drizzle-orm absent. This is the website/lib/samples.ts:25 case. Counterfactual: the old comment-only stripComments scanner passes drizzle-orm through, so reverting fails it.
  3. scanBareImports: a specifier reachable only through a dynamic import is retained. app/page.ts doing await import('./chart.ts') where chart.ts imports chart-pkg. Assert chart-pkg present. This guards the one way Step 1 could have been written wrong (using transitiveDeps, which ignores dynamic edges, would produce an unresolvable bare specifier at runtime).
  4. reachedBareImports: the elision skip set prunes a specifier and the subtree behind it. Rebuild of the old skipFiles excludes specifiers reachable only via elided components test (L104) against the new function. Elided badge.ts imports dayjs and a relative ./fmt.ts that imports fmt-pkg; shipping counter.ts imports zod and the same shared-pkg as badge.ts. Assert dayjs and fmt-pkg absent, zod and shared-pkg present. The fmt-pkg half is new: the old filesystem walk kept it, the graph walk prunes the subtree.

Unit, parity, new file packages/server/test/vendor/scan-parity.test.js

Sibling naming matches prune-pin.test.js / prune-differential-property.test.js. The invariant #197 and #446 exist to protect gets its own explicit test rather than living implicitly inside a scan test.

One fixture app carrying all three shapes at once: a shipping component importing zod, a display-only component importing dayjs (elided), and scripts/build.mjs importing playwright. Then, with no network call:

  • pin = await scanBareImports(dir) and runtime = reachedBareImports(graph, entries, dir, skip) computed from a real buildModuleGraph plus analyzeElision (the same three calls dev.js:944-968 makes).
  • Assert runtime is a subset of pin. This is the superset relation prunePinToReachable depends on, and it is the assertion that actually fails if a future change narrows the pin path without narrowing the runtime path.
  • Assert playwright is in neither.
  • Assert prunePinToReachable(pinAsImportMap, {}, runtime).imports has no dayjs key, which is the served-map parity statement in the same terms Pinned importmap skips elision pruning (pinned != unpinned) #197 wrote it.

Bun, new files test/bun/vendor-scan.mjs and test/bun/vendor-scan.test.mjs

Required, not optional. .claude/hooks/require-bun-parity-with-runtime-src.sh matches /dev\.js in its runtime-sensitive regex, so a commit staging packages/server/src/dev.js with no test/bun/** file is blocked. It is also warranted on the merits: an app scaffolded with --runtime bun runs its whole analysis under Bun, and a vendor set that differed between runtimes would serve a Bun author a different importmap from the same source. The scan is readdir plus regex over a blanked mask with no runtime-specific API, so a divergence would be a real Bun bug.

Copy the shape of test/bun/elision-report.mjs and test/bun/elision-report.test.mjs exactly, including the "runs under whichever runtime executes it" docblock and the plain-assert-script-plus-thin-wrapper split. Build one fixture app with a shipping component, an elided component, a .server.ts, a scripts/ file and a template-literal code sample, then assert the sorted scanBareImports result and the sorted reachedBareImports result are the exact expected arrays. Run both:

node scripts/run-bun-tests.js
bun test/bun/vendor-scan.mjs

and report the result. npm test does not run the Bun matrix.

e2e, test/e2e/

Applies, and it is already there. The differential elision (#181) block and the #170 property test (vendor package used only by a display-only component is never fetched) both assert the served importmap has no dayjs entry for the blog. They are the end-to-end proof that the runtime narrowing did not break the elision prune. Run the e2e suite with WEBJS_E2E=1 and report it green. Read test/e2e/fixtures/stub-jspm.mjs first: its docblock explains that with elision ON the blog's scan finds nothing and api.jspm.io is never called, which is exactly the property this change must preserve. No new e2e test is needed, because no new user-visible navigation behaviour is introduced.

Layers that do NOT apply, with reasons

  • Browser (*/test/**/browser/*). Nothing about hydration, the DOM, slots, the client router or custom-element upgrade changes. The importmap the browser receives is asserted at the e2e layer, which is where the served bytes are visible.
  • Smoke (test/examples/*/smoke/*). The in-repo apps boot the same way. The existing smoke suites will exercise the new path implicitly on every run; a dedicated smoke test would assert nothing the parity unit test does not.

Convention checks

Run from inside each app, never the repo root: ( cd gallery && npx webjs check ), ( cd examples/blog && npx webjs check ), ( cd website && npx webjs check ). Run webjs doctor in all three as well, since the required conventions CI job runs it over them and this change alters the importmap-coherence check's liveImports input.

Docs

  • packages/server/src/vendor.js:81-99, the scanBareImports docblock. Mandatory. It is the reference an agent reads and it is already stale at HEAD (it omits *.config.* and the dot-directory rule). Replaced wholesale by the docblock given in Step 4, which states the reachability criterion and, deliberately, no exclusion list.
  • packages/server/AGENTS.md:83, the vendor.js row. It currently says resolveVendorImports invokes "the whole-app scanBareImports walk". Rewrite that clause to say the scan is rooted in the module graph at the browser-bound entry set, and add the pin-is-a-superset sentence so the next agent does not have to re-derive why the CLI path skips the elision analysis.
  • packages/server/AGENTS.md:84, the module-graph.js row. Add reachableBareSpecifiers to the "Bare (npm vendor) edges (IMPORTANT: vendor deps waterfall over the CDN (no modulepreload for reached vendor entries) #754)" sentence, noting it follows dynamic edges (unlike transitiveDeps) because the importmap must cover a lazily imported module's specifier.
  • packages/server/index.d.ts:340. Signature change, covered in Step 6.
  • Not changed: root AGENTS.md, the docs site, website/, the scaffold templates, README.md. The user-visible vendor surface is unchanged: same CLI commands, same flags, same pin-file format, same importmap semantics. What changes is which specifiers are found, which is a correctness fix inside an already-documented behaviour, not a new surface. Do not use WEBJS_NO_DOC_GATE=1; the two packages/server/AGENTS.md rows plus the docblock satisfy the gate.

Acceptance criteria

  • scanBareImports(appDir) is rooted in the module graph. A bare specifier imported only from a file that no page, layout or component reaches does not appear in its result, whatever the file is named or where it sits.
  • scanBareImports on website/ returns only @webjsdev/core subpaths, so vendorImportMapEntries builds an empty install list and jspmGenerate returns {} without touching the network. website logs no playwright 401 on a cold analysis.
  • drizzle-orm no longer appears in website's vendor set. An import written inside a template literal is not a vendor specifier.
  • A specifier reachable only through a string-literal dynamic import() is still in the importmap.
  • The runtime set is a subset of the pin set for the same app, asserted directly in packages/server/test/vendor/scan-parity.test.js.
  • examples/blog still serves an importmap with no dayjs entry with elision on, pinned and unpinned alike (Add e2e network probes for vendor-never-fetched + inert-route zero-JS elision #170 / Pinned importmap skips elision pruning (pinned != unpinned) #197 preserved). The e2e differential-elision block is green under WEBJS_E2E=1.
  • Each of the four new unit tests has a hand-verified counterfactual: reverting the change fails it.
  • node scripts/run-bun-tests.js and bun test/bun/vendor-scan.mjs both green, reported in the PR.
  • webjs check and webjs doctor clean in gallery, examples/blog and website.
  • The vendor.js docblock and both packages/server/AGENTS.md rows describe the reachability criterion, and no stale exclusion list survives anywhere.
  • packages/server/src/dev.js is touched only at L51-52, L830, L949, L1143 and L2874. Nothing at L1552-1567 or L2107-2140.

Out of scope

  • Do not add a scripts/ name exclusion. That is option 1, rejected above. If the graph rooting somehow cannot land, reopen the decision rather than shipping both.
  • Do not touch packages/server/src/dev.js at L1552-1567 or L2107-2140, the /public/* serve path. dogfood: rapid edits leave the dev page unstyled (#893 residual gap) #1397 is being planned in parallel and edits exactly those regions plus dev-reload-worker.js.
  • Do not memoize the module graph across webjs doctor checks. Doctor will build a second graph for liveImports. elision-report.js's docblock states the deliberate position that out-of-process consumers run the analysis once and exit, and a cross-check memo is a separate design question.
  • Do not add support for a bare import inside an inline <script type="module"> in a page template. Today's walk finds it only by not masking template literals, the same accident that admits every docs code sample, and the two cannot be separated. An app that genuinely needs it can add a real module import somewhere in its graph. WebJs has no users, so this ships as a clean break with no shim.
  • Do not change jspmGenerate, the 401 fallback, prunePinToReachable, or the pin-file format. This issue changes only which specifiers are found, never how they are resolved or stored.
  • Do not pin website. In-repo apps must resolve vendors live; a committed .webjs/vendor/importmap.json broke the Add e2e network probes for vendor-never-fetched + inert-route zero-JS elision #170 elision e2e once already and was reverted in Revert the blog vendor pin that broke the #170 elision e2e #196.
  • Do not file follow-up issues for anything this turns up. Report it in the PR and let the owner decide.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions