Skip to content

chore(doctor): parity-test the base-path port, fix the versions false positive, validate config at boot #1300

Description

@vivek7405

Line anchors in this body were verified at HEAD e5806e24. Re-check with grep -n before editing, since sibling PRs in packages/cli are in flight.

Problem

Three cleanups in webjs doctor and the webjs config-validation story, found while shipping the doctor severity gate (#1257, PR #1296). None is a regression from #1257. Each predates it; the gate merely made them visible by putting doctor in the required conventions CI job (.github/workflows/ci.yml:51).

They are sequenced as three independently-mergeable PRs against this one issue. See the sequencing paragraph that opens ## Implementation plan.

Part 3 (the priority slice). WEBJS_VERSIONS false-positives on every in-repo app

checkWebjsVersions (packages/cli/lib/doctor.js:850) reads the installed version at packages/cli/lib/doctor.js:884:

const installedPkg = join(appDir, 'node_modules', dep, 'package.json');
if (!existsSync(installedPkg)) {
  missing.push(dep);
  continue;
}

Under npm workspaces the @webjsdev/* deps hoist to the ROOT node_modules, so an app subdirectory has no local copy and every declared dep is reported missing. The install is fine.

Measured before-state at HEAD e5806e24, from webjs doctor --json run in each app directory:

App dir WEBJS_VERSIONS status Message
examples/blog warn 4 @webjsdev/* dependency not installed: @webjsdev/cli, @webjsdev/core, @webjsdev/server, @webjsdev/intellisense.
website warn 3 @webjsdev/* dependency not installed: @webjsdev/cli, @webjsdev/core, @webjsdev/server.
docs warn No package.json found in this directory.
packages/ui/packages/website warn No package.json found in this directory.

The original filing's "all four in-repo apps" claim is stale and is corrected here. At HEAD there are only TWO live in-repo apps. docs/ contains nothing but node_modules and public and carries no package.json; packages/ui/packages/website is an EMPTY directory. Both therefore hit the earlier no-package.json branch (packages/cli/lib/doctor.js:854), which is correct behaviour for a directory that is not an app, and neither is affected by this issue. .github/workflows/ci.yml:51 runs doctor over examples/blog and website only, which matches.

In the same runs FRAMEWORK_RESOLVE reports pass on all four directories (@webjsdev/core resolves from the app directory). So today the two checks openly contradict each other in the same output: one says the framework resolves, the other says it is not installed.

Both apps declare ranges that the installed versions satisfy, so the correct verdict is pass:

Dep examples/blog website Installed
@webjsdev/cli ^0.10.0 ^0.10.0 0.10.52
@webjsdev/core ^0.7.0 ^0.7.0 0.7.48
@webjsdev/server ^0.8.0 ^0.8.0 0.8.60
@webjsdev/intellisense ^0.5.0 not declared 0.5.5

Consequence named in the original filing and confirmed: the check is ungatable today. An app cannot set "WEBJS_VERSIONS": "error" in webjs.doctor.gate because it would red CI on a healthy install. After the fix it becomes gatable.

Part 1. readAppBasePath is a second, hand-maintained implementation of the server's base-path normalization

readAppBasePath (packages/cli/lib/doctor.js:1336, docblock at packages/cli/lib/doctor.js:1319-1335) re-implements normalizeBasePath (packages/server/src/base-path.js:60), which readBasePath (packages/server/src/base-path.js:92) wraps. The original filing cites L1274 for readAppBasePath and names readBasePath as the ported function. Both are corrected here: the function is at L1336 and what it ports is normalizeBasePath; readAppBasePath is the combination of both server functions, since it reads the package.json AND normalizes.

The behaviours, verified against both sources, are identical and identically ordered:

  1. non-string to ''
  2. trim()
  3. '' or '/' to ''
  4. reject .., ://, \, any whitespace
  5. reject a leading // network-path reference BEFORE collapsing leading slashes
  6. prepend exactly one /, collapse the rest of the leading slashes
  7. strip trailing slashes
  8. a residual '' or '/' to ''

Step 5's ordering is the load-bearing one. Collapsing first would turn //evil.com into /evil.com and admit an origin escape instead of failing safe. Both implementations comment on it, and nothing tests that they agree.

Change base-path semantics in one and the UNMARKED_ASSET_LINKS check silently disagrees with what the server serves. The drift risk is not hypothetical: base-path.js was created in #298 and changed again in #1237 (fix!: key the HTML cache by origin and centralize proxy trust), while the port landed later in #1244.

Confirmed: base-path.js is NOT re-exported from packages/server/index.js or packages/server/index.d.ts, so the "not on the public surface" half of the docblock's justification holds at HEAD.

Part 2. The webjs JSON Schema never validates anything outside an editor

packages/server/webjs-config.schema.json reaches users through exactly one wire, packages/cli/templates/.vscode/settings.json:9:

"webjs": { "$ref": "./node_modules/@webjsdev/server/webjs-config.schema.json" }

So a typo'd key is caught only for a VS Code user with package.json open. Verified at HEAD: the schema is {"type":"object","additionalProperties":false} over 17 top-level keys (elide, seed, clientRouter, headers, redirects, trailingSlash, basePath, allowedOrigins, csp, maxBodyBytes, maxMultipartBytes, requestTimeoutMs, headersTimeoutMs, keepAliveTimeoutMs, dev, start, doctor), so it already carries everything needed to catch a typo. Nothing at runtime reads it.

The module docblock of packages/core/src/webjs-config.d.ts:11-14 states the schema's purpose as closing exactly that gap:

Without a type or schema a typo'd key (e.g. redirect for redirects) was silently dropped and the feature stayed at its default with no diagnostic. This type plus the published JSON Schema close that gap.

Outside an editor it does not close it. This is why #1257 had to hand-write shape validation in the CLI for doctor.gate (readDoctorPolicy, packages/cli/lib/doctor.js:160) rather than lean on the schema, a point its own docblock makes at packages/cli/lib/doctor.js:147-149.

Design / approach

Part 3. Resolve with Node's own resolver, locally, with an exports-map fallback

Settled: add a private helper to packages/cli/lib/doctor.js that resolves the dep's manifest through createRequire anchored at appDir, trying <dep>/package.json first and falling back to the main entry plus a bounded walk to the package root. No @webjsdev/server import, no new export, no server change.

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;
}

Why Node's resolver rather than a directory walk. The check asserts "would this app resolve this dependency at runtime, and at what version". Node's resolution algorithm IS that question's definition, so anything that re-implements it can only be a worse approximation. It already handles workspace hoisting, symlinked workspace links, nested non-hoisted trees, and package.json imports, for free and for ever.

Measured, both halves of the fallback are required. Neither single strategy works. Run at HEAD from examples/blog:

Dep require.resolve(dep + '/package.json') require.resolve(dep)
@webjsdev/cli resolves throws MODULE_NOT_FOUND
@webjsdev/core resolves resolves
@webjsdev/server throws ERR_PACKAGE_PATH_NOT_EXPORTED resolves
@webjsdev/intellisense resolves resolves

@webjsdev/server's exports map is ['.', './check', './testing', './webjs-config.schema.json'], with no ./package.json, so the direct manifest resolve is refused. @webjsdev/cli declares only bin (no main, no exports), so it has no main entry to resolve at all. Combining them resolves all four on both apps, returns null for an undeclared package, and returns null from a directory with nothing installed (verified against /tmp).

Rejected: swap to getPackageManifest and add a version field (the design in the original filing). Wrong twice, both measured.

  1. getPackageVersion(pkgName, appDir) ALREADY exists (packages/server/src/vendor.js:271), is already hoist-aware over the same resolvePackageDir (packages/server/src/vendor.js:238), and is already exported at packages/server/index.js:25 with a declaration at packages/server/index.d.ts:356. So the proposed server-side work is unnecessary.
  2. It still does not fix the bug. getPackageVersion resolves the MAIN ENTRY, so for the bin-only @webjsdev/cli it returns null. Verified from examples/blog: @webjsdev/cli => null, @webjsdev/core => 0.7.48, @webjsdev/server => 0.8.60, @webjsdev/intellisense => 0.5.5. A drop-in swap would leave @webjsdev/cli reported missing on BOTH apps, which is the same false positive with more machinery and a new cross-package dependency.

Rejected: walk up parent directories looking for <dir>/node_modules/<dep>/package.json. It re-implements Node's algorithm and gets it wrong in the ways that matter here. It is blind to exports maps, so it would happily read a manifest for a package Node refuses to resolve, reporting healthy where the app would crash. It mishandles a paths-style or self-referencing resolution. And it duplicates logic that resolvePackageDir already got right once in this repo.

Rejected: import @webjsdev/server from this check. 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. That is the same argument the readAppBasePath docblock makes for part 1, and it applies verbatim here. The prior art to copy lives in this very file: frameworkResolves (packages/cli/lib/doctor.js:1512) already does a directory-relative createRequire probe with no server import.

Consistency with FRAMEWORK_RESOLVE, deliberately. Both checks now ask Node the same question anchored at the same directory, so they cannot contradict each other the way they do today. They stay distinct: FRAMEWORK_RESOLVE asks whether @webjsdev/core alone resolves and diagnoses the worktree cause; WEBJS_VERSIONS asks whether every declared @webjsdev/* range is satisfied by what is installed.

No default gate severity changes in this PR. No webjs.doctor.gate block is edited in any app, and no check's default level moves. Changing a default is a behaviour change of its own and belongs to whoever decides that CI should start failing on it. The deliverable here is that the check becomes gatable, which is the app's call to make afterwards. It is also a no-op today, since after the fix the check passes on both apps, so gating it error would change nothing now and would be an untested trap later.

Part 1. A parity test, NOT a dedupe

Read readAppBasePath's docblock (packages/cli/lib/doctor.js:1319-1335) before touching it. The port is deliberate and both its reasons hold at HEAD:

  • normalizeBasePath / readBasePath are not on @webjsdev/server's public surface (verified: base-path.js appears in neither packages/server/index.js nor packages/server/index.d.ts).
  • Doctor must stay usable when the framework does not resolve from the app dir at all, the dogfood: a fresh git worktree can't resolve @webjsdev/* (no node_modules) #954 fresh-worktree case this same command exists to diagnose (checkFrameworkResolves, same file). Importing the server helper unconditionally would break doctor in the situation doctor is for.

So keep the port and make drift DETECTABLE. One parity test feeds an identical input table through readBasePath and readAppBasePath and asserts identical output. That converts silent drift into a red test for a few lines. Record the test's path in the readAppBasePath docblock so the next person to edit either side finds it.

Rejected: a lazy import with a fallback to the port. It doubles the code paths, keeps the port anyway, and still needs the parity test to prove the two agree, so it buys nothing and costs a branch.

Rejected: moving the shared normalizer into a package both can import. packages/cli does not depend on @webjsdev/server at runtime for exactly the #954 reason above, and a third package for one 15-line pure function is worse than a tested duplicate.

Part 2. Promote the validator that already exists, and WARN at boot

The validator is already written. validateWebjsBlock(schema, value) lives at packages/server/test/config/webjs-config-schema.test.js:220, and its own docblock calls it "a tiny structural validator standing in for ajv (which the repo does not ship)". It checks unknown-key membership under additionalProperties: false, enum membership, and boolean / integer leaf types. That is exactly the typo case. It is trapped in a test file.

Settled: warn, never throw, on every boot. Promote the function to packages/server/src/webjs-config-validate.js, call it from createRequestHandler (packages/server/src/dev.js) right after applyEnvValidation (packages/server/src/dev.js:532), and emit ONE aggregated logger.warn listing every problem. The boot always continues.

Three things settle it, in order of weight.

  1. Prior art. Next validates its config on every boot: validateConfigSchema (~/Documents/Projects/frameworks/next.js/packages/next/src/server/config.ts:2500) runs configSchema.safeParse(userConfig) and splits the result via normalizeNextConfigZodErrors. Unknown or invalid options are warnings that print and let the boot continue; only options that are required or have been migrated are fatal. A typo'd webjs key is squarely in Next's warning bucket, and WebJs has no required or migrated keys at all (every key is optional with a default, per packages/core/src/webjs-config.d.ts), so nothing in this block belongs in the fatal bucket.
  2. The repo already documented this posture. .agents/skills/webjs/references/built-ins.md:148 states that "a malformed entry in a key the SERVER reads is dropped at boot with a warning, never crashing the pipeline". A throw would contradict shipped documentation and turn a whole class of drift into an outage.
  3. Blast radius. A hard boot failure in production over a schema quibble is worse than the typo it catches. The typo's cost is one feature silently at its default; the throw's cost is the entire app down, at the worst possible moment, on a deploy.

Settled: both webjs dev and webjs start, via the shared entry point. The call goes in createRequestHandler, which startServer wraps (packages/server/src/dev.js:1703) and which every embedded host also calls, so dev, prod, and embedded get identical behaviour from one call site. Validating in dev only would be the worst option available: the typo would be caught exactly where a developer is least likely to be surprised by it and missed exactly where it silently costs a production feature. Warning in prod is safe precisely because it never throws.

Settled: #1257's hand-written doctor.gate validation STAYS. It is not replaced. Three reasons, each sufficient.

  1. It runs in the CLI (readDoctorPolicy, packages/cli/lib/doctor.js:160), which must work when @webjsdev/server does not resolve. Same dogfood: a fresh git worktree can't resolve @webjsdev/* (no node_modules) #954 argument as parts 1 and 3.
  2. Replacing it would LOSE coverage. readDoctorPolicy checks each gate key against the real DOCTOR_CODES set (packages/cli/lib/doctor.js:95). The schema can only express propertyNames: { pattern: "^[A-Z][A-Z0-9_]*$" } (verified in packages/server/webjs-config.schema.json), so it cannot catch a well-shaped but wrong code such as NODE_VERSIONS. Enumerating the codes in the schema would create a fifth copy of a list that has drifted before.
  3. The two have deliberately opposite failure modes. doctor.gate fails CLOSED (a hard error that exits 1) because a silently-ignored gate leaves CI un-gated while the package.json looks gated, which is strictly worse than no gate. The boot validator warns. One function cannot honour both, and collapsing them would silently downgrade the gate.

The plan therefore ships the two side by side and says so in the code, so the next reader does not "clean up" the apparent duplication.

Do not add a validation dependency. The repo ships no ajv on purpose. Reusing the existing tiny validator is the whole point.

Validator depth stays where it is. Top-level key membership, enum, and boolean / integer leaves, which is the exact gap the webjs-config.d.ts docblock names. Descending into nested objects (webjs.dev.beforee) is a larger behaviour change with its own false-positive risk against headers / redirects / csp free-form shapes, and it is listed under ## Out of scope rather than half-done.

Implementation plan

Sequencing. Three PRs against this one issue, in this order. PR 1 is part 3 alone, the WEBJS_VERSIONS resolution fix. It is standalone, mergeable on its own, touches one file plus one test file, and closes nothing, so its body must NOT carry a Closes #1300 line. PR 2 is part 1 (the base-path parity test). PR 3 is part 2 (the boot-time config validator), which is the risky slice and goes last so the two cheap wins are already banked. Only PR 3 carries Closes #1300. This issue stays OPEN until all three land. Do NOT split parts 1 and 2 into new issues; they are sequenced PRs against this one.

No follow-up issues. The implementer files NO follow-up issues for anything this work turns up, and fixes every finding inside the same PR that turned it up. The one legitimate exception is already built into this issue: parts 1 and 2 are sequenced PRs against THIS issue, not new issues. If a finding is genuinely out of reach of the PR that found it, report it to the user as a note in the PR description. Do not file it. A small tweak in a file the PR already touches gets folded into that PR.

Each PR cuts its own worktree from origin/main per AGENTS.md, and runs npm run worktree:link inside it before running anything.

PR 1, part 3. Hoist-aware WEBJS_VERSIONS

  1. In packages/cli/lib/doctor.js, confirm createRequire is already imported (it is, used by frameworkResolves at L1512) and that dirname is available from node:path; add it to the existing node:path import if it is not.
  2. Add a module-private helper readInstalledVersion(dep, appDir) immediately above checkWebjsVersions (currently L850). Body as given in ## Design / approach, part 3. Return the parsed version string, or null when the dep does not resolve, when the walk finds no manifest, or when the manifest is unreadable / unparseable.
  3. Write its docblock. State that Node's resolver is the ground truth because the check asks a resolution question; that the ERR_PACKAGE_PATH_NOT_EXPORTED fallback exists because @webjsdev/server locks ./package.json out of its exports; that the direct-manifest attempt comes FIRST because @webjsdev/cli is bin-only and has no main entry to resolve; and that the helper is local rather than getPackageVersion from @webjsdev/server because doctor must run when the framework does not resolve (dogfood: a fresh git worktree can't resolve @webjsdev/* (no node_modules) #954) and because getPackageVersion returns null for a bin-only package. Name the test file that pins all of this.
  4. Replace the loop body at packages/cli/lib/doctor.js:884-897. Call readInstalledVersion(dep, appDir); push to missing when it returns null or an empty string; otherwise run the existing satisfiesRange(installedVersion, ranges[dep]) comparison unchanged. The null return from satisfiesRange (an unverifiable range shape) keeps its current no-warn treatment.
  5. Leave every message string, the bestEffort posture, the pass / warn statuses, and DOCTOR_CODES untouched. The check's contract does not change; only its resolution does.
  6. Change no webjs.doctor.gate block in any app and no default severity.
  7. Verify by running webjs doctor --json from examples/blog and from website and confirming WEBJS_VERSIONS is pass in both, with the four / three deps counted in the pass message.

PR 2, part 1. Base-path parity test

  1. Export readAppBasePath from packages/cli/lib/doctor.js (currently module-private at L1336). Add export to the declaration. No other production change to the function.
  2. Add the parity test at test/cli/base-path-parity.test.mjs (new file). It deep-imports readBasePath from ../../packages/server/src/base-path.js, which is established practice for repo-internal server modules in test/ (see test/bun/routing-boundaries.mjs:16), and imports readAppBasePath from ../../packages/cli/lib/doctor.js.
  3. For each row of the table in ## Tests, write {"webjs":{"basePath":<raw>}} into a tmp dir's package.json, call await readAppBasePath(dir), call readBasePath({ webjs: { basePath: raw } }), and assert the two are equal AND equal to the expected column. Asserting against BOTH matters: equality alone would pass if both drifted the same way, and the expected column alone would not prove they agree.
  4. Cover the two file-level branches too, where only readAppBasePath has a code path: a missing package.json and an unparseable one both return '', matching readBasePath(undefined).
  5. Update the readAppBasePath docblock (packages/cli/lib/doctor.js:1319-1335). Keep the existing justification verbatim. Correct the one stale sentence that names test/cli/doctor.test.mjs as what pins the forms, and add the parity test's path. State that the port is intentional per dogfood: a fresh git worktree can't resolve @webjsdev/* (no node_modules) #954 and that changing either side without the other reds this test.
  6. Add the reciprocal pointer in packages/server/src/base-path.js, in normalizeBasePath's docblock (L60), naming the parity test and the CLI port. A reader editing the server side must find the test from there, since that is the side that changed in fix!: key the HTML cache by origin and centralize proxy trust #1237.

PR 3, part 2. Boot-time webjs config validation

  1. Create packages/server/src/webjs-config-validate.js. Plain .js with JSDoc, no .ts. Export two functions.
    • validateWebjsBlock(schema, value), moved verbatim from packages/server/test/config/webjs-config-schema.test.js:220 including its docblock. Keep the same signature and the same string[] return.
    • validateAppWebjsConfig(pkg), which takes a parsed package.json, returns [] when pkg.webjs is absent or is not a plain object, loads the schema from new URL('../webjs-config.schema.json', import.meta.url), and returns validateWebjsBlock's result.
  2. Wire it into createRequestHandler in packages/server/src/dev.js, immediately after the await applyEnvValidation(appDir, ...) call at L532. Read the app package.json (a missing, unreadable, or unparseable file is a silent no-op, matching every other webjs.* reader in that file), run validateAppWebjsConfig, and on a non-empty result emit ONE logger.warn naming package.json, listing every problem, and pointing at the config docs. Never throw. Never process.exit.
  3. The warning fires once per boot, not per request. It sits in createRequestHandler, which runs once, so this is structural rather than a guard.
  4. In packages/server/test/config/webjs-config-schema.test.js, delete the local validateWebjsBlock definition and import the promoted one. The existing assertions at L242 and L265 stay byte-identical, which is the proof the move was faithful.
  5. Add validateWebjsBlock and validateAppWebjsConfig to packages/server/index.js and packages/server/index.d.ts only if a consumer outside the package needs them. Nothing in this plan does, so default to NOT exporting them and keep the public surface unchanged.
  6. Update the packages/core/src/webjs-config.d.ts module docblock (L11-14). Its claim that the type plus the schema "close that gap" becomes true only at the editor level today; restate it accurately now that the schema also runs at boot.
  7. Do NOT touch readDoctorPolicy (packages/cli/lib/doctor.js:160). Add one sentence to its docblock recording that the boot validator exists, that the two are deliberately separate, and why (CLI must run without the server resolving; the gate fails closed while the boot validator warns; the schema cannot express DOCTOR_CODES membership).
  8. Follow the packages/server/AGENTS.md three-surface lockstep only if a webjs.* KEY is added. This PR adds no key, so the schema, the WebjsConfig type, the readers, and the KNOWN_KEYS drift test are all untouched.

Tests

packages/cli doctor tests live at the repo root under test/cli/, not under packages/cli/test/ (which holds scaffold and task-runner fixtures only). Follow the sibling naming in test/cli/ (doctor.test.mjs, elision.test.mjs, routes.test.mjs).

Part 3, in test/cli/doctor.test.mjs

Extend the existing file. It already builds tmp fixture appDirs and drives runDoctorChecks(appDir, opts) directly, so the new cases follow that pattern.

  1. Workspace fixture (the headline case). Build a tmp tree: a root package.json declaring workspaces, root node_modules/@webjsdev/<pkg>/package.json files carrying real versions, and an app subdirectory whose package.json declares the @webjsdev/* ranges and which has NO local node_modules. Assert WEBJS_VERSIONS is pass and the message counts every declared dep.
  2. Bin-only package. One fixture dep with bin and no main and no exports, mirroring @webjsdev/cli. Assert it resolves and is counted, which pins why the direct-manifest attempt comes first.
  3. Exports-locked package. One fixture dep whose exports map omits ./package.json, mirroring @webjsdev/server. Assert it resolves via the fallback, which pins why the fallback exists.
  4. Genuinely missing dep, non-workspace app. A standalone app dir with a declared @webjsdev/* dep and nothing installed anywhere above it. Assert warn and that the message names the dep. This is the regression guard that the fix did not make the check vacuous.
  5. Version drift still reported. An installed version outside the declared range. Assert warn with the drift message, proving the resolved version is real rather than undefined (an undefined version cannot produce a drift warning, so this case doubles as the non-undefined assertion the acceptance criteria ask for).
  6. Unverifiable range shape. Keep the existing no-warn behaviour for a range satisfiesRange returns null for.

Counterfactual. Revert step 4 of PR 1 (restore join(appDir, 'node_modules', dep, 'package.json')) and cases 1, 2, and 3 all red with "N @webjsdev/* dependency not installed", which is exactly the measured before-state on examples/blog and website.

Part 1, in test/cli/base-path-parity.test.mjs (new file)

Table-driven, every branch of normalizeBasePath covered.

Input webjs.basePath Expected Branch pinned
key omitted '' non-string / undefined
42 '' non-string
true '' non-string
null '' non-string
{} '' non-string
[] '' non-string
"" '' empty
" " '' trim to empty
"/" '' root
" /app " /app trim
"app" /app leading slash prepended
"/app" /app identity
"/app/" /app trailing slash stripped
"/app///" /app multiple trailing slashes
"///app" /app leading slashes collapsed
"/foo/bar" /foo/bar nested preserved
"/foo/bar/" /foo/bar nested plus trailing
"../app" '' .. rejected
"/app/../x" '' .. rejected mid-path
"https://evil.com" '' :// rejected
"/app\\x" '' backslash rejected
"/my app" '' interior whitespace rejected
"/app\tx" '' interior tab rejected
"//evil.com" '' network-path rejected BEFORE collapse
"//evil.com/app" '' network-path rejected BEFORE collapse
"//" '' network-path rejected BEFORE collapse
"///" '' network-path rejected BEFORE collapse

Plus two file-level rows where only readAppBasePath has a path: a missing package.json and an unparseable one, both ''.

Each row asserts three-way: readAppBasePath(dir) equals readBasePath(pkg) equals the expected value.

Counterfactual. Move the startsWith('//') guard below the leading-slash collapse in EITHER packages/server/src/base-path.js:60 or packages/cli/lib/doctor.js:1336 and the four network-path rows red, because that side now returns /evil.com. Delete the trim in either and the two whitespace-padded rows red. That is the drift the test exists to catch.

Part 2

  1. packages/server/test/config/webjs-config-schema.test.js. The existing valid-config and typo / bad-enum / bad-type assertions keep running byte-identically against the IMPORTED function. Counterfactual: break the promoted validateWebjsBlock's unknown-key branch and the { redirect: [] } assertion reds, from the same test that guarded it before the move.
  2. packages/server/test/config/webjs-config-validate.test.js (new file). Cover validateAppWebjsConfig directly: no webjs block returns []; a non-object webjs returns []; { redirect: [] } returns a problem naming redirect; { trailingSlash: 'sometimes' } returns a problem; a representative valid block returns [].
  3. Boot behaviour, in the same new file. Boot a fixture app whose package.json carries a typo'd webjs key through createRequestHandler with an injected logger, and assert BOTH halves: the logger received exactly one warning naming the typo'd key, AND createRequestHandler resolved rather than throwing. The second assertion is the one that matters and is the whole ruling. Add the mirror case: a clean config produces zero warnings, so a healthy app's boot output is unchanged.
  4. Counterfactual. Change the warn to a throw and the "boot completes" assertion reds. Remove the createRequestHandler call site and the "exactly one warning" assertion reds while the unit tests stay green, which is why the boot assertion is separate from the unit ones.

Bun parity, judged per part

  • Part 3: NOT owed. webjs doctor is Node-side tooling. AGENTS.md's runtime-sensitive list is the serializer, the node:http versus Bun.serve listener and request path, SSR / action / CSRF dispatch, streams, node:crypto, the TS stripper, and auth / session / cors. A createRequire probe plus fs reads in a CLI check is on none of them, and AGENTS.md states explicitly that the test / db / check tooling stays on Node.
  • Part 1: NOT owed. Pure string normalization in two Node-side modules, no runtime surface, no Bun.serve involvement.
  • Part 2: OWED. It runs inside createRequestHandler, which is the boot path BOTH the node:http shell and the Bun.serve shell go through, so it is on the runtime-sensitive list by AGENTS.md's own wording. Ship test/bun/webjs-config-validate.mjs asserting that a typo'd webjs key warns once and the boot completes under Bun, and run node scripts/run-bun-tests.js plus the touched test/bun/*.mjs under bun.

Full verification per PR

npm test does NOT run browser, e2e, or Bun. None of these three parts touches a browser-facing surface, so the browser and e2e suites are not the headline assertion for any of them. Per PR, run and report: npm test, webjs check, and webjs doctor in both examples/blog and website (the two apps the required conventions job covers). PR 3 additionally runs the Bun suite.

Docs

Every surface below is verified to exist at HEAD e5806e24. The scaffold's agent skill is NOT a separate copy: packages/cli/lib/create.js:666-675 copies the repo-root .agents/skills/webjs/ into a generated app, so editing the repo-root reference IS the scaffold sync for that surface.

Part 3

  • packages/cli/AGENTS.md:175, the webjs doctor row. It describes the check as "@webjsdev/* version coherence". Add that the installed version is resolved through Node's resolver anchored at the app dir, so a workspace-hoisted install resolves, and that this is why it agrees with FRAMEWORK_RESOLVE.
  • AGENTS.md:511, the CLI reference webjs doctor line. Only if the check's user-visible description changes. It currently does not enumerate WEBJS_VERSIONS, so a change here is likely unnecessary; confirm rather than assume.
  • No docs-site change. website/app/docs/configuration/page.ts:62 describes the check only as "@webjsdev/* version coherence", which stays accurate.

Part 1

  • packages/cli/lib/doctor.js:1319-1335, the readAppBasePath docblock (a code comment, but the doc surface that matters most here).
  • packages/server/src/base-path.js:60, normalizeBasePath's docblock, the reciprocal pointer.
  • No user-facing doc surface. The parity test changes no behaviour.

Part 2

  • packages/core/src/webjs-config.d.ts:11-14, the module docblock whose "close that gap" claim this part makes true outside an editor.
  • .agents/skills/webjs/references/built-ins.md. The webjs config block section, and specifically line 148, which already states the warn-at-boot posture for server-read keys. Extend it to say that an UNKNOWN top-level webjs key now warns at boot too, in dev and in prod, and never fails the boot.
  • website/app/docs/configuration/page.ts, the webjs config block section. Same addition, in the docs-site voice.
  • packages/server/AGENTS.md. Add webjs-config-validate.js to the module table, and note that it does NOT change the three-surface lockstep because no key is added.
  • packages/cli/AGENTS.md:184, the doctor severity gate paragraph. It currently says the JSON Schema "is editor-only, so it can never be the enforcement". Correct that sentence: the schema now also runs at boot, and record that readDoctorPolicy still exists separately and why (fails closed versus warns; DOCTOR_CODES membership the schema cannot express; CLI must run without the server resolving).
  • packages/cli/templates/.vscode/settings.json. No change. The editor wire stays exactly as it is; boot validation is additive.
  • README.md. No change. This is not a headline capability.

Acceptance criteria

Part 3 (PR 1)

  • WEBJS_VERSIONS reports pass when run from examples/blog and from website, replacing the measured 4 ... not installed and 3 ... not installed warnings
  • The installed version is resolved through createRequire anchored at the app dir, with the ERR_PACKAGE_PATH_NOT_EXPORTED fallback to the main entry plus a bounded walk to the package root
  • A bin-only package (no main, no exports) resolves, and an exports-locked package (no ./package.json in exports) resolves
  • The resolved version is proven non-undefined by a drift case that could not warn otherwise
  • A genuinely missing dependency in a non-workspace app still reports warn naming the dep
  • A workspace-shaped fixture in test/cli/doctor.test.mjs is the counterfactual and reds against the naive per-app read
  • @webjsdev/server is NOT imported by this check, and no @webjsdev/server export was added or changed
  • No webjs.doctor.gate block and no default severity changed in any app
  • The PR body does NOT carry Closes #1300

Part 1 (PR 2)

  • test/cli/base-path-parity.test.mjs proves readAppBasePath and readBasePath normalize the full input table identically, asserting three-way against the expected value
  • The table covers every branch including all four //host network-path rows, and reds if the guard is reordered on EITHER side
  • The missing and unparseable package.json rows are covered
  • readAppBasePath is exported from packages/cli/lib/doctor.js, with no other change to its body
  • The readAppBasePath docblock records that the port is intentional (dogfood: a fresh git worktree can't resolve @webjsdev/* (no node_modules) #954) and names the parity test; the stale reference to test/cli/doctor.test.mjs pinning the forms is corrected
  • normalizeBasePath's docblock in packages/server/src/base-path.js carries the reciprocal pointer
  • The PR body does NOT carry Closes #1300

Part 2 (PR 3)

  • validateWebjsBlock lives in packages/server/src/webjs-config-validate.js and packages/server/test/config/webjs-config-schema.test.js imports it instead of defining its own copy
  • No validation dependency was added
  • A typo'd webjs key WARNS once at boot and the boot still completes, asserted as two separate assertions
  • Validation runs through createRequestHandler, so webjs dev, webjs start, and an embedded host all get it from one call site
  • A clean config produces zero warnings
  • readDoctorPolicy is unchanged in behaviour, and its docblock records why it coexists with the boot validator
  • test/bun/webjs-config-validate.mjs proves the warn-and-continue behaviour on Bun
  • packages/core/src/webjs-config.d.ts, .agents/skills/webjs/references/built-ins.md, website/app/docs/configuration/page.ts, packages/server/AGENTS.md, and packages/cli/AGENTS.md:184 are updated
  • The PR body carries Closes #1300

All three

  • npm test and webjs check pass, and webjs doctor is clean in examples/blog and website
  • No follow-up issue was filed for anything this work turned up
  • packages/ gained no .ts file
  • Prose invariant 11 holds in every comment, docblock, and doc touched

Out of scope

The implementer must NOT widen into any of the following.

  • Deduplicating readAppBasePath into a shared module. Ruled out in ## Design / approach, part 1. The port is deliberate and the test is the deliverable.
  • Deepening the config validator into nested objects. webjs.dev.beforee stays uncaught. Nested descent carries real false-positive risk against the free-form headers, redirects, and csp shapes and is a separate behaviour change.
  • Throwing on an invalid config, or gating it behind an env flag or a webjs check rule. Warn-and-continue is the ruling. webjs check is correctness and webjs doctor is project health; a boot-time config warning is neither, and must not be smuggled into either.
  • Replacing readDoctorPolicy's hand-written validation. It stays, for three reasons given in ## Design / approach, part 2.
  • Changing any default doctor severity, or any app's webjs.doctor.gate block. Including gating WEBJS_VERSIONS to error now that it passes.
  • Adding a webjs.* config key. No key is added by any of the three parts, so the packages/server/AGENTS.md three-surface lockstep and the KNOWN_KEYS drift test are not in play.
  • Adding ajv or any validation dependency.
  • ELISION_CARRIERS. Left ungated and unedited in website/package.json and examples/blog/package.json. The original filing's reason for descoping it was wrong and is corrected here. It claimed the six flagged page/layout modules were lexical false positives from regex quote pairing in hasModuleScopeSideEffect. A comment on this issue measured the predicate's own scan over an unmodified examples/blog/lib/utils/cn.ts and found no quote desync, brace depth 0, and a real module-scope call borderGroups( at cn.ts:74, an ordinary true positive from the top-level-call branch. So it is genuine app debt, not an analyser bug. It is tracked with a measured fix at dogfood: ui kit cn.ts and native-select.ts do module-scope work, pinning every page #1320 (dogfood: ui kit cn.ts and native-select.ts do module-scope work, pinning every page, open), which is where that work belongs. Do not touch it here, and do not re-file it.
  • Elision analyser precision. Same boundary. It changes what ships to browsers, needs its own differential verification, and has nothing to do with doctor.
  • packages/ui/packages/website and docs/. Neither is a live app at HEAD. Do not add a package.json to either to make doctor run there.

Sibling PR in the same package, no conflict

Issue #1299 (Type-check test files: every app tsconfig excludes test/, open) is being planned in parallel and touches packages/cli/lib/create.js. This issue touches packages/cli/lib/doctor.js, packages/server/src/, and the test and doc surfaces listed above. Different files in the same package, so expect a sibling PR in packages/cli but no merge conflict. Rebase on origin/main as usual.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions