You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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)).
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>} */exportfunctionreachableBareSpecifiers(graph,entryFiles,appDir,skip){/** @type {Set<string>} */constspecs=newSet();constbare=BARE_EDGES.get(graph);if(!bare||!bare.size)returnspecs;constdynamic=DYNAMIC_EDGES.get(graph);/** @type {Set<string>} */constvisited=newSet();/** @type {string[]} */constqueue=[];for(constentryofentryFiles){if(!entry||!entry.startsWith(appDir))continue;if(skip&&skip.has(entry))continue;visited.add(entry);queue.push(entry);}while(queue.length){constfile=/** @type {string} */(queue.shift());if(!SERVER_FILE_RE.test(file)){constfileBare=bare.get(file);if(fileBare)for(constspecoffileBare)specs.add(spec);conststaticDeps=graph.get(file);constdynDeps=dynamic&&dynamic.get(file);for(constsetof[staticDeps,dynDeps]){if(!set)continue;for(constdepofset){if(visited.has(dep))continue;if(!dep.startsWith(appDir))continue;if(skip&&skip.has(dep))continue;visited.add(dep);queue.push(dep);}}}}returnspecs;}
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>} */exportfunctionbrowserEntryFiles(routeTable,components){/** @type {Set<string>} */constentries=newSet();for(constpageofrouteTable.pages){if(page.file)entries.add(page.file);for(constfofpage.layouts||[])entries.add(f);for(constfofpage.errors||[])entries.add(f);for(constfofpage.loadings||[])entries.add(f);for(constfofpage.forbiddens||[])entries.add(f);for(constfofpage.unauthorizeds||[])entries.add(f);}if(routeTable.notFound)entries.add(routeTable.notFound);if(routeTable.notFounds){for(constfofrouteTable.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(constcofcomponents)entries.add(c.file);returnentries;}
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:
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):
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):
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>>} */exportasyncfunctionscanBareImports(appDir){letgraph,components,routeTable;try{graph=awaitbuildModuleGraph(appDir);components=awaitscanComponents(appDir);routeTable=awaitbuildRouteTable(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.returnnewSet();}returnreachedBareImports(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>} */exportfunctionreachedBareImports(graph,entryFiles,appDir,skip){constfound=reachableBareSpecifiers(graph,entryFiles,appDir,skip);for(constbofBUILTIN)found.delete(b);for(constspecoffound){constp=extractPackageName(spec);if(p&&(BUILTIN.has(p)||FRAMEWORK_SERVER_ONLY.has(p)))found.delete(spec);}returnfound;}
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
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).
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:
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.
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.
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).
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.
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/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.
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 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.
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/.mtsfile underappDirthat 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-99and implemented inwalk()atvendor.js:176-228. They arenode_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 noscripts/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:19andwebsite/scripts/generate-og-why.mjs:16both open withimport { 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 toscripts/anywhere inwebsite/are npm-script invocations inpackage.jsonand two prose comments. Soplaywrightreaches the vendor resolver and is sent to jspm, which fails every time:2. Code samples are scanned. The scan strips comments (
stripComments,vendor.js:145-149) but does not mask template literals or plain strings, so animportwritten as example text inside a template literal is indistinguishable from a real one.website/lib/samples.ts:25holdsimport { eq } from 'drizzle-orm';inside the exportedACTION_SAMPLEtemplate literal, a docs code sample.drizzle-ormtherefore also reaches jspm on every cold analysis ofwebsite.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.jsscans overredactStringsAndTemplates(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'sbareImports(graph)(#754) and the importmap comes fromscanBareImports, which is whywebsitecurrently serves adrizzle-ormimportmap 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.
PINisscanBareImports(appDir)aspinAllandwebjs doctorcall it,RUNTIMEisscanBareImports(appDir, skipFiles)asdev.js:1143calls it,GRAPHis what a reachability-rooted scan would produce (browser-bound files only, server files excluded, specifiers read frombareImports(graph)).website/@/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)@webjsdev/core/directives,drizzle-orm,playwright@webjsdev/core,@webjsdev/core/directivesBoth survivors in the GRAPH row are in the
BUILTINset, sovendorImportMapEntriesproduces an empty install list, andjspmGeneratereturns{}without a network call (vendor.js:577). The jspm round trip forwebsitedoes not shrink, it disappears.examples/blog/@webjsdev/core/directives,dayjs@webjsdev/core/directives@webjsdev/core,@webjsdev/core/directives,dayjs@webjsdev/core,@webjsdev/core/directivesdayjsis reachable only through the elidedcomponents/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.buildModuleGraphcost, measured: 199ms onwebsite, 74ms ongallery, 68ms onexamples/blog.Corrections to the original report
vendor.js:81-99, not85-99, and the exclusion list it enumerates is missing*.config.*and the dot-directory rule thatwalk()actually applies. It is already out of date before this change touches it.scripts/only. It is not.drizzle-ormfrom a docs code sample is the same defect, and 21 of the 24 specifierspinAllcurrently finds inwebsiteare phantom.websitegoes to zero, so the call is not made at all.jspmGenerate(vendor.js:576-650) runs one unified call, and on a permanent 401 it probes each install alone viajspmProbeOne(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:949computesstate.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 atdev.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:1143passes today are not that root.state.elidableComponents,state.inertRouteModulesandstate.importOnlyRouteModulesare 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 animportwritten as text inside a template literal. That deletes the duplicate weaker scanner invendor.jsrather 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 explicitoptimizeDeps.entries, elsebuild.rollupOptions.input, else an**/*.htmlglob, andprepareRolldownScannerthen crawls from those entries. The hardcoded convention-name ignores (**/__tests__/**,**/coverage/**,globEntriesL333-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, walkstransitiveDepswith the elidable components skipped, drops server files, and unions each reached file'sbareImports(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 atdev.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:pinAllatvendor.js:1286, andwebjs doctor'sliveImportsatpackages/cli/lib/doctor.js:752): root at the browser-bound set, no elision analysis. Cheaper (noanalyzeElisioncall) and a superset by construction.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/blogabove: pin gives{core, core/directives, dayjs}, runtime gives{core, core/directives}, andprunePinToReachableintersects to the runtime answer, which is the #170 expectation of nodayjsentry.So the answer to "can
pinAllbe given the same reachability data" is yes, and it does not need a booted server.elision-report.js:66-73andcheck.js:1536-1538already buildbuildModuleGraph+scanComponents+buildRouteTableout of process for exactly this purpose.pinAlldoes the same three calls, thenreachableFromEntries. Cost is the 68-199ms measured above, paid by a CLI command that then makes network calls anyway.webjs doctorwill 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 inwalk()would fixplaywrightand nothing else. It leavesdrizzle-ormand the other 20 phantom specifierspinAllfinds inwebsite, 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 namedscripts/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
importwritten 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
.jswith JSDoc. Do not add a.tsfile underpackages/.Step 1. Add a reachability-rooted bare-specifier walk to
module-graph.jspackages/server/src/module-graph.jsalready exportsbareImports(graph)(L173) andreachableFromEntries(graph, entryFiles, appDir)(L337). Add one export beside them that composes the two. Put it directly afterreachableFromEntriesends at L375.It must follow static and dynamic edges, like
reachableFromEntriesand unliketransitiveDeps. This is load-bearing and is the one place the modulepreload analogue inssr.jsdeliberately differs: a lazilyimport('./chart.ts')-ed module must not be preloaded, but itschart.jsspecifier absolutely must be in the importmap, or the dynamic import fails to resolve when it finally runs.SERVER_FILE_RE,BARE_EDGESandDYNAMIC_EDGESare already module-private in this file, so nothing else needs exporting.Step 2. Extract the browser-bound entry set into a shared module
computeBrowserBoundFilesatpackages/server/src/dev.js:2874builds the entry set and then callsreachableFromEntries. Only the entry set half is needed out of process, so split it out. Createpackages/server/src/browser-entries.js:The body is moved verbatim from
dev.js:2875-2898, including the existing comments aboutinstrumentation-clientand lazy components. Do not paraphrase them.Then
dev.js:2874becomes a two-line wrapper. Keep the existing docblock above it (dev.js:2850-2873) and add a line saying the entry set now comes frombrowser-entries.js:Add
import { browserEntryFiles } from './browser-entries.js';to the dev.js import block near L52.Step 3. Keep the entry set on
stateso the vendor scan can reuse itpackages/server/src/dev.js:949reads:Change it to compute the entries once and keep them, so the vendor closure at L1143 does not need the
componentslocal (which is scoped toensureReady):reachableFromEntriesis already imported atdev.js:52. InitialisebrowserEntryFiles: new Set()in thestateobject literal alongsideelidableComponents: new Set()atdev.js:830.Step 4. Rewrite the two vendor entry points
packages/server/src/vendor.js. Replace the whole ofscanBareImports(L100-112), thewalk()helper (L176-228),isServerOnlyFile(L156-161) andCONFIG_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_REandstripComments(L142-149) also go: the graph's scanner replaces them.extractPackageName,BUILTINandFRAMEWORK_SERVER_ONLYall stay.Add imports at the top of
vendor.js(currently L43-49):No cycle:
router.jsimports onlyfs-walk.js,component-scanner.jsimportsfs-walk.js/@webjsdev/core/js-scan.js, andmodule-graph.jsimportsjs-scan.js. None of them importvendor.js.Two exports replace the one:
Note the one behaviour fix folded in: the old filter loop (
vendor.js:105-110) deleted a specifier only whenextractPackageNamematchedFRAMEWORK_SERVER_ONLY, so@webjsdev/core/directivessurvivedscanBareImportsand was dropped later insidevendorImportMapEntries(vendor.js:687). AddingBUILTIN.has(p)to the same loop drops core subpaths at the same place as everything else.vendorImportMapEntrieskeeps 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:1143reads:Change it to:
It stays
asyncbecauseresolveVendorImportsand theprunePinToReachablebranch atdev.js:1149-1155bothawaitthe 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.jsimport atdev.js:51to bring inreachedBareImportsalongside (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 ofawait 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
pinAllatpackages/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:20re-exportsscanBareImports. AddreachedBareImportsbeside it only if a test imports it through the package index; the in-repo tests import from../../src/vendor.jsdirectly, so prefer not to widen the public surface.packages/server/index.d.ts:340readsexport 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 thescanBareImportsblock, 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: apackage.json, anapp/page.tsthat imports the fixture module, and the fixture module itself. Follow the existing harness exactly (mkdirundertmpdir(), awebjs-test-vendor-<purpose>-${Date.now()}directory name,rm(dir, { recursive: true, force: true })at the end,node:testplusnode: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.tsexcluded, relative excluded,@webjsdev/coreexcluded.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 withapp/page.tsimportingcomponents/widget.ts(which importsreal-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). Assertreal-depis 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 onbareImports(graph).get(file).Add these four, which are the new behaviour:
scanBareImports: a bare specifier imported only from scripts/ never reaches the result. The literal repro. Fixture app plusscripts/generate-og.mjscontainingimport { chromium } from 'playwright';. Assert!found.has('playwright'). Counterfactual: revert Step 4 (restore the filesystemwalk()) and this fails, because the old walk has noscripts/exclusion. Confirm by hand before shipping.scanBareImports: an import written inside a template literal is not a vendor specifier.app/page.tsexportingconst SAMPLE = `import { eq } from 'drizzle-orm';`and importing a realzod. Assertzodpresent,drizzle-ormabsent. This is thewebsite/lib/samples.ts:25case. Counterfactual: the old comment-onlystripCommentsscanner passesdrizzle-ormthrough, so reverting fails it.scanBareImports: a specifier reachable only through a dynamic import is retained.app/page.tsdoingawait import('./chart.ts')wherechart.tsimportschart-pkg. Assertchart-pkgpresent. This guards the one way Step 1 could have been written wrong (usingtransitiveDeps, which ignores dynamic edges, would produce an unresolvable bare specifier at runtime).reachedBareImports: the elision skip set prunes a specifier and the subtree behind it. Rebuild of the oldskipFiles excludes specifiers reachable only via elided componentstest (L104) against the new function. Elidedbadge.tsimportsdayjsand a relative./fmt.tsthat importsfmt-pkg; shippingcounter.tsimportszodand the sameshared-pkgasbadge.ts. Assertdayjsandfmt-pkgabsent,zodandshared-pkgpresent. Thefmt-pkghalf 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.jsSibling 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 importingdayjs(elided), andscripts/build.mjsimportingplaywright. Then, with no network call:pin = await scanBareImports(dir)andruntime = reachedBareImports(graph, entries, dir, skip)computed from a realbuildModuleGraphplusanalyzeElision(the same three callsdev.js:944-968makes).runtimeis a subset ofpin. This is the superset relationprunePinToReachabledepends on, and it is the assertion that actually fails if a future change narrows the pin path without narrowing the runtime path.playwrightis in neither.prunePinToReachable(pinAsImportMap, {}, runtime).importshas nodayjskey, 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.mjsandtest/bun/vendor-scan.test.mjsRequired, not optional.
.claude/hooks/require-bun-parity-with-runtime-src.shmatches/dev\.jsin its runtime-sensitive regex, so a commit stagingpackages/server/src/dev.jswith notest/bun/**file is blocked. It is also warranted on the merits: an app scaffolded with--runtime bunruns 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 isreaddirplus 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.mjsandtest/bun/elision-report.test.mjsexactly, 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, ascripts/file and a template-literal code sample, then assert the sortedscanBareImportsresult and the sortedreachedBareImportsresult are the exact expected arrays. Run both:and report the result.
npm testdoes 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 nodayjsentry for the blog. They are the end-to-end proof that the runtime narrowing did not break the elision prune. Run the e2e suite withWEBJS_E2E=1and report it green. Readtest/e2e/fixtures/stub-jspm.mjsfirst: its docblock explains that with elision ON the blog's scan finds nothing andapi.jspm.iois 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
*/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.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 ). Runwebjs doctorin all three as well, since the requiredconventionsCI job runs it over them and this change alters theimportmap-coherencecheck'sliveImportsinput.Docs
packages/server/src/vendor.js:81-99, thescanBareImportsdocblock. 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, thevendor.jsrow. It currently saysresolveVendorImportsinvokes "the whole-appscanBareImportswalk". 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, themodule-graph.jsrow. AddreachableBareSpecifiersto 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 (unliketransitiveDeps) because the importmap must cover a lazily imported module's specifier.packages/server/index.d.ts:340. Signature change, covered in Step 6.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 useWEBJS_NO_DOC_GATE=1; the twopackages/server/AGENTS.mdrows 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.scanBareImportsonwebsite/returns only@webjsdev/coresubpaths, sovendorImportMapEntriesbuilds an empty install list andjspmGeneratereturns{}without touching the network.websitelogs noplaywright401 on a cold analysis.drizzle-ormno longer appears inwebsite's vendor set. Animportwritten inside a template literal is not a vendor specifier.import()is still in the importmap.packages/server/test/vendor/scan-parity.test.js.examples/blogstill serves an importmap with nodayjsentry 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 underWEBJS_E2E=1.node scripts/run-bun-tests.jsandbun test/bun/vendor-scan.mjsboth green, reported in the PR.webjs checkandwebjs doctorclean ingallery,examples/blogandwebsite.vendor.jsdocblock and bothpackages/server/AGENTS.mdrows describe the reachability criterion, and no stale exclusion list survives anywhere.packages/server/src/dev.jsis touched only at L51-52, L830, L949, L1143 and L2874. Nothing at L1552-1567 or L2107-2140.Out of scope
scripts/name exclusion. That is option 1, rejected above. If the graph rooting somehow cannot land, reopen the decision rather than shipping both.packages/server/src/dev.jsat 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 plusdev-reload-worker.js.webjs doctorchecks. Doctor will build a second graph forliveImports.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.importinside 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.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.website. In-repo apps must resolve vendors live; a committed.webjs/vendor/importmap.jsonbroke 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.