From 329572c19c23bc0248397a17e7bcea7d92595d07 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sat, 8 Aug 2026 22:39:48 +0530 Subject: [PATCH 1/3] fix: resolve @webjsdev versions through Node so a hoisted install passes webjs doctor's WEBJS_VERSIONS check read /node_modules//package.json directly. Under npm workspaces the @webjsdev/* deps hoist to the root node_modules, so an app subdirectory has no local copy and every declared dep was reported not installed on a perfectly healthy install. Both in-repo apps warned that way, while FRAMEWORK_RESOLVE in the same output said the framework resolves fine, so the two checks openly contradicted each other. Ask Node's resolver instead, anchored at the app dir. That is the question the check actually asks, so it needs no hoist-awareness of its own and picks up symlinked workspace links and nested trees for free. Both halves of the resolve are load-bearing: @webjsdev/cli is bin-only so it has no main entry, and @webjsdev/server locks ./package.json out of its exports map. The check now becomes gatable, which it could not be while it warned on a healthy install. --- packages/cli/lib/doctor.js | 95 +++++++++++++++++++++++++----- test/cli/doctor.test.mjs | 115 +++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 14 deletions(-) diff --git a/packages/cli/lib/doctor.js b/packages/cli/lib/doctor.js index 7ce4efead..6429f8ab8 100644 --- a/packages/cli/lib/doctor.js +++ b/packages/cli/lib/doctor.js @@ -52,7 +52,7 @@ import { existsSync, statSync, readdirSync, readFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; -import { join, relative } from 'node:path'; +import { dirname, join, relative } from 'node:path'; import { createRequire } from 'node:module'; import { checkNodeInline } from './node-preflight.js'; @@ -837,13 +837,87 @@ async function checkImportmapCoherence(appDir, opts) { }; } +/** + * Read a dependency's INSTALLED version as resolved FROM `appDir`, or null when + * it does not resolve there at all. + * + * Node's own resolver is the ground truth here, not a directory read. The check + * this serves asks "would this app resolve this dependency at runtime, and at + * what version", and Node's resolution algorithm IS that question's definition, + * so anything re-implementing it can only be a worse approximation. Asking Node + * handles workspace hoisting (the bug this fixes: under npm workspaces the + * `@webjsdev/*` deps hoist to the ROOT node_modules, so an app subdirectory has + * no local copy and a per-app `node_modules//package.json` read reported + * every declared dep missing on a healthy install), symlinked workspace links, + * nested non-hoisted trees, and `package.json` `imports`, for free and for ever. + * + * The direct `/package.json` resolve is attempted FIRST because a package + * may declare no main entry at all: `@webjsdev/cli` is bin-only (no `main`, no + * `exports`), so `require.resolve('@webjsdev/cli')` throws MODULE_NOT_FOUND. + * The ERR_PACKAGE_PATH_NOT_EXPORTED fallback exists because a package may lock + * its manifest out of its `exports` map: `@webjsdev/server` exports only `.`, + * `./check`, `./testing`, and `./webjs-config.schema.json`, so the direct + * manifest resolve is refused and the main entry plus a bounded walk up to the + * package root is the way in. Neither strategy alone resolves all four + * `@webjsdev/*` packages; both halves are required. + * + * Local rather than `getPackageVersion` from `@webjsdev/server` for two reasons. + * Doctor must stay usable when the framework does not resolve from the app dir + * at all, which is the #954 fresh-worktree case doctor exists to diagnose, so + * this check cannot import the server (the same argument `frameworkResolves` + * below already follows). And `getPackageVersion` resolves the main entry only, + * so it returns null for a bin-only package, which would leave `@webjsdev/cli` + * reported missing: the same false positive with more machinery. + * + * Pinned by the workspace, bin-only, and exports-locked fixtures in + * `test/cli/doctor.test.mjs`. + * @param {string} dep package name, e.g. `@webjsdev/server` + * @param {string} appDir directory to anchor resolution at + * @returns {Promise} the installed version, or null when unresolvable + */ +async function readInstalledVersion(dep, appDir) { + // The base file need not exist; createRequire only uses it to anchor the + // node_modules lookup at appDir. + const require = createRequire(join(appDir, '__webjs_resolve_probe__.js')); + let manifestPath = null; + try { + manifestPath = require.resolve(dep + '/package.json'); + } catch (err) { + if (err?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') return null; + let entry; + try { + entry = require.resolve(dep); + } catch { + return null; + } + let dir = dirname(entry); + for (let i = 0; i < 12; i++) { + const candidate = join(dir, 'package.json'); + if (existsSync(candidate)) { + manifestPath = candidate; + break; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + if (!manifestPath) return null; + } + try { + return JSON.parse(await readFile(manifestPath, 'utf8')).version || null; + } catch { + return null; + } +} + /** * CHECK 5, @webjsdev/* version coherence. WARN-level only (a version drift is * not a crash). Reads the app package.json `@webjsdev/*` ranges across - * dependencies + devDependencies, then for each reads the INSTALLED version from - * `node_modules/@webjsdev//package.json` and checks it satisfies the - * declared range. PASS when every @webjsdev dep is present + satisfied; WARN on - * a missing install or a range drift. + * dependencies + devDependencies, then for each resolves the INSTALLED version + * through Node's own resolver anchored at the app dir (see + * `readInstalledVersion`, which is why a workspace-hoisted install resolves) + * and checks it satisfies the declared range. PASS when every @webjsdev dep is + * present + satisfied; WARN on a missing install or a range drift. * @param {string} appDir * @returns {Promise} */ @@ -881,15 +955,8 @@ async function checkWebjsVersions(appDir) { const missing = []; const drift = []; for (const dep of webjsDeps) { - const installedPkg = join(appDir, 'node_modules', dep, 'package.json'); - if (!existsSync(installedPkg)) { - missing.push(dep); - continue; - } - let installedVersion = ''; - try { - installedVersion = JSON.parse(await readFile(installedPkg, 'utf8')).version || ''; - } catch { + const installedVersion = await readInstalledVersion(dep, appDir); + if (!installedVersion) { missing.push(dep); continue; } diff --git a/test/cli/doctor.test.mjs b/test/cli/doctor.test.mjs index 275ed4ec3..ddaa0561c 100644 --- a/test/cli/doctor.test.mjs +++ b/test/cli/doctor.test.mjs @@ -191,6 +191,7 @@ test('version check WARNS on a missing @webjsdev install', async () => { const v = byName(results, 'webjs-versions'); assert.equal(v.status, 'warn'); assert.match(v.message, /not installed/); + assert.match(v.message, /@webjsdev\/core/, 'the message must name the dep that is missing'); assert.match(v.fix, /npm install/); }); @@ -219,6 +220,120 @@ test('version check PASSES when installed satisfies the declared range', async ( assert.equal(byName(results, 'webjs-versions').status, 'pass'); }); +// --------------------------------------------------------------------------- +// Resolution, not a per-app directory read (#1300 part 3). +// +// The check used to read `/node_modules//package.json` directly. +// Under npm workspaces the `@webjsdev/*` deps hoist to the ROOT node_modules, so +// an app subdirectory has no local copy and every declared dep was reported +// missing on a healthy install. It now asks Node's resolver instead, anchored at +// the app dir, which is the same question `framework-resolve` asks (the two +// openly contradicted each other before this). +// +// COUNTERFACTUAL: restore the `join(appDir, 'node_modules', dep, 'package.json')` +// read and the three fixtures below all red with "N @webjsdev/* dependency not +// installed", which is the measured before-state on examples/blog and website. +// --------------------------------------------------------------------------- + +/** + * A workspace-shaped tree: deps installed ONLY in the root node_modules, plus an + * app subdirectory with its own package.json and no node_modules of its own. + * Returns the app dir. + */ +function workspaceFixture(installs, ranges) { + const root = tmpDir(); + write(root, 'package.json', JSON.stringify({ name: 'root', workspaces: ['apps/*'] })); + for (const [name, manifest] of Object.entries(installs)) { + write(root, `node_modules/${name}/package.json`, JSON.stringify(manifest)); + // Every fixture package carries a real entry file, so the exports-map + // fallback has something to resolve. + write(root, `node_modules/${name}/index.js`, 'export const x = 1;\n'); + } + write(root, 'apps/web/package.json', JSON.stringify({ name: 'web', dependencies: ranges })); + return join(root, 'apps/web'); +} + +test('version check PASSES for a workspace app whose deps hoist to the root node_modules', async () => { + const appDir = workspaceFixture( + { + '@webjsdev/core': { name: '@webjsdev/core', version: '0.7.48', main: 'index.js' }, + '@webjsdev/server': { name: '@webjsdev/server', version: '0.8.60', main: 'index.js' }, + }, + { '@webjsdev/core': '^0.7.0', '@webjsdev/server': '^0.8.0' } + ); + const results = await runDoctorChecks(appDir, baseOpts({ nodeVersion: '24.0.0' })); + const v = byName(results, 'webjs-versions'); + assert.equal(v.status, 'pass', v.message); + assert.match(v.message, /All 2 @webjsdev\/\* dependency/); +}); + +test('version check resolves a BIN-ONLY package (no main, no exports), like @webjsdev/cli', async () => { + // require.resolve('') throws MODULE_NOT_FOUND for a package with no main + // entry, which is why the direct `/package.json` resolve is attempted + // FIRST rather than as a fallback. + const appDir = workspaceFixture( + { '@webjsdev/cli': { name: '@webjsdev/cli', version: '0.10.52', bin: { webjs: 'bin/webjs.js' } } }, + { '@webjsdev/cli': '^0.10.0' } + ); + const results = await runDoctorChecks(appDir, baseOpts({ nodeVersion: '24.0.0' })); + const v = byName(results, 'webjs-versions'); + assert.equal(v.status, 'pass', v.message); +}); + +test('version check resolves an EXPORTS-LOCKED package whose map omits ./package.json', async () => { + // @webjsdev/server's exports map has no './package.json' entry, so the direct + // manifest resolve is refused with ERR_PACKAGE_PATH_NOT_EXPORTED and the main + // entry plus a walk to the package root is the only way in. + const appDir = workspaceFixture( + { + '@webjsdev/server': { + name: '@webjsdev/server', + version: '0.8.60', + exports: { '.': './index.js', './check': './check.js' }, + }, + }, + { '@webjsdev/server': '^0.8.0' } + ); + const results = await runDoctorChecks(appDir, baseOpts({ nodeVersion: '24.0.0' })); + const v = byName(results, 'webjs-versions'); + assert.equal(v.status, 'pass', v.message); +}); + +test('version check still reports drift for a hoisted install, so the resolved version is real', async () => { + // An undefined version could never produce a drift message, so this doubles as + // the proof that the resolved version is the manifest's own string. + const appDir = workspaceFixture( + { '@webjsdev/core': { name: '@webjsdev/core', version: '0.8.0', main: 'index.js' } }, + { '@webjsdev/core': '^0.7.0' } + ); + const results = await runDoctorChecks(appDir, baseOpts({ nodeVersion: '24.0.0' })); + const v = byName(results, 'webjs-versions'); + assert.equal(v.status, 'warn'); + assert.match(v.message, /drift/); + assert.match(v.message, /@webjsdev\/core@0\.8\.0/, 'the real installed version must appear'); +}); + +test('version check WARNS for a workspace app declaring a dep nothing installed', async () => { + // The regression guard: resolving through Node must not make the check vacuous. + const appDir = workspaceFixture( + { '@webjsdev/core': { name: '@webjsdev/core', version: '0.7.48', main: 'index.js' } }, + { '@webjsdev/core': '^0.7.0', '@webjsdev/server': '^0.8.0' } + ); + const results = await runDoctorChecks(appDir, baseOpts({ nodeVersion: '24.0.0' })); + const v = byName(results, 'webjs-versions'); + assert.equal(v.status, 'warn'); + assert.match(v.message, /not installed: @webjsdev\/server/); +}); + +test('version check does NOT warn on a range shape it cannot statically verify', async () => { + const appDir = workspaceFixture( + { '@webjsdev/core': { name: '@webjsdev/core', version: '0.7.48', main: 'index.js' } }, + { '@webjsdev/core': 'github:webjsdev/webjs#main' } + ); + const results = await runDoctorChecks(appDir, baseOpts({ nodeVersion: '24.0.0' })); + assert.equal(byName(results, 'webjs-versions').status, 'pass'); +}); + // --------------------------------------------------------------------------- // framework resolvability (#954): the fresh-git-worktree trap. // --------------------------------------------------------------------------- From c9730b24c135c511adf80ac8a2f57218f13c6ad4 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sat, 8 Aug 2026 22:40:47 +0530 Subject: [PATCH 2/3] docs(cli): record how WEBJS_VERSIONS resolves an installed version --- packages/cli/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index dc1669ec6..71e10e395 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -172,7 +172,7 @@ README.md npm-facing package readme. | `webjs routes [--json\|--table] [--no-headers]` | Prints the route table to stdout (#975): every page (path, owner file, dynamic params) and every `route.{js,ts}` handler (path, owner file, HTTP methods). Reuses `buildRouteTable` from `@webjsdev/server` (the ONE walker, shared with `webjs types` + the dev server) and the shared `projectRoutes` projector from `@webjsdev/mcp/routes-report`, so `--json` is byte-identical to the MCP `list_routes` tool (the same split as `check --json` / `check-report.js`). Default is a grouped tree; `--table` is aligned KIND/PATH/METHODS/FILE columns and `--no-headers` drops the header row for piping. Read-only. Tests: `test/cli/routes.test.mjs` | | `webjs elision [--json] [--verify] [--routes ]` | `analyzeAppElision()` from `@webjsdev/server` (#1308). Prints the display-only elision verdict: every component module as elided or shipped (a shipped one naming the EVIDENCE that forced it, `own` / `observed` / `closure` / `render` / `import` / `unreadable`, and the module that did the forcing), every page/layout as inert / import-only / ships-whole, and every ORPHAN class, one with no registration call or a computed tag, which the scanner cannot see either way. `--json` is byte-identical to the MCP `list_elision` tool (drift-tested). `--verify` boots two `createRequestHandler` instances with `WEBJS_ELIDE` flipped, renders the app's static page corpus through both, and diffs the masked SSR bytes via the shared `maskJsSet` / `staticPageRoutes` leaf: the framework's own differential guard pointed at an arbitrary app. It FORCES the ON side on (the override wins over `webjs.elide`, so an opted-out app still gets a real pair), skips dynamic routes by name (`--routes` adds real paths), skips a nondeterministic route rather than failing it, and exits non-zero on a divergence OR on a corpus where nothing was compared. It reports how many modules elision dropped, so a pass over a corpus with nothing elidable is visibly trivial rather than mistaken for proof | | `webjs mcp` | Delegates to `runMcpServer()` from the standalone `@webjsdev/mcp` package (#415; full surface in `packages/mcp/AGENTS.md`). A read-only MCP stdio server: INTROSPECTION (`list_routes` / `list_actions` / `list_components` / `list_elision` / `check`), KNOWLEDGE (`init` primer, `docs`, `resources`, `prompts`), and a `source` tool. The scaffold's `.claude.json` registers the server directly as `{ "command": "npx", "args": ["@webjsdev/mcp"] }` (mountable in any MCP host, e.g. Cursor `.cursor/mcp.json`); `webjs mcp` stays as a back-compat alias. STDOUT is the JSON-RPC channel (diagnostics go to stderr) | -| `webjs doctor` | `runDoctorChecks()` from `lib/doctor.js`. TWO elision checks share ONE `analyzeAppElision()` call so the module graph is built once per run: `ELISION_CARRIERS` (#646, the page/layout carrier advisory) and `ELISION_COMPONENTS` (#1308, the other direction, which PASSES with the elided inventory and warns ONLY on an orphan, the one shape that gets no verdict and has no escape hatch; gateable like any other code via `webjs.doctor.gate`). A project-health checklist over existing signals (Node major, tsconfig `erasableSyntaxOnly`, `.env` drift vs `.env.example`, vendor-pin freshness, the `.gitignore` keeping `.webjs/vendor/` committable (`vendor-gitignore`, moved here from `webjs check` in #461 as a warn since it is a project-config concern, not source correctness), `@webjsdev/*` version coherence, a framework-resolve probe (#954: `checkFrameworkResolves` + the exported `frameworkResolves` helper WARN when `@webjsdev/core` cannot be resolved FROM the app dir via a directory-relative `createRequire` probe, naming the fresh-git-worktree-without-node_modules cause and the fix; silent PASS when it resolves, so a healthy app is untouched), importmap coherence, git pre-commit hook, and a page/layout elision advisory (#646: `checkElisionCarriers` runs `@webjsdev/server`'s `analyzeAppElision` and WARNS, naming the first client-effecting blocker, for each page/layout that ships whole instead of being elided as a carrier; advisory-only, skipped when elision is off or there is no `app/`), and an unmarked-stylesheet-link advisory (#1095: `checkUnmarkedAssetLinks` scans every `app/**` route module that renders markup (`page` / `layout`, plus the always-shipped `error` / `not-found` / `forbidden` / `unauthorized` / `loading` boundaries and `global-error`, which writes its own ``), skipping `_private` folders the router never routes, for a `` whose href is a STATIC, quoted, root-absolute `/public/...` literal, i.e. one not wrapped in `asset()`, and WARNs with `file:line`, since that url is un-versioned and a deploy cannot bust a CDN's copy. Scoped to `rel="stylesheet"`: a cross-origin sheet must keep its exact url, `rel="icon"` is a legitimate deliberate non-mark (the website leaves its favicons bare so the SEO repo-health tests parse the hrefs literally), `rel="preload"` MUST stay unversioned or its hint could never match the request the CSS `url()` makes, and an `href=${expr}` hole is undecidable from source and is exactly the marked shape. It reads the author's SOURCE and rewrites nothing, deliberately: the automatic form was built and rejected in #1196, and this is the same authoring-time posture Rails and Remix take)). PURE checks render with a `[pass]` / `[off]` / `[warn]` / `[fail]` marker; the exit is non-zero when a HARD check fails (Node below the floor, or `erasableSyntaxOnly` missing in an existing tsconfig) OR when a check the app gated `error` reports something (#1257, see below); an ungated warn (drift / staleness) never fails the exit. Each `DoctorResult` carries a stable SCREAMING_SNAKE `code` (#975, the `DOCTOR_CODES` map; e.g. `NODE_VERSION`, `TSCONFIG_ERASABLE`, `IMPORTMAP_COHERENCE`) so an agent branches on the failure KIND, not the message text. `--json` emits `{ results, summary }` where `results` is the raw `DoctorResult[]` (each carrying a `code` + its effective `severity`) and `summary` is `{ pass, warn, fail, off, strict, ok }` (the same array-under-a-key convention as `check --json`). A REJECTED `webjs.doctor` config is the one path that adds a third key, `configErrors`, an array of `{ kind }` entries (`malformed` / `unknown-key` / `unknown-code` / `bad-severity`) with `results` empty because no check ran; `--strict` additionally fails the exit on every REMAINING warning (on top of hard failures and gated errors), so doctor can gate a fully-clean fix loop. The only network touch (pin freshness, plus the importmap-coherence live resolve) is best-effort: a fetch failure is a warn, never a hard fail. The importmap-coherence check (#450) runs `@webjsdev/server`'s `checkImportmapCoherence` IDENTICALLY over the live importmap AND the vendored `.webjs/vendor/importmap.json`, warning when a pinned package needs a newer version of another pinned package than is pinned (the #446 skew class); it reads dependency metadata from the already-installed node_modules manifests (no network of its own) and degrades to "could not verify" when a manifest is unavailable. Tests: `test/cli/doctor.test.mjs` | +| `webjs doctor` | `runDoctorChecks()` from `lib/doctor.js`. TWO elision checks share ONE `analyzeAppElision()` call so the module graph is built once per run: `ELISION_CARRIERS` (#646, the page/layout carrier advisory) and `ELISION_COMPONENTS` (#1308, the other direction, which PASSES with the elided inventory and warns ONLY on an orphan, the one shape that gets no verdict and has no escape hatch; gateable like any other code via `webjs.doctor.gate`). A project-health checklist over existing signals (Node major, tsconfig `erasableSyntaxOnly`, `.env` drift vs `.env.example`, vendor-pin freshness, the `.gitignore` keeping `.webjs/vendor/` committable (`vendor-gitignore`, moved here from `webjs check` in #461 as a warn since it is a project-config concern, not source correctness), `@webjsdev/*` version coherence (`WEBJS_VERSIONS`, which resolves each declared dep's INSTALLED version through Node's own resolver anchored at the app dir rather than reading `/node_modules//package.json`, so a workspace-hoisted install resolves and the check agrees with `FRAMEWORK_RESOLVE` instead of contradicting it, #1300; the resolve tries `/package.json` FIRST because a bin-only package like `@webjsdev/cli` has no main entry, and falls back through the main entry plus a bounded walk to the package root on `ERR_PACKAGE_PATH_NOT_EXPORTED` because `@webjsdev/server` locks its manifest out of its `exports` map, so neither half alone resolves all four packages. Local to `lib/doctor.js` rather than `getPackageVersion` from `@webjsdev/server`, since doctor must run when the framework does not resolve at all (#954) and that helper returns null for a bin-only package. Before the fix the check warned on every healthy workspace install, which made it ungatable), a framework-resolve probe (#954: `checkFrameworkResolves` + the exported `frameworkResolves` helper WARN when `@webjsdev/core` cannot be resolved FROM the app dir via a directory-relative `createRequire` probe, naming the fresh-git-worktree-without-node_modules cause and the fix; silent PASS when it resolves, so a healthy app is untouched), importmap coherence, git pre-commit hook, and a page/layout elision advisory (#646: `checkElisionCarriers` runs `@webjsdev/server`'s `analyzeAppElision` and WARNS, naming the first client-effecting blocker, for each page/layout that ships whole instead of being elided as a carrier; advisory-only, skipped when elision is off or there is no `app/`), and an unmarked-stylesheet-link advisory (#1095: `checkUnmarkedAssetLinks` scans every `app/**` route module that renders markup (`page` / `layout`, plus the always-shipped `error` / `not-found` / `forbidden` / `unauthorized` / `loading` boundaries and `global-error`, which writes its own ``), skipping `_private` folders the router never routes, for a `` whose href is a STATIC, quoted, root-absolute `/public/...` literal, i.e. one not wrapped in `asset()`, and WARNs with `file:line`, since that url is un-versioned and a deploy cannot bust a CDN's copy. Scoped to `rel="stylesheet"`: a cross-origin sheet must keep its exact url, `rel="icon"` is a legitimate deliberate non-mark (the website leaves its favicons bare so the SEO repo-health tests parse the hrefs literally), `rel="preload"` MUST stay unversioned or its hint could never match the request the CSS `url()` makes, and an `href=${expr}` hole is undecidable from source and is exactly the marked shape. It reads the author's SOURCE and rewrites nothing, deliberately: the automatic form was built and rejected in #1196, and this is the same authoring-time posture Rails and Remix take)). PURE checks render with a `[pass]` / `[off]` / `[warn]` / `[fail]` marker; the exit is non-zero when a HARD check fails (Node below the floor, or `erasableSyntaxOnly` missing in an existing tsconfig) OR when a check the app gated `error` reports something (#1257, see below); an ungated warn (drift / staleness) never fails the exit. Each `DoctorResult` carries a stable SCREAMING_SNAKE `code` (#975, the `DOCTOR_CODES` map; e.g. `NODE_VERSION`, `TSCONFIG_ERASABLE`, `IMPORTMAP_COHERENCE`) so an agent branches on the failure KIND, not the message text. `--json` emits `{ results, summary }` where `results` is the raw `DoctorResult[]` (each carrying a `code` + its effective `severity`) and `summary` is `{ pass, warn, fail, off, strict, ok }` (the same array-under-a-key convention as `check --json`). A REJECTED `webjs.doctor` config is the one path that adds a third key, `configErrors`, an array of `{ kind }` entries (`malformed` / `unknown-key` / `unknown-code` / `bad-severity`) with `results` empty because no check ran; `--strict` additionally fails the exit on every REMAINING warning (on top of hard failures and gated errors), so doctor can gate a fully-clean fix loop. The only network touch (pin freshness, plus the importmap-coherence live resolve) is best-effort: a fetch failure is a warn, never a hard fail. The importmap-coherence check (#450) runs `@webjsdev/server`'s `checkImportmapCoherence` IDENTICALLY over the live importmap AND the vendored `.webjs/vendor/importmap.json`, warning when a pinned package needs a newer version of another pinned package than is pinned (the #446 skew class); it reads dependency metadata from the already-installed node_modules manifests (no network of its own) and degrades to "could not verify" when a manifest is unavailable. Tests: `test/cli/doctor.test.mjs` | | `webjs types` | `generateRouteTypes()` from `@webjsdev/server`, writes `.webjs/routes.d.ts` (typed `Route` union + per-route params, #258). Also auto-emitted at `webjs dev` startup | | `webjs typecheck [tsc args]` | Resolves the project's own `typescript/bin/tsc` (via `createRequire` from the app cwd) and spawns it with `--noEmit`, passing extra args through. Exits non-zero on a type error (a CI gate). A clear message + non-zero exit when typescript is not installed (#265). The framework runs the standard compiler, it does not embed one | | `webjs create [--template …] [--db …] [--runtime node\|bun]` | `scaffoldApp()` from `lib/create.js`. `` is validated by `lib/app-name.js` BEFORE any file is written (#1066; npm package-name rules minus the lowercase-only clause, which never protected anything and which `webjs create MyApp` relied on), at all three entries (this bin, `scaffoldApp()`, and the `create-webjs` wrapper). `--runtime bun` (or `bun create webjs`, auto-detected) emits a Bun-flavored app (#541): `dev`/`start` scripts force `bun --bun`, `bun.lock`, a pure `oven/bun:1` Dockerfile + bun-install CI, and bun-command agent docs. Orthogonal to `--template` (invariant 1 stays exactly 3 templates). | From 21812bd8bb31c3f31e58d0e337ea447777615834 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sat, 8 Aug 2026 22:55:12 +0530 Subject: [PATCH 3/3] test: make the bin-only doctor fixture actually bin-only The workspace fixture builder wrote an index.js for every package, so the bin-only case had no main and no exports but did have an entry file to fall back to. require.resolve('') therefore succeeded and the case stopped pinning the resolve ORDER it exists to pin: swapping the two attempts left it green. Write an entry file only for a manifest that declares main or exports. Verified by the swap: entry-first ordering now reds the bin-only case. --- test/cli/doctor.test.mjs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/test/cli/doctor.test.mjs b/test/cli/doctor.test.mjs index ddaa0561c..1bcf93aaa 100644 --- a/test/cli/doctor.test.mjs +++ b/test/cli/doctor.test.mjs @@ -231,7 +231,7 @@ test('version check PASSES when installed satisfies the declared range', async ( // openly contradicted each other before this). // // COUNTERFACTUAL: restore the `join(appDir, 'node_modules', dep, 'package.json')` -// read and the three fixtures below all red with "N @webjsdev/* dependency not +// read and all six fixtures below red with "N @webjsdev/* dependency not // installed", which is the measured before-state on examples/blog and website. // --------------------------------------------------------------------------- @@ -239,15 +239,22 @@ test('version check PASSES when installed satisfies the declared range', async ( * A workspace-shaped tree: deps installed ONLY in the root node_modules, plus an * app subdirectory with its own package.json and no node_modules of its own. * Returns the app dir. + * + * An entry file is written ONLY for a manifest that declares `main` or + * `exports`, so a bin-only manifest models a real bin-only package. Writing one + * unconditionally would be the difference between a fixture and a prop: CJS + * resolution falls back to `index.js`, so `require.resolve('')` would + * succeed for a package with no main entry and the bin-only case would stop + * pinning the resolve ORDER it exists to pin. */ function workspaceFixture(installs, ranges) { const root = tmpDir(); write(root, 'package.json', JSON.stringify({ name: 'root', workspaces: ['apps/*'] })); for (const [name, manifest] of Object.entries(installs)) { write(root, `node_modules/${name}/package.json`, JSON.stringify(manifest)); - // Every fixture package carries a real entry file, so the exports-map - // fallback has something to resolve. - write(root, `node_modules/${name}/index.js`, 'export const x = 1;\n'); + if (manifest.main || manifest.exports) { + write(root, `node_modules/${name}/index.js`, 'export const x = 1;\n'); + } } write(root, 'apps/web/package.json', JSON.stringify({ name: 'web', dependencies: ranges })); return join(root, 'apps/web'); @@ -269,8 +276,9 @@ test('version check PASSES for a workspace app whose deps hoist to the root node test('version check resolves a BIN-ONLY package (no main, no exports), like @webjsdev/cli', async () => { // require.resolve('') throws MODULE_NOT_FOUND for a package with no main - // entry, which is why the direct `/package.json` resolve is attempted - // FIRST rather than as a fallback. + // entry and no index.js to fall back to, which is why the direct + // `/package.json` resolve is attempted FIRST rather than as a fallback. + // Reorder the two attempts in readInstalledVersion and this case reds. const appDir = workspaceFixture( { '@webjsdev/cli': { name: '@webjsdev/cli', version: '0.10.52', bin: { webjs: 'bin/webjs.js' } } }, { '@webjsdev/cli': '^0.10.0' }