From 8eb2fa8eed340125efbe0753236488872c5652d0 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 22:05:09 +0530 Subject: [PATCH 01/24] feat(server): thread the per-component elision verdict into the app report The elision report told an app which pages ship when they could have been elided, which is the benign over-ship direction. Nothing reported the other direction: which components were DROPPED, and on what evidence. That is the direction where a wrong verdict silently loses interactivity in production. analyzeElision now records WHY each shipping component ships, alongside every mustShip write, and returns it as componentVerdicts. analyzeAppElision projects the whole verdict (components, route modules, orphans, summary) into one sorted, app-relative, JSON-serializable object. Nothing is re-analysed: the data was already in memory and was being discarded. maskJsSet moves into a shared leaf so the framework's own differential guard and the app-facing one cannot drift apart on what the JS-loaded set even is. --- packages/cli/lib/doctor.js | 98 +++++++++-- packages/server/AGENTS.md | 25 ++- packages/server/index.d.ts | 96 ++++++++++- packages/server/index.js | 1 + packages/server/src/component-elision.js | 81 +++++++-- packages/server/src/elision-differential.js | 101 +++++++++++ packages/server/src/elision-report.js | 158 ++++++++++++++---- .../test/elision/differential-elision.test.js | 39 +---- 8 files changed, 493 insertions(+), 106 deletions(-) create mode 100644 packages/server/src/elision-differential.js diff --git a/packages/cli/lib/doctor.js b/packages/cli/lib/doctor.js index b08ae37fb..c0bda1037 100644 --- a/packages/cli/lib/doctor.js +++ b/packages/cli/lib/doctor.js @@ -103,6 +103,7 @@ export const DOCTOR_CODES = { 'importmap-coherence': 'IMPORTMAP_COHERENCE', 'git-hook': 'GIT_HOOK', 'Page/layout elision (carrier hygiene)': 'ELISION_CARRIERS', + 'Component elision (what the browser drops)': 'ELISION_COMPONENTS', 'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS', 'Asset urls (unmarked stylesheet links)': 'UNMARKED_ASSET_LINKS', }; @@ -1002,43 +1003,101 @@ function checkGitHook(appDir) { * named line. WARN only: a page legitimately MAY ship, and the analyser is * biased toward shipping by design (server AGENTS invariant 7), so this is a * "you may not have intended this" hint, never a hard fail. - * @param {string} appDir + * @param {Promise} elisionPromise the ONE shared report (#1308) * @returns {Promise} */ -async function checkElisionCarriers(appDir) { +async function checkElisionCarriers(elisionPromise) { const name = 'Page/layout elision (carrier hygiene)'; - let report; - try { - const { analyzeAppElision } = await import('@webjsdev/server'); - report = await analyzeAppElision(appDir); - } catch { + const report = await elisionPromise; + if (!report) { // Analysis unavailable (no app, malformed, server import failed): no advice. return { name, status: 'pass', message: 'not analysed (no routable app or analysis unavailable)' }; } if (!report.analysed) { return { name, status: 'pass', message: 'not analysed (no routable app, or elision is disabled)' }; } - if (report.shipped.length === 0) { + // Paths and reasons arrive app-relative from `analyzeAppElision` (#1308). + const shipped = report.routeModules.filter((r) => r.verdict === 'shipped'); + if (shipped.length === 0) { return { name, status: 'pass', message: 'every page/layout is elided (a pure import-only or inert carrier)' }; } - const rel = (f) => relative(appDir, f) || f; // Name the FIRST client-effecting blocker (there may be more than one; the // module stays shipped until every such blocker is moved out). - const lines = report.shipped.map(({ file, blocker, reason }) => + const lines = shipped.map(({ file, blocker, reason }) => blocker - ? `${rel(file)} ships whole. Its first client-effecting blocker is ${rel(blocker)}, which ${reason} and is not a component` - : `${rel(file)} ships whole because it ${reason}`, + ? `${file} ships whole. Its first client-effecting blocker is ${blocker}, which ${reason} and is not a component` + : `${file} ships whole because it ${reason}`, ); return { name, status: 'warn', message: - `${report.shipped.length} page/layout module(s) ship to the browser instead of being elided:\n` + + `${shipped.length} page/layout module(s) ship to the browser instead of being elided:\n` + lines.map((l) => ` ${l}`).join('\n'), fix: 'Move the client work out of the page/layout closure (into a component, or a .server module reached through an action) so the carrier can be elided, or accept that it ships. See references/components.md in the skill.', }; } +/** + * The OTHER direction of the elision verdict (#1308): which COMPONENT modules + * the browser never downloads. `checkElisionCarriers` above reports the benign + * over-ship direction; this one reports what was DROPPED, which is where a + * wrong verdict silently costs an app its interactivity. + * + * Pass-only except for orphans, deliberately. An elided component is the + * DESIRED outcome, so warning on one would fire on every healthy app and train + * the reader to skip doctor output. The passing message carries the elided + * inventory instead, which makes it the discovery surface, while `webjs + * elision` is the detail surface. The one always-wrong condition is an ORPHAN: + * a `class X extends WebComponent` with no literal-tag registration is + * invisible to the scanner, so it gets no verdict at all, its module is + * dropped, and `static interactive = true` cannot rescue it (nothing consults + * the component analyser for a component the scanner never saw). Never `fail`: + * an app that wants an orphan to break CI gates `ELISION_COMPONENTS` to + * `error` via `webjs.doctor.gate`. + * + * @param {Promise} elisionPromise the ONE shared report + * @returns {Promise} + */ +async function checkElisionComponents(elisionPromise) { + const name = 'Component elision (what the browser drops)'; + const report = await elisionPromise; + const notAnalysed = { name, status: /** @type {const} */ ('pass'), message: 'not analysed (no routable app or analysis unavailable)' }; + if (!report) return notAnalysed; + if (!report.analysed) { + return report.skipped === 'elide-off' + ? { name, status: 'pass', message: 'elision is disabled (webjs.elide false or WEBJS_ELIDE), so every component module ships' } + : notAnalysed; + } + if (report.orphans.length > 0) { + const lines = report.orphans.map(({ file, className }) => + `${className} in ${file} registers no literal tag, so the scanner never sees it`, + ); + return { + name, + status: 'warn', + message: + `${report.orphans.length} component class(es) are dropped with NO elision verdict:\n` + + lines.map((l) => ` ${l}`).join('\n') + + '\n A class registered with a computed tag is invisible to the component scanner, so its module ' + + 'is dropped from the boot and `static interactive = true` cannot rescue it.', + fix: 'Pass a literal tag to Class.register(\'my-tag\') (invariant 3 already requires one), or delete the unregistered class.', + }; + } + const elided = report.components.filter((c) => c.verdict === 'elided'); + const tags = elided.flatMap((c) => c.tags); + const shown = tags.slice(0, 8).join(', '); + const tail = tags.length > 8 ? `, +${tags.length - 8} more` : ''; + return { + name, + status: 'pass', + message: + `${report.summary.elided} of ${report.summary.components} component module(s) are elided (never downloaded)` + + (tags.length ? `: ${shown}${tail}` : '') + + '. Run `webjs elision` for the full verdict.', + }; +} + // Directories never worth walking for the CSS-freshness advisory (mirrors // dev-regenerate's IGNORE_DIRS): build output, deps, VCS + framework caches. const FRESHNESS_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']); @@ -1517,6 +1576,16 @@ export function checkFrameworkResolves(appDir) { export async function runDoctorChecks(appDir, opts = {}) { const cliDir = opts.cliDir || new URL('.', import.meta.url).pathname; + // ONE elision report for BOTH elision checks (#1308). Started before the + // batch and awaited inside each check, so the module graph is built once per + // doctor run and the two checks still run in parallel with everything else. + // Fails soft to null, exactly as the carrier check's own try/catch did. + const elision = (async () => { + try { + const { analyzeAppElision } = await import('@webjsdev/server'); + return await analyzeAppElision(appDir); + } catch { return null; } + })(); const results = await Promise.all([ checkNode(cliDir, opts), checkTsconfig(appDir), @@ -1527,7 +1596,8 @@ export async function runDoctorChecks(appDir, opts = {}) { Promise.resolve(checkFrameworkResolves(appDir)), checkImportmapCoherence(appDir, opts), Promise.resolve(checkGitHook(appDir)), - checkElisionCarriers(appDir), + checkElisionCarriers(elision), + checkElisionComponents(elision), checkStaticAssetFreshness(appDir), checkUnmarkedAssetLinks(appDir), ]); diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index fceaf3de3..235744d22 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -83,8 +83,9 @@ with metadata, Suspense, streaming) for HTML, or `api.js` / | `module-graph.js` | Dependency graph for transitive preload hints. Both walks (`transitiveDeps` for preloads, `reachableFromEntries` for the auth gate) stop at `.server.*` boundaries, so a preload set is always a subset of the servable set. The import scanner runs its regexes over the FULLY-BLANKED mask (`redactStringsAndTemplates(src, true)`, which blanks every comment / string / template / regex body to spaces, delimiters kept) and reads each specifier back from raw `src` via the match's group indices (the `d` flag). Scanning over the blanked mask means a stray `import`/`export` word inside ANY literal or comment (example code in an `html\`\`` template, a `// comment`, a `/regex/`) can never anchor a regex match whose lazy body spans across the literal's closing delimiter into the NEXT real `from ''` and consumes it, silently dropping that real edge (a differential test against a real AST caught this class across the comment / template / regex delimiters, #753). A differential test (`packages/server/test/scanner-fuzz/`) proves the lexer's import-edge, `register()` / `customElements.define()`, and WebComponent class-body extraction agrees with a real TypeScript parse over the repo corpus plus adversarial fixtures (the register/class corpus tolerates only verdict-safe over-matches, never a MISS). **Dynamic-import edges (#751):** a string-literal `import('./widget.ts')` (matched by `DYNAMIC_IMPORT_RE`, same `#`-alias rules as the static scan) is tracked as a SEPARATE edge class kept in a `WeakMap` keyed by the graph (read via `dynamicEdges(graph)`). The dynamic scan does NOT run over the fully-blanked mask the static scan uses (which blanks `${...}` template-hole code too): it runs over `redactToPlaceholders(src)` (#918), which keeps hole code readable so a dynamic import written directly inside a hole (`html\`${import('./x.ts')}\``, real code in an expression position) is captured instead of 404ing, while string / template-TEXT bodies are tokenized to `__STR___` (the specifier recovered from the `literals` array) and comment / regex bodies blanked, so a dynamic import written as literal TEXT is still not an edge. The static `IMPORT_RE` / `EXPORT_FROM_RE` stay on the blanked mask (a statement cannot appear in a `${}` hole, so only the dynamic form needs hole-awareness) and `DYNAMIC_IMPORT_RE` is tightly anchored on `import(` (no lazy cross-delimiter span), so the placeholder scan does not reopen the #753 swallow class. `reachableFromEntries` (the gate) unions these in so a lazily-imported app module is servable instead of 404ing, and a dynamically-imported module's own static subtree is walked too; but `transitiveDeps` (preload) and the elision analysis stay on the STATIC graph only, so a dynamic import is admitted-but-not-preloaded (lazy by author intent) and never flips an elision verdict. The `.server.*` boundary holds for dynamic edges (a dynamic `import('./x.server.ts')` is admitted as a stub, not traversed into). A computed `import(expr)` cannot be captured and stays out (a `webjs check` warning surfaces it). **`#` path-alias expansion (#555):** `appImportsMap(appDir)` reads + caches the app's `package.json "imports"` map, and `expandImportAlias(spec, appDir)` expands a matching `#`-prefixed specifier (e.g. `#lib/db.server.ts` under the scaffold's catch-all `"#*": "./*"`) to its real app-relative target. `resolveImport` calls it BEFORE the relative branch and `parseFile` lets alias specs through, so the graph / auth gate / elision / `no-server-import-in-browser-module` all see the REAL path (an alias cannot launder a `.server.ts` past the boundary). Key-shape-agnostic (wildcard + exact, any base); `IMPORTS_CACHE` is cleared per appDir on each `buildModuleGraph`. **Bare (npm vendor) edges (#754):** a bare specifier (`dayjs`, `@scope/pkg/sub`) is NOT a static graph edge (the gate / elision are unchanged), but the exact specifier is recorded per file in a SEPARATE `WeakMap` keyed by the graph (read via `bareImports(graph)`) so `ssr.js` can map it to a vendor importmap URL and emit a `modulepreload` (flattening the CDN waterfall one level). `node:` builtins + protocol specifiers are excluded; because the scan runs over the fully-blanked mask (#753), a `from ''` written inside example code cannot become a phantom vendor edge (the earlier #754 quote-position guard is subsumed by the blanked-mask scan). | | `importmap.js` | Browser import-map builder. `setCoreInstall(coreDir, distMode)` binds the importmap to the resolved `@webjsdev/core` install and runs `buildCoreEntries()`, which reads the package's `package.json` and derives one importmap line per exported subpath from its `exports` field, picking the `default` condition in dist mode and the `source` (`src/*.js`) condition otherwise. In dist mode the browser surface is ONE self-contained bundle: the `exports` `default` for the always-load browser subpaths (`/directives`, `/context`, `/task`, `/client-router`) all point at `dist/webjs-core-browser.js`, so those entries plus the bare specifier collapse onto that single file (each import picks its named exports from it) instead of a fan of per-subpath bundles + code-split chunks. `/lazy-loader` keeps its own file (on-demand). In src/dev mode each subpath stays granular (`src/*.js`) since there is no bundle to collapse into. `dev.js` calls `setCoreInstall` at boot based on `existsSync(coreDir/dist/webjs-core.js) && existsSync(coreDir/dist/webjs-core-browser.js)`. The bare `@webjsdev/core` specifier always points at the BROWSER entry (`index-browser.js` or `dist/webjs-core-browser.js`); the slim entry drops `renderToString`, `renderToStream`, and `setCspNonceProvider` so server-only bytes do not ride the wire. Node-side consumers resolve via the package.json exports and still get the full `index.js`. `buildImportMap({ fingerprint })` content-hashes each same-origin target via `asset-hash.js`'s `withAssetHash` when `fingerprint` is true (the served map); the internal `importMapHash()` computation passes `false` so the published build id stays a stable per-deploy fingerprint independent of per-file hashes (#243). `vendorPreconnectOrigins(max?)` derives the cross-origin vendor CDN origins from the resolved vendor map (`_extraEntries`), most-common first + bounded, for the auto vendor preconnect (#243): returns `[]` for a same-origin pinned / empty map. **`#` alias browser scopes (#555):** `importAliasBrowserEntries(importsMap, topLevelDirs)` derives the browser importmap entries for the app's `"imports"` aliases, derived from the SAME map the server resolver reads (lockstep). The scaffold's catch-all `"#*": "./*"` expands into one trailing-slash prefix scope per top-level dir (`#lib/` -> `/lib/`, ...; a bare `#` cannot prefix-match, so dev.js's `appTopLevelDirs` scan supplies the dirs and a new folder is covered on the next boot); a per-dir or exact key maps directly. `setImportAliasEntries` binds them at boot and folds them into `buildImportMap`. **`vendorPreloadTargets(specifiers)` (#754):** maps a set of reached bare specifiers to `[{ href, integrity }]` taken DIRECTLY from `buildImportMap().imports[spec]` (byte-identical to the importmap target, so the browser does not double-fetch) + the matching `integrity`; excludes `@webjsdev/core*` (same-origin, already on the boot path), dedups by href, and DROPS a specifier absent from the importmap (unpinned / unreached / elided, so no over-fetch). `ssr.js` feeds it the reached vendor set and emits a `modulepreload` per target. | | `component-scanner.js` | Maps every webjs component class to its browser-visible URL | -| `component-elision.js` | Static analyser deciding which display-only component modules can be elided from the browser, plus the serve-time side-effect-import stripper. Conservative denylist of interactivity signals (single source of truth). `analyzeElision` also returns `shippedRouteModules` (#646): for each page/layout that ships whole (neither inert nor import-only), the first client-effecting blocker that pins it (a non-component on a component-free path from the module, #963, or `null` when the module's own code is the cause) plus a human `reason`. A reporting layer over the existing verdict, consumed by the `webjs doctor` advisory | -| `elision-report.js` | `analyzeAppElision(appDir)` (#646): builds the module graph + runs `analyzeElision`, returning the page/layout route modules that ship whole, each with its named blocker + reason. The app-level wrapper the `webjs doctor` carrier-hygiene advisory calls; returns an empty report for a non-app dir, a malformed app, or when elision is disabled. A reporting layer over the analysis, NOT a build (webjs is no-build) | +| `component-elision.js` | Static analyser deciding which display-only component modules can be elided from the browser, plus the serve-time side-effect-import stripper. Conservative denylist of interactivity signals (single source of truth). `analyzeElision` also returns `shippedRouteModules` (#646): for each page/layout that ships whole (neither inert nor import-only), the first client-effecting blocker that pins it (a non-component on a component-free path from the module, #963, or `null` when the module's own code is the cause) plus a human `reason`. It further returns `componentVerdicts` (#1308): per component FILE, its sorted tag list, whether it ships, and the EVIDENCE that forced it (`own` / `observed` / `closure` / `render` / `import` / `unreadable`, first match wins) plus the module that did the forcing. Both are projections of what the passes already computed, never a second analysis. A reporting layer over the existing verdict, consumed by the `webjs doctor` advisory | +| `elision-report.js` | `analyzeAppElision(appDir)` (#646, #1308): builds the module graph + runs `analyzeElision` ONCE, returning the WHOLE verdict as a sorted, app-relative, JSON-serializable `{ analysed, skipped, components, routeModules, orphans, summary }`. Both directions: which component modules the browser never downloads and why each shipped one ships, which page/layout is inert / import-only / shipped, and every orphan class that gets no verdict at all. Consumed by `webjs elision` (plus `--json`), the MCP `list_elision` tool, and BOTH `webjs doctor` elision checks, which share one call so the graph is built once. `skipped` names why nothing was analysed (`no-app` / `elide-off` / `unanalysable`), and the report caches nothing (every consumer runs once and exits). A reporting layer over the analysis, NOT a build (webjs is no-build) | +| `elision-differential.js` | The differential primitives shared by the framework's own guard and the app-facing one (#1308): `maskJsSet(html)` (the ONE definition of the JS-loaded set, so `test/elision/differential-elision.test.js` and `webjs elision --verify` can never disagree about what the invariant means) and `staticPageRoutes(table)` (the dynamic-free render corpus). A leaf: no filesystem, no module graph | | `js-scan.js` | Shared lexical scanners (`redactStringsAndTemplates`, `redactToPlaceholders`, `extractWebComponentClassBodies`, `matchClosingBrace`) used by `check.js`, `component-scanner.js`, and `component-elision.js`. `redactToPlaceholders` (#634) masks comments and replaces each string / template body with a `__STR___` placeholder (originals returned in a `literals` array, `${...}` holes scanned as code), so the component scanner and the elision import / side-effect scanners see a real top-level `register(...)` / `import` while an identical token shown inside a code-sample string is inert | | `fs-walk.js` | Async recursive directory walker | | `logger.js` | `defaultLogger` (JSON-shaped in prod, pretty in dev) | @@ -471,9 +472,23 @@ and the reader key set never diverge (a counterfactual unknown key proves mapped to a tag via the component class name): any graph-reachable module observing a tag forces that component to ship. Verdict-safe (it only ever ships more). The residual caveat is the part static analysis - cannot see (a dynamic tag string, a `:defined` rule in an external - stylesheet outside the module graph), documented in the skill's - `references/components.md`; for those, add an interactivity signal. + cannot see (an OBSERVER that computes the tag it waits for, a + `:defined` rule in an external stylesheet outside the module graph), + documented in the skill's `references/components.md` and asserted in + `test/elision/residual-contract.test.js`; for those, add an + interactivity signal, in practice `static interactive = true`. A + component's OWN registration tag is a different case: invariant 3 + requires a LITERAL, because `scanComponents` matches only a literal, so + a `Class.register(tagVar)` component is never in the component set at + all, gets no verdict, and `static interactive = true` cannot rescue it + (nothing consults the analyser for a component the scanner never saw). + That shape surfaces as an `orphans` row in `analyzeAppElision` and a + `webjs doctor` warning, not as an elision verdict. + `webjs elision` is the inspection surface for all of this: it prints + the per-module verdict with the evidence behind each ship, and + `webjs elision --verify` runs THIS differential over an arbitrary app's + own route table, so an app proves the invariant locally instead of + inheriting a guarantee it cannot check. ## Tests diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts index 3271b027d..57f5bcb82 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -484,19 +484,97 @@ export declare function findOrphanComponents( ): Promise>; // --------------------------------------------------------------------------- -// elision-report.js (#646) +// elision-report.js (#646, #1308) +// --------------------------------------------------------------------------- + +/** Which rule in the analyser forced a component to ship. First match wins. */ +export type ElisionEvidence = 'own' | 'observed' | 'closure' | 'render' | 'import' | 'unreadable'; + +/** One component module's verdict. `evidence` / `reason` / `by` are null when elided. */ +export interface ElisionComponentRow { + /** App-relative path of the component module. */ + file: string; + /** Every tag this file registers, sorted. */ + tags: string[]; + verdict: 'elided' | 'shipped'; + evidence: ElisionEvidence | null; + reason: string | null; + /** App-relative path of the module that forced the ship, where one did. */ + by: string | null; +} + +/** One page/layout route module's verdict. */ +export interface ElisionRouteModuleRow { + file: string; + verdict: 'inert' | 'import-only' | 'shipped'; + /** For `import-only`: the component modules the boot emits in its place. */ + emits: string[]; + /** For `shipped`: the first client-effecting blocker, or null when its own code is the cause. */ + blocker: string | null; + reason: string | null; +} + +/** A `class X extends WebComponent` with no literal-tag registration. */ +export interface ElisionOrphanRow { + file: string; + className: string; +} + +export interface ElisionSummary { + components: number; + elided: number; + shipped: number; + routeModules: number; + inert: number; + importOnly: number; + shippedWhole: number; + orphans: number; +} + +/** The whole app-level elision verdict. Every path is app-relative; every array is sorted by `file`. */ +export interface ElisionReport { + analysed: boolean; + /** Why nothing was analysed, when `analysed` is false. */ + skipped: 'no-app' | 'elide-off' | 'unanalysable' | null; + components: ElisionComponentRow[]; + routeModules: ElisionRouteModuleRow[]; + orphans: ElisionOrphanRow[]; + summary: ElisionSummary; +} + +/** + * App-level elision report, BOTH directions (#646, #1308): every component + * module as elided or shipped (a shipped one naming the evidence and the module + * that forced it), every page/layout as inert / import-only / shipped (a + * shipped one naming the first client-effecting blocker), and every orphan + * component class that gets no verdict at all. A reporting layer over + * `analyzeElision`, never a second analysis and never a build artifact. + * Consumed by `webjs elision`, the MCP `list_elision` tool, and both `webjs + * doctor` elision checks. + */ +export declare function analyzeAppElision(appDir: string): Promise; + +// --------------------------------------------------------------------------- +// elision-differential.js (#1308) // --------------------------------------------------------------------------- /** - * App-level elision report: the page/layout route modules that SHIP WHOLE to - * the browser instead of being elided as carriers, each with the first - * client-effecting blocker that pins it (or `null` when the module's own code - * is the cause) plus a human reason. A reporting layer over `analyzeElision`, - * consumed by the `webjs doctor` carrier-hygiene advisory (#646). + * Mask the JS-loaded set (importmap, boot script, modulepreload hints, vendor + * connection hints, content stamps) out of an SSR response so a diff sees only + * observable output. The ONE definition shared by the framework's own + * differential guard and `webjs elision --verify`, so an app can diff its own + * two captures with exactly the framework's comparator. */ -export declare function analyzeAppElision( - appDir: string, -): Promise<{ analysed: boolean; shipped: Array<{ file: string; blocker: string | null; reason: string }> }>; +export declare function maskJsSet(html: string): string; + +/** + * The URL paths of every STATIC page route in a `buildRouteTable` result, + * sorted and deduped. Dynamic routes are excluded (rendering one would mean + * inventing param values). + */ +export declare function staticPageRoutes(table: { + pages?: Array<{ routeDir?: string; paramNames?: string[] }>; +}): string[]; // --------------------------------------------------------------------------- // context.js (per-request context helpers) diff --git a/packages/server/index.js b/packages/server/index.js index 64f6fd76b..6dc302591 100644 --- a/packages/server/index.js +++ b/packages/server/index.js @@ -44,6 +44,7 @@ export { export { buildModuleGraph, transitiveDeps } from './src/module-graph.js'; export { scanComponents, primeComponentRegistry, extractComponents, findOrphanComponents } from './src/component-scanner.js'; export { analyzeAppElision } from './src/elision-report.js'; +export { maskJsSet, staticPageRoutes } from './src/elision-differential.js'; export { headers, cookies, getRequest, withRequest, cspNonce, requestId } from './src/context.js'; export { defaultLogger } from './src/logger.js'; export { rateLimit, parseWindow, clientIp, stampRemoteIp } from './src/rate-limit.js'; diff --git a/packages/server/src/component-elision.js b/packages/server/src/component-elision.js index 3145b08ce..da9b5fe5a 100644 --- a/packages/server/src/component-elision.js +++ b/packages/server/src/component-elision.js @@ -904,12 +904,12 @@ export async function computeElidableComponents(components, moduleGraph, readFil * global (`window`, `document`, …), or a shipping component. Anything * ambiguous or unreadable keeps shipping. * - * @param {Array<{ tag: string, file: string }>} components + * @param {Array<{ tag: string, file: string, className?: string }>} components * @param {string[]} routeModules absolute paths of page + layout files * @param {import('./module-graph.js').ModuleGraph} moduleGraph * @param {(file: string) => Promise} readFileFn * @param {string} [appDir] - * @returns {Promise<{ elidableComponents: Set, inertRouteModules: Set, importOnlyRouteModules: Map, shippedRouteModules: Map }>} + * @returns {Promise<{ elidableComponents: Set, inertRouteModules: Set, importOnlyRouteModules: Map, shippedRouteModules: Map, componentVerdicts: Map }>} */ export async function analyzeElision(components, routeModules, moduleGraph, readFileFn, appDir) { /** @type {Set} */ @@ -926,6 +926,23 @@ export async function analyzeElision(components, routeModules, moduleGraph, read /** @type {Set} */ const mustShip = new Set(); + /** + * WHY each shipping component ships (#1308), so the app-level report can name + * the evidence instead of only the verdict. Written alongside every + * `mustShip.add` of a COMPONENT file; FIRST write wins, matching the + * first-match convention the analyser uses everywhere else. + * @type {Map} + */ + const shipEvidence = new Map(); + /** + * @param {string} file the component file that ships + * @param {string} evidence one of own | observed | closure | render | import | unreadable + * @param {string|null} by the module that forced it, where one exists + * @param {string|null} [reason] + */ + const noteShip = (file, evidence, by, reason) => { + if (!shipEvidence.has(file)) shipEvidence.set(file, { evidence, by, reason: reason ?? null }); + }; /** @type {Map>} */ const fileTags = new Map(); /** @type {Set} modules importing a reactive primitive from core */ @@ -939,6 +956,8 @@ export async function analyzeElision(components, routeModules, moduleGraph, read /** @type {Set} component files forced to ship because some module * observes their registration (whenDefined / :defined / instanceof). */ const observedComponentFiles = new Set(); + /** @type {Map} observed component file -> the module observing it (#1308) */ + const observedBy = new Map(); /** @type {Set} */ const allFiles = new Set(componentFiles); @@ -957,7 +976,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read catch { // A component file we cannot read ships conservatively; a helper we // cannot read simply contributes no tags. - if (componentFiles.has(file)) mustShip.add(file); + if (componentFiles.has(file)) { mustShip.add(file); noteShip(file, 'unreadable', null, null); } continue; } if (typeof src !== 'string') continue; @@ -989,8 +1008,9 @@ export async function analyzeElision(components, routeModules, moduleGraph, read hasModuleScopeSideEffect(redacted, literals)) { clientGlobalOrBareFiles.add(file); } - if (componentFiles.has(file) && analyzeComponentSource(masked).interactive) { - mustShip.add(file); + if (componentFiles.has(file)) { + const v = analyzeComponentSource(masked); + if (v.interactive) { mustShip.add(file); noteShip(file, 'own', null, v.reason); } } // Cross-module registration observation (#169): if THIS module observes // another component's tag, that component must register client-side, so @@ -999,13 +1019,13 @@ export async function analyzeElision(components, routeModules, moduleGraph, read // (all components are known up front, but we collect here while we hold // each source). Verdict-safe: only ever forces MORE components to ship. for (const m of masked.matchAll(WHEN_DEFINED_RE)) { - const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f); + const f = tagToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); } } for (const m of masked.matchAll(TAG_DEFINED_RE)) { - const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f); + const f = tagToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); } } for (const m of masked.matchAll(INSTANCEOF_RE)) { - const f = classToFile.get(m[1]); if (f) observedComponentFiles.add(f); + const f = classToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); } } } @@ -1013,7 +1033,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read // render/import rules propagate from it too. Dynamic tag strings and external // (non graph-reachable) stylesheets remain an author-facing caveat, since // static analysis cannot see them. - for (const f of observedComponentFiles) mustShip.add(f); + for (const f of observedComponentFiles) { mustShip.add(f); noteShip(f, 'observed', observedBy.get(f) ?? null, null); } // Reverse import edges (who imports each file), built once from the graph. // Drives both the closure-client-work reachability below and the fixpoint's @@ -1065,7 +1085,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read if (!deps) continue; for (const dep of deps) { if (serverFiles.has(dep)) continue; - if (reachesClientWork.has(dep)) { mustShip.add(file); break; } + if (reachesClientWork.has(dep)) { mustShip.add(file); noteShip(file, 'closure', dep, null); break; } } } } @@ -1108,14 +1128,14 @@ export async function analyzeElision(components, routeModules, moduleGraph, read if (tags) { for (const tag of tags) { const childFile = tagToFile.get(tag); - if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); queue.push(childFile); } + if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); noteShip(childFile, 'render', node, null); queue.push(childFile); } } } const importers = importersOf.get(node); if (importers) { for (const imp of importers) { if (!componentFiles.has(imp)) continue; // import rule is component -> component - if (!mustShip.has(imp)) { mustShip.add(imp); queue.push(imp); } + if (!mustShip.has(imp)) { mustShip.add(imp); noteShip(imp, 'import', node, null); queue.push(imp); } } } } @@ -1146,6 +1166,41 @@ export async function analyzeElision(components, routeModules, moduleGraph, read return 'does client work'; }; + // Per-component verdict plus the evidence that produced it (#1308). Assembled + // ENTIRELY from what the passes above already computed: nothing is re-read and + // nothing is re-analysed, so this is a projection, not a second analysis. A + // file may register more than one tag, so rows are keyed by FILE with a + // sorted tag list. + /** @type {Map} */ + const componentVerdicts = new Map(); + for (const c of components) { + let row = componentVerdicts.get(c.file); + if (!row) { + const shipped = mustShip.has(c.file); + const ev = shipped ? shipEvidence.get(c.file) : undefined; + row = { + tags: [], className: c.className ?? null, shipped, + evidence: ev ? ev.evidence : null, + by: ev ? ev.by : null, + // An ELIDED component carries no reason on purpose: elision is the + // ABSENCE of every signal, so there is no positive fact to report. + // A shipping component with no evidence is only reachable if a future + // rule adds to `mustShip` without calling `noteShip`, so report null + // rather than a wrong claim (sigil-coverage guards against that drift). + reason: !shipped || !ev ? null + : ev.evidence === 'own' ? ev.reason + : ev.evidence === 'observed' ? `its registration is observed by ${ev.by}` + : ev.evidence === 'closure' ? `its import ${ev.by} ${clientEffectReason(/** @type {string} */ (ev.by))}` + : ev.evidence === 'render' ? `${ev.by} ships and can render its tag` + : ev.evidence === 'import' ? `${ev.by} ships and imports it` + : 'its source could not be read (ships conservatively)', + }; + componentVerdicts.set(c.file, row); + } + if (!row.tags.includes(c.tag)) row.tags.push(c.tag); + } + for (const row of componentVerdicts.values()) row.tags.sort(); + // Route modules fall into three classes by their effective client closure // (skipping elided components and server stubs, which never load on the // client): @@ -1242,7 +1297,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read } } - return { elidableComponents, inertRouteModules, importOnlyRouteModules, shippedRouteModules }; + return { elidableComponents, inertRouteModules, importOnlyRouteModules, shippedRouteModules, componentVerdicts }; } /** Match a whole-line side-effect import: `import './x.js';` (no bindings). */ diff --git a/packages/server/src/elision-differential.js b/packages/server/src/elision-differential.js new file mode 100644 index 000000000..ac6c050a9 --- /dev/null +++ b/packages/server/src/elision-differential.js @@ -0,0 +1,101 @@ +/** + * The differential-elision primitives, shared by the framework's own guard and + * by the app-facing one (#1308). + * + * Elision's defining invariant is that removing the elided JS NEVER changes + * observable output. `packages/server/test/elision/differential-elision.test.js` + * proves that for the blog corpus by rendering every route with elision ON and + * OFF and diffing the bytes with the JS-loaded set masked out. `webjs elision + * --verify` runs the identical comparison over an arbitrary app's own route + * table, so every app gets the framework's own guard rather than inheriting a + * guarantee it cannot verify locally. + * + * `maskJsSet` therefore lives HERE and not in either consumer: it is the + * definition of "the JS-loaded set", and two copies is the one way the two + * guards could silently disagree about what the invariant even means. + * + * A leaf module: `node:path` is not even needed, and nothing here reads the + * filesystem or builds a graph. + */ + +/** + * Mask the JS-loaded set so the diff sees only observable output. The + * importmap, the boot module script, and the modulepreload hints are + * REMOVED (not placeheld) because their COUNT differs on vs off, and the + * build-id hash is derived from them; collapsing whitespace afterwards + * means the differing-length preload block in the head leaves no trace. The + * two responses come from the identical SSR template pipeline, so any + * legitimate text/whitespace is the same on both sides regardless. + * + * Because the whole JS-loaded set is masked BY CONSTRUCTION, only the + * DANGEROUS direction (elision changed what the SSR emits) can fail a diff + * built on this. The SAFE direction (over-ship) lives entirely inside the + * masked region and is invisible here, by design. + * + * @param {string} html + * @returns {string} + */ +export function maskJsSet(html) { + return html + .replace(/' + + '' + + '

hello

'; + const masked = maskJsSet(html); + assert.ok(!masked.includes('importmap')); + assert.ok(!masked.includes('modulepreload')); + assert.ok(!masked.includes('preconnect')); + assert.ok(!masked.includes('data-webjs-build')); + assert.ok(masked.includes('

hello

'), 'observable output survives'); +}); diff --git a/packages/server/test/elision/elision-report.test.js b/packages/server/test/elision/elision-report.test.js new file mode 100644 index 000000000..ee0a3e2e1 --- /dev/null +++ b/packages/server/test/elision/elision-report.test.js @@ -0,0 +1,267 @@ +/** + * The app-level elision report contract (#1308). + * + * `analyzeAppElision` is the ONE function `webjs elision`, `webjs elision + * --json`, the MCP `list_elision` tool, and both `webjs doctor` elision checks + * read, so its shape IS the public contract. It had no test at all before this + * file (the #646 version was only exercised indirectly, through the doctor + * advisory's message text). + * + * The properties asserted here are the ones a consumer depends on and that a + * refactor can silently break: every path app-relative (no absolute filesystem + * path anywhere, INCLUDING inside a prose `reason`), every array sorted by + * `file`, the summary counts equal to the array lengths, each `skipped` value + * distinguishable, and one row of every `evidence` value. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, isAbsolute } from 'node:path'; + +import { analyzeAppElision } from '../../src/elision-report.js'; + +/** @param {Record} files app-relative path -> source */ +async function withApp(files, fn) { + const dir = await mkdtemp(join(tmpdir(), 'webjs-elision-report-')); + try { + for (const [rel, src] of Object.entries(files)) { + const abs = join(dir, rel); + await mkdir(join(abs, '..'), { recursive: true }); + await writeFile(abs, src); + } + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const DISPLAY_ONLY = ` +import { WebComponent, html } from '@webjsdev/core'; +export class Badge extends WebComponent { + render() { return html\`verified\`; } +} +Badge.register('my-badge'); +`; + +const INTERACTIVE = ` +import { WebComponent, html } from '@webjsdev/core'; +export class Counter extends WebComponent { + render() { return html\`\`; } +} +Counter.register('my-counter'); +`; + +/** Every string value anywhere in the report, for the no-absolute-path sweep. */ +function allStrings(value, out = []) { + if (typeof value === 'string') out.push(value); + else if (Array.isArray(value)) for (const v of value) allStrings(v, out); + else if (value && typeof value === 'object') for (const v of Object.values(value)) allStrings(v, out); + return out; +} + +test('the full shape: verdicts, sorting, summary, and no absolute path anywhere', async () => { + await withApp({ + 'components/badge.js': DISPLAY_ONLY, + 'components/counter.js': INTERACTIVE, + 'app/page.js': "import { html } from '@webjsdev/core';\nimport '../components/counter.js';\nexport default () => html``;", + 'app/about/page.js': "import { html } from '@webjsdev/core';\nimport '../../components/badge.js';\nexport default () => html``;", + }, async (dir) => { + const r = await analyzeAppElision(dir); + assert.equal(r.analysed, true); + assert.equal(r.skipped, null); + + const badge = r.components.find((c) => c.file.includes('badge')); + const counter = r.components.find((c) => c.file.includes('counter')); + assert.deepEqual(badge, { file: 'components/badge.js', tags: ['my-badge'], verdict: 'elided', evidence: null, reason: null, by: null }); + assert.equal(counter.verdict, 'shipped'); + assert.equal(counter.evidence, 'own'); + assert.match(counter.reason, /@event binding/); + assert.equal(counter.by, null); + + const about = r.routeModules.find((m) => m.file.includes('about')); + const home = r.routeModules.find((m) => m.file === 'app/page.js'); + assert.equal(about.verdict, 'inert', 'a page whose only component is elided ships nothing'); + assert.deepEqual(about.emits, []); + assert.equal(home.verdict, 'import-only'); + assert.deepEqual(home.emits, ['components/counter.js']); + + // Sorted by file, so two runs diff cleanly. + for (const arr of [r.components, r.routeModules, r.orphans]) { + assert.deepEqual(arr.map((x) => x.file), [...arr.map((x) => x.file)].sort(), 'rows are sorted by file'); + } + // Summary equals the arrays it summarises. + assert.equal(r.summary.components, r.components.length); + assert.equal(r.summary.elided, r.components.filter((c) => c.verdict === 'elided').length); + assert.equal(r.summary.shipped, r.components.filter((c) => c.verdict === 'shipped').length); + assert.equal(r.summary.routeModules, r.routeModules.length); + assert.equal(r.summary.inert, r.routeModules.filter((m) => m.verdict === 'inert').length); + assert.equal(r.summary.importOnly, r.routeModules.filter((m) => m.verdict === 'import-only').length); + assert.equal(r.summary.shippedWhole, r.routeModules.filter((m) => m.verdict === 'shipped').length); + assert.equal(r.summary.orphans, r.orphans.length); + + // No absolute path may reach the contract, including inside a reason + // sentence, which `analyzeElision` builds from absolute paths. + for (const s of allStrings(r)) { + assert.ok(!s.includes(dir), `report leaked an absolute path: ${s}`); + for (const word of s.split(/\s+/)) assert.ok(!isAbsolute(word), `report leaked an absolute path: ${s}`); + } + // JSON-serializable, since two consumers print it verbatim. + assert.deepEqual(JSON.parse(JSON.stringify(r)), r); + }); +}); + +test('a shipped route module names its blocker and reason, both app-relative', async () => { + await withApp({ + 'lib/track.js': 'window.__hits = (window.__hits || 0) + 1;\nexport const track = () => {};', + 'app/page.js': "import { html } from '@webjsdev/core';\nimport { track } from '../lib/track.js';\nexport default () => html`

${String(track)}

`;", + }, async (dir) => { + const r = await analyzeAppElision(dir); + const home = r.routeModules.find((m) => m.file === 'app/page.js'); + assert.equal(home.verdict, 'shipped'); + assert.equal(home.blocker, 'lib/track.js'); + assert.match(home.reason, /browser global|module scope/); + }); +}); + +test('evidence: observed', async () => { + await withApp({ + 'components/badge.js': DISPLAY_ONLY, + 'components/observer.js': "customElements.whenDefined('my-badge').then(() => {});", + 'app/page.js': "import { html } from '@webjsdev/core';\nimport '../components/badge.js';\nimport '../components/observer.js';\nexport default () => html``;", + }, async (dir) => { + const r = await analyzeAppElision(dir); + const badge = r.components.find((c) => c.file.includes('badge')); + assert.equal(badge.verdict, 'shipped'); + assert.equal(badge.evidence, 'observed'); + assert.equal(badge.by, 'components/observer.js'); + assert.equal(badge.reason, 'its registration is observed by components/observer.js'); + }); +}); + +test('evidence: closure', async () => { + await withApp({ + 'lib/live.js': "import { signal } from '@webjsdev/core';\nexport const n = signal(0);", + 'components/badge.js': "import { WebComponent, html } from '@webjsdev/core';\nimport { n } from '../lib/live.js';\nexport class Badge extends WebComponent {\n render() { return html`${String(n)}`; }\n}\nBadge.register('my-badge');", + 'app/page.js': "import { html } from '@webjsdev/core';\nimport '../components/badge.js';\nexport default () => html``;", + }, async (dir) => { + const r = await analyzeAppElision(dir); + const badge = r.components.find((c) => c.file.includes('components/badge')); + assert.equal(badge.verdict, 'shipped'); + assert.equal(badge.evidence, 'closure'); + assert.equal(badge.by, 'lib/live.js'); + assert.match(badge.reason, /^its import lib\/live\.js /); + }); +}); + +test('evidence: render', async () => { + await withApp({ + 'components/badge.js': DISPLAY_ONLY, + 'components/shell.js': "import { WebComponent, html } from '@webjsdev/core';\nimport '../components/badge.js';\nexport class Shell extends WebComponent {\n render() { return html``; }\n}\nShell.register('my-shell');", + 'app/page.js': "import { html } from '@webjsdev/core';\nimport '../components/shell.js';\nexport default () => html``;", + }, async (dir) => { + const r = await analyzeAppElision(dir); + const badge = r.components.find((c) => c.file.includes('components/badge')); + assert.equal(badge.verdict, 'shipped'); + // The render rule and the import rule both reach it; first match wins, and + // either is a truthful account of why the module stays on the wire. + assert.ok(['render', 'import'].includes(badge.evidence), `unexpected evidence ${badge.evidence}`); + assert.equal(badge.by, 'components/shell.js'); + }); +}); + +test('an orphan class is reported with no verdict', async () => { + await withApp({ + 'components/dyn-badge.js': "import { WebComponent, html } from '@webjsdev/core';\nconst TAG = 'dyn-' + 'badge';\nexport class DynBadge extends WebComponent {\n render() { return html`x`; }\n}\nDynBadge.register(TAG);", + 'app/page.js': "import { html } from '@webjsdev/core';\nimport '../components/dyn-badge.js';\nexport default () => html`

hi

`;", + }, async (dir) => { + const r = await analyzeAppElision(dir); + assert.deepEqual(r.orphans, [{ file: 'components/dyn-badge.js', className: 'DynBadge' }]); + assert.equal(r.components.length, 0, 'the scanner never saw it, so there is no verdict for it'); + assert.equal(r.summary.orphans, 1); + }); +}); + +test('skipped: no-app', async () => { + await withApp({ 'components/badge.js': DISPLAY_ONLY }, async (dir) => { + const r = await analyzeAppElision(dir); + assert.equal(r.analysed, false); + assert.equal(r.skipped, 'no-app'); + assert.deepEqual(r.components, []); + assert.deepEqual(r.summary, { components: 0, elided: 0, shipped: 0, routeModules: 0, inert: 0, importOnly: 0, shippedWhole: 0, orphans: 0 }); + }); +}); + +test('skipped: elide-off, distinguishable from no-app', async () => { + await withApp({ + 'package.json': JSON.stringify({ name: 'x', webjs: { elide: false } }), + 'app/page.js': "import { html } from '@webjsdev/core';\nexport default () => html`

hi

`;", + }, async (dir) => { + const r = await analyzeAppElision(dir); + assert.equal(r.analysed, false); + assert.equal(r.skipped, 'elide-off', 'a machine consumer must tell the switch from a missing app'); + }); +}); + +test('skipped: elide-off via the WEBJS_ELIDE override', async () => { + await withApp({ + 'app/page.js': "import { html } from '@webjsdev/core';\nexport default () => html`

hi

`;", + }, async (dir) => { + const ORIG = process.env.WEBJS_ELIDE; + try { + process.env.WEBJS_ELIDE = '0'; + const r = await analyzeAppElision(dir); + assert.equal(r.skipped, 'elide-off'); + } finally { + if (ORIG === undefined) delete process.env.WEBJS_ELIDE; + else process.env.WEBJS_ELIDE = ORIG; + } + }); +}); + +test('a malformed app degrades to an analysed empty report rather than throwing', async () => { + // The third `skipped` value, `unanalysable`, is DEFENSIVE and has no + // filesystem trigger: every builder in the pipeline is individually + // error-tolerant, so an `app` that is a plain file, a directory the walk + // cannot read, a page/route collision, and a malformed `imports` map all + // produce an EMPTY analysis rather than a throw (each verified by hand + // against this build). The value still belongs in the contract, because the + // catch must be able to name itself instead of lying as `no-app`. + // + // What matters to a consumer, and what IS testable, is that none of these + // shapes throws or returns a half-report. + for (const files of [ + { app: 'not a directory' }, + { 'app/page.js': 'export default () => null;', 'app/route.js': 'export const GET = () => new Response(1);' }, + { 'package.json': '{"name":"x","imports":"nope"}', 'app/page.js': 'export default () => null;' }, + ]) { + await withApp(files, async (dir) => { + const r = await analyzeAppElision(dir); + assert.ok(Array.isArray(r.components) && Array.isArray(r.routeModules) && Array.isArray(r.orphans)); + assert.equal(typeof r.summary.components, 'number'); + assert.deepEqual(JSON.parse(JSON.stringify(r)), r); + }); + } +}); + +test('every no-verdict report carries the identical empty shape', async () => { + // A consumer branches on `skipped` and then reads the arrays; those must be + // present and empty on EVERY skip path, never undefined. + const empty = { + components: [], routeModules: [], orphans: [], + summary: { components: 0, elided: 0, shipped: 0, routeModules: 0, inert: 0, importOnly: 0, shippedWhole: 0, orphans: 0 }, + }; + await withApp({ 'components/badge.js': DISPLAY_ONLY }, async (dir) => { + const r = await analyzeAppElision(dir); + assert.equal(r.skipped, 'no-app'); + assert.deepEqual({ components: r.components, routeModules: r.routeModules, orphans: r.orphans, summary: r.summary }, empty); + }); + await withApp({ + 'package.json': JSON.stringify({ name: 'x', webjs: { elide: false } }), + 'app/page.js': 'export default () => null;', + }, async (dir) => { + const r = await analyzeAppElision(dir); + assert.equal(r.skipped, 'elide-off'); + assert.deepEqual({ components: r.components, routeModules: r.routeModules, orphans: r.orphans, summary: r.summary }, empty); + }); +}); diff --git a/packages/server/test/elision/residual-contract.test.js b/packages/server/test/elision/residual-contract.test.js new file mode 100644 index 000000000..5237bb08f --- /dev/null +++ b/packages/server/test/elision/residual-contract.test.js @@ -0,0 +1,192 @@ +/** + * The two documented elision RESIDUALS, and the one case that looks like a + * residual but is not (#1308). + * + * `packages/server/AGENTS.md` invariant 7 and the skill's + * `references/components.md` both promise that `static interactive = true` + * rescues the interactivity static analysis cannot see. Until now that promise + * was prose: nothing asserted either residual, so a change in either direction + * (the analyser learning to see one, or the escape hatch quietly ceasing to + * work) was invisible. + * + * These tests build a REAL app on disk and drive `buildModuleGraph` + + * `scanComponents` + `analyzeElision`, rather than the faked-graph helper the + * sibling route-elision tests use. That is load-bearing for residual (b), + * which is precisely about a file that is NOT in the module graph: against a + * faked graph the assertion would be vacuous. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { buildModuleGraph } from '../../src/module-graph.js'; +import { scanComponents, findOrphanComponents } from '../../src/component-scanner.js'; +import { analyzeElision } from '../../src/component-elision.js'; + +/** A display-only badge: static markup, no events, no props, no hooks, light DOM. */ +const badge = (extra = '') => ` +import { WebComponent, html } from '@webjsdev/core'; +export class Badge extends WebComponent { + ${extra} + render() { return html\`verified\`; } +} +Badge.register('my-badge'); +`; + +/** The same badge with a COMPUTED registration tag (invariant 3 forbids this). */ +const badgeComputedRegistration = (extra = '') => ` +import { WebComponent, html } from '@webjsdev/core'; +const TAG = 'my-' + 'badge'; +export class Badge extends WebComponent { + ${extra} + render() { return html\`verified\`; } +} +Badge.register(TAG); +`; + +const PAGE = ` +import { html } from '@webjsdev/core'; +import '../components/badge.js'; +export default () => html\`\`; +`; + +/** + * Write a throwaway app, run the real pipeline over it, and return the verdict + * plus the absolute paths the assertions key on. + * @param {{ badgeSrc: string, observerSrc?: string, css?: string }} spec + */ +async function analyseApp(spec) { + const dir = await mkdtemp(join(tmpdir(), 'webjs-residual-')); + try { + await mkdir(join(dir, 'app'), { recursive: true }); + await mkdir(join(dir, 'components'), { recursive: true }); + await writeFile(join(dir, 'components/badge.js'), spec.badgeSrc); + let page = PAGE; + if (spec.observerSrc) { + await writeFile(join(dir, 'components/observer.js'), spec.observerSrc); + page = page.replace("import '../components/badge.js';", "import '../components/badge.js';\nimport '../components/observer.js';"); + } + if (spec.css) { + await mkdir(join(dir, 'public'), { recursive: true }); + await writeFile(join(dir, 'public/app.css'), spec.css); + } + await writeFile(join(dir, 'app/page.js'), page); + + const graph = await buildModuleGraph(dir); + const components = await scanComponents(dir); + const pageFile = join(dir, 'app/page.js'); + const badgeFile = join(dir, 'components/badge.js'); + const verdict = await analyzeElision( + components, [pageFile], graph, (f) => import('node:fs/promises').then((m) => m.readFile(f, 'utf8')), dir, + ); + return { dir, components, verdict, pageFile, badgeFile, orphans: await findOrphanComponents(dir) }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +// --------------------------------------------------------------------------- +// Control: observation the analyser CAN see +// --------------------------------------------------------------------------- + +test('control: a LITERAL whenDefined observer keeps the badge shipped', async () => { + const { verdict, badgeFile } = await analyseApp({ + badgeSrc: badge(), + observerSrc: "customElements.whenDefined('my-badge').then(() => {});", + }); + assert.ok(!verdict.elidableComponents.has(badgeFile), 'a literally-observed badge must ship'); + assert.equal(verdict.componentVerdicts.get(badgeFile).evidence, 'observed'); +}); + +// --------------------------------------------------------------------------- +// Residual (a): the OBSERVER computes the tag it waits for +// --------------------------------------------------------------------------- + +test('residual (a): a COMPUTED whenDefined tag leaves the badge elided', async () => { + // WHEN_DEFINED_RE reads a literal tag out of the observer's source. A + // variable does not match, so the observation is invisible and the badge is + // elided, which means its `register` never runs and the observer's await + // never settles. Asserted as the documented limitation, so a change in + // EITHER direction is visible rather than silent. + const { verdict, badgeFile } = await analyseApp({ + badgeSrc: badge(), + observerSrc: "const TAG = 'my-' + 'badge';\ncustomElements.whenDefined(TAG).then(() => {});", + }); + assert.ok(verdict.elidableComponents.has(badgeFile), 'the computed observation is invisible to the analyser'); + const row = verdict.componentVerdicts.get(badgeFile); + assert.equal(row.shipped, false); + assert.equal(row.evidence, null, 'an elided component reports no evidence'); + assert.equal(row.reason, null, 'elision is the absence of every signal, so there is no reason to give'); +}); + +test('residual (a) rescue: static interactive = true ships the badge anyway', async () => { + const { verdict, badgeFile } = await analyseApp({ + badgeSrc: badge('static interactive = true;'), + observerSrc: "const TAG = 'my-' + 'badge';\ncustomElements.whenDefined(TAG).then(() => {});", + }); + assert.ok(!verdict.elidableComponents.has(badgeFile), 'the override must force the ship'); + const row = verdict.componentVerdicts.get(badgeFile); + assert.equal(row.evidence, 'own'); + assert.match(row.reason, /static interactive/); +}); + +// --------------------------------------------------------------------------- +// Residual (b): a :defined rule in an EXTERNAL stylesheet +// --------------------------------------------------------------------------- + +test('residual (b): an external-stylesheet :defined rule leaves the badge elided and the page inert', async () => { + // TAG_DEFINED_RE only scans graph-reachable MODULE source, and a + // `public/app.css` is not in the module graph at all, so the rule is + // invisible. The badge is elided AND the page becomes inert, which drops + // both modules from the boot entirely. + const { verdict, badgeFile, pageFile } = await analyseApp({ + badgeSrc: badge(), + css: 'my-badge:defined { opacity: 1 }', + }); + assert.ok(verdict.elidableComponents.has(badgeFile), 'an external stylesheet is outside the module graph'); + assert.ok(verdict.inertRouteModules.has(pageFile), 'with its only component elided the page is inert'); +}); + +test('residual (b) rescue: static interactive = true ships the badge and makes the page import-only', async () => { + const { verdict, badgeFile, pageFile } = await analyseApp({ + badgeSrc: badge('static interactive = true;'), + css: 'my-badge:defined { opacity: 1 }', + }); + assert.ok(!verdict.elidableComponents.has(badgeFile), 'the override must force the ship'); + assert.ok(!verdict.inertRouteModules.has(pageFile), 'a shipping component reclassifies the page'); + assert.deepEqual(verdict.importOnlyRouteModules.get(pageFile), [badgeFile]); +}); + +// --------------------------------------------------------------------------- +// NOT a residual: a computed REGISTRATION tag, which the override cannot reach +// --------------------------------------------------------------------------- + +test('a computed Class.register(tag) is invisible to the SCANNER, so it gets no verdict at all', async () => { + // `scanComponents` requires a literal tag (invariant 3 already does too), so + // this component never enters the component set. `analyzeComponentSource` is + // never consulted for it, the page sees only a `register(...)` call (which + // hasModuleScopeSideEffect explicitly exempts) and is classified INERT, so + // both modules are dropped and the element silently never registers. + const { components, verdict, pageFile, badgeFile, orphans } = await analyseApp({ + badgeSrc: badgeComputedRegistration(), + }); + assert.deepEqual(components, [], 'the scanner sees no component'); + assert.equal(verdict.componentVerdicts.size, 0, 'no component means no verdict to report'); + assert.ok(verdict.inertRouteModules.has(pageFile), 'the page is classified inert'); + assert.deepEqual(orphans, [{ className: 'Badge', file: badgeFile }], 'it surfaces as an ORPHAN instead'); +}); + +test('static interactive = true does NOT rescue a computed registration tag', async () => { + // The measured finding the docs used to get wrong: the override is a + // property the ANALYSER reads, and nothing consults the analyser for a + // component the scanner never saw. Adding it changes nothing. + const { components, verdict, pageFile, orphans } = await analyseApp({ + badgeSrc: badgeComputedRegistration('static interactive = true;'), + }); + assert.deepEqual(components, [], 'still invisible to the scanner'); + assert.equal(verdict.componentVerdicts.size, 0, 'still no verdict'); + assert.ok(verdict.inertRouteModules.has(pageFile), 'the page is still inert, so the module is still dropped'); + assert.equal(orphans.length, 1, 'still an orphan'); +}); diff --git a/packages/server/test/elision/sigil-coverage.test.js b/packages/server/test/elision/sigil-coverage.test.js index ea67aa236..80cdc70c1 100644 --- a/packages/server/test/elision/sigil-coverage.test.js +++ b/packages/server/test/elision/sigil-coverage.test.js @@ -23,6 +23,9 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; + +/** The closed evidence set the report contract promises (#1308). */ +const EVIDENCE = ['own', 'observed', 'closure', 'render', 'import', 'unreadable']; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; @@ -36,6 +39,8 @@ import { const here = dirname(fileURLToPath(import.meta.url)); const coreSrc = resolve(here, '../../../core/src'); +/** The in-repo app that exercises every ship rule (#1308 evidence guard). */ +const BLOG = resolve(here, '../../../../examples/blog'); /** * Pure partition check, reused so the counterfactual can prove it is sensitive: @@ -149,3 +154,30 @@ test('the interactivity static-field registry is the known set (change-detector) // convention, update both, then update this expected set. assert.deepEqual([...INTERACTIVITY_STATIC_FIELDS].sort(), ['interactive', 'shadow']); }); + +test('every shipping component carries the evidence that forced it (#1308)', async () => { + // The report names WHY each component ships, and that evidence is recorded + // alongside every `mustShip.add` of a component file rather than derived + // afterwards. A future rule that adds to `mustShip` without calling + // `noteShip` would emit `evidence: null` for a real ship, which reads to a + // consumer as "shipped, no idea why" and is exactly the kind of silent + // diagnostics gap this file exists to make loud. + // + // Asserted over the blog corpus, which exercises every rule (own, observed, + // closure, render, import) rather than a synthetic fixture that would only + // cover the rules it was written for. + const { analyzeAppElision } = await import('../../src/elision-report.js'); + const report = await analyzeAppElision(BLOG); + assert.ok(report.analysed, 'precondition: the blog corpus is analysable'); + const shipped = report.components.filter((c) => c.verdict === 'shipped'); + assert.ok(shipped.length > 5, `precondition: the corpus ships components (got ${shipped.length})`); + for (const c of shipped) { + assert.ok(EVIDENCE.includes(c.evidence), + `${c.file} ships with evidence ${JSON.stringify(c.evidence)}; every mustShip write must call noteShip with one of ${EVIDENCE.join(' / ')}`); + assert.equal(typeof c.reason, 'string', `${c.file} ships with no reason string`); + assert.ok(c.reason.length > 0, `${c.file} ships with an empty reason`); + } + // And the rules the corpus actually reaches must be more than one, so this + // cannot pass by covering only the `own` case. + assert.ok(new Set(shipped.map((c) => c.evidence)).size > 1, 'the corpus must exercise more than one ship rule'); +}); diff --git a/test/bun/elision-report.mjs b/test/bun/elision-report.mjs new file mode 100644 index 000000000..c6a093c38 --- /dev/null +++ b/test/bun/elision-report.mjs @@ -0,0 +1,101 @@ +/** + * Cross-runtime proof that the app-level elision verdict (#1308) is IDENTICAL + * under whichever runtime runs it: + * + * node test/bun/elision-report.mjs + * bun test/bun/elision-report.mjs + * + * WebJs runs on Node 24+ AND Bun (#508), and an app scaffolded with `--runtime + * bun` runs `webjs elision` against a Bun-served app, so a verdict that drifted + * between runtimes would mean the report told a Bun author something untrue + * about their own app. The analysis is filesystem reads plus regular + * expressions with no runtime-specific API, so there is nothing legitimate to + * skip and this file carries no DENYLIST entry. + * + * The fixture covers one component of each verdict and one route module of each + * class, so a divergence in ANY of the projection's moving parts (the tag sort, + * the evidence pick, the path relativization, the summary counts) shows up as a + * concrete row mismatch rather than a vague count difference. Run from the repo + * root so `@webjsdev/server` resolves. + */ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { analyzeAppElision } from '@webjsdev/server'; + +const runtime = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`; +const dir = mkdtempSync(join(tmpdir(), 'webjs-elision-x-')); + +const write = (rel, src) => { + const abs = join(dir, rel); + mkdirSync(join(abs, '..'), { recursive: true }); + writeFileSync(abs, src); +}; + +try { + write('package.json', JSON.stringify({ name: 'elision-fixture', type: 'module' })); + // Display-only: static markup, no events, no props, no hooks, light DOM. + write('components/badge.js', ` +import { WebComponent, html } from '@webjsdev/core'; +export class Badge extends WebComponent { + render() { return html\`verified\`; } +} +Badge.register('my-badge'); +`); + // Interactive: an @event binding is the plainest ship signal there is. + write('components/counter.js', ` +import { WebComponent, html } from '@webjsdev/core'; +export class Counter extends WebComponent { + render() { return html\`\`; } +} +Counter.register('my-counter'); +`); + // Inert: its only component is elided, so this page ships nothing. + write('app/about/page.js', "import { html } from '@webjsdev/core';\nimport '../../components/badge.js';\nexport default () => html``;"); + // Import-only: the page itself does no client work, and the only client work + // its closure reaches is a shipping component. + write('app/page.js', "import { html } from '@webjsdev/core';\nimport '../components/counter.js';\nexport default () => html``;"); + + const r = await analyzeAppElision(dir); + + assert.equal(r.analysed, true, 'the fixture app must be analysable'); + assert.equal(r.skipped, null); + + // Component rows, exact. + assert.deepEqual( + r.components.map((c) => [c.file, c.tags.join(','), c.verdict, c.evidence]), + [ + ['components/badge.js', 'my-badge', 'elided', null], + ['components/counter.js', 'my-counter', 'shipped', 'own'], + ], + 'both the verdicts and their order must match across runtimes', + ); + assert.match(r.components[1].reason, /@event binding/, 'the ship reason is the analyser\'s own words'); + assert.equal(r.components[0].reason, null, 'an elided component reports no reason'); + + // Route-module rows, exact. + assert.deepEqual( + r.routeModules.map((m) => [m.file, m.verdict, m.emits.join(',')]), + [ + ['app/about/page.js', 'inert', ''], + ['app/page.js', 'import-only', 'components/counter.js'], + ], + ); + + assert.deepEqual(r.orphans, []); + assert.deepEqual(r.summary, { + components: 2, elided: 1, shipped: 1, + routeModules: 2, inert: 1, importOnly: 1, shippedWhole: 0, + orphans: 0, + }); + + // Two consumers print this object verbatim, so it must survive a JSON + // round-trip identically on both runtimes. + assert.deepEqual(JSON.parse(JSON.stringify(r)), r, 'the report is JSON-serializable'); + + console.log(`OK webjs elision verdict is identical on ${runtime}`); +} finally { + rmSync(dir, { recursive: true, force: true }); +} diff --git a/test/bun/elision-report.test.mjs b/test/bun/elision-report.test.mjs new file mode 100644 index 000000000..4a37ed725 --- /dev/null +++ b/test/bun/elision-report.test.mjs @@ -0,0 +1,11 @@ +/** + * Run the cross-runtime elision-verdict proof (#1308) under WHICHEVER runtime + * executes the suite. Picked up by the root `node --test` runner (Node path); + * the CI `bun` job also runs `bun test/bun/elision-report.mjs` for the Bun path. + * The proof is a plain assert script (not `*.test.mjs`), so importing it runs it. + */ +import { test } from 'node:test'; + +test('the app-level elision verdict is identical on this runtime (#1308)', async () => { + await import('./elision-report.mjs'); +}); From fe8a64c61e7ee1f068180f04ab26a442298ef69e Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 22:27:09 +0530 Subject: [PATCH 04/24] test: cover the elision CLI, the doctor check, the MCP tool, and the override The CLI tests drive every --verify exit path, including the two that matter most and are easy to get wrong: a corpus where nothing could be compared exits 1 rather than reporting a vacuous pass, and a real divergence is produced for real rather than stubbed (a component whose SSR output reads WEBJS_ELIDE is exactly the class of bug the differential exists to catch). The doctor tests keep the three carrier cases unchanged, which is the regression guard for rewiring that check onto the shared report, and pin the new check to pass-except-orphans plus its gate contract. The e2e probe is the first coverage anywhere above the analyser unit that static interactive = true actually keeps a module on the wire; build-stamp on the same run is the negative control. --- packages/mcp/test/mcp.test.mjs | 43 ++++++- test/cli/doctor.test.mjs | 89 +++++++++++++ test/cli/elision.test.mjs | 220 +++++++++++++++++++++++++++++++++ test/e2e/e2e.test.mjs | 37 ++++++ 4 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 test/cli/elision.test.mjs diff --git a/packages/mcp/test/mcp.test.mjs b/packages/mcp/test/mcp.test.mjs index 47e906630..d7ab0e995 100644 --- a/packages/mcp/test/mcp.test.mjs +++ b/packages/mcp/test/mcp.test.mjs @@ -112,7 +112,7 @@ test('mcp: tools/list returns the introspection + knowledge tools with inputSche ]); const tools = frames[0].result.tools; const names = tools.map((t) => t.name).sort(); - assert.deepEqual(names, ['check', 'docs', 'init', 'list_actions', 'list_components', 'list_routes', 'source', 'ui']); + assert.deepEqual(names, ['check', 'docs', 'init', 'list_actions', 'list_components', 'list_elision', 'list_routes', 'source', 'ui']); for (const t of tools) { assert.equal(typeof t.description, 'string'); assert.equal(t.inputSchema.type, 'object'); @@ -229,6 +229,47 @@ test('mcp: tools/call list_components reports tag + file + className', async () assert.match(c.file, /my-thing\.ts$/); }); +test('mcp: list_elision output equals analyzeAppElision (no drift with the CLI)', async () => { + // `list_elision` returns the report VERBATIM, and `webjs elision --json` + // prints the same object, so the two surfaces cannot disagree about an app. + // Unlike list_routes there is no projector leaf to delegate to, which makes + // this equality the only thing holding the contract together. + const { analyzeAppElision } = await import('@webjsdev/server'); + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'x', type: 'module' })); + write(dir, 'components/badge.ts', + `import { WebComponent, html } from '@webjsdev/core';\n` + + `export class Badge extends WebComponent { render() { return html\`x\`; } }\n` + + `Badge.register('my-badge');\n`); + write(dir, 'app/page.ts', + `import { html } from '@webjsdev/core';\nimport '../components/badge.ts';\nexport default () => html\`\`;\n`); + + const { frames } = await driveMcp(dir, [ + { jsonrpc: '2.0', id: 20, method: 'tools/call', params: { name: 'list_elision', arguments: {} } }, + ]); + const toolOut = JSON.parse(frames[0].result.content[0].text); + assert.deepEqual(toolOut, JSON.parse(JSON.stringify(await analyzeAppElision(dir)))); + const badge = toolOut.components.find((c) => c.file.includes('badge')); + assert.equal(badge.verdict, 'elided', 'the display-only badge is never downloaded'); +}); + +test('mcp: list_components stays a cheap lexical inventory (no elision fields)', async () => { + // The guard for the rejected alternative: growing an `elided` flag onto + // list_components would silently turn a scan that loads no module and builds + // no graph into one that does both, and it still could not carry the route + // verdicts or the orphans. The elision verdict lives in its own tool. + const dir = tmpDir(); + write(dir, 'components/my-thing.ts', + `import { WebComponent, html } from '@webjsdev/core';\n` + + `export class MyThing extends WebComponent { render() { return html\`

x

\`; } }\n` + + `MyThing.register('my-thing');\n`); + const { frames } = await driveMcp(dir, [ + { jsonrpc: '2.0', id: 21, method: 'tools/call', params: { name: 'list_components', arguments: {} } }, + ]); + const comps = JSON.parse(frames[0].result.content[0].text); + assert.deepEqual(Object.keys(comps[0]).sort(), ['className', 'file', 'tag']); +}); + test('mcp: unknown method -> JSON-RPC -32601', async () => { const dir = tmpDir(); const { frames } = await driveMcp(dir, [ diff --git a/test/cli/doctor.test.mjs b/test/cli/doctor.test.mjs index 8730a37ff..c66832f0b 100644 --- a/test/cli/doctor.test.mjs +++ b/test/cli/doctor.test.mjs @@ -1153,6 +1153,95 @@ test('elision disabled (webjs.elide=false) skips the carrier advisory', async () assert.equal(byName(results, CARRIER_CHECK).status, 'pass', 'opted-out apps ship everything by design, so no advice'); }); +// --------------------------------------------------------------------------- +// Component elision verdict (#1308): the OTHER direction. The carrier check +// above reports the benign over-ship; this one reports what was DROPPED, which +// is where a wrong verdict silently costs an app its interactivity. +// --------------------------------------------------------------------------- +const COMPONENT_CHECK = 'Component elision (what the browser drops)'; + +/** A page rendering one display-only component, which the analyser elides. */ +function elidedComponentApp(extraPkg = {}) { + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'x', type: 'module', ...extraPkg })); + write(dir, 'components/badge.js', + `import { WebComponent, html } from '@webjsdev/core';\nexport class Badge extends WebComponent {\n render() { return html\`verified\`; }\n}\nBadge.register('my-badge');\n`); + write(dir, 'app/page.js', + `import { html } from '@webjsdev/core';\nimport '../components/badge.js';\nexport default () => html\`\`;\n`); + return dir; +} + +test('a healthy app PASSES and the message carries the elided inventory', async () => { + // An elided component is the DESIRED outcome, so this must never warn about + // one: a check that fires on every healthy app trains the reader to skip + // doctor output entirely. The inventory rides the passing message instead, + // which is what makes the check a discovery surface. + const r = byName(await runDoctorChecks(elidedComponentApp(), baseOpts()), COMPONENT_CHECK); + assert.equal(r.status, 'pass'); + assert.match(r.message, /1 of 1 component module\(s\) are elided/); + assert.match(r.message, /my-badge/, 'names the tag the browser never downloads'); + assert.match(r.message, /webjs elision/, 'points at the detail surface'); +}); + +test('an orphan class WARNS, names the class and file, and never fails', async () => { + // The one always-wrong condition: a class registered with a computed tag is + // invisible to the scanner, so its module is dropped with no verdict at all + // and `static interactive = true` cannot rescue it. + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'x', type: 'module' })); + write(dir, 'components/dyn.js', + `import { WebComponent, html } from '@webjsdev/core';\nconst TAG = 'dyn-' + 'badge';\nexport class DynBadge extends WebComponent {\n render() { return html\`x\`; }\n}\nDynBadge.register(TAG);\n`); + write(dir, 'app/page.js', + `import { html } from '@webjsdev/core';\nimport '../components/dyn.js';\nexport default () => html\`

hi

\`;\n`); + + const results = await runDoctorChecks(dir, baseOpts()); + const r = byName(results, COMPONENT_CHECK); + assert.equal(r.status, 'warn'); + assert.match(r.message, /DynBadge/, 'names the class'); + assert.match(r.message, /components\/dyn\.js/, 'names the file'); + assert.match(r.message, /static interactive = true. cannot rescue/, 'says the override does not help'); + assert.ok(r.fix, 'offers an actionable fix line'); + assert.ok(!results.some((x) => x.status === 'fail'), 'this check never hard-fails'); +}); + +test('elision disabled reports pass and names the switch', async () => { + const r = byName(await runDoctorChecks(elidedComponentApp({ webjs: { elide: false } }), baseOpts()), COMPONENT_CHECK); + assert.equal(r.status, 'pass'); + assert.match(r.message, /elision is disabled/); + assert.match(r.message, /WEBJS_ELIDE/); +}); + +test('the check carries the stable code ELISION_COMPONENTS and is gateable', async () => { + // `webjs.doctor.gate` (#1257) addresses a check by its stable code, and + // `readDoctorPolicy` rejects an UNKNOWN code as a hard config error, so this + // is also the counterfactual for the DOCTOR_CODES entry: drop that entry and + // the gate below stops being accepted. + const r = byName(await runDoctorChecks(elidedComponentApp(), baseOpts()), COMPONENT_CHECK); + assert.equal(r.code, 'ELISION_COMPONENTS'); + + // Gate the case that actually FIRES: a passing check contributes `pass` + // whatever the gate says (a check that did not fire contributes nothing), so + // only the orphan warning can demonstrate the clamp. + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ + name: 'x', type: 'module', webjs: { doctor: { gate: { ELISION_COMPONENTS: 'off' } } }, + })); + write(dir, 'components/dyn.js', + `import { WebComponent, html } from '@webjsdev/core';\nconst TAG = 'dyn-' + 'badge';\nexport class DynBadge extends WebComponent {\n render() { return html\`x\`; }\n}\nDynBadge.register(TAG);\n`); + write(dir, 'app/page.js', + `import { html } from '@webjsdev/core';\nimport '../components/dyn.js';\nexport default () => html\`

hi

\`;\n`); + + const policy = readDoctorPolicy(dir); + assert.deepEqual(policy.malformed, [], 'a known code is accepted, so the DOCTOR_CODES entry is present'); + assert.deepEqual(policy.unknownCodes ?? [], [], 'ELISION_COMPONENTS is a known code'); + assert.equal(policy.gate.ELISION_COMPONENTS, 'off'); + + const cli = runCliArgs(dir, ['--json']); + const gated = JSON.parse(cli.stdout).results.find((x) => x.code === 'ELISION_COMPONENTS'); + assert.equal(gated.status, 'warn', 'the check still FOUND the orphan'); + assert.equal(gated.severity, 'off', 'but the gate silences what it contributes'); +}); + // --------------------------------------------------------------------------- // Static build-output freshness (dev.regenerate, #967): the parity backstop. // Dev recompiles on request; this WARNs when a committed / built output is diff --git a/test/cli/elision.test.mjs b/test/cli/elision.test.mjs new file mode 100644 index 000000000..b8f9bdd0c --- /dev/null +++ b/test/cli/elision.test.mjs @@ -0,0 +1,220 @@ +/** + * Tests for `webjs elision` (#1308): the elision-verdict printer and its + * app-level differential. + * + * Two layers, the same split `routes.test.mjs` uses: + * - `analyzeAppElision(appDir)` against tmp fixture apps, so the verdict is + * asserted without spawning anything. + * - The CLI integration: spawn the binary and assert the human report, the + * `--json` contract (which MUST equal the direct call, since the MCP + * `list_elision` tool returns the same object), and every `--verify` exit + * path INCLUDING the vacuous one, because a verification command that can + * pass while comparing nothing is worse than no command at all. + */ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(__dirname, '..', '..'); +const CLI = resolve(REPO, 'packages', 'cli', 'bin', 'webjs.js'); + +const { analyzeAppElision } = await import('@webjsdev/server'); + +const cleanup = []; +after(() => { for (const d of cleanup) rmSync(d, { recursive: true, force: true }); }); + +function tmpDir() { + const dir = mkdtempSync(join(tmpdir(), 'elision-cli-')); + cleanup.push(dir); + return dir; +} + +function write(dir, rel, content) { + const full = join(dir, rel); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content); +} + +/** + * `--verify` boots two REAL request handlers over the fixture, so unlike the + * report path the fixture has to resolve `@webjsdev/core` from its own tree. + * A tmp dir outside the repo resolves nothing, so link the repo's modules in. + */ +function linkFramework(dir) { + symlinkSync(join(REPO, 'node_modules'), join(dir, 'node_modules'), 'dir'); +} + +function runCli(cwd, args) { + return spawnSync(process.execPath, [CLI, 'elision', ...args], { cwd, encoding: 'utf8' }); +} + +const DISPLAY_ONLY = ` +import { WebComponent, html } from '@webjsdev/core'; +export class Badge extends WebComponent { + render() { return html\`verified\`; } +} +Badge.register('my-badge'); +`; + +const INTERACTIVE = ` +import { WebComponent, html } from '@webjsdev/core'; +export class Counter extends WebComponent { + render() { return html\`\`; } +} +Counter.register('my-counter'); +`; + +/** One elided component, one shipped, one inert page, one import-only page. */ +function fixtureApp() { + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'fx', type: 'module' })); + write(dir, 'components/badge.js', DISPLAY_ONLY); + write(dir, 'components/counter.js', INTERACTIVE); + write(dir, 'app/about/page.js', "import { html } from '@webjsdev/core';\nimport '../../components/badge.js';\nexport default () => html``;"); + write(dir, 'app/page.js', "import { html } from '@webjsdev/core';\nimport '../components/counter.js';\nexport default () => html``;"); + linkFramework(dir); + return dir; +} + +// --------------------------------------------------------------------------- +// The analysis, direct. +// --------------------------------------------------------------------------- + +test('analyzeAppElision reports both verdicts and both route classes', async () => { + const r = await analyzeAppElision(fixtureApp()); + assert.equal(r.analysed, true); + assert.deepEqual( + r.components.map((c) => [c.file, c.verdict]), + [['components/badge.js', 'elided'], ['components/counter.js', 'shipped']], + ); + assert.deepEqual( + r.routeModules.map((m) => [m.file, m.verdict]), + [['app/about/page.js', 'inert'], ['app/page.js', 'import-only']], + ); +}); + +// --------------------------------------------------------------------------- +// CLI: the report. +// --------------------------------------------------------------------------- + +test('webjs elision --json equals the direct analyzeAppElision call', async () => { + const dir = fixtureApp(); + const r = runCli(dir, ['--json']); + assert.equal(r.status, 0, r.stderr); + // The byte-identity contract: the MCP `list_elision` tool returns this same + // object, so the two surfaces can never disagree about an app. + assert.deepEqual(JSON.parse(r.stdout), JSON.parse(JSON.stringify(await analyzeAppElision(dir)))); +}); + +test('webjs elision names the elided component, the ship reason, and the route classes', () => { + const r = runCli(fixtureApp(), []); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /Elided components/); + assert.match(r.stdout, /components\/badge\.js\s+my-badge/); + assert.match(r.stdout, /Shipped components/); + assert.match(r.stdout, /components\/counter\.js.*own: template has an @event binding/); + assert.match(r.stdout, /inert\s+app\/about\/page\.js/); + assert.match(r.stdout, /import-only\s+app\/page\.js\s+emits components\/counter\.js/); +}); + +test('webjs elision lists an orphan under its own heading', () => { + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'fx', type: 'module' })); + write(dir, 'components/dyn.js', "import { WebComponent, html } from '@webjsdev/core';\nconst TAG = 'dyn-' + 'badge';\nexport class DynBadge extends WebComponent {\n render() { return html`x`; }\n}\nDynBadge.register(TAG);"); + write(dir, 'app/page.js', "import { html } from '@webjsdev/core';\nimport '../components/dyn.js';\nexport default () => html`

hi

`;"); + const r = runCli(dir, []); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /Orphan components/); + assert.match(r.stdout, /DynBadge in components\/dyn\.js/); + assert.match(r.stdout, /static interactive = true. cannot rescue/); +}); + +test('webjs elision names WHY it analysed nothing', () => { + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'fx', type: 'module' })); + assert.match(runCli(dir, []).stdout, /no app\/ directory/); + + const off = fixtureApp(); + write(off, 'package.json', JSON.stringify({ name: 'fx', type: 'module', webjs: { elide: false } })); + assert.match(runCli(off, []).stdout, /elision is disabled/); +}); + +// --------------------------------------------------------------------------- +// CLI: --verify. +// --------------------------------------------------------------------------- + +test('webjs elision --verify passes on a static app and prints the post-hydration caveat', () => { + const r = runCli(fixtureApp(), ['--verify']); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /2 route\(s\) identical with elision on vs off/); + // The boundary must be stated by the COMMAND, not only by the docs: this is + // the half of the guarantee --verify cannot see. + assert.match(r.stdout, /does NOT prove\s+post-hydration behaviour/); + assert.match(r.stdout, /WEBJS_ELIDE=0 /); +}); + +test('webjs elision --verify reports dynamic routes as skipped by name', () => { + const dir = fixtureApp(); + write(dir, 'app/blog/[slug]/page.js', "import { html } from '@webjsdev/core';\nexport default () => html`

post

`;"); + const r = runCli(dir, ['--verify']); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /skipped \(dynamic: \/blog\/\[slug\]\)/); +}); + +test('webjs elision --verify exits 1 when NOTHING could be compared (a vacuous pass is a failure)', () => { + // An app whose only page is dynamic has an empty static corpus. Reporting + // "identical" over zero routes would be a lie of omission, so this is the + // one posture a verification command must get right. + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'fx', type: 'module' })); + write(dir, 'app/blog/[slug]/page.js', "import { html } from '@webjsdev/core';\nexport default () => html`

post

`;"); + linkFramework(dir); + const r = runCli(dir, ['--verify']); + assert.equal(r.status, 1, r.stdout + r.stderr); + assert.match(r.stderr, /nothing was compared/); + assert.match(r.stderr, /--routes/); +}); + +test('webjs elision --verify --routes adds a path outside the static set', () => { + const dir = fixtureApp(); + write(dir, 'app/blog/[slug]/page.js', "import { html } from '@webjsdev/core';\nexport default ({ params }) => html`

post ${params.slug}

`;"); + const r = runCli(dir, ['--verify', '--routes', '/blog/hello']); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /3 route\(s\) identical/, 'the named dynamic path joins the corpus'); +}); + +test('webjs elision --verify FAILS on a --routes path that does not render', () => { + // A path the author named explicitly is required to render; silently + // counting it as a skip would let a typo look like a passing run. + const r = runCli(fixtureApp(), ['--verify', '--routes', '/nope']); + assert.equal(r.status, 1, r.stdout + r.stderr); + assert.match(r.stderr, /\/nope \(404\): a --routes path must render/); +}); + +test('webjs elision --verify FAILS when elision changes the served bytes', () => { + // The divergence path, driven for real rather than stubbed. The page renders + // a tag whose module is elided ON and shipped OFF, and the component's SSR + // output differs between those two states, because it reads the flag itself. + // That is exactly the class of bug the differential exists to catch: a + // component whose rendered output is not independent of its own elision. + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'fx', type: 'module' })); + write(dir, 'components/leaky.js', ` +import { WebComponent, html } from '@webjsdev/core'; +export class Leaky extends WebComponent { + render() { return html\`\${process.env.WEBJS_ELIDE === '0' ? 'off' : 'on'}\`; } +} +Leaky.register('leaky-badge'); +`); + write(dir, 'app/page.js', "import { html } from '@webjsdev/core';\nimport '../components/leaky.js';\nexport default () => html``;"); + linkFramework(dir); + const r = runCli(dir, ['--verify']); + assert.equal(r.status, 1, r.stdout + r.stderr); + assert.match(r.stderr, /elision changed observable output/); + assert.match(r.stderr, /diverged out of 1 compared/); +}); diff --git a/test/e2e/e2e.test.mjs b/test/e2e/e2e.test.mjs index 1ba141386..6323abdf8 100644 --- a/test/e2e/e2e.test.mjs +++ b/test/e2e/e2e.test.mjs @@ -1869,6 +1869,43 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1 'an observed display-only component module MUST be downloaded (forced to ship)'); }); + test('static interactive = true forces a display-only module onto the wire (#1308)', async () => { + // The OTHER route to a ship, on the same page as the observation probe. + // is display-only in every respect (static markup, no + // events, no reactive props, no lifecycle hook, light DOM); the one thing + // keeping it on the wire is the author's `static interactive = true`. + // Until now that override's only coverage stopped at the analyser + // returning a boolean, and nothing proved the boot script honours it. + // + // build-stamp on the SAME run is the negative control: without it this + // assertion would also pass if elision had stopped working entirely. + /** @type {string[]} */ + const requested = []; + const onRequest = (req) => requested.push(req.url()); + page.on('request', onRequest); + try { + await page.setCacheEnabled(false); + await page.goto(`${baseUrl}/observed`, { waitUntil: 'domcontentloaded', timeout: 15000 }); + await sleep(3000); + } finally { + page.off('request', onRequest); + await page.setCacheEnabled(true); + } + + const forcedFetched = requested.some((u) => /\/components\/forced-badge\.(ts|js)/.test(u)); + const stampFetched = requested.some((u) => /\/components\/build-stamp\.(ts|js)/.test(u)); + const forcedText = await page.evaluate( + () => document.querySelector('forced-badge')?.textContent?.trim() || '', + ); + + // The progressive-enhancement half: the markup is in the first paint. + assert.match(forcedText, /forced badge/i, 'forced-badge SSR content is present'); + assert.equal(forcedFetched, true, + 'static interactive = true MUST keep the module on the wire'); + assert.equal(stampFetched, false, + 'negative control: an unoverridden display-only module is still elided on this run'); + }); + test('willUpdate-derived state and reflected props are in the SSR HTML before JS (#217)', async () => { // derives its text in willUpdate and flips a // reflect:true `ready` boolean there. The SSR walker now runs willUpdate From ef13733d67dad1707d25372960c718875d33e7e0 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 22:33:51 +0530 Subject: [PATCH 05/24] docs: give display-only elision its own page, and stop the false orphans Elision was described across six docs pages with no page owning it, and the opt-out appeared on none of them, which is exactly how the switch went undocumented. A capability with its own command, config key, env override, doctor check, and MCP tool has outgrown being a paragraph inside five topics. The skill gets the agent-facing half: how to read the evidence values, what to do with each verdict, and the two-run recipe for the behaviour half that the byte differential cannot see. Dogfooding the new doctor check turned up a real defect in the orphan scan. It read raw source, so every WebComponent subclass written inside an html template as a CODE SAMPLE counted as an unregistered component, and the repo own website reported 17 false orphans. That was tolerable as dev-console noise and is not tolerable as a doctor warning, so the scan now redacts strings and templates exactly as extractComponents already did. Both dogfood apps go from 17 and 0 false orphans to none, and a real orphan in the same tree still fires. --- .agents/skills/webjs/SKILL.md | 1 + .agents/skills/webjs/references/testing.md | 19 +++ packages/server/src/component-scanner.js | 23 ++- .../test/scanner/component-scanner.test.js | 29 ++++ website/app/docs/components/page.ts | 2 +- website/app/docs/configuration/page.ts | 7 + website/app/docs/elision/page.ts | 151 ++++++++++++++++++ website/app/docs/layout.ts | 1 + .../app/docs/progressive-enhancement/page.ts | 4 + 9 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 website/app/docs/elision/page.ts diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index c9bf72124..0ebd7f322 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -39,6 +39,7 @@ Classify the task first, then load the smallest useful reference set. Each refer | --------------------------------------------------------------------------- | --------------------------------------------- | | Pages, layouts, dynamic routes, route handlers, metadata, redirects, 404s | `references/routing-and-pages.md` | | Writing components: reactive props, signals, lifecycle, light vs shadow DOM | `references/components.md` | +| Why a component's JS was or was not downloaded, `webjs elision`, `static interactive = true` | `references/components.md` | | Server actions, mutations, queries, validation, the `ActionResult` envelope | `references/data-and-actions.md` | | Sessions, login flows, route protection, `forbidden()` / `unauthorized()` | `references/auth-and-sessions.md` | | Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout | `references/styling.md` | diff --git a/.agents/skills/webjs/references/testing.md b/.agents/skills/webjs/references/testing.md index 8e9ecdf89..f91c14aba 100644 --- a/.agents/skills/webjs/references/testing.md +++ b/.agents/skills/webjs/references/testing.md @@ -170,6 +170,25 @@ A cross-runtime proof is often a plain assert script rather than a test file, so - **Assert the exit code, never the logs.** These scripts conventionally pass a `quiet` logger, so anything that only logs is invisible. If your script catches its own failure, report it with an explicit `process.exit(1)` rather than a `console.error` alone, guarded as `if (import.meta.main) process.exit(1); else throw failure;`. The guard matters when a `.test.mjs` wrapper imports the script under `node --test`: an unguarded exit kills the whole single-process run and hides every other file's results, while the throw lets the harness report one failed test. - **Prove the script can FAIL before you trust it passing.** Break one assertion on purpose and confirm the run exits non-zero. A proof that cannot go red is worse than no proof: it reports success forever. +## Proving display-only elision did not break anything + +WebJs strips the JavaScript of every component that does no client work, so a wrong verdict costs an app real interactivity and does it silently. Two commands cover the two halves, and you need both. + +```sh +webjs elision --verify +``` + +renders every static page route with elision on and off and diffs the served bytes. It is the framework's own differential guard pointed at your route table, and it exits non-zero on a divergence AND on a corpus where nothing could be compared, so it belongs in CI. Dynamic routes are skipped by name; add real paths with `--routes /,/blog/hello`. + +That proves the bytes you SERVE did not change. It cannot prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Run your own browser or e2e suite twice for that half: + +```sh +WEBJS_ELIDE=1 npm run test:e2e +WEBJS_ELIDE=0 npm run test:e2e +``` + +A test that passes under one and fails under the other is a wrong verdict, and `webjs elision` tells you which module and on what evidence. If the component's interactivity is genuinely invisible to static analysis, the fix is `static interactive = true` on it; see `components.md` for what that override does and does not rescue. + ## Convention validation (`webjs check`) `npm run check` is the correctness validator. Every rule catches code that is wrong to ship, a crash, a security leak, a reactive prop that silently stops re-rendering, or a type-strip failure. Run it and fix every violation before considering the change done (`npm run check -- --json` for an agent loop, `npm run check -- --rules` to list the rules). It is separate from `CONVENTIONS.md`, which carries the customizable project conventions you follow by judgment. diff --git a/packages/server/src/component-scanner.js b/packages/server/src/component-scanner.js index d9484588b..cc8e73569 100644 --- a/packages/server/src/component-scanner.js +++ b/packages/server/src/component-scanner.js @@ -167,22 +167,33 @@ export async function findOrphanComponents(appDir) { for await (const file of walk(appDir, filter)) { let src; try { src = await readFile(file, 'utf8'); } catch { continue; } + // Scan REDACTED source, the same way `extractComponents` above does. A + // `class X extends WebComponent` written inside an `html` template or a + // string is a CODE SAMPLE (every docs page is full of them), not a real + // declaration, and reporting it as an unregistered component is a false + // orphan. Redaction blanks string / template bodies and comments while + // preserving positions, so a genuine top-level declaration still matches + // and the registration's literal tag survives as a `__STR___` + // placeholder. + const { redacted } = redactToPlaceholders(src); // Find every class that extends WebComponent (exact name: we trust // the framework convention). const classRe = /\b(?:export\s+)?(?:default\s+)?class\s+([A-Z][A-Za-z0-9_$]*)\s+extends\s+WebComponent\b/g; // A class counts as "registered" if either Class.register('tag') or - // customElements.define('tag', Class) appears in the file. - const registerRe = /\b([A-Z][A-Za-z0-9_$]*)\.register\s*\(\s*['"][^'"]+['"]\s*\)/g; - const defineRe = /\bcustomElements\.define\s*\(\s*['"][^'"]+['"]\s*,\s*([A-Z][A-Za-z0-9_$]*)\b/g; + // customElements.define('tag', Class) appears in the file. The tag is a + // placeholder after redaction; an orphan is about the CLASS, not the tag, + // so the placeholder is matched rather than read. + const registerRe = /\b([A-Z][A-Za-z0-9_$]*)\.register\s*\(\s*['"`][^'"`]+['"`]\s*\)/g; + const defineRe = /\bcustomElements\.define\s*\(\s*['"`][^'"`]+['"`]\s*,\s*([A-Z][A-Za-z0-9_$]*)\b/g; const declared = new Set(); let m; - while ((m = classRe.exec(src)) !== null) declared.add(m[1]); + while ((m = classRe.exec(redacted)) !== null) declared.add(m[1]); if (declared.size === 0) continue; const registered = new Set(); - while ((m = registerRe.exec(src)) !== null) registered.add(m[1]); - while ((m = defineRe.exec(src)) !== null) registered.add(m[1]); + while ((m = registerRe.exec(redacted)) !== null) registered.add(m[1]); + while ((m = defineRe.exec(redacted)) !== null) registered.add(m[1]); for (const cls of declared) { if (!registered.has(cls)) { diff --git a/packages/server/test/scanner/component-scanner.test.js b/packages/server/test/scanner/component-scanner.test.js index 50e771ba5..ed197a32a 100644 --- a/packages/server/test/scanner/component-scanner.test.js +++ b/packages/server/test/scanner/component-scanner.test.js @@ -147,6 +147,35 @@ test('findOrphanComponents: ignores files with no WebComponent subclass', async } }); +test('findOrphanComponents: a class in a CODE SAMPLE is not an orphan (#1308)', async () => { + // Every docs page writes `class X extends WebComponent` inside an `html` + // template to SHOW the reader what a component looks like. Scanning raw + // source counted each of those as a real unregistered class: the repo's own + // website reported 17 false orphans this way. Now that an orphan is a + // `webjs doctor` WARNING and not just dev-console noise, a false one is a + // check that cries wolf on a healthy app, so the scan redacts strings and + // templates exactly like `extractComponents` already did. + const dir = await scaffold({ + 'app/docs/page.ts': + `import { html } from '@webjsdev/core';\n` + + 'export default () => html`\n' + + '
class Sample extends WebComponent {\n' +
+      '    render() { return html`

hi

`; }\n' + + ' }
\n' + + '`;\n', + 'lib/prose.ts': `export const doc = "class Quoted extends WebComponent {}";\n`, + // A REAL orphan in the same tree, so this cannot pass by the scan going blind. + 'components/orphan.ts': `export class Orphan extends WebComponent {\n render() {}\n}\n`, + }); + try { + const orphans = await findOrphanComponents(dir); + assert.deepEqual(orphans.map((o) => o.className).sort(), ['Orphan'], + 'only the real declaration is an orphan; the sample and the string are not'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test('primeComponentRegistry: lookupModuleUrl returns URL after priming', async () => { const dir = await scaffold({ 'components/widget.ts': diff --git a/website/app/docs/components/page.ts b/website/app/docs/components/page.ts index 10314cc7d..c4a06eaf1 100644 --- a/website/app/docs/components/page.ts +++ b/website/app/docs/components/page.ts @@ -497,7 +497,7 @@ card.querySelector('slot').addEventListener('slotchange', ...);

A generic DOM library that reaches into a component should operate on the assigned nodes, never on the host element itself.

-

Live writes need the component's JS on the page. A display-only slotted wrapper (a component that only renders a <slot>, with no interactivity) is elided, so it ships no JavaScript and its post-mount native writes are inert, the same as any elided component. A component that is actually interacted with ships automatically (a client module references its tag); if a consumer reaches an otherwise-display-only wrapper through a string selector the analyzer cannot see, force it to ship with static interactive = true. Shadow-DOM components always ship, so this is the one boundary set by elision rather than by slots.

+

Live writes need the component's JS on the page. A display-only slotted wrapper (a component that only renders a <slot>, with no interactivity) is elided, so it ships no JavaScript and its post-mount native writes are inert, the same as any elided component. A component that is actually interacted with ships automatically (a client module references its tag); if a consumer reaches an otherwise-display-only wrapper through a string selector the analyzer cannot see, force it to ship with static interactive = true. Shadow-DOM components always ship, so this is the one boundary set by elision rather than by slots. See Display-Only Elision for what the override does and does not rescue.

Forwarded slots project their content everywhere. A template can forward a slot into a nested component (html\`<inner-shell><slot></slot></inner-shell>\`), and the outer component's content projects through it on a client-only mount, in the server-rendered first paint, and across hydration (it does not flash back to the fallback on the client). The renderer stamps each slot with the host whose template produced it, so a forwarded slot routes to the outer component that rendered it rather than the child it nests in.

diff --git a/website/app/docs/configuration/page.ts b/website/app/docs/configuration/page.ts index ea636159c..ef9b61f86 100644 --- a/website/app/docs/configuration/page.ts +++ b/website/app/docs/configuration/page.ts @@ -129,6 +129,13 @@ webjs routes --help # one command's help (flag form)

The client router is automatic: it auto-enables in the browser whenever @webjsdev/core loads (any page that ships a component), so SPA-style navigation needs no import or setup. To opt the whole app out and use plain full-page (multi-page) navigation instead, set webjs.clientRouter to false. Components still hydrate and stay interactive; only the link and form interception is disabled, so every navigation is a full browser load. The default (and any value other than false) keeps the router on. See Client Router for the runtime disableClientRouter() / enableClientRouter() escape hatches.

{ "webjs": { "clientRouter": false } } +

Display-only elision

+

WebJs never downloads a component module that does no client work: the import is stripped from the served source and the module, its modulepreload hint, and any vendor reachable only through it are pruned. This is on by default and biased toward shipping, so anything ambiguous keeps its JavaScript. Set webjs.elide to false to turn it off app-wide, which makes every module ship.

+ { "webjs": { "elide": false } } +

The WEBJS_ELIDE environment variable overrides the config key per run (0 / false / off / no force it off, 1 / true / on / yes force it on). That override is also the seam webjs elision --verify uses to render one app both ways in a single process.

+ WEBJS_ELIDE=0 npm run start +

Reach for the switch to isolate a bug, not as a permanent setting: everything it turns off is JavaScript your users would otherwise never download. To see WHAT is being dropped and why, run webjs elision. See Display-Only Elision.

+

Request limits & server timeouts

The server caps inbound request bodies and bounds connection lifetimes by default, so an uncapped body is not a memory-exhaustion vector and a slow connection is not a slowloris vector. Both apply with secure defaults when unset and are configurable in package.json (env overrides win, and a value of 0 disables that limit / timeout).

Body-size limit (413). Every request body the server reads (the action RPC endpoint, route.{js,ts} handlers via readBody, and the no-JS form-action dispatch path) is capped. A JSON / RPC body defaults to 1 MiB (webjs.maxBodyBytes or WEBJS_MAX_BODY_BYTES); a form / multipart body defaults to 10 MiB (webjs.maxMultipartBytes or WEBJS_MAX_MULTIPART_BYTES). An over-limit body responds 413 Payload Too Large and is never buffered whole: a Content-Length over the cap is rejected before the body is read, and a chunked body with no declared length is abandoned the instant it crosses the cap.

diff --git a/website/app/docs/elision/page.ts b/website/app/docs/elision/page.ts new file mode 100644 index 000000000..439609237 --- /dev/null +++ b/website/app/docs/elision/page.ts @@ -0,0 +1,151 @@ +import { html } from '@webjsdev/core'; + +export const metadata = { + title: 'Display-Only Elision | WebJs', + description: + 'WebJs never downloads a component module that does no client work. Elision is automatic and biased toward shipping. Inspect the verdict per module with webjs, and prove it for your own app with webjs --verify.', +}; + +export default function Elision() { + return html` +

Display-Only Elision

+ +

+ A component that does no client-side work renders the same HTML with or without its JavaScript. WebJs proves that statically and then acts on it: the component's import is stripped from the served source, its modulepreload hint and importmap entry go with it, and any vendor package reachable only through it is pruned too. The browser never downloads the module at all. +

+ +

+ This is the mechanism that makes progressive enhancement pay. A page whose whole subtree is display-only ships zero application JavaScript, while a page with one interactive leaf ships that leaf and nothing else. +

+ +

+ Elision is automatic, and it stays automatic. There is no 'use client' and no per-component annotation to remember, because a directive-based model puts the failure on the author with no compiler to catch a forgotten one. The analyser instead biases toward SHIPPING: a wrong "display-only" verdict breaks a page, a wrong "interactive" verdict only misses an optimization, so anything ambiguous or unreadable keeps its JavaScript. +

+ +

What keeps a component shipping

+ +

+ A component stays elidable while it has none of the following. Any one of them is a client-work signal and the module ships. +

+ +
    +
  • An @event binding, or a native handler property like .onclick.
  • +
  • A factory-declared reactive property that is not { state: true }.
  • +
  • An overridden lifecycle hook, renderFallback() and renderError() included.
  • +
  • An imported signal / computed / watch / Task / ref or a streaming directive, or a call to addController / requestUpdate.
  • +
  • Code that runs at module load: a top-level call, a non-data new, a dynamic import(...), a top-level await. Only declarations and the register(...) call are inert.
  • +
  • A browser global at module scope, or a side-effect import of an npm package.
  • +
  • The dynamic slot READ surface (slotchange, assignedNodes / assignedElements / assignedSlot). Merely rendering a <slot> does not ship, because the SSR output already carries the placed children.
  • +
  • Being rendered or imported by a component that itself ships.
  • +
  • Another module observing its registration: a whenDefined('its-tag'), a CSS its-tag:defined rule in a module the graph reaches, or an instanceof TheClass.
  • +
+ +

+ A bare async render() is not a signal on its own. Its SSR pass bakes the resolved data into the first paint, so a light-DOM async leaf with no other signal is elided like any display-only component, which drops the module AND the redundant on-hydration re-fetch. +

+ +

The two always-ship carve-outs

+ +

+ static shadow = true always ships. Declarative Shadow DOM attaches only during HTML parsing, so a shadow component that arrives through a soft-nav swap or a streamed boundary needs its module to re-run attachShadow. +

+ +

+ static interactive = true is the explicit author override. It forces the module to ship when the component's interactivity is invisible to static analysis. There are exactly two such shapes: +

+ +
    +
  • An observer that computes the tag it waits for. customElements.whenDefined(TAG) with a variable does not name a tag the analyser can resolve, so the observed component is elided, its registration never runs, and the await never settles. Put the override on the OBSERVED component.
  • +
  • A :defined rule in an external stylesheet. A public/app.css is not in the module graph, so my-badge:defined { ... } is invisible. Same fix, on the component the rule names.
  • +
+ +

A computed registration tag is a different problem

+ +

+ static interactive = true does not rescue a component whose own registration tag is computed: +

+ + // Broken: the component scanner requires a literal tag. +const TAG = buildTag(); +Badge.register(TAG); // invisible + +// Correct: +Badge.register('my-badge'); + +

+ A custom-element tag must be a literal string anyway, but the consequence here is specific: the scanner never sees that component, so it gets no elision verdict at all, nothing consults the analyser for it, and the override has nothing to attach to. The module is dropped and the element silently never registers. webjs dev warns about this shape, and webjs elision and webjs doctor report it as an orphan. +

+ +

Inspecting the verdict

+ +

+ Elision is the one thing WebJs decides about your code that you did not write down, so it is inspectable rather than something to reason about from the rules above. +

+ + webjs elision # per-module verdict, and the evidence behind every ship +webjs elision --json # the same object, for a tool or an agent + +

+ Every component is reported as elided or shipped. A shipped one carries the evidence that forced it, first match wins: +

+ +
    +
  • own is its own source, and the reason names the exact signal.
  • +
  • observed means another module observes its registration; by names the observer.
  • +
  • closure means something it imports does client work; by names the import.
  • +
  • render means a shipping component can render its tag.
  • +
  • import means a shipping component imports it.
  • +
  • unreadable means its source could not be read, so it ships conservatively.
  • +
+ +

+ An elided row carries no reason on purpose. Elision is the ABSENCE of every signal, so there is no positive fact to report. +

+ +

+ Every page and layout is reported too, as inert (ships nothing), import-only (the boot emits its components directly and drops the module), or shipped (with the first client-effecting blocker that pins it). The same verdict is available to an agent as the MCP list_elision tool, and webjs doctor carries it as a one-line inventory that warns only on an orphan. +

+ +

Proving it for your own app

+ + webjs elision --verify +webjs elision --verify --routes /,/blog/hello + +

+ This renders every static page route with elision on and off in one process and diffs the served bytes with the JavaScript-loaded set masked out. It is the framework's own differential guard pointed at your route table, so your app proves the invariant locally instead of inheriting a guarantee it cannot check. It exits non-zero on a divergence and on a corpus where nothing could be compared, so it is safe to put in CI. +

+ +

+ What it proves, and what it does not. The mask covers the whole JS-loaded set by construction, so --verify proves elision did not change the bytes your app SERVES. It cannot prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Cover that half by running your own browser or e2e suite twice: +

+ + WEBJS_ELIDE=1 npm run test:e2e +WEBJS_ELIDE=0 npm run test:e2e + +

+ Dynamic routes are skipped by name, because rendering one would mean inventing param values; pass real ones with --routes. A route whose two same-side renders already differ is reported as nondeterministic and excluded, since a differential over live data proves nothing either way. +

+ +

Turning it off

+ +

+ Elision is on by default. Disable it app-wide in package.json: +

+ + { + "webjs": { + "elide": false + } +} + +

+ Or per-run with the environment override, which wins over the config key and is the seam --verify itself uses: +

+ + WEBJS_ELIDE=0 npm run start + +

+ With elision off, every module ships and webjs elision reports that rather than a verdict. Reach for the switch to isolate a bug, not as a permanent setting: everything it turns off is JavaScript your users would otherwise never download. +

+ `; +} diff --git a/website/app/docs/layout.ts b/website/app/docs/layout.ts index cdc1fbfd0..e64cdfff6 100644 --- a/website/app/docs/layout.ts +++ b/website/app/docs/layout.ts @@ -47,6 +47,7 @@ const NAV_SECTIONS = [ { href: '/docs/directives', label: 'Directives' }, { href: '/docs/ssr', label: 'Server-Side Rendering' }, { href: '/docs/progressive-enhancement', label: 'Progressive Enhancement' }, + { href: '/docs/elision', label: 'Display-Only Elision' }, { href: '/docs/styling', label: 'Styling' }, { href: '/docs/suspense', label: 'Streaming & Suspense' }, { href: '/docs/loading-states', label: 'Loading States' }, diff --git a/website/app/docs/progressive-enhancement/page.ts b/website/app/docs/progressive-enhancement/page.ts index fcd321d6d..2e115c25f 100644 --- a/website/app/docs/progressive-enhancement/page.ts +++ b/website/app/docs/progressive-enhancement/page.ts @@ -69,6 +69,10 @@ export default function ProgressiveEnhancement() { One boundary to know. Eliding a module means its customElements.define never runs in the browser, so the tag stays an un-upgraded element. That is invisible for a tag that exists only as server-rendered markup, but it would matter if shipping client code observes the registration. The framework detects the statically visible forms of that observation, a literal customElements.whenDefined('the-tag'), a CSS the-tag:defined rule, or an instanceof TheClass check anywhere in your code, and automatically ships the observed component instead of eliding it. You only need to act in the cases static analysis cannot see: a tag name built from a dynamic / interpolated string, or a :defined rule in an external stylesheet outside the module graph. There, give the component an interactivity signal (an @event, a non-state reactive property, or a lifecycle hook) so it ships. This is rare in idiomatic webjs, where display-only elements are read as plain server-rendered markup.

+

+ Run webjs elision to see the verdict for every module in your app, and webjs elision --verify to prove elision changed nothing your app serves. See Display-Only Elision for the full signal list and the two escape hatches. +

+

The same applies to whole routes. A page or layout that does no client work, even transitively (no event, signal, client router, npm import, client global, or interactive component anywhere in its subtree), is dropped from the boot script entirely, so a fully-static route ships zero application JavaScript and is pure server-rendered HTML. It still navigates and submits forms via native browser behavior, which is exactly the progressive-enhancement baseline. A layout that only carries interactive components is import-only, so it drops too and the boot emits those components directly; the client router rides along automatically when any of them loads @webjsdev/core.

From a88b54e8b49665178077561c27b3088b0d406eb2 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 22:38:59 +0530 Subject: [PATCH 06/24] test: classify the two new server exports and keep the fuzz corpus clean The scaffold gallery-coverage gate requires every server export to be either demoed or exempted; maskJsSet and staticPageRoutes are verify-command plumbing, so they are exempt with the reason they exist at all. The elision-report fixtures move to template literals. The scanner-fuzz corpus sweep reads every file under test/elision and compares its lexical class window against a real AST, and redaction blanks a template body while keeping a plain string verbatim, so a fixture class in a plain string skews that differential. --- .../test/elision/elision-report.test.js | 44 +++++++++++++++++-- test/scaffolds/gallery-coverage.json | 10 ++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/server/test/elision/elision-report.test.js b/packages/server/test/elision/elision-report.test.js index ee0a3e2e1..c95d16580 100644 --- a/packages/server/test/elision/elision-report.test.js +++ b/packages/server/test/elision/elision-report.test.js @@ -52,6 +52,44 @@ export class Counter extends WebComponent { Counter.register('my-counter'); `; +/** + * A badge whose own source is inert but whose IMPORT does client work. + * + * Fixture sources here are template literals, never plain strings: the + * scanner-fuzz corpus sweep reads every file under `test/elision` and compares + * its lexical class window against a real AST, and redaction blanks a template + * body while keeping a plain string verbatim. A class written in a plain string + * would therefore skew that differential. + */ +const BADGE_IMPORTING_SIGNAL = ` +import { WebComponent, html } from '@webjsdev/core'; +import { n } from '../lib/live.js'; +export class Badge extends WebComponent { + render() { return html\`\${String(n)}\`; } +} +Badge.register('my-badge'); +`; + +/** An interactive shell that renders the badge's tag. */ +const SHELL_RENDERING_BADGE = ` +import { WebComponent, html } from '@webjsdev/core'; +import '../components/badge.js'; +export class Shell extends WebComponent { + render() { return html\`\`; } +} +Shell.register('my-shell'); +`; + +/** A badge registered with a COMPUTED tag, which the scanner never sees. */ +const BADGE_COMPUTED_TAG = ` +import { WebComponent, html } from '@webjsdev/core'; +const TAG = 'dyn-' + 'badge'; +export class DynBadge extends WebComponent { + render() { return html\`x\`; } +} +DynBadge.register(TAG); +`; + /** Every string value anywhere in the report, for the no-absolute-path sweep. */ function allStrings(value, out = []) { if (typeof value === 'string') out.push(value); @@ -142,7 +180,7 @@ test('evidence: observed', async () => { test('evidence: closure', async () => { await withApp({ 'lib/live.js': "import { signal } from '@webjsdev/core';\nexport const n = signal(0);", - 'components/badge.js': "import { WebComponent, html } from '@webjsdev/core';\nimport { n } from '../lib/live.js';\nexport class Badge extends WebComponent {\n render() { return html`${String(n)}`; }\n}\nBadge.register('my-badge');", + 'components/badge.js': BADGE_IMPORTING_SIGNAL, 'app/page.js': "import { html } from '@webjsdev/core';\nimport '../components/badge.js';\nexport default () => html``;", }, async (dir) => { const r = await analyzeAppElision(dir); @@ -157,7 +195,7 @@ test('evidence: closure', async () => { test('evidence: render', async () => { await withApp({ 'components/badge.js': DISPLAY_ONLY, - 'components/shell.js': "import { WebComponent, html } from '@webjsdev/core';\nimport '../components/badge.js';\nexport class Shell extends WebComponent {\n render() { return html``; }\n}\nShell.register('my-shell');", + 'components/shell.js': SHELL_RENDERING_BADGE, 'app/page.js': "import { html } from '@webjsdev/core';\nimport '../components/shell.js';\nexport default () => html``;", }, async (dir) => { const r = await analyzeAppElision(dir); @@ -172,7 +210,7 @@ test('evidence: render', async () => { test('an orphan class is reported with no verdict', async () => { await withApp({ - 'components/dyn-badge.js': "import { WebComponent, html } from '@webjsdev/core';\nconst TAG = 'dyn-' + 'badge';\nexport class DynBadge extends WebComponent {\n render() { return html`x`; }\n}\nDynBadge.register(TAG);", + 'components/dyn-badge.js': BADGE_COMPUTED_TAG, 'app/page.js': "import { html } from '@webjsdev/core';\nimport '../components/dyn-badge.js';\nexport default () => html`

hi

`;", }, async (dir) => { const r = await analyzeAppElision(dir); diff --git a/test/scaffolds/gallery-coverage.json b/test/scaffolds/gallery-coverage.json index 151676bbb..d6a1b725f 100644 --- a/test/scaffolds/gallery-coverage.json +++ b/test/scaffolds/gallery-coverage.json @@ -530,6 +530,9 @@ "loginAndGetCookies": { "demoed": true }, + "maskJsSet": { + "exempt": "internal: the differential comparator behind `webjs elision --verify`, exported so an app can diff its own two captures with the framework comparator instead of a copy" + }, "matchApi": { "demoed": true }, @@ -662,8 +665,8 @@ "startServer": { "demoed": true }, - "submitForm": { - "demoed": true + "staticPageRoutes": { + "exempt": "internal: the `webjs elision --verify` render corpus, exported alongside maskJsSet for the same reason" }, "storeSession": { "demoed": true @@ -677,6 +680,9 @@ "streamResponse": { "demoed": true }, + "submitForm": { + "demoed": true + }, "testRequest": { "demoed": true }, From 45d9120aa32658fe22a012a9c72ce62d016d4b4f Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 22:58:00 +0530 Subject: [PATCH 07/24] fix(cli): let a help entry title its own prose block, and widen the file column The help renderer hardcoded the heading Config:, which is what the doctor severity gate is and what its help test pins. The elision block is a caveat about what --verify proves, not configuration, so an entry now names its own heading and doctor keeps the default. The verdict table capped every column at the same width, which is right for the tag column (a file registering five tags is the rare case) and wrong for the file column: on a freshly scaffolded app almost every module path is longer than the cap, so almost every row overflowed instead of one outlier. --- packages/cli/bin/webjs.js | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 5ff6c3010..66a74c55a 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -189,6 +189,7 @@ const HELP = { { flag: '--verify', description: 'Render every static page route with elision on and off and diff the observable SSR bytes. Exits non-zero on a divergence.' }, { flag: '--routes ', description: 'Comma-separated URL paths to add to the --verify corpus (the only way to cover a dynamic route).' }, ], + notesTitle: 'What --verify proves', notes: [ '--verify proves elision did not change the bytes your app serves. It does NOT', 'prove post-hydration behaviour: a wrongly dropped module shows up as a dead', @@ -308,9 +309,12 @@ function printCommandHelp(name) { console.log('Options:'); for (const o of options) console.log(` ${o.flag.padEnd(width)} ${o.description}`); // Optional per-command prose for surface a flag table cannot carry (doctor's - // package.json severity gate is the one that needs it). + // package.json severity gate is the one that needs it). The heading defaults + // to `Config:` because that is what doctor's block is and what its help test + // pins; a command whose prose is not configuration names its own heading + // (`webjs elision`'s is a caveat about what --verify proves). if (h.notes) { - console.log('\nConfig:'); + console.log(`\n${h.notesTitle || 'Config'}:`); for (const line of h.notes) console.log(` ${line}`); } console.log('\nExamples:'); @@ -1104,22 +1108,25 @@ async function main() { const elided = report.components.filter((c) => c.verdict === 'elided'); const shipped = report.components.filter((c) => c.verdict === 'shipped'); - // Column width, capped so one outlier (a file registering five tags) - // does not push every other row off the terminal. An over-long cell just - // overflows its own row rather than widening the table. - const pad = (rows, col, cap = 34) => Math.min(cap, Math.max(0, ...rows.map((r) => r[col].length))); + // Column width, capped so one outlier does not push every other row off + // the terminal. An over-long cell overflows its own row rather than + // widening the table. The FILE column gets the generous cap because a + // module path is what a reader scans by; the tag column gets the tight + // one because a file registering five tags is the rare case. + const pad = (rows, col, cap) => Math.min(cap, Math.max(0, ...rows.map((r) => r[col].length))); + const FILE_W = 58, TAG_W = 30; if (elided.length) { console.log('Elided components (the browser never downloads these)'); const rows = elided.map((c) => [c.file, c.tags.join(', ')]); - const w = pad(rows, 0); + const w = pad(rows, 0, FILE_W); for (const [file, tags] of rows) console.log(` ${file.padEnd(w)} ${tags}`.trimEnd()); console.log(); } if (shipped.length) { console.log('Shipped components (and the evidence that forced each one)'); const rows = shipped.map((c) => [c.file, c.tags.join(', '), `${c.evidence || 'unknown'}: ${c.reason || 'no evidence recorded'}`]); - const w0 = pad(rows, 0), w1 = pad(rows, 1); + const w0 = pad(rows, 0, FILE_W), w1 = pad(rows, 1, TAG_W); for (const [file, tags, why] of rows) console.log(` ${file.padEnd(w0)} ${tags.padEnd(w1)} ${why}`.trimEnd()); console.log(); } @@ -1131,7 +1138,7 @@ async function main() { : r.verdict === 'shipped' ? (r.blocker ? `blocked by ${r.blocker}, which ${r.reason}` : `it ${r.reason}`) : '', ]); - const w0 = pad(rows, 0), w1 = pad(rows, 1); + const w0 = pad(rows, 0, 12), w1 = pad(rows, 1, FILE_W); for (const [verdict, file, note] of rows) { console.log(` ${verdict.padEnd(w0)} ${file.padEnd(w1)}${note ? ' ' + note : ''}`.trimEnd()); } From f499ed4216bd0416abb1be46116374b963d91475 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 23:24:01 +0530 Subject: [PATCH 08/24] fix: --verify forces elision on, and the e2e control can actually fail Three things this PR sells could lie. --verify deleted WEBJS_ELIDE for its ON side, which only falls back to webjs.elide, so on an app that opts out BOTH handlers ran with elision off and the command reported the routes identical with elision on vs off and exited 0. That is a confident pass on a run where elision was never on, worse than the zero-route vacuity the exit code already guarded. The ON side is now forced on through the override, which wins over the config key, and the run reports how many modules elision actually dropped so a trivially-true pass is visible too. The e2e probe asserted build-stamp was not downloaded on /observed as its negative control, but build-stamp is only on /, so that assertion was true whether or not elision worked and the test was one positive assertion wearing a control. /observed now renders it, and WEBJS_ELIDE=0 reds the control. The new docs page meta description named two commands that do not exist, and list_elision was missing from six surfaces that enumerate the MCP tools, including the published README and the CLI help text. --- .agents/skills/webjs/references/components.md | 2 +- .agents/skills/webjs/references/testing.md | 2 +- AGENTS.md | 2 +- examples/blog/app/observed/page.ts | 6 +++ packages/cli/AGENTS.md | 2 +- packages/cli/bin/webjs.js | 39 ++++++++++++++++--- packages/mcp/README.md | 4 ++ test/cli/elision.test.mjs | 36 +++++++++++++++++ website/app/docs/ai-first/page.ts | 1 + website/app/docs/elision/page.ts | 6 ++- 10 files changed, 89 insertions(+), 11 deletions(-) diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index 2c31167a4..4c8913278 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -308,7 +308,7 @@ WEBJS_ELIDE=1 npm run test:e2e WEBJS_ELIDE=0 npm run test:e2e ``` -It exits non-zero on a divergence AND on a corpus where nothing could be compared, so it is safe to put in CI. Dynamic routes are skipped by name (rendering one would mean inventing param values); pass real ones with `--routes`. A route whose two same-side renders already differ is reported as nondeterministic and excluded, since a differential over live data proves nothing. +It exits non-zero on a divergence AND on a corpus where nothing could be compared, so it is safe to put in CI. The ON side is forced on rather than read from your config, so the comparison is a real one even in an app that has elision switched off, and the run reports how many modules elision actually dropped so a trivially-true pass is visible. Dynamic routes are skipped by name (rendering one would mean inventing param values); pass real ones with `--routes`. A route whose two same-side renders already differ is reported as nondeterministic and excluded, since a differential over live data proves nothing. `webjs doctor` carries the same verdict as a one-line inventory, and warns only on an orphan. diff --git a/.agents/skills/webjs/references/testing.md b/.agents/skills/webjs/references/testing.md index f91c14aba..32014c30d 100644 --- a/.agents/skills/webjs/references/testing.md +++ b/.agents/skills/webjs/references/testing.md @@ -178,7 +178,7 @@ WebJs strips the JavaScript of every component that does no client work, so a wr webjs elision --verify ``` -renders every static page route with elision on and off and diffs the served bytes. It is the framework's own differential guard pointed at your route table, and it exits non-zero on a divergence AND on a corpus where nothing could be compared, so it belongs in CI. Dynamic routes are skipped by name; add real paths with `--routes /,/blog/hello`. +renders every static page route with elision on and off and diffs the served bytes. It is the framework's own differential guard pointed at your route table, and it exits non-zero on a divergence AND on a corpus where nothing could be compared, so it belongs in CI. It forces the ON side on rather than reading your config, and reports how many modules elision actually dropped, so a pass that compared two identical renders is visible rather than silent. Dynamic routes are skipped by name; add real paths with `--routes /,/blog/hello`. That proves the bytes you SERVE did not change. It cannot prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Run your own browser or e2e suite twice for that half: diff --git a/AGENTS.md b/AGENTS.md index 5e07c0239..80b492b33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,7 @@ Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab or the ## Framework source: where to find it -Plain JS with JSDoc lives in `node_modules/@webjsdev/` (`core/`, `server/`, `cli/`, `mcp/`, `intellisense/`, `ui/`); what you read is what runs. Starting points: SSR `@webjsdev/server/src/ssr.js`, client hydration `@webjsdev/core/src/render-client.js`, client router `@webjsdev/core/src/router-client.js`, convention rules `@webjsdev/server/src/check.js`. For UI debugging use the Playwright MCP server; for live introspection the scaffold wires the read-only `@webjsdev/mcp` server (`npx @webjsdev/mcp`, also reachable as `webjs mcp`): `list_routes`, `list_actions`, `list_components`, `check`, `ui` (the `@webjsdev/ui` kit inventory + a component's helpers / paste-ready example / a11y header), plus a knowledge layer (docs / recipes / framework source). +Plain JS with JSDoc lives in `node_modules/@webjsdev/` (`core/`, `server/`, `cli/`, `mcp/`, `intellisense/`, `ui/`); what you read is what runs. Starting points: SSR `@webjsdev/server/src/ssr.js`, client hydration `@webjsdev/core/src/render-client.js`, client router `@webjsdev/core/src/router-client.js`, convention rules `@webjsdev/server/src/check.js`. For UI debugging use the Playwright MCP server; for live introspection the scaffold wires the read-only `@webjsdev/mcp` server (`npx @webjsdev/mcp`, also reachable as `webjs mcp`): `list_routes`, `list_actions`, `list_components`, `list_elision` (what the browser never downloads, and why each shipped module ships), `check`, `ui` (the `@webjsdev/ui` kit inventory + a component's helpers / paste-ready example / a11y header), plus a knowledge layer (docs / recipes / framework source). --- diff --git a/examples/blog/app/observed/page.ts b/examples/blog/app/observed/page.ts index 2abcc7746..3afb837d3 100644 --- a/examples/blog/app/observed/page.ts +++ b/examples/blog/app/observed/page.ts @@ -3,6 +3,11 @@ import '#components/observed-badge.ts'; import '#components/observe-badge.ts'; import '#components/ssr-derived-badge.ts'; import '#components/forced-badge.ts'; +// The same-run NEGATIVE control for the forced-badge probe (#1308): display-only +// with no override and nothing observing it, so it must stay elided on a run +// where forced-badge ships. Without a control on THIS page the probe would also +// pass if elision had stopped working altogether. +import '#components/build-stamp.ts'; export const metadata = { title: 'Observed badge · WebJs Blog', @@ -32,6 +37,7 @@ export default function Observed() {

+ `; diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index da7244252..09fa034a1 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -157,7 +157,7 @@ README.md npm-facing package readme. | `webjs test [--server\|--browser]` | Runtime-native test runner (#570): server tests run under `node --test` on Node and `bun test` on Bun (`bun --test` is invalid), dispatched on `process.versions.bun`; browser tests run the app's resolved `@web/test-runner` (`wtr`) bin via `process.execPath` (no `npx`). | | `webjs check [--rules] [--json]` | `checkConventions()` from `@webjsdev/server/check`. `--rules` lists the checks. `--json` emits the structured violations + a summary count as JSON (via `projectCheck` from `@webjsdev/mcp/check-report`, the same projector the MCP `check` tool uses, #415), so an agent in a loop consumes structured data instead of regex-scraping stdout; the non-zero exit on violations is preserved. Report-only: each violation carries a prose `fix` hint, but there is no `--fix` autofix flag (the rules either rewrite code or rename files, so an automatic codemod is not safe) | | `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 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` / `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 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`. 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 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 | diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 66a74c55a..6e75ebf56 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -92,7 +92,7 @@ const USAGE = `webjs commands: webjs routes [--json|--table] [--no-headers] Print the route table (path / owner file / methods). Default tree; --json matches the MCP list_routes shape; --no-headers drops the --table header webjs elision [--json] [--verify] Report which component modules are elided and why each shipped one ships; --verify diffs SSR output with elision on vs off (exits non-zero on a divergence) - webjs mcp Start the read-only MCP server (routes / actions / components / check) + webjs mcp Start the read-only MCP server (routes / actions / components / elision / check) webjs doctor [--json] [--strict] Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision, un-versioned stylesheet links). --json emits the structured results (with stable codes). --strict additionally fails on every remaining warning. Per-check severity is CONFIG: map a code to off/warn/error under "webjs": { "doctor": { "gate": {...} } } @@ -269,7 +269,7 @@ const HELP = { }, mcp: { usage: 'webjs mcp', - summary: 'Start the read-only MCP server (routes / actions / components / check + a docs/source knowledge layer).', + summary: 'Start the read-only MCP server (routes / actions / components / elision / check + a docs/source knowledge layer).', examples: ['webjs mcp'], }, version: { @@ -991,14 +991,27 @@ async function main() { const resp = await h.handle(new Request('http://localhost' + r)); return { status: resp.status, html: await resp.text() }; }; + // The module URLs a response preloads, with the content-hash query + // stripped. Comparing the two sides' sets is how the run reports what + // elision actually DROPPED, which is the only thing that distinguishes + // a real pass from two identical renders. + const preloadSet = (html) => new Set( + [...html.matchAll(/ m[1].split('?')[0]), + ); const ORIG = process.env.WEBJS_ELIDE; /** @type {Record} */ const onA = {}, onB = {}, off = {}; try { - // Elision ON (the default). Warm fully so the memoized verdict is - // locked before the env flips for the second handler. - delete process.env.WEBJS_ELIDE; + // Elision ON, FORCED. Deleting the override would only fall back to + // `webjs.elide`, so on an app that opts out this side would run with + // elision OFF too and the command would compare two identical renders + // and report them "identical with elision on vs off", which is false + // about a run where elision was never on. The env override wins over + // the config key, which is exactly what makes it the right seam here. + // Warm fully so the memoized verdict is locked before the env flips + // for the second handler. + process.env.WEBJS_ELIDE = '1'; const hOn = await createRequestHandler({ appDir, dev: false, logger: quiet }); if (hOn.warmup) await hOn.warmup(); for (const r of routes) onA[r] = await capture(hOn, r); @@ -1024,11 +1037,18 @@ async function main() { } const unrenderable = [], nondeterministic = [], diverged = []; + /** @type {Set} modules the OFF side preloads and the ON side does not */ + const dropped = new Set(); let compared = 0; for (const r of routes) { if (onA[r].status >= 400) { unrenderable.push(`${r} (${onA[r].status})`); continue; } if (maskJsSet(onA[r].html) !== maskJsSet(onB[r].html)) { nondeterministic.push(r); continue; } compared++; + // What elision removed on this route. A pass over a corpus where this + // stays empty is TRUE but trivially so, and the author needs to see + // that rather than read it as proof elision was exercised. + const onSet = preloadSet(onA[r].html); + for (const u of preloadSet(off[r].html)) if (!onSet.has(u)) dropped.add(u); const a = maskJsSet(onA[r].html); const b = maskJsSet(off[r].html); if (onA[r].status !== off[r].status) { @@ -1076,7 +1096,14 @@ async function main() { ); process.exit(1); } - console.log(`webjs elision --verify: ${compared} route(s) identical with elision on vs off, ${skips.join(', ')}.`); + console.log( + `webjs elision --verify: ${compared} route(s) identical with elision on vs off, ${skips.join(', ')}.\n` + + (dropped.size + ? `Elision dropped ${dropped.size} module(s) across that corpus, so the comparison was a real one.` + : 'Elision dropped NO modules across that corpus, so the two sides were identical by construction ' + + 'and this run proves nothing about elision. Nothing on these routes was elidable; run ' + + '`webjs elision` to see why.'), + ); console.log(VERIFY_CAVEAT); break; } diff --git a/packages/mcp/README.md b/packages/mcp/README.md index c104623e2..560d2e939 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -28,6 +28,10 @@ subcommand) delegates to this same server, so both routes run identical code. `list_actions` (RPC endpoints plus the full data contract: HTTP verb, cache config, and boolean flags for tags/invalidates/validate/middleware; reserved config exports are excluded from the callable-action list), `list_components`, + `list_elision` (the display-only elision verdict: which component modules the + browser never downloads, the evidence behind each one that ships, every + page/layout as inert / import-only / shipped, and any orphan class that gets + no verdict at all; identical to `webjs elision --json`), `check` (the structured `webjs check` violations). Each projects an existing `@webjsdev/server` data function and mutates nothing. - **Knowledge layer**: an `init` mental-model primer, a `docs` retrieval tool, diff --git a/test/cli/elision.test.mjs b/test/cli/elision.test.mjs index b8f9bdd0c..b7b59846c 100644 --- a/test/cli/elision.test.mjs +++ b/test/cli/elision.test.mjs @@ -196,6 +196,42 @@ test('webjs elision --verify FAILS on a --routes path that does not render', () assert.match(r.stderr, /\/nope \(404\): a --routes path must render/); }); +test('webjs elision --verify forces elision ON, so an opted-out app still gets a real comparison', () => { + // Deleting WEBJS_ELIDE only falls back to `webjs.elide`, so on an app that + // opts out BOTH handlers would run with elision off, the two renders would be + // identical by construction, and the command would report the routes + // "identical with elision on vs off" and exit 0. That is a confident pass on a + // run where elision was never on, which is worse than the zero-route vacuity + // the exit code already guards. The env override wins over the config key, + // which is what makes it the right seam. + const dir = fixtureApp(); + write(dir, 'package.json', JSON.stringify({ name: 'fx', type: 'module', webjs: { elide: false } })); + const r = runCli(dir, ['--verify']); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /2 route\(s\) identical/); + assert.match(r.stdout, /Elision dropped [1-9]\d* module\(s\)/, + 'the ON side must really have elided something, or the comparison was two identical renders'); +}); + +test('webjs elision --verify says so when elision dropped nothing', () => { + // A corpus where every component carries a client-work signal compares two + // identical renders. That is a true pass and it must not fail, but reading it + // as proof elision was exercised would be wrong, so the run says which it is. + // Both the component AND the page must ship: an interactive component alone + // still leaves the PAGE import-only, and dropping the page module from the + // boot is itself something elision removed from the wire. + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'fx', type: 'module' })); + write(dir, 'components/counter.js', INTERACTIVE); + write(dir, 'lib/track.js', "if (typeof window !== 'undefined') { window.__hits = 1; }\nexport const track = () => {};"); + write(dir, 'app/page.js', "import { html } from '@webjsdev/core';\nimport '../components/counter.js';\nimport { track } from '../lib/track.js';\nexport default () => html`${String(track)}`;"); + linkFramework(dir); + const r = runCli(dir, ['--verify']); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /Elision dropped NO modules/); + assert.match(r.stdout, /proves nothing about elision/); +}); + test('webjs elision --verify FAILS when elision changes the served bytes', () => { // The divergence path, driven for real rather than stubbed. The page renders // a tag whose module is elided ON and shipped OFF, and the component's SSR diff --git a/website/app/docs/ai-first/page.ts b/website/app/docs/ai-first/page.ts index ce4bffe91..663dd538a 100644 --- a/website/app/docs/ai-first/page.ts +++ b/website/app/docs/ai-first/page.ts @@ -48,6 +48,7 @@ export default function AIFirst() {
  • list_routes: the live route table.
  • list_actions: server actions with their /__webjs/action/<hash>/<fn> RPC endpoints (the real hashes).
  • list_components: the registered custom-element tags.
  • +
  • list_elision: the display-only elision verdict, which component modules the browser never downloads and the evidence behind each one that ships. See Display-Only Elision.
  • check: the structured webjs check violations.
  • ui: the @webjsdev/ui kit inventory, or one component's helper signatures, paste-ready example, and a11y header.
  • diff --git a/website/app/docs/elision/page.ts b/website/app/docs/elision/page.ts index 439609237..c3b5b5dd9 100644 --- a/website/app/docs/elision/page.ts +++ b/website/app/docs/elision/page.ts @@ -3,7 +3,7 @@ import { html } from '@webjsdev/core'; export const metadata = { title: 'Display-Only Elision | WebJs', description: - 'WebJs never downloads a component module that does no client work. Elision is automatic and biased toward shipping. Inspect the verdict per module with webjs, and prove it for your own app with webjs --verify.', + 'WebJs never downloads a component module that does no client work. Elision is automatic and biased toward shipping. Inspect the verdict per module with `webjs elision`, and prove it for your own app with `webjs elision --verify`.', }; export default function Elision() { @@ -122,6 +122,10 @@ webjs elision --verify --routes /,/blog/hello WEBJS_ELIDE=1 npm run test:e2e WEBJS_ELIDE=0 npm run test:e2e +

    + The ON side is forced on with the environment override rather than left to your config, so --verify compares a real on-vs-off pair even in an app that has elision switched off. The run also reports how many modules elision actually dropped across the corpus, because a pass over a corpus where it dropped none is true but trivially so. +

    +

    Dynamic routes are skipped by name, because rendering one would mean inventing param values; pass real ones with --routes. A route whose two same-side renders already differ is reported as nondeterministic and excluded, since a differential over live data proves nothing either way.

    From 4eb1a5c7d61ac45f258c1e1853f1e53543cb1460 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 23:38:12 +0530 Subject: [PATCH 09/24] docs: finish the list_elision sweep and the blog fixture inventory The previous commit added list_elision to six surfaces and missed two: the CLI-reference block in AGENTS.md, which is a different section of the same file it edited, and the start-work skill. examples/blog/AGENTS.md owns the section that enumerates the elision fixtures, which is what a future reader consults before touching one, and it was wrong twice: build-stamp is now the negative control on /observed as well as /, and forced-badge was not listed at all. It also now records the doc-comment discipline those fixtures depend on, since prose naming a tag or a whenDefined call would register as a real signal and make the tests pass vacuously. The new docs page carried literal backticks into its meta description, which is emitted verbatim into the meta tag and the llms.txt entry, and the paragraph explaining that --verify forces the ON side sat after the two-run e2e block, so it read as describing that recipe instead. The zero-drop message said the run proves nothing about elision while exiting 0, which reads as a self-contradiction. Exit 0 is right there (the question was answered, and a corpus with nothing elidable is a legitimate app), so the message now says what actually happened instead of disowning the run. --- .claude/skills/webjs-start-work/SKILL.md | 2 +- AGENTS.md | 2 +- examples/blog/AGENTS.md | 23 ++++++++++++++++++++--- packages/cli/bin/webjs.js | 6 +++--- test/cli/elision.test.mjs | 8 +++++++- website/app/docs/elision/page.ts | 10 +++++----- 6 files changed, 37 insertions(+), 14 deletions(-) diff --git a/.claude/skills/webjs-start-work/SKILL.md b/.claude/skills/webjs-start-work/SKILL.md index 388f1607c..93afef2bf 100644 --- a/.claude/skills/webjs-start-work/SKILL.md +++ b/.claude/skills/webjs-start-work/SKILL.md @@ -144,7 +144,7 @@ Doc drift is the #1 way a framework rots. Documentation MUST stay in sync with c 3. **User-facing docs site** under `website/app/docs//page.ts` (these are `.ts` files, not markdown, so they're excluded by the markdown query but they're the canonical user-facing reference). If the change is visible to a user reading the docs site, update the matching topic page. Add a new page if the surface is new and there's no obvious home. 4. **Scaffold templates** under `packages/cli/templates/` and the generators `packages/cli/lib/{create,api-gallery}.js`. Update if the change affects what `webjs create` generates. The scaffold ships a gallery index home + layout + db wiring, a densely-commented feature gallery (`packages/cli/templates/gallery/**`, demos under `app/features/` plus `app/examples/todo`) and the api showcase (`api-gallery.js`), plus one cross-agent skill at `.agents/skills/webjs/` (SKILL.md + references) that the agent grows in place; there are no per-agent rule files. A feature change that agents should know about lands in the skill; a generated-code change lands in the generators, verified with `generate + boot + webjs check`. 5. **The MCP server** (the standalone `@webjsdev/mcp` package, `packages/mcp/src/{mcp,mcp-docs,mcp-source}.js`, extracted from the CLI in #415; `webjs mcp` and `npx @webjsdev/mcp` both run it). The MCP is how AI agents learn and introspect webjs, so it must stay in lockstep with the surfaces it exposes. Update it whenever the change touches what it serves: - - **Introspection tools** (`list_routes` / `list_actions` / `list_components` / `check`): if you change the route table shape, the action/RPC-hash scheme, component registration, or a `webjs check` rule, update the matching tool projection so the MCP reports reality. + - **Introspection tools** (`list_routes` / `list_actions` / `list_components` / `list_elision` / `check`): if you change the route table shape, the action/RPC-hash scheme, component registration, or a `webjs check` rule, update the matching tool projection so the MCP reports reality. - **Knowledge layer** (resources + `init` + `docs` + prompts): the resources are the skill at `.agents/skills/webjs/` (SKILL.md + references/) + `AGENTS.md`, so a docs change is picked up automatically (it is bundled at `prepack`). But if you add or rename a skill reference file, ADD A NEW INVARIANT, change the execution model, or add an authoring concept an agent should know, also: (a) confirm the `init` primer still pulls the right `AGENTS.md` sections (it sources the Execution-model + Invariants headings, so a heading rename breaks it), and (b) add a guided-workflow PROMPT for any new common recipe (a new page/route/action/component-shaped task). New recipes without a prompt are a silent gap. - **Heuristic:** if your change would make an agent reading only the old MCP output write WRONG webjs code, the MCP is part of your change. Update it on this PR, with a test in `packages/mcp/test/*.test.mjs`, or write "N/A because " in the PR body. 6. **The editor plugins** (epic #381, now under `packages/editors/` after the #402 reorg; the suite overview that maps all three + the full dev/publish flow is `packages/editors/AGENTS.md`): the all-in-one `webjs` VS Code extension (`packages/editors/vscode`), `webjs.nvim` (`packages/editors/nvim`), and the shared language service `@webjsdev/intellisense` (`packages/editors/intellisense`, renamed from `@webjsdev/ts-plugin` in #416/#420) that BOTH editor plugins bundle. Note `webjs.nvim` is developed here but installed by users from a SEPARATE repo `webjsdev/webjs.nvim` (a git-subtree split of `packages/editors/nvim`), so nvim changes are not live until that split is re-pushed on release (`packages/editors/nvim/PUBLISHING.md`). They are how a developer's editor understands webjs, so they must stay in lockstep with the surfaces they expose. Update them whenever the change touches what they project. Do this automatically when the task demands it; never make the user ask: diff --git a/AGENTS.md b/AGENTS.md index 80b492b33..8a583895a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -506,7 +506,7 @@ webjs test [--server] [--browser] [--watch] webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop webjs routes [--json] [--table] [--no-headers] # print the route table (path / owner file / methods, #975). Default tree; --json is byte-identical to the MCP list_routes tool; --no-headers drops the --table header for piping webjs elision [--json] [--verify] [--routes ] # the elision verdict (#1308): every component module as elided or shipped (a shipped one naming the EVIDENCE that forced it and the module that did the forcing), every page/layout as inert / import-only / ships-whole, and every orphan class that gets no verdict at all. --json is byte-identical to the MCP list_elision tool. --verify renders every static page route with elision on and off and diffs the observable SSR bytes (the framework's own differential guard, pointed at your app): exit 0 on parity, non-zero on a divergence OR on a corpus where nothing could be compared. It proves elision did not change the bytes you SERVE, NOT post-hydration behaviour (a wrongly dropped module is a dead click, not different bytes), so run your browser/e2e suite twice under WEBJS_ELIDE=1 / WEBJS_ELIDE=0 for that half. Dynamic routes are skipped by name; --routes adds real paths -webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check, ui kit +webjs mcp # read-only MCP: routes, actions (RPC hashes), components, elision (what the browser drops, and why each shipped module ships), check, ui kit webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory PLUS the component-elision verdict, which warns only on an orphan (#1308); a warning when a route module writes a `` without `asset()`, #1095); non-zero exit on a hard fail OR on a check the app gated `error`. --json emits `{ results, summary }` (results is the DoctorResult[], each carrying a stable code + its effective severity; summary counts pass/warn/fail/off), plus a third `configErrors` key on the one path where a rejected `webjs.doctor` config stops any check running; --strict additionally fails on every REMAINING warning (#975). Per-check severity is CONFIG, not a flag: `webjs.doctor.gate` maps a code to `off` / `warn` / `error` so CI gates a chosen subset (#1257) webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258) webjs version # print the installed @webjsdev/cli version (also: webjs --version / -v, #975) diff --git a/examples/blog/AGENTS.md b/examples/blog/AGENTS.md index 3db42a5ce..d9cef1418 100644 --- a/examples/blog/AGENTS.md +++ b/examples/blog/AGENTS.md @@ -127,9 +127,12 @@ Root `app/layout.ts` exports `generateMetadata(ctx)` that derives an absolute `o The app carries display-only and inert-route fixtures so the network probes in `test/e2e/e2e.test.mjs` can assert that no dead JS ships. -- `components/build-stamp.ts` (rendered on `/`): a display-only - component whose module is stripped from the served page source, so the - browser never downloads it. +- `components/build-stamp.ts` (rendered on `/` AND on `/observed`): a + display-only component whose module is stripped from the served page + source, so the browser never downloads it. It is the NEGATIVE CONTROL on + both routes: a probe asserting some other module IS downloaded needs a + module that is still elided on the same run, or the probe would also pass + if elision had stopped working altogether. - `components/vendor-badge.ts` (rendered on `/`): a display-only component whose only non-core dependency is `dayjs` (a binding import, not an interactivity signal). Because the component is elided, the @@ -145,6 +148,20 @@ probes in `test/e2e/e2e.test.mjs` can assert that no dead JS ships. The observation forces the badge to ship, so the probe asserts its module IS downloaded (the cross-module-registration fix, #169). The unobserved `build-stamp` is the negative control. +- `components/forced-badge.ts` (rendered on `/observed`): display-only in + every respect (static markup, no events, no reactive props, no lifecycle + hook, light DOM) EXCEPT `static interactive = true`, the explicit author + override. It pins that override end to end (#1308): before it, the only + coverage stopped at the analyser returning a boolean, and nothing proved + the boot script actually keeps the module. The probe asserts its module is + downloaded on a run where `build-stamp` on the same page still is not. + +Two of these fixtures carry doc comments that deliberately AVOID writing a +literal tag in angle brackets or a `whenDefined` call shape, because the +elision analyser scans raw source including comments, so such prose would +register as a real rendered tag or observation and the fixture would ship +for the wrong reason (making its test pass vacuously). Keep that discipline +when editing them. ### Client-router script reactivation (#1102) `app/script-swap/` exists ONLY as an e2e fixture. Its layout emits two inline diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 6e75ebf56..984a4f048 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -1100,9 +1100,9 @@ async function main() { `webjs elision --verify: ${compared} route(s) identical with elision on vs off, ${skips.join(', ')}.\n` + (dropped.size ? `Elision dropped ${dropped.size} module(s) across that corpus, so the comparison was a real one.` - : 'Elision dropped NO modules across that corpus, so the two sides were identical by construction ' - + 'and this run proves nothing about elision. Nothing on these routes was elidable; run ' - + '`webjs elision` to see why.'), + : 'Elision dropped NO modules across that corpus: nothing on these routes was elidable, so ' + + 'there was nothing for elision to change. The comparison holds, it just had no work to ' + + 'do. Run `webjs elision` to see why every module here ships.'), ); console.log(VERIFY_CAVEAT); break; diff --git a/test/cli/elision.test.mjs b/test/cli/elision.test.mjs index b7b59846c..8682cf545 100644 --- a/test/cli/elision.test.mjs +++ b/test/cli/elision.test.mjs @@ -229,7 +229,13 @@ test('webjs elision --verify says so when elision dropped nothing', () => { const r = runCli(dir, ['--verify']); assert.equal(r.status, 0, r.stdout + r.stderr); assert.match(r.stdout, /Elision dropped NO modules/); - assert.match(r.stdout, /proves nothing about elision/); + assert.match(r.stdout, /nothing on these routes was elidable/); + // Exit 0 is deliberate. The command's question is whether elision changed the + // bytes this app serves, and "it dropped nothing" answers that truthfully. + // That is NOT the zero-route vacuity above, where the question was never + // asked and the author has a remedy (--routes); failing here would put a + // permanent red on a legitimate app whose every module ships, with nothing + // its author could do but delete the command from CI. }); test('webjs elision --verify FAILS when elision changes the served bytes', () => { diff --git a/website/app/docs/elision/page.ts b/website/app/docs/elision/page.ts index c3b5b5dd9..c743203be 100644 --- a/website/app/docs/elision/page.ts +++ b/website/app/docs/elision/page.ts @@ -3,7 +3,7 @@ import { html } from '@webjsdev/core'; export const metadata = { title: 'Display-Only Elision | WebJs', description: - 'WebJs never downloads a component module that does no client work. Elision is automatic and biased toward shipping. Inspect the verdict per module with `webjs elision`, and prove it for your own app with `webjs elision --verify`.', + 'WebJs never downloads a component module that does no client work. Elision is automatic and biased toward shipping. Inspect the verdict per module with webjs elision, and prove it for your own app with webjs elision --verify.', }; export default function Elision() { @@ -115,6 +115,10 @@ webjs elision --verify --routes /,/blog/hello This renders every static page route with elision on and off in one process and diffs the served bytes with the JavaScript-loaded set masked out. It is the framework's own differential guard pointed at your route table, so your app proves the invariant locally instead of inheriting a guarantee it cannot check. It exits non-zero on a divergence and on a corpus where nothing could be compared, so it is safe to put in CI.

    +

    + The ON side is forced on with the environment override rather than left to your config, so --verify compares a real on-vs-off pair even in an app that has elision switched off. The run also reports how many modules elision actually dropped across the corpus, because a pass over a corpus where it dropped none is true but trivially so. +

    +

    What it proves, and what it does not. The mask covers the whole JS-loaded set by construction, so --verify proves elision did not change the bytes your app SERVES. It cannot prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Cover that half by running your own browser or e2e suite twice:

    @@ -122,10 +126,6 @@ webjs elision --verify --routes /,/blog/hello WEBJS_ELIDE=1 npm run test:e2e WEBJS_ELIDE=0 npm run test:e2e -

    - The ON side is forced on with the environment override rather than left to your config, so --verify compares a real on-vs-off pair even in an app that has elision switched off. The run also reports how many modules elision actually dropped across the corpus, because a pass over a corpus where it dropped none is true but trivially so. -

    -

    Dynamic routes are skipped by name, because rendering one would mean inventing param values; pass real ones with --routes. A route whose two same-side renders already differ is reported as nondeterministic and excluded, since a differential over live data proves nothing either way.

    From d74540a36044b796231720f120f325eb257dfba1 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 23:50:25 +0530 Subject: [PATCH 10/24] docs: drop the comment-scanning claim #179 already closed The fixture inventory I added said the analyser reads tags and whenDefined calls out of COMMENTS, so the fixtures deliberately avoid that prose. That was true once; #179 closed it by masking comments before every signal scan. Driving a display-only badge whose doc comment names its own tag in angle brackets AND calls whenDefined on it leaves the badge elided, so the discipline guards nothing. Removing it rather than correcting its count, since a rule that protects against a closed failure mode is worse in the fixture inventory than in the aside it came from. forced-badge.ts carried the same claim and is new here, so it goes too. The zero-drop hint also dead-ended: --verify forces the override on while the plain report path reads the config, so on the opted-out app that forcing exists for, a bare `webjs elision` answers "elision is disabled" and no verdict. The hint now carries the same override the run used. --- examples/blog/AGENTS.md | 7 ------- examples/blog/components/forced-badge.ts | 5 ----- packages/cli/bin/webjs.js | 3 ++- test/cli/elision.test.mjs | 5 +++++ 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/examples/blog/AGENTS.md b/examples/blog/AGENTS.md index d9cef1418..7a166f011 100644 --- a/examples/blog/AGENTS.md +++ b/examples/blog/AGENTS.md @@ -156,13 +156,6 @@ probes in `test/e2e/e2e.test.mjs` can assert that no dead JS ships. the boot script actually keeps the module. The probe asserts its module is downloaded on a run where `build-stamp` on the same page still is not. -Two of these fixtures carry doc comments that deliberately AVOID writing a -literal tag in angle brackets or a `whenDefined` call shape, because the -elision analyser scans raw source including comments, so such prose would -register as a real rendered tag or observation and the fixture would ship -for the wrong reason (making its test pass vacuously). Keep that discipline -when editing them. - ### Client-router script reactivation (#1102) `app/script-swap/` exists ONLY as an e2e fixture. Its layout emits two inline scripts as SIBLINGS of `${children}`, one on each side, so both are TOP-LEVEL diff --git a/examples/blog/components/forced-badge.ts b/examples/blog/components/forced-badge.ts index 040edf998..685b580d2 100644 --- a/examples/blog/components/forced-badge.ts +++ b/examples/blog/components/forced-badge.ts @@ -13,11 +13,6 @@ import { WebComponent, html } from '@webjsdev/core'; * keeps the module. The e2e probe asserts the browser downloads THIS module on * a run where the unobserved build-stamp element is still not downloaded, so * the assertion cannot pass because elision stopped working altogether. - * - * The doc comments here deliberately avoid literal tag-in-angle-brackets and - * whenDefined-call syntax, because the elision analyser scans raw source - * (comments included), so such prose would be read as a real rendered tag or - * observation and this fixture would ship for the wrong reason. */ export class ForcedBadge extends WebComponent { static interactive = true; diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 984a4f048..dafb7ccfe 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -1102,7 +1102,8 @@ async function main() { ? `Elision dropped ${dropped.size} module(s) across that corpus, so the comparison was a real one.` : 'Elision dropped NO modules across that corpus: nothing on these routes was elidable, so ' + 'there was nothing for elision to change. The comparison holds, it just had no work to ' - + 'do. Run `webjs elision` to see why every module here ships.'), + + 'do. Run `WEBJS_ELIDE=1 webjs elision` to see why every module here ships (the same ' + + 'override this run used, so the verdict matches even in an app that opts out).'), ); console.log(VERIFY_CAVEAT); break; diff --git a/test/cli/elision.test.mjs b/test/cli/elision.test.mjs index 8682cf545..544987028 100644 --- a/test/cli/elision.test.mjs +++ b/test/cli/elision.test.mjs @@ -230,6 +230,11 @@ test('webjs elision --verify says so when elision dropped nothing', () => { assert.equal(r.status, 0, r.stdout + r.stderr); assert.match(r.stdout, /Elision dropped NO modules/); assert.match(r.stdout, /nothing on these routes was elidable/); + // The follow-up hint must carry the override: --verify forces elision on, + // while the plain report path reads the config, so on an opted-out app a bare + // `webjs elision` would answer "elision is disabled" instead of the verdict this + // run just pointed the author at. + assert.match(r.stdout, /WEBJS_ELIDE=1 webjs elision/); // Exit 0 is deliberate. The command's question is whether elision changed the // bytes this app serves, and "it dropped nothing" answers that truthfully. // That is NOT the zero-route vacuity above, where the question was never From 55708fa2d834b77ef5083c8749fe150d0c3fd1ea Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 23:57:25 +0530 Subject: [PATCH 11/24] docs: finish removing the comment-scanning claim from the sibling fixtures The previous commit established the claim was false and deleted it from the fixture index and from the one fixture this branch adds, which left it stated in the two original fixtures and in an analyze.test.js rationale. Three places asserting the rule and two denying it is worse than the state I started from. The two fixture comments now say the awkward prose is a habit from before #179 rather than a rule, so the next editor knows they can write normally. The test rationale needed a distinction rather than a deletion: it is true of analyzeComponentSource, the leaf that test calls directly and which does not mask, and false of the pipeline, which masks before calling it. --- examples/blog/components/observe-badge.ts | 8 ++++---- examples/blog/components/observed-badge.ts | 9 ++++----- packages/server/test/elision/analyze.test.js | 7 +++++-- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/examples/blog/components/observe-badge.ts b/examples/blog/components/observe-badge.ts index 5e20170ce..91637f940 100644 --- a/examples/blog/components/observe-badge.ts +++ b/examples/blog/components/observe-badge.ts @@ -9,9 +9,9 @@ * server's customElements shim returns a promise that simply never resolves * there, so no browser-only API is touched during SSR. * - * Note: the doc prose avoids angle-bracket tag syntax on purpose. The - * elision analyser scans raw source including comments, so a tag written in - * angle brackets would be misread as a rendered tag. The real observation - * is the executable line below. + * The doc prose here avoids angle-bracket tag syntax, which is a habit from + * before #179: comments are masked before every signal scan now, so prose + * naming a tag registers as nothing and the discipline is no longer load + * bearing. The real observation is the executable line below. */ void customElements.whenDefined('observed-badge'); diff --git a/examples/blog/components/observed-badge.ts b/examples/blog/components/observed-badge.ts index 22c8dd4af..6b45c463b 100644 --- a/examples/blog/components/observed-badge.ts +++ b/examples/blog/components/observed-badge.ts @@ -14,11 +14,10 @@ import { WebComponent, html } from '@webjsdev/core'; * counterpart, an unobserved display-only component that is never * downloaded. * - * The doc comments here deliberately avoid literal tag-in-angle-brackets - * and whenDefined-call syntax, because the elision analyser scans raw - * source (comments included), so such prose would be read as a real - * rendered tag or observation and skew the verdict. See observe-badge.ts - * for the actual observer code. + * The doc prose here avoids literal tag-in-angle-brackets and whenDefined + * syntax, which is a habit from before #179: comments are masked before + * every signal scan now, so such prose registers as nothing. See + * observe-badge.ts for the actual observer code. */ export class ObservedBadge extends WebComponent { render() { diff --git a/packages/server/test/elision/analyze.test.js b/packages/server/test/elision/analyze.test.js index b6f6fca52..ec466924a 100644 --- a/packages/server/test/elision/analyze.test.js +++ b/packages/server/test/elision/analyze.test.js @@ -701,8 +701,11 @@ test('component with no parseable WebComponent body ships', () => { }); test('an @event in a JS comment, not a template, does not falsely relax', () => { - // The analyser scans raw source, so a stray marker in a comment only - // ever over-detects (ships). This pins that direction. + // `analyzeComponentSource` is the LEAF and does not mask comments itself, + // so a stray marker in one only ever over-detects (ships), which is the + // verdict-safe direction this pins. The PIPELINE masks first (#179), so a + // marker in a comment reaches this function blanked and changes nothing; + // `comment-false-signals.test.js` pins that end of it. const src = DISPLAY_ONLY.replace('render()', '// uses @click=${} elsewhere\n render()'); assert.equal(analyzeComponentSource(src).interactive, true); }); From e74249d4b7722611552596191226d86c70a04043 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 00:01:55 +0530 Subject: [PATCH 12/24] docs: the MCP post said four tools, and there are five The post enumerates what the read-only MCP server exposes, and the count plus the list went stale the moment list_elision landed. The website publishes the blog directory directly, so a reader lands on a page that undercounts the tool set of the thing it is describing. --- blog/mcp-server-for-ai-coding-agents.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/blog/mcp-server-for-ai-coding-agents.md b/blog/mcp-server-for-ai-coding-agents.md index be86f18bb..ef7a5f246 100644 --- a/blog/mcp-server-for-ai-coding-agents.md +++ b/blog/mcp-server-for-ai-coding-agents.md @@ -21,11 +21,12 @@ Think of it as a plug. On one side is your AI assistant. On the other is anythin # What the WebJs MCP server exposes -The server is intentionally small and gives the agent four tools plus a knowledge layer. +The server is intentionally small and gives the agent five tools plus a knowledge layer. - `list_routes` returns every route your app actually serves, derived from the `app/` file tree the same way the router derives it. - `list_actions` returns your server actions, including the RPC hash each one is called through and its per-action config (the HTTP verb, the cache settings). This is the big one, more on it below. - `list_components` returns the custom elements your app registers. +- `list_elision` returns which of your component modules the browser never downloads, because WebJs strips the JavaScript of anything that does no client-side work. For each module that does ship, it also returns the evidence that forced it, so the agent can see when a component turned out interactive by accident. - `check` runs the `webjs check` correctness validator and hands back the violations as structured data. On top of those, there is a knowledge layer that serves the WebJs docs, the recipes, and the framework source. So the agent can look up how a feature is meant to be used, straight from the authoritative reference, without you pasting docs into the chat. From 4369eaab27ddba73bd8b182681d45971c74ef088 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 00:05:07 +0530 Subject: [PATCH 13/24] fix: correct two comments I got wrong while correcting the first one ssr-derived-badge.ts was the third fixture carrying the closed-since-#179 comment-scanning claim, not the second. Grepped the repo this time instead of counting from memory. The analyze.test.js rationale I wrote to replace it was over-broad in the same direction. The leaf DOES mask comments for most signals: a lifecycle hook in a class-body comment is not detected, nor is a signal import in a comment. Only the four template-marker regexes run on raw source, which is what that test actually covers. The component-scanner.js comment claimed redactToPlaceholders preserves positions. It does not, since a placeholder is a different length than the body it replaces; that property belongs to redactStringsAndTemplates. No consequence today because an orphan is reported by class name and file, so the comment now says that and names the function to use if the scan ever reports a position. --- examples/blog/components/ssr-derived-badge.ts | 5 +++-- packages/server/src/component-scanner.js | 11 +++++++---- packages/server/test/elision/analyze.test.js | 13 ++++++++----- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/examples/blog/components/ssr-derived-badge.ts b/examples/blog/components/ssr-derived-badge.ts index c4c0c32f7..8807445fb 100644 --- a/examples/blog/components/ssr-derived-badge.ts +++ b/examples/blog/components/ssr-derived-badge.ts @@ -10,8 +10,9 @@ import { WebComponent, html, prop } from '@webjsdev/core'; * assert that, then loads it in a real browser to assert hydration does * not change either (no flash). * - * The doc comment avoids literal tag-in-angle-brackets so the elision - * analyser does not read this prose as a rendered tag. + * The doc prose avoids literal tag-in-angle-brackets, which is a habit from + * before #179: comments are masked before every signal scan now, so prose + * naming a tag registers as nothing. */ export class SsrDerivedBadge extends WebComponent({ seed: String, diff --git a/packages/server/src/component-scanner.js b/packages/server/src/component-scanner.js index cc8e73569..d004431b1 100644 --- a/packages/server/src/component-scanner.js +++ b/packages/server/src/component-scanner.js @@ -171,10 +171,13 @@ export async function findOrphanComponents(appDir) { // `class X extends WebComponent` written inside an `html` template or a // string is a CODE SAMPLE (every docs page is full of them), not a real // declaration, and reporting it as an unregistered component is a false - // orphan. Redaction blanks string / template bodies and comments while - // preserving positions, so a genuine top-level declaration still matches - // and the registration's literal tag survives as a `__STR___` - // placeholder. + // orphan. Redaction blanks comments and swaps each string / template body + // for a `__STR___` placeholder, so a genuine top-level declaration + // still matches while a sample inside a template does not. It does NOT + // preserve offsets (a placeholder is a different length than the body it + // replaces), which is fine here because an orphan is reported by class + // name and file, never by position. Reach for `redactStringsAndTemplates` + // instead if this scan ever needs to report a line or column. const { redacted } = redactToPlaceholders(src); // Find every class that extends WebComponent (exact name: we trust // the framework convention). diff --git a/packages/server/test/elision/analyze.test.js b/packages/server/test/elision/analyze.test.js index ec466924a..588c9b467 100644 --- a/packages/server/test/elision/analyze.test.js +++ b/packages/server/test/elision/analyze.test.js @@ -701,11 +701,14 @@ test('component with no parseable WebComponent body ships', () => { }); test('an @event in a JS comment, not a template, does not falsely relax', () => { - // `analyzeComponentSource` is the LEAF and does not mask comments itself, - // so a stray marker in one only ever over-detects (ships), which is the - // verdict-safe direction this pins. The PIPELINE masks first (#179), so a - // marker in a comment reaches this function blanked and changes nothing; - // `comment-false-signals.test.js` pins that end of it. + // The leaf masks comments for MOST signals (the class-body scan and the + // import scans redact internally), but the four template-marker regexes + // (@event, the handler property, the dynamic slot surface, the client + // global) run on RAW src, so a marker in a comment reaches them. That can + // only ever over-detect (ship), which is the verdict-safe direction this + // pins. The PIPELINE masks first (#179), so in a real analysis the comment + // is blanked before it gets here; `comment-false-signals.test.js` pins + // that end. const src = DISPLAY_ONLY.replace('render()', '// uses @click=${} elsewhere\n render()'); assert.equal(analyzeComponentSource(src).interactive, true); }); From 6437576250a7500b834659e754bc2c8e34a0edf2 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 00:13:56 +0530 Subject: [PATCH 14/24] fix: an orphan is two shapes, and both messages only described one findOrphanComponents reports a class with a computed registration tag AND a class with no registration call at all. The second is the original case it was written for and what the dev server has always warned about. Routing it into a doctor warning and into the elision report while describing only the computed tag meant someone who forgot to register a class was told their tag is computed, and the CLI offered a fix that does not apply. Both messages now name both shapes, with a doctor test for the forgot-to-register one. The redaction comment I added last round pointed a future editor at redactStringsAndTemplates for position-preserving redaction. Its default form keeps plain-string bodies and single-line untagged templates verbatim, so following that advice would reintroduce the false orphan this branch fixed. It now names the blank-strings argument that makes it safe. --- .agents/skills/webjs/references/components.md | 2 +- packages/cli/bin/webjs.js | 6 ++++-- packages/cli/lib/doctor.js | 9 ++++---- packages/mcp/src/mcp.js | 2 +- packages/server/AGENTS.md | 6 +++++- packages/server/src/component-scanner.js | 7 +++++-- test/cli/doctor.test.mjs | 21 +++++++++++++++++++ website/app/docs/elision/page.ts | 2 +- 8 files changed, 43 insertions(+), 12 deletions(-) diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index 4c8913278..e2e2fd384 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -273,7 +273,7 @@ The analyser reads source lexically, so exactly two real shapes escape it, and t - **An OBSERVER that computes the tag it waits for.** `customElements.whenDefined(TAG)` where `TAG` is a variable does not name a tag the analyser can resolve, so the observed component is elided, its `register` never runs, and the `await` never settles. Put `static interactive = true` on the OBSERVED component. - **A `:defined` rule in an external stylesheet.** `public/app.css` is not in the module graph, so a `my-badge:defined { … }` rule is invisible. Same fix, on the component the rule names. -**It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The module is dropped and the element silently never registers. Always pass a literal: `Badge.register('my-badge')`. `webjs dev` warns about this shape, and `webjs elision` / `webjs doctor` report it as an **orphan**. +**It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The module is dropped and the element silently never registers. Always pass a literal: `Badge.register('my-badge')`. `webjs dev` warns about this shape, and `webjs elision` / `webjs doctor` report it as an **orphan**. An orphan is either of two things, and both fail the same way: a class whose registration tag is computed, or a class with no registration call at all. ### Inspecting and proving the verdict diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index dafb7ccfe..d4890c322 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -1175,9 +1175,11 @@ async function main() { if (report.orphans.length) { console.log('Orphan components (dropped with NO verdict; `static interactive = true` cannot rescue these)'); for (const o of report.orphans) { - console.log(` ${o.className} in ${o.file} registers no literal tag, so the component scanner never sees it`); + console.log(` ${o.className} in ${o.file} is never registered with a literal tag`); } - console.log(' Fix: pass a literal tag to Class.register(\'my-tag\') (invariant 3 already requires one).'); + console.log(' Either there is no registration call at all, or the tag is computed. The scanner matches'); + console.log(' only a literal tag, so either way the element never upgrades and the module gets no verdict.'); + console.log(' Fix: register it with a literal tag, Class.register(\'my-tag\'), or delete the class.'); console.log(); } if (!report.components.length && !report.routeModules.length) { diff --git a/packages/cli/lib/doctor.js b/packages/cli/lib/doctor.js index c0bda1037..d565f4928 100644 --- a/packages/cli/lib/doctor.js +++ b/packages/cli/lib/doctor.js @@ -1071,7 +1071,7 @@ async function checkElisionComponents(elisionPromise) { } if (report.orphans.length > 0) { const lines = report.orphans.map(({ file, className }) => - `${className} in ${file} registers no literal tag, so the scanner never sees it`, + `${className} in ${file} is never registered with a literal tag`, ); return { name, @@ -1079,9 +1079,10 @@ async function checkElisionComponents(elisionPromise) { message: `${report.orphans.length} component class(es) are dropped with NO elision verdict:\n` + lines.map((l) => ` ${l}`).join('\n') + - '\n A class registered with a computed tag is invisible to the component scanner, so its module ' + - 'is dropped from the boot and `static interactive = true` cannot rescue it.', - fix: 'Pass a literal tag to Class.register(\'my-tag\') (invariant 3 already requires one), or delete the unregistered class.', + '\n Either it has no registration call at all, or it registers a computed tag. The component ' + + 'scanner matches only a literal tag, so either way it never sees the class: the element never ' + + 'upgrades, the module gets no elision verdict, and `static interactive = true` cannot rescue it.', + fix: 'Register it with a literal tag, Class.register(\'my-tag\') (invariant 3 already requires one), or delete the class if nothing uses it.', }; } const elided = report.components.filter((c) => c.verdict === 'elided'); diff --git a/packages/mcp/src/mcp.js b/packages/mcp/src/mcp.js index a9be14b63..d3d6b968d 100644 --- a/packages/mcp/src/mcp.js +++ b/packages/mcp/src/mcp.js @@ -166,7 +166,7 @@ const TOOL_DEFS = [ { name: 'list_elision', description: - 'Report the display-only elision verdict: every component module with whether it is elided (the browser never downloads it) or shipped plus the evidence that produced the verdict, every page/layout route module as inert / import-only / shipped (with the first client-effecting blocker that pins it), and any orphan component class that registers with no literal tag and is therefore dropped with no verdict at all. Identical to `webjs elision --json`. Read-only.', + 'Report the display-only elision verdict: every component module with whether it is elided (the browser never downloads it) or shipped plus the evidence that produced the verdict, every page/layout route module as inert / import-only / shipped (with the first client-effecting blocker that pins it), and any ORPHAN component class, one that either has no registration call at all or registers a computed tag, which the scanner cannot see either way, so it never upgrades and gets no verdict at all. Identical to `webjs elision --json`. Read-only.', inputSchema: APPDIR_SCHEMA, }, { diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index 235744d22..1d5e152dd 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -483,7 +483,11 @@ and the reader key set never diverge (a counterfactual unknown key proves all, gets no verdict, and `static interactive = true` cannot rescue it (nothing consults the analyser for a component the scanner never saw). That shape surfaces as an `orphans` row in `analyzeAppElision` and a - `webjs doctor` warning, not as an elision verdict. + `webjs doctor` warning, not as an elision verdict. `findOrphanComponents` + reports TWO shapes under that one name, so any message about it must cover + both: a class with a computed registration tag, and a class with no + registration call at all (the original forgot-to-register case the dev + server has always warned about). `webjs elision` is the inspection surface for all of this: it prints the per-module verdict with the evidence behind each ship, and `webjs elision --verify` runs THIS differential over an arbitrary app's diff --git a/packages/server/src/component-scanner.js b/packages/server/src/component-scanner.js index d004431b1..1daa6e899 100644 --- a/packages/server/src/component-scanner.js +++ b/packages/server/src/component-scanner.js @@ -176,8 +176,11 @@ export async function findOrphanComponents(appDir) { // still matches while a sample inside a template does not. It does NOT // preserve offsets (a placeholder is a different length than the body it // replaces), which is fine here because an orphan is reported by class - // name and file, never by position. Reach for `redactStringsAndTemplates` - // instead if this scan ever needs to report a line or column. + // name and file, never by position. If this scan ever needs a line or + // column, reach for `redactStringsAndTemplates(src, true)`, WITH the + // blank-strings argument: the default form keeps plain-string bodies and + // single-line untagged templates verbatim, so a sample written either way + // would match again and the false orphan would be back. const { redacted } = redactToPlaceholders(src); // Find every class that extends WebComponent (exact name: we trust // the framework convention). diff --git a/test/cli/doctor.test.mjs b/test/cli/doctor.test.mjs index c66832f0b..233e061ff 100644 --- a/test/cli/doctor.test.mjs +++ b/test/cli/doctor.test.mjs @@ -1204,6 +1204,27 @@ test('an orphan class WARNS, names the class and file, and never fails', async ( assert.ok(!results.some((x) => x.status === 'fail'), 'this check never hard-fails'); }); +test('an orphan with NO registration call at all is reported the same way', async () => { + // `findOrphanComponents` reports TWO shapes under one name, and this is the + // ORIGINAL one the dev server has always warned about (a class someone + // forgot to register). The computed-tag shape is the other. The message must + // fit both, or a plain forgot-to-register class gets diagnosed with a cause + // it does not have. + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'x', type: 'module' })); + write(dir, 'components/unreg.js', + `import { WebComponent, html } from '@webjsdev/core';\nexport class Unregistered extends WebComponent {\n render() { return html\`x\`; }\n}\n`); + write(dir, 'app/page.js', + `import { html } from '@webjsdev/core';\nimport '../components/unreg.js';\nexport default () => html\`

    hi

    \`;\n`); + + const r = byName(await runDoctorChecks(dir, baseOpts()), COMPONENT_CHECK); + assert.equal(r.status, 'warn'); + assert.match(r.message, /Unregistered/); + assert.match(r.message, /no registration call at all/, + 'the message must name this shape, not only the computed-tag one'); + assert.match(r.fix, /Register it with a literal tag/); +}); + test('elision disabled reports pass and names the switch', async () => { const r = byName(await runDoctorChecks(elidedComponentApp({ webjs: { elide: false } }), baseOpts()), COMPONENT_CHECK); assert.equal(r.status, 'pass'); diff --git a/website/app/docs/elision/page.ts b/website/app/docs/elision/page.ts index c743203be..e6b252ee3 100644 --- a/website/app/docs/elision/page.ts +++ b/website/app/docs/elision/page.ts @@ -73,7 +73,7 @@ Badge.register(TAG); // invisible Badge.register('my-badge');

    - A custom-element tag must be a literal string anyway, but the consequence here is specific: the scanner never sees that component, so it gets no elision verdict at all, nothing consults the analyser for it, and the override has nothing to attach to. The module is dropped and the element silently never registers. webjs dev warns about this shape, and webjs elision and webjs doctor report it as an orphan. + A custom-element tag must be a literal string anyway, but the consequence here is specific: the scanner never sees that component, so it gets no elision verdict at all, nothing consults the analyser for it, and the override has nothing to attach to. The module is dropped and the element silently never registers. webjs dev warns about this shape, and webjs elision and webjs doctor report it as an orphan. An orphan is either of two things, and both fail identically: a class whose registration tag is computed, or a class with no registration call at all.

    Inspecting the verdict

    From 1e90bac6074c94a2a9006a966c73f17290e69e97 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 00:41:50 +0530 Subject: [PATCH 15/24] fix: the dev orphan warning described one shape and named the wrong API Last commit wrote an invariant saying any message about findOrphanComponents must cover both shapes it reports, fixed the doctor and the CLI, and left the third consumer alone. So webjs dev told an author with a computed tag that they have no registration call, a wrong diagnosis with a fix that does not apply, while the docs from that same commit claimed dev warns about that shape. It also recommended customElements.define where the convention is Class.register. The published type declaration for that function claimed it finds classes no page or component IMPORTS. It computes nothing of the kind and never has. packages/cli/AGENTS.md had no row for the command this branch adds, and its doctor row predated the second elision check. --- packages/cli/AGENTS.md | 3 ++- packages/cli/bin/webjs.js | 2 +- packages/server/index.d.ts | 8 +++++++- packages/server/src/dev.js | 10 ++++++++-- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 09fa034a1..056d94ff3 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -157,8 +157,9 @@ README.md npm-facing package readme. | `webjs test [--server\|--browser]` | Runtime-native test runner (#570): server tests run under `node --test` on Node and `bun test` on Bun (`bun --test` is invalid), dispatched on `process.versions.bun`; browser tests run the app's resolved `@web/test-runner` (`wtr`) bin via `process.execPath` (no `npx`). | | `webjs check [--rules] [--json]` | `checkConventions()` from `@webjsdev/server/check`. `--rules` lists the checks. `--json` emits the structured violations + a summary count as JSON (via `projectCheck` from `@webjsdev/mcp/check-report`, the same projector the MCP `check` tool uses, #415), so an agent in a loop consumes structured data instead of regex-scraping stdout; the non-zero exit on violations is preserved. Report-only: each violation carries a prose `fix` hint, but there is no `--fix` autofix flag (the rules either rewrite code or rename files, so an automatic codemod is not safe) | | `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`. 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 dropped with no verdict and 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 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). | diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index d4890c322..f87ce1624 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -93,7 +93,7 @@ const USAGE = `webjs commands: webjs elision [--json] [--verify] Report which component modules are elided and why each shipped one ships; --verify diffs SSR output with elision on vs off (exits non-zero on a divergence) webjs mcp Start the read-only MCP server (routes / actions / components / elision / check) - webjs doctor [--json] [--strict] Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision, un-versioned stylesheet links). + webjs doctor [--json] [--strict] Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision, component elision, un-versioned stylesheet links). --json emits the structured results (with stable codes). --strict additionally fails on every remaining warning. Per-check severity is CONFIG: map a code to off/warn/error under "webjs": { "doctor": { "gate": {...} } } in package.json, so CI gates on a chosen subset without every warning becoming fatal diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts index 57f5bcb82..776842617 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -478,7 +478,13 @@ export declare function primeComponentRegistry( ): Promise<{ count: number }>; /** Extract `{ className, tag }` pairs from a component module's source. */ export declare function extractComponents(src: string): Array<{ className: string; tag: string }>; -/** Find component classes that no page / component imports (orphans). */ +/** + * Find ORPHAN component classes: a `class X extends WebComponent` that its own + * file never registers with a LITERAL tag, either because there is no + * registration call or because the tag is computed. The scanner matches only a + * literal tag, so both shapes fail the same way: the element never upgrades and + * the module gets no elision verdict. Nothing here is about import reachability. + */ export declare function findOrphanComponents( appDir: string, ): Promise>; diff --git a/packages/server/src/dev.js b/packages/server/src/dev.js index bd62b73b1..110fe251f 100644 --- a/packages/server/src/dev.js +++ b/packages/server/src/dev.js @@ -986,10 +986,16 @@ export async function createRequestHandler(opts) { } t.elision = now() - m; if (dev) { + // An orphan is EITHER shape `findOrphanComponents` reports: no + // registration call at all, or one whose tag is computed. The + // scanner matches only a literal tag, so both fail identically + // and one message has to cover both (the doctor check and + // `webjs elision` say the same thing). for (const { className, file } of await findOrphanComponents(appDir)) { logger.warn?.( - `[webjs] ${className} extends WebComponent but has no customElements.define(...) call in ${file}. ` + - `Add \`customElements.define('', ${className});\` or <${kebab(className)}> tags won't upgrade.`, + `[webjs] ${className} extends WebComponent but is never registered with a literal tag in ${file} ` + + `(either there is no registration call, or its tag is computed). ` + + `Add \`${className}.register('');\` or <${kebab(className)}> tags won't upgrade.`, ); } // The elision summary (#1308), one server-console line. NOT a From 6fe962dd1aea4f73a7e2d975b03c2233068a27bb Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 00:53:02 +0530 Subject: [PATCH 16/24] fix: a computed-tag orphan does not always fail to upgrade Every surface I wrote said an orphan means the element never upgrades. That holds for a class with no registration call. It does not hold for a computed tag: Badge.register(TAG) is ordinary code that runs whenever the module reaches the browser. Built the case to check, and a page that ships whole for its own reason emits its module and keeps the import, so the element upgrades. What is actually lost either way is the elision verdict, the tag-to-module registry entry, and the preload hint; the upgrade is lost only when the importer is elided or inert. Every surface now says that, and a contract test pins the shipping-importer case beside the inert one the old claim was generalised from. The scanner is self-consistent again too. Its function JSDoc described one shape and named only customElements.define, contradicting the .d.ts fixed beside it, and claimed it matches subclasses, which it does not. The module header still called customElements.define the convention when the scanner matches Class.register first. The dev warning rewritten last commit shipped with no test. It has one, and it goes red on the old wording. --- .agents/skills/webjs/references/components.md | 2 +- packages/cli/bin/webjs.js | 4 +- packages/cli/lib/doctor.js | 6 ++- packages/server/index.d.ts | 8 +++- packages/server/src/component-scanner.js | 37 +++++++++++------- packages/server/src/dev.js | 6 ++- packages/server/test/dev/dev-handler.test.js | 34 ++++++++++++++++ .../test/elision/residual-contract.test.js | 39 +++++++++++++++++++ website/app/docs/elision/page.ts | 2 +- 9 files changed, 116 insertions(+), 22 deletions(-) diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index e2e2fd384..584202684 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -273,7 +273,7 @@ The analyser reads source lexically, so exactly two real shapes escape it, and t - **An OBSERVER that computes the tag it waits for.** `customElements.whenDefined(TAG)` where `TAG` is a variable does not name a tag the analyser can resolve, so the observed component is elided, its `register` never runs, and the `await` never settles. Put `static interactive = true` on the OBSERVED component. - **A `:defined` rule in an external stylesheet.** `public/app.css` is not in the module graph, so a `my-badge:defined { … }` rule is invisible. Same fix, on the component the rule names. -**It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The module is dropped and the element silently never registers. Always pass a literal: `Badge.register('my-badge')`. `webjs dev` warns about this shape, and `webjs elision` / `webjs doctor` report it as an **orphan**. An orphan is either of two things, and both fail the same way: a class whose registration tag is computed, or a class with no registration call at all. +**It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The registration still runs whenever the module reaches the browser, so what you always lose is the verdict, the tag-to-module registry entry, and the preload hint. If the importing page is elided or inert, the import goes with it and the element never registers at all. Always pass a literal: `Badge.register('my-badge')`. `webjs dev` warns about this shape, and `webjs elision` / `webjs doctor` report it as an **orphan**. An orphan is either of two things, and both fail the same way: a class whose registration tag is computed, or a class with no registration call at all. ### Inspecting and proving the verdict diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index f87ce1624..1c65637ea 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -1178,7 +1178,9 @@ async function main() { console.log(` ${o.className} in ${o.file} is never registered with a literal tag`); } console.log(' Either there is no registration call at all, or the tag is computed. The scanner matches'); - console.log(' only a literal tag, so either way the element never upgrades and the module gets no verdict.'); + console.log(' only a literal tag, so either way the module gets no verdict, no registry entry, and no'); + console.log(' preload hint. With no registration call the element never upgrades at all; with a computed'); + console.log(' tag it upgrades only while its module still reaches the browser through a shipping importer.'); console.log(' Fix: register it with a literal tag, Class.register(\'my-tag\'), or delete the class.'); console.log(); } diff --git a/packages/cli/lib/doctor.js b/packages/cli/lib/doctor.js index d565f4928..e62d254b3 100644 --- a/packages/cli/lib/doctor.js +++ b/packages/cli/lib/doctor.js @@ -1080,8 +1080,10 @@ async function checkElisionComponents(elisionPromise) { `${report.orphans.length} component class(es) are dropped with NO elision verdict:\n` + lines.map((l) => ` ${l}`).join('\n') + '\n Either it has no registration call at all, or it registers a computed tag. The component ' - + 'scanner matches only a literal tag, so either way it never sees the class: the element never ' - + 'upgrades, the module gets no elision verdict, and `static interactive = true` cannot rescue it.', + + 'scanner matches only a literal tag, so either way it never sees the class: no elision verdict, no ' + + 'registry entry, no preload hint, and `static interactive = true` cannot rescue it. With no ' + + 'registration call the element never upgrades at all; with a computed tag it upgrades only while ' + + 'its module still reaches the browser through an importer that ships.', fix: 'Register it with a literal tag, Class.register(\'my-tag\') (invariant 3 already requires one), or delete the class if nothing uses it.', }; } diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts index 776842617..a113c3b41 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -482,8 +482,12 @@ export declare function extractComponents(src: string): Array<{ className: strin * Find ORPHAN component classes: a `class X extends WebComponent` that its own * file never registers with a LITERAL tag, either because there is no * registration call or because the tag is computed. The scanner matches only a - * literal tag, so both shapes fail the same way: the element never upgrades and - * the module gets no elision verdict. Nothing here is about import reachability. + * literal tag, so neither is visible to it. They fail DIFFERENTLY, though: with + * no registration call the element never upgrades at all, while a computed tag + * still registers if its module reaches the browser, and what is lost there is + * the verdict, the registry entry, and the preload hint (plus the upgrade + * itself, if its importer is elided or inert). Nothing here is about import + * reachability. */ export declare function findOrphanComponents( appDir: string, diff --git a/packages/server/src/component-scanner.js b/packages/server/src/component-scanner.js index 1daa6e899..3bc33a8ca 100644 --- a/packages/server/src/component-scanner.js +++ b/packages/server/src/component-scanner.js @@ -7,15 +7,13 @@ * renders a component tag, `lookupModuleUrl(tag)` already has the URL * ready for `` hints. * - * The convention WebJs uses is the web-standard one: - * - * class Counter extends WebComponent { … } - * customElements.define('my-counter', Counter); - * - * The scanner looks for `customElements.define('', )` - * calls: static text patterns that are cheap to regex-match without - * a full TS parse. A full parse would be ~50× slower for no payoff; - * we only need `{ tag, className, moduleUrl }` tuples. + * The idiomatic WebJs registration is `Class.register('my-counter')`; the + * web-standard `customElements.define('my-counter', Counter)` is equally + * supported. The scanner matches BOTH, as static text patterns that are cheap + * to regex-match without a full TS parse. A full parse would be ~50× slower + * for no payoff; we only need `{ tag, className, moduleUrl }` tuples. Either + * way the tag must be a LITERAL (invariant 3): a computed one is invisible + * here, which is what makes it an orphan (see `findOrphanComponents`). */ import { readFile, stat } from 'node:fs/promises'; @@ -148,10 +146,23 @@ export async function primeComponentRegistry(appDir, components) { } /** - * Find `class X extends WebComponent` (or its subclasses) declarations - * that are NOT accompanied by a `customElements.define(tag, X)` call in - * the same file. Lets the dev server warn authors early when they - * forget the registration step. + * Find ORPHAN components: a `class X extends WebComponent` whose own file + * never registers it with a LITERAL tag, via either `X.register('tag')` or + * `customElements.define('tag', X)`. + * + * TWO shapes land here and they fail differently, so any message about this + * must cover both: + * + * - No registration call at all (the forgot-to-register case). Nothing ever + * registers the tag, so the element NEVER upgrades. + * - A registration whose tag is COMPUTED (`X.register(TAG)`). The call does + * run if the module reaches the browser, so the element may well upgrade. + * What is lost is that the scanner cannot see it: no elision verdict, no + * tag-to-module registry entry, no modulepreload hint, and if its importer + * is elided or inert the import is dropped and it never upgrades either. + * + * Matches the literal `extends WebComponent` only, so a class extending a + * component SUBCLASS is not reported. * * @param {string} appDir * @returns {Promise>} diff --git a/packages/server/src/dev.js b/packages/server/src/dev.js index 110fe251f..28d16c90c 100644 --- a/packages/server/src/dev.js +++ b/packages/server/src/dev.js @@ -994,8 +994,10 @@ export async function createRequestHandler(opts) { for (const { className, file } of await findOrphanComponents(appDir)) { logger.warn?.( `[webjs] ${className} extends WebComponent but is never registered with a literal tag in ${file} ` + - `(either there is no registration call, or its tag is computed). ` + - `Add \`${className}.register('');\` or <${kebab(className)}> tags won't upgrade.`, + `(either there is no registration call, or its tag is computed), so the scanner cannot see it: ` + + `no elision verdict, no preload hint, and with no registration call at all ` + + `<${kebab(className)}> tags never upgrade. ` + + `Add \`${className}.register('');\` with a literal tag.`, ); } // The elision summary (#1308), one server-console line. NOT a diff --git a/packages/server/test/dev/dev-handler.test.js b/packages/server/test/dev/dev-handler.test.js index 94cc56c49..4109c0edd 100644 --- a/packages/server/test/dev/dev-handler.test.js +++ b/packages/server/test/dev/dev-handler.test.js @@ -682,6 +682,40 @@ test('handle: orphan component warning fires in dev', async () => { ); }); +test('handle: the orphan warning covers BOTH shapes and names Class.register (#1308)', async () => { + // `findOrphanComponents` reports two shapes: a class with no registration + // call, and one whose registration tag is COMPUTED. The warning used to + // describe only the first ("has no customElements.define(...) call"), so an + // author with a computed tag was told they had not registered at all, and + // was pointed at `customElements.define` rather than the idiomatic + // `Class.register`. Nothing asserted this message, so it could regress + // silently while the doctor and CLI siblings stayed correct. + const warns = []; + const logger = { info: () => {}, warn: (m) => warns.push(m), error: () => {} }; + const appDir = makeApp({ + 'app/page.js': + `import { html } from ${JSON.stringify(HTML_URL)};\n` + + `export default function P() { return html\`

    x

    \`; }\n`, + // Registered, but with a COMPUTED tag, so the scanner cannot see it. + 'components/dyn.ts': + `import { WebComponent } from '@webjsdev/core';\n` + + `const TAG = 'dyn-' + 'badge';\n` + + `export class DynBadge extends WebComponent {}\n` + + `DynBadge.register(TAG);\n`, + }); + const app = await createRequestHandler({ appDir, dev: true, logger }); + await app.handle(new Request('http://x/')); + + const warning = warns.find((m) => /DynBadge/.test(m)); + assert.ok(warning, `expected a warning for DynBadge; got: ${warns.join('\n')}`); + assert.match(warning, /never registered with a literal tag/, + 'must not claim there is no registration call, since there is one'); + assert.match(warning, /its tag is computed/, 'must name the computed-tag shape'); + assert.match(warning, /DynBadge\.register\(/, 'must point at Class.register, the idiomatic form'); + assert.doesNotMatch(warning, /Add `customElements\.define/, + 'the fix line should lead with Class.register, not the native API'); +}); + /* ------------ metadata routes (sitemap.xml / robots.txt) ------------ */ test('handle: metadata route returns string body with inferred content-type', async () => { diff --git a/packages/server/test/elision/residual-contract.test.js b/packages/server/test/elision/residual-contract.test.js index 5237bb08f..fcbfe34dd 100644 --- a/packages/server/test/elision/residual-contract.test.js +++ b/packages/server/test/elision/residual-contract.test.js @@ -178,6 +178,45 @@ test('a computed Class.register(tag) is invisible to the SCANNER, so it gets no assert.deepEqual(orphans, [{ className: 'Badge', file: badgeFile }], 'it surfaces as an ORPHAN instead'); }); +test('a computed-tag component still REGISTERS when its importer ships (#1308)', async () => { + // The two orphan shapes fail differently, and the docs used to claim both + // "never upgrade". Not so: a computed tag is invisible to the SCANNER, but + // `Badge.register(TAG)` is ordinary code that runs whenever the module + // reaches the browser. Here the page ships whole for its own reason, so the + // page module is emitted AND keeps its import of the component, which means + // the element does upgrade. What is genuinely lost either way is the + // verdict, the registry entry, and the preload hint. + // + // The inert-page case (the test above) is the one where the claim holds, + // because the page is dropped and the import goes with it. + const dir = await mkdtemp(join(tmpdir(), 'webjs-residual-upgrade-')); + try { + await mkdir(join(dir, 'app'), { recursive: true }); + await mkdir(join(dir, 'components'), { recursive: true }); + await mkdir(join(dir, 'lib'), { recursive: true }); + await writeFile(join(dir, 'components/badge.js'), badgeComputedRegistration()); + // Client-effecting at module scope, so the PAGE ships whole. + await writeFile(join(dir, 'lib/track.js'), + "if (typeof window !== 'undefined') { window.__hits = 1; }\nexport const track = () => {};\n"); + await writeFile(join(dir, 'app/page.js'), + "import { html } from '@webjsdev/core';\nimport '../components/badge.js';\n" + + "import { track } from '../lib/track.js';\nexport default () => html`${String(track)}`;\n"); + + const graph = await buildModuleGraph(dir); + const components = await scanComponents(dir); + const pageFile = join(dir, 'app/page.js'); + const verdict = await analyzeElision( + components, [pageFile], graph, (f) => import('node:fs/promises').then((m) => m.readFile(f, 'utf8')), dir, + ); + assert.deepEqual(components, [], 'still invisible to the scanner'); + assert.ok(!verdict.inertRouteModules.has(pageFile), 'the page is NOT inert here'); + assert.ok(verdict.shippedRouteModules.has(pageFile), + 'the page ships whole, so its import of the component survives and the registration runs'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test('static interactive = true does NOT rescue a computed registration tag', async () => { // The measured finding the docs used to get wrong: the override is a // property the ANALYSER reads, and nothing consults the analyser for a diff --git a/website/app/docs/elision/page.ts b/website/app/docs/elision/page.ts index e6b252ee3..63694221c 100644 --- a/website/app/docs/elision/page.ts +++ b/website/app/docs/elision/page.ts @@ -73,7 +73,7 @@ Badge.register(TAG); // invisible Badge.register('my-badge');

    - A custom-element tag must be a literal string anyway, but the consequence here is specific: the scanner never sees that component, so it gets no elision verdict at all, nothing consults the analyser for it, and the override has nothing to attach to. The module is dropped and the element silently never registers. webjs dev warns about this shape, and webjs elision and webjs doctor report it as an orphan. An orphan is either of two things, and both fail identically: a class whose registration tag is computed, or a class with no registration call at all. + A custom-element tag must be a literal string anyway, but the consequence here is specific: the scanner never sees that component, so it gets no elision verdict at all, nothing consults the analyser for it, and the override has nothing to attach to. The registration itself still runs whenever the module reaches the browser, so the failure is quieter than it sounds: what you lose is the verdict, the tag-to-module registry entry, and the preload hint. But if the importing page is elided or inert, the import goes with it and the element never registers at all. webjs dev warns about this shape, and webjs elision and webjs doctor report it as an orphan. An orphan is either of two things, and both fail identically: a class whose registration tag is computed, or a class with no registration call at all.

    Inspecting the verdict

    From 642e3ba6b66579fd2177fb74f04515a131d57f17 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 01:01:17 +0530 Subject: [PATCH 17/24] docs: sweep the retracted orphan claim instead of patching it piecemeal Correcting the never-upgrades claim paragraph by paragraph left the sentences either side of each one saying the opposite. Two contradicted text written in the same commit, one ended the very sentence that was rewritten, and the MCP tool description still carried the claim verbatim. This pass greps every phrasing across the repo, decides each hit, and re-greps to prove none survives: what is left of never-upgrades is only the conditional form, and fail-the-same-way / fail-identically / dropped-silently no longer appear near an orphan. Separating two claims that had been conflated, which is what made the earlier passes sloppy. Gets no elision verdict is TRUE for both shapes and was never retracted, since the class is not in the component set at all. Only never-upgrades and its-module-is-dropped were over-broad. --- .agents/skills/webjs/references/components.md | 2 +- packages/cli/bin/webjs.js | 2 +- packages/cli/lib/doctor.js | 6 +++--- packages/mcp/src/mcp.js | 2 +- packages/server/src/dev.js | 8 +++++--- packages/server/src/elision-report.js | 11 ++++++++--- test/cli/doctor.test.mjs | 2 +- website/app/docs/elision/page.ts | 2 +- 8 files changed, 21 insertions(+), 14 deletions(-) diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index 584202684..9daa011eb 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -273,7 +273,7 @@ The analyser reads source lexically, so exactly two real shapes escape it, and t - **An OBSERVER that computes the tag it waits for.** `customElements.whenDefined(TAG)` where `TAG` is a variable does not name a tag the analyser can resolve, so the observed component is elided, its `register` never runs, and the `await` never settles. Put `static interactive = true` on the OBSERVED component. - **A `:defined` rule in an external stylesheet.** `public/app.css` is not in the module graph, so a `my-badge:defined { … }` rule is invisible. Same fix, on the component the rule names. -**It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The registration still runs whenever the module reaches the browser, so what you always lose is the verdict, the tag-to-module registry entry, and the preload hint. If the importing page is elided or inert, the import goes with it and the element never registers at all. Always pass a literal: `Badge.register('my-badge')`. `webjs dev` warns about this shape, and `webjs elision` / `webjs doctor` report it as an **orphan**. An orphan is either of two things, and both fail the same way: a class whose registration tag is computed, or a class with no registration call at all. +**It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The registration still runs whenever the module reaches the browser, so what you always lose is the verdict, the tag-to-module registry entry, and the preload hint. If the importing page is elided or inert, the import goes with it and the element never registers at all. Always pass a literal: `Badge.register('my-badge')`. `webjs dev` warns about this shape, and `webjs elision` / `webjs doctor` report it as an **orphan**. An orphan is either of two things, and they fail differently: a class with no registration call at all never upgrades, full stop, while a class with a computed tag still registers whenever its module reaches the browser. ### Inspecting and proving the verdict diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 1c65637ea..ad1d7eaed 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -1173,7 +1173,7 @@ async function main() { console.log(); } if (report.orphans.length) { - console.log('Orphan components (dropped with NO verdict; `static interactive = true` cannot rescue these)'); + console.log('Orphan components (no elision verdict; `static interactive = true` cannot rescue these)'); for (const o of report.orphans) { console.log(` ${o.className} in ${o.file} is never registered with a literal tag`); } diff --git a/packages/cli/lib/doctor.js b/packages/cli/lib/doctor.js index e62d254b3..fc02613da 100644 --- a/packages/cli/lib/doctor.js +++ b/packages/cli/lib/doctor.js @@ -1050,9 +1050,9 @@ async function checkElisionCarriers(elisionPromise) { * inventory instead, which makes it the discovery surface, while `webjs * elision` is the detail surface. The one always-wrong condition is an ORPHAN: * a `class X extends WebComponent` with no literal-tag registration is - * invisible to the scanner, so it gets no verdict at all, its module is - * dropped, and `static interactive = true` cannot rescue it (nothing consults - * the component analyser for a component the scanner never saw). Never `fail`: + * invisible to the scanner, so it gets no verdict at all and `static + * interactive = true` cannot rescue it (nothing consults the component + * analyser for a component the scanner never saw). Never `fail`: * an app that wants an orphan to break CI gates `ELISION_COMPONENTS` to * `error` via `webjs.doctor.gate`. * diff --git a/packages/mcp/src/mcp.js b/packages/mcp/src/mcp.js index d3d6b968d..4dd100997 100644 --- a/packages/mcp/src/mcp.js +++ b/packages/mcp/src/mcp.js @@ -166,7 +166,7 @@ const TOOL_DEFS = [ { name: 'list_elision', description: - 'Report the display-only elision verdict: every component module with whether it is elided (the browser never downloads it) or shipped plus the evidence that produced the verdict, every page/layout route module as inert / import-only / shipped (with the first client-effecting blocker that pins it), and any ORPHAN component class, one that either has no registration call at all or registers a computed tag, which the scanner cannot see either way, so it never upgrades and gets no verdict at all. Identical to `webjs elision --json`. Read-only.', + 'Report the display-only elision verdict: every component module with whether it is elided (the browser never downloads it) or shipped plus the evidence that produced the verdict, every page/layout route module as inert / import-only / shipped (with the first client-effecting blocker that pins it), and any ORPHAN component class, one that either has no registration call at all or registers a computed tag, which the scanner cannot see either way, so it always loses its elision verdict, its tag-to-module registry entry, and its preload hint (and never upgrades at all when there is no registration call). Identical to `webjs elision --json`. Read-only.', inputSchema: APPDIR_SCHEMA, }, { diff --git a/packages/server/src/dev.js b/packages/server/src/dev.js index 28d16c90c..6fe522b92 100644 --- a/packages/server/src/dev.js +++ b/packages/server/src/dev.js @@ -988,9 +988,11 @@ export async function createRequestHandler(opts) { if (dev) { // An orphan is EITHER shape `findOrphanComponents` reports: no // registration call at all, or one whose tag is computed. The - // scanner matches only a literal tag, so both fail identically - // and one message has to cover both (the doctor check and - // `webjs elision` say the same thing). + // scanner matches only a literal tag, so it sees neither, but + // they fail DIFFERENTLY (no call never upgrades; a computed tag + // still registers when its module reaches the browser), so one + // message has to distinguish them. The doctor check and + // `webjs elision` say the same thing. for (const { className, file } of await findOrphanComponents(appDir)) { logger.warn?.( `[webjs] ${className} extends WebComponent but is never registered with a literal tag in ${file} ` + diff --git a/packages/server/src/elision-report.js b/packages/server/src/elision-report.js index 0c333d337..e48a62996 100644 --- a/packages/server/src/elision-report.js +++ b/packages/server/src/elision-report.js @@ -19,9 +19,14 @@ * blocker that pins it (a non-component on a component-free path from the * module, #963, or its own signal when the module itself is the cause). * - `orphans`: a `class X extends WebComponent` with no literal-tag - * registration. The scanner never sees it (invariant 3 requires a literal - * tag), so it gets NO verdict at all and `static interactive = true` - * cannot rescue it. It is the one shape that is dropped silently. + * registration, either because there is no registration call or because + * the tag is computed. The scanner never sees it (invariant 3 requires a + * literal tag), so it always loses its elision verdict, its tag-to-module + * registry entry, and its preload hint, and `static interactive = true` + * cannot rescue it. Whether the ELEMENT still upgrades differs by shape: + * with no registration call it never does, while a computed tag registers + * fine as long as its module reaches the browser. It is the one shape the + * verdict cannot speak for. * * Consumed by `webjs elision` (the CLI report + `--json`), the MCP * `list_elision` tool, and `webjs doctor`'s two elision checks, which share diff --git a/test/cli/doctor.test.mjs b/test/cli/doctor.test.mjs index 233e061ff..275ed4ec3 100644 --- a/test/cli/doctor.test.mjs +++ b/test/cli/doctor.test.mjs @@ -1185,7 +1185,7 @@ test('a healthy app PASSES and the message carries the elided inventory', async test('an orphan class WARNS, names the class and file, and never fails', async () => { // The one always-wrong condition: a class registered with a computed tag is - // invisible to the scanner, so its module is dropped with no verdict at all + // invisible to the scanner, so it gets no elision verdict at all // and `static interactive = true` cannot rescue it. const dir = tmpDir(); write(dir, 'package.json', JSON.stringify({ name: 'x', type: 'module' })); diff --git a/website/app/docs/elision/page.ts b/website/app/docs/elision/page.ts index 63694221c..c75b49914 100644 --- a/website/app/docs/elision/page.ts +++ b/website/app/docs/elision/page.ts @@ -73,7 +73,7 @@ Badge.register(TAG); // invisible Badge.register('my-badge');

    - A custom-element tag must be a literal string anyway, but the consequence here is specific: the scanner never sees that component, so it gets no elision verdict at all, nothing consults the analyser for it, and the override has nothing to attach to. The registration itself still runs whenever the module reaches the browser, so the failure is quieter than it sounds: what you lose is the verdict, the tag-to-module registry entry, and the preload hint. But if the importing page is elided or inert, the import goes with it and the element never registers at all. webjs dev warns about this shape, and webjs elision and webjs doctor report it as an orphan. An orphan is either of two things, and both fail identically: a class whose registration tag is computed, or a class with no registration call at all. + A custom-element tag must be a literal string anyway, but the consequence here is specific: the scanner never sees that component, so it gets no elision verdict at all, nothing consults the analyser for it, and the override has nothing to attach to. The registration itself still runs whenever the module reaches the browser, so the failure is quieter than it sounds: what you lose is the verdict, the tag-to-module registry entry, and the preload hint. But if the importing page is elided or inert, the import goes with it and the element never registers at all. webjs dev warns about this shape, and webjs elision and webjs doctor report it as an orphan. An orphan is either of two things, and they fail differently: a class with no registration call at all never upgrades, while a class with a computed tag still registers whenever its module reaches the browser.

    Inspecting the verdict

    From 8c2871d9a2a1feb2ac09bf1a5f30728d1555b32d Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 01:08:12 +0530 Subject: [PATCH 18/24] fix: import-only drops the orphan too, and it is the ordinary case Correcting the never-upgrades claim, I wrote that the element still upgrades unless its importer is elided or inert. That enumeration misses IMPORT-ONLY, which is the shape an author actually hits: any page mixing one interactive component with a display-only orphan. Ran it, and the boot emits only the real component, so the page module is dropped, its import of the orphan goes with it, and register(TAG) never runs. Four surfaces were telling an author their element upgrades in exactly the situation they are most likely to be in. Stating the rule as a list of losing verdicts is what allowed a case to go missing, so it is now positive: the element upgrades only when its importer ships WHOLE. A contract test pins the import-only shape beside the ships-whole and inert ones. The doctor message and the CLI package AGENTS row still carried the retracted dropped wording that this branch removed from their neighbours. --- .agents/skills/webjs/references/components.md | 2 +- packages/cli/AGENTS.md | 2 +- packages/cli/lib/doctor.js | 2 +- packages/server/index.d.ts | 7 ++-- packages/server/src/component-scanner.js | 11 ++++-- .../test/elision/residual-contract.test.js | 37 +++++++++++++++++++ website/app/docs/elision/page.ts | 2 +- 7 files changed, 52 insertions(+), 11 deletions(-) diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index 9daa011eb..36ab9cda5 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -273,7 +273,7 @@ The analyser reads source lexically, so exactly two real shapes escape it, and t - **An OBSERVER that computes the tag it waits for.** `customElements.whenDefined(TAG)` where `TAG` is a variable does not name a tag the analyser can resolve, so the observed component is elided, its `register` never runs, and the `await` never settles. Put `static interactive = true` on the OBSERVED component. - **A `:defined` rule in an external stylesheet.** `public/app.css` is not in the module graph, so a `my-badge:defined { … }` rule is invisible. Same fix, on the component the rule names. -**It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The registration still runs whenever the module reaches the browser, so what you always lose is the verdict, the tag-to-module registry entry, and the preload hint. If the importing page is elided or inert, the import goes with it and the element never registers at all. Always pass a literal: `Badge.register('my-badge')`. `webjs dev` warns about this shape, and `webjs elision` / `webjs doctor` report it as an **orphan**. An orphan is either of two things, and they fail differently: a class with no registration call at all never upgrades, full stop, while a class with a computed tag still registers whenever its module reaches the browser. +**It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The registration still runs if the module reaches the browser, so what you ALWAYS lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades depends on one thing: the importing module has to ship WHOLE. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. Import-only is the ordinary case (any page mixing one interactive component with a display-only orphan), so assume it does not upgrade. Always pass a literal: `Badge.register('my-badge')`. `webjs dev` warns about this shape, and `webjs elision` / `webjs doctor` report it as an **orphan**. An orphan is either of two things, and they fail differently: a class with no registration call at all never upgrades, full stop, while a class with a computed tag still registers whenever its module reaches the browser. ### Inspecting and proving the verdict diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 056d94ff3..91a9812b6 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -159,7 +159,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 dropped with no verdict and 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, 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). | diff --git a/packages/cli/lib/doctor.js b/packages/cli/lib/doctor.js index fc02613da..7ce4efead 100644 --- a/packages/cli/lib/doctor.js +++ b/packages/cli/lib/doctor.js @@ -1077,7 +1077,7 @@ async function checkElisionComponents(elisionPromise) { name, status: 'warn', message: - `${report.orphans.length} component class(es) are dropped with NO elision verdict:\n` + + `${report.orphans.length} component class(es) get NO elision verdict:\n` + lines.map((l) => ` ${l}`).join('\n') + '\n Either it has no registration call at all, or it registers a computed tag. The component ' + 'scanner matches only a literal tag, so either way it never sees the class: no elision verdict, no ' diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts index a113c3b41..939de7da3 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -484,9 +484,10 @@ export declare function extractComponents(src: string): Array<{ className: strin * registration call or because the tag is computed. The scanner matches only a * literal tag, so neither is visible to it. They fail DIFFERENTLY, though: with * no registration call the element never upgrades at all, while a computed tag - * still registers if its module reaches the browser, and what is lost there is - * the verdict, the registry entry, and the preload hint (plus the upgrade - * itself, if its importer is elided or inert). Nothing here is about import + * still registers IF its module reaches the browser, which requires its + * importer to ship WHOLE (an inert, import-only, or elided importer is dropped + * from the boot and takes the import with it). Either way the verdict, the + * registry entry, and the preload hint are lost. Nothing here is about import * reachability. */ export declare function findOrphanComponents( diff --git a/packages/server/src/component-scanner.js b/packages/server/src/component-scanner.js index 3bc33a8ca..f1c04d176 100644 --- a/packages/server/src/component-scanner.js +++ b/packages/server/src/component-scanner.js @@ -156,10 +156,13 @@ export async function primeComponentRegistry(appDir, components) { * - No registration call at all (the forgot-to-register case). Nothing ever * registers the tag, so the element NEVER upgrades. * - A registration whose tag is COMPUTED (`X.register(TAG)`). The call does - * run if the module reaches the browser, so the element may well upgrade. - * What is lost is that the scanner cannot see it: no elision verdict, no - * tag-to-module registry entry, no modulepreload hint, and if its importer - * is elided or inert the import is dropped and it never upgrades either. + * run if the module reaches the browser, which requires its importer to + * ship WHOLE: an inert, import-only, or elided importer is dropped from + * the boot and takes the import with it, so the element does not upgrade + * there either. Import-only is the ordinary case, since an orphan is not + * in `componentFiles` and so never joins the emitted frontier. Lost in + * every case: the elision verdict, the tag-to-module registry entry, and + * the modulepreload hint. * * Matches the literal `extends WebComponent` only, so a class extending a * component SUBCLASS is not reported. diff --git a/packages/server/test/elision/residual-contract.test.js b/packages/server/test/elision/residual-contract.test.js index fcbfe34dd..a6977a23f 100644 --- a/packages/server/test/elision/residual-contract.test.js +++ b/packages/server/test/elision/residual-contract.test.js @@ -217,6 +217,43 @@ test('a computed-tag component still REGISTERS when its importer ships (#1308)', } }); +test('an IMPORT-ONLY importer drops the computed-tag orphan, the ordinary case (#1308)', async () => { + // The third importer verdict, and the one an author actually hits: a page + // mixing one interactive component with a computed-tag orphan. The orphan is + // not in `componentFiles`, so it never joins the import-only frontier, and + // the boot emits only the frontier in the page module's place. The page's + // import of the orphan goes with the dropped page module, so `register(TAG)` + // never runs. + // + // This is what makes "only when its importer ships WHOLE" the right rule: + // enumerating the losing verdicts is how import-only got missed. + const dir = await mkdtemp(join(tmpdir(), 'webjs-residual-importonly-')); + try { + await mkdir(join(dir, 'app'), { recursive: true }); + await mkdir(join(dir, 'components'), { recursive: true }); + await writeFile(join(dir, 'components/badge.js'), badgeComputedRegistration()); + await writeFile(join(dir, 'components/counter.js'), + "import { WebComponent, html } from '@webjsdev/core';\nexport class Counter extends WebComponent {\n" + + " render() { return html``; }\n}\nCounter.register('my-counter');\n"); + await writeFile(join(dir, 'app/page.js'), + "import { html } from '@webjsdev/core';\nimport '../components/counter.js';\n" + + "import '../components/badge.js';\nexport default () => html``;\n"); + + const graph = await buildModuleGraph(dir); + const components = await scanComponents(dir); + const pageFile = join(dir, 'app/page.js'); + const verdict = await analyzeElision( + components, [pageFile], graph, (f) => import('node:fs/promises').then((m) => m.readFile(f, 'utf8')), dir, + ); + const emits = verdict.importOnlyRouteModules.get(pageFile); + assert.ok(emits, 'the page is import-only, not shipped whole'); + assert.deepEqual(emits, [join(dir, 'components/counter.js')], + 'only the real component is emitted; the orphan is not in the frontier, so its import is dropped'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test('static interactive = true does NOT rescue a computed registration tag', async () => { // The measured finding the docs used to get wrong: the override is a // property the ANALYSER reads, and nothing consults the analyser for a diff --git a/website/app/docs/elision/page.ts b/website/app/docs/elision/page.ts index c75b49914..e239a9b6e 100644 --- a/website/app/docs/elision/page.ts +++ b/website/app/docs/elision/page.ts @@ -73,7 +73,7 @@ Badge.register(TAG); // invisible Badge.register('my-badge');

    - A custom-element tag must be a literal string anyway, but the consequence here is specific: the scanner never sees that component, so it gets no elision verdict at all, nothing consults the analyser for it, and the override has nothing to attach to. The registration itself still runs whenever the module reaches the browser, so the failure is quieter than it sounds: what you lose is the verdict, the tag-to-module registry entry, and the preload hint. But if the importing page is elided or inert, the import goes with it and the element never registers at all. webjs dev warns about this shape, and webjs elision and webjs doctor report it as an orphan. An orphan is either of two things, and they fail differently: a class with no registration call at all never upgrades, while a class with a computed tag still registers whenever its module reaches the browser. + A custom-element tag must be a literal string anyway, but the consequence here is specific: the scanner never sees that component, so it gets no elision verdict at all, nothing consults the analyser for it, and the override has nothing to attach to. The registration itself still runs if the module reaches the browser, so what you always lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades comes down to one thing: the importing module has to ship whole. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. Import-only is the ordinary case, so assume it does not upgrade. webjs dev warns about this shape, and webjs elision and webjs doctor report it as an orphan. An orphan is either of two things, and they fail differently: a class with no registration call at all never upgrades, while a class with a computed tag still registers whenever its module reaches the browser.

    Inspecting the verdict

    From 20e7477bed532dcabd5c14fc6227da232e94314f Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 01:17:33 +0530 Subject: [PATCH 19/24] docs: keep the orphan explanation in one place instead of eight Nine review rounds running have found stale prose about one concept while the code stayed unchanged. The cause is that the orphan semantics were re-explained in full on eight surfaces, so every correction left seven places to contradict it. The full explanation now lives only in the findOrphanComponents JSDoc, and every other surface states the part that is always true (no verdict, no registry entry, no preload hint) plus one qualified clause about the upgrade. The causal claim in that JSDoc was also wrong: import-only is not the ordinary case BECAUSE an orphan is missing from componentFiles. Read literally that argues the opposite, since a page whose only component-shaped import is the orphan has an empty frontier and is inert, which is what the neighbouring test asserts. It is ordinary because a page usually renders a real component alongside the orphan. Two paragraphs still ended on the retracted reassurance, the dev warning implied by omission that a computed tag upgrades, and the report JSDoc still said registers fine. The new test also reintroduced the plain-string fixture this branch banned two commits earlier, in the directory the fuzz corpus reads. --- .agents/skills/webjs/references/components.md | 2 +- packages/mcp/src/mcp.js | 2 +- packages/server/src/component-scanner.js | 22 ++++++++++++------- packages/server/src/dev.js | 16 ++++++++------ packages/server/src/elision-report.js | 6 ++--- .../test/elision/residual-contract.test.js | 19 +++++++++++++--- website/app/docs/elision/page.ts | 2 +- 7 files changed, 45 insertions(+), 24 deletions(-) diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index 36ab9cda5..cfcf119ec 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -273,7 +273,7 @@ The analyser reads source lexically, so exactly two real shapes escape it, and t - **An OBSERVER that computes the tag it waits for.** `customElements.whenDefined(TAG)` where `TAG` is a variable does not name a tag the analyser can resolve, so the observed component is elided, its `register` never runs, and the `await` never settles. Put `static interactive = true` on the OBSERVED component. - **A `:defined` rule in an external stylesheet.** `public/app.css` is not in the module graph, so a `my-badge:defined { … }` rule is invisible. Same fix, on the component the rule names. -**It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The registration still runs if the module reaches the browser, so what you ALWAYS lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades depends on one thing: the importing module has to ship WHOLE. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. Import-only is the ordinary case (any page mixing one interactive component with a display-only orphan), so assume it does not upgrade. Always pass a literal: `Badge.register('my-badge')`. `webjs dev` warns about this shape, and `webjs elision` / `webjs doctor` report it as an **orphan**. An orphan is either of two things, and they fail differently: a class with no registration call at all never upgrades, full stop, while a class with a computed tag still registers whenever its module reaches the browser. +**It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The registration still runs if the module reaches the browser, so what you ALWAYS lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades depends on one thing: the importing module has to ship WHOLE. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. Import-only is the ordinary case (any page mixing one interactive component with a display-only orphan), so assume it does not upgrade. Always pass a literal: `Badge.register('my-badge')`. `webjs dev` warns about this shape, and `webjs elision` / `webjs doctor` report it as an **orphan**. ### Inspecting and proving the verdict diff --git a/packages/mcp/src/mcp.js b/packages/mcp/src/mcp.js index 4dd100997..d24d561b9 100644 --- a/packages/mcp/src/mcp.js +++ b/packages/mcp/src/mcp.js @@ -166,7 +166,7 @@ const TOOL_DEFS = [ { name: 'list_elision', description: - 'Report the display-only elision verdict: every component module with whether it is elided (the browser never downloads it) or shipped plus the evidence that produced the verdict, every page/layout route module as inert / import-only / shipped (with the first client-effecting blocker that pins it), and any ORPHAN component class, one that either has no registration call at all or registers a computed tag, which the scanner cannot see either way, so it always loses its elision verdict, its tag-to-module registry entry, and its preload hint (and never upgrades at all when there is no registration call). Identical to `webjs elision --json`. Read-only.', + 'Report the display-only elision verdict: every component module with whether it is elided (the browser never downloads it) or shipped plus the evidence that produced the verdict, every page/layout route module as inert / import-only / shipped (with the first client-effecting blocker that pins it), and any ORPHAN component class, one that either has no registration call at all or registers a computed tag, which the scanner cannot see either way, so it always loses its elision verdict, its tag-to-module registry entry, and its preload hint (and never upgrades at all with no registration call, or when a computed-tag class has no importer that ships whole). Identical to `webjs elision --json`. Read-only.', inputSchema: APPDIR_SCHEMA, }, { diff --git a/packages/server/src/component-scanner.js b/packages/server/src/component-scanner.js index f1c04d176..a84358a4e 100644 --- a/packages/server/src/component-scanner.js +++ b/packages/server/src/component-scanner.js @@ -155,14 +155,20 @@ export async function primeComponentRegistry(appDir, components) { * * - No registration call at all (the forgot-to-register case). Nothing ever * registers the tag, so the element NEVER upgrades. - * - A registration whose tag is COMPUTED (`X.register(TAG)`). The call does - * run if the module reaches the browser, which requires its importer to - * ship WHOLE: an inert, import-only, or elided importer is dropped from - * the boot and takes the import with it, so the element does not upgrade - * there either. Import-only is the ordinary case, since an orphan is not - * in `componentFiles` and so never joins the emitted frontier. Lost in - * every case: the elision verdict, the tag-to-module registry entry, and - * the modulepreload hint. + * - A registration whose tag is COMPUTED (`X.register(TAG)`). That call is + * ordinary code, so it runs IF the module reaches the browser, which + * requires the importing module to ship WHOLE. An inert, import-only, or + * elided importer is dropped from the boot and takes the import with it, + * and then the element does not upgrade either. Assume it does not: an + * orphan is not in `componentFiles`, so it never joins the frontier an + * import-only page emits in its place, and a page that renders any real + * component alongside the orphan IS import-only. That mix is the ordinary + * shape, so shipping-whole is the exception rather than the rule. + * + * Lost in EVERY case, whichever shape: the elision verdict, the tag-to-module + * registry entry, and the modulepreload hint. That, not the upgrade, is the + * part that is always true, and it is what every message about this should + * lead with. * * Matches the literal `extends WebComponent` only, so a class extending a * component SUBCLASS is not reported. diff --git a/packages/server/src/dev.js b/packages/server/src/dev.js index 6fe522b92..a5a21cb9c 100644 --- a/packages/server/src/dev.js +++ b/packages/server/src/dev.js @@ -988,17 +988,19 @@ export async function createRequestHandler(opts) { if (dev) { // An orphan is EITHER shape `findOrphanComponents` reports: no // registration call at all, or one whose tag is computed. The - // scanner matches only a literal tag, so it sees neither, but - // they fail DIFFERENTLY (no call never upgrades; a computed tag - // still registers when its module reaches the browser), so one - // message has to distinguish them. The doctor check and - // `webjs elision` say the same thing. + // scanner matches only a literal tag, so it sees neither, and + // what is ALWAYS lost is the verdict, the registry entry, and the + // preload hint. The upgrade is the part that differs, and a + // computed tag only survives when its importer ships WHOLE (see + // findOrphanComponents for why that is the exception). The doctor + // check and `webjs elision` say the same thing. for (const { className, file } of await findOrphanComponents(appDir)) { logger.warn?.( `[webjs] ${className} extends WebComponent but is never registered with a literal tag in ${file} ` + `(either there is no registration call, or its tag is computed), so the scanner cannot see it: ` + - `no elision verdict, no preload hint, and with no registration call at all ` + - `<${kebab(className)}> tags never upgrade. ` + + `no elision verdict, no registry entry, no preload hint. ` + + `<${kebab(className)}> tags never upgrade with no registration call, and a computed tag ` + + `upgrades only while its importing module ships whole. ` + `Add \`${className}.register('');\` with a literal tag.`, ); } diff --git a/packages/server/src/elision-report.js b/packages/server/src/elision-report.js index e48a62996..b026adf70 100644 --- a/packages/server/src/elision-report.js +++ b/packages/server/src/elision-report.js @@ -24,9 +24,9 @@ * literal tag), so it always loses its elision verdict, its tag-to-module * registry entry, and its preload hint, and `static interactive = true` * cannot rescue it. Whether the ELEMENT still upgrades differs by shape: - * with no registration call it never does, while a computed tag registers - * fine as long as its module reaches the browser. It is the one shape the - * verdict cannot speak for. + * with no registration call it never does, and with a computed tag only + * while its importing module ships WHOLE, which is the exception rather + * than the rule. It is the one shape the verdict cannot speak for. * * Consumed by `webjs elision` (the CLI report + `--json`), the MCP * `list_elision` tool, and `webjs doctor`'s two elision checks, which share diff --git a/packages/server/test/elision/residual-contract.test.js b/packages/server/test/elision/residual-contract.test.js index a6977a23f..0d17ea286 100644 --- a/packages/server/test/elision/residual-contract.test.js +++ b/packages/server/test/elision/residual-contract.test.js @@ -46,6 +46,21 @@ export class Badge extends WebComponent { Badge.register(TAG); `; +/** + * An interactive component, as a template literal rather than a plain string. + * The scanner-fuzz corpus sweep reads every file under `test/elision` and + * compares its lexical class window against a real AST; redaction blanks a + * template body while keeping a plain-string body verbatim, so a class written + * in a plain string skews that differential. + */ +const INTERACTIVE_COUNTER = ` +import { WebComponent, html } from '@webjsdev/core'; +export class Counter extends WebComponent { + render() { return html\`\`; } +} +Counter.register('my-counter'); +`; + const PAGE = ` import { html } from '@webjsdev/core'; import '../components/badge.js'; @@ -232,9 +247,7 @@ test('an IMPORT-ONLY importer drops the computed-tag orphan, the ordinary case ( await mkdir(join(dir, 'app'), { recursive: true }); await mkdir(join(dir, 'components'), { recursive: true }); await writeFile(join(dir, 'components/badge.js'), badgeComputedRegistration()); - await writeFile(join(dir, 'components/counter.js'), - "import { WebComponent, html } from '@webjsdev/core';\nexport class Counter extends WebComponent {\n" + - " render() { return html``; }\n}\nCounter.register('my-counter');\n"); + await writeFile(join(dir, 'components/counter.js'), INTERACTIVE_COUNTER); await writeFile(join(dir, 'app/page.js'), "import { html } from '@webjsdev/core';\nimport '../components/counter.js';\n" + "import '../components/badge.js';\nexport default () => html``;\n"); diff --git a/website/app/docs/elision/page.ts b/website/app/docs/elision/page.ts index e239a9b6e..81a62a238 100644 --- a/website/app/docs/elision/page.ts +++ b/website/app/docs/elision/page.ts @@ -73,7 +73,7 @@ Badge.register(TAG); // invisible Badge.register('my-badge');

    - A custom-element tag must be a literal string anyway, but the consequence here is specific: the scanner never sees that component, so it gets no elision verdict at all, nothing consults the analyser for it, and the override has nothing to attach to. The registration itself still runs if the module reaches the browser, so what you always lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades comes down to one thing: the importing module has to ship whole. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. Import-only is the ordinary case, so assume it does not upgrade. webjs dev warns about this shape, and webjs elision and webjs doctor report it as an orphan. An orphan is either of two things, and they fail differently: a class with no registration call at all never upgrades, while a class with a computed tag still registers whenever its module reaches the browser. + A custom-element tag must be a literal string anyway, but the consequence here is specific: the scanner never sees that component, so it gets no elision verdict at all, nothing consults the analyser for it, and the override has nothing to attach to. The registration itself still runs if the module reaches the browser, so what you always lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades comes down to one thing: the importing module has to ship whole. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. Import-only is the ordinary case, so assume it does not upgrade. webjs dev warns about this shape, and webjs elision and webjs doctor report it as an orphan.

    Inspecting the verdict

    From 62301a986ec0142a33076df0bb8ba4dff077e206 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 01:28:16 +0530 Subject: [PATCH 20/24] fix: the type declaration missed the consolidation, and another universal index.d.ts still re-explained the orphan semantics in full, which is what the previous commit removed from seven other surfaces, and it is the copy an app consumer editor renders. It had drifted to the reassuring version, and its closing line about import reachability sat directly after two clauses about whether the importing module ships. It now states what is always true and points at the canonical explanation. The replacement causal claim carried the same over-broad-universal defect as the one it replaced: a page rendering a real component alongside the orphan is import-only UNLESS it also does its own client work, which ships it whole, and that is exactly the case the sibling test pins as the one where the element does upgrade. The fixture rule was challenged as unfounded. It is real, and reverting a fixture to a plain string reds the corpus sweep, but the comments said it skews the differential, which reads as the over-match direction that sweep explicitly accepts. They now name the mechanism: the class-body count mismatch throws into the asserted miss list. --- packages/server/index.d.ts | 14 ++++++-------- packages/server/src/component-scanner.js | 9 +++++---- .../server/test/elision/residual-contract.test.js | 11 +++++++---- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts index 939de7da3..0d49b9476 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -481,14 +481,12 @@ export declare function extractComponents(src: string): Array<{ className: strin /** * Find ORPHAN component classes: a `class X extends WebComponent` that its own * file never registers with a LITERAL tag, either because there is no - * registration call or because the tag is computed. The scanner matches only a - * literal tag, so neither is visible to it. They fail DIFFERENTLY, though: with - * no registration call the element never upgrades at all, while a computed tag - * still registers IF its module reaches the browser, which requires its - * importer to ship WHOLE (an inert, import-only, or elided importer is dropped - * from the boot and takes the import with it). Either way the verdict, the - * registry entry, and the preload hint are lost. Nothing here is about import - * reachability. + * registration call or because the tag is computed. Neither is visible to the + * scanner, so both always lose the elision verdict, the tag-to-module registry + * entry, and the preload hint; assume the element does not upgrade either. + * This is about REGISTRATION, never about import reachability. The full + * semantics, including the one case where a computed tag still upgrades, live + * on `findOrphanComponents` in `src/component-scanner.js`. */ export declare function findOrphanComponents( appDir: string, diff --git a/packages/server/src/component-scanner.js b/packages/server/src/component-scanner.js index a84358a4e..354a1ad7e 100644 --- a/packages/server/src/component-scanner.js +++ b/packages/server/src/component-scanner.js @@ -159,11 +159,12 @@ export async function primeComponentRegistry(appDir, components) { * ordinary code, so it runs IF the module reaches the browser, which * requires the importing module to ship WHOLE. An inert, import-only, or * elided importer is dropped from the boot and takes the import with it, - * and then the element does not upgrade either. Assume it does not: an + * and then the element does not upgrade either. Assume it does not. An * orphan is not in `componentFiles`, so it never joins the frontier an - * import-only page emits in its place, and a page that renders any real - * component alongside the orphan IS import-only. That mix is the ordinary - * shape, so shipping-whole is the exception rather than the rule. + * import-only page emits in its place, and a page that renders a real + * component alongside the orphan is import-only unless it ALSO does its + * own client work (which ships it whole, #963). Shipping whole is the + * narrower case, so treat the upgrade as lost until proven otherwise. * * Lost in EVERY case, whichever shape: the elision verdict, the tag-to-module * registry entry, and the modulepreload hint. That, not the upgrade, is the diff --git a/packages/server/test/elision/residual-contract.test.js b/packages/server/test/elision/residual-contract.test.js index 0d17ea286..b83e1c2aa 100644 --- a/packages/server/test/elision/residual-contract.test.js +++ b/packages/server/test/elision/residual-contract.test.js @@ -48,10 +48,13 @@ Badge.register(TAG); /** * An interactive component, as a template literal rather than a plain string. - * The scanner-fuzz corpus sweep reads every file under `test/elision` and - * compares its lexical class window against a real AST; redaction blanks a - * template body while keeping a plain-string body verbatim, so a class written - * in a plain string skews that differential. + * The scanner-fuzz corpus sweep reads every file under `test/elision`, and + * `redactStringsAndTemplates` keeps a plain-string body VERBATIM while blanking + * a template one. A class in a plain string therefore reaches the sweep's name + * window but not its class-body extractor, the two counts disagree, and the + * internal assert throws into the miss list, which FAILS the suite. A bare + * over-match would be fine, that direction is explicitly accepted; the count + * mismatch is what reds it. Verified by reverting this fixture. */ const INTERACTIVE_COUNTER = ` import { WebComponent, html } from '@webjsdev/core'; From 55c88f5f511f44218e879c44a393310dbbc51ca2 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 01:31:00 +0530 Subject: [PATCH 21/24] docs: name the exact mechanism behind the template-literal fixture rule The rule was challenged as unfounded and it is not, but the comments justified it as skewing the differential, which reads as the over-match direction the corpus sweep explicitly accepts. The real path is narrower and worth writing down: a plain-string body survives redaction verbatim, so the embedded html-template backtick reaches the mask, matchClosingBrace returns -1, and the class-body extractor finds 0 bodies against 1 name-window match. That count assert throws into the asserted miss list. --- .../server/test/elision/elision-report.test.js | 16 +++++++++++----- .../test/elision/residual-contract.test.js | 16 +++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/server/test/elision/elision-report.test.js b/packages/server/test/elision/elision-report.test.js index c95d16580..fa9161234 100644 --- a/packages/server/test/elision/elision-report.test.js +++ b/packages/server/test/elision/elision-report.test.js @@ -55,11 +55,17 @@ Counter.register('my-counter'); /** * A badge whose own source is inert but whose IMPORT does client work. * - * Fixture sources here are template literals, never plain strings: the - * scanner-fuzz corpus sweep reads every file under `test/elision` and compares - * its lexical class window against a real AST, and redaction blanks a template - * body while keeping a plain string verbatim. A class written in a plain string - * would therefore skew that differential. + * Fixture sources here are template literals, never plain strings. The + * scanner-fuzz corpus sweep reads every file under `test/elision`. + * `redactStringsAndTemplates` keeps a plain-string body VERBATIM, so the + * embedded html-template BACKTICK survives into the mask, `matchClosingBrace` + * returns -1, and the class-body extractor yields 0 bodies against 1 + * name-window match. That internal count assert THROWS, the corpus test's + * try/catch pushes the throw into `misses`, and `misses` is asserted, so the + * suite FAILS. A bare over-match would be fine (that direction is explicitly + * accepted); this is a count mismatch, which is not the same thing. Verified + * both by reverting a fixture and by an independent check. + * This file is where that was first hit. */ const BADGE_IMPORTING_SIGNAL = ` import { WebComponent, html } from '@webjsdev/core'; diff --git a/packages/server/test/elision/residual-contract.test.js b/packages/server/test/elision/residual-contract.test.js index b83e1c2aa..736359678 100644 --- a/packages/server/test/elision/residual-contract.test.js +++ b/packages/server/test/elision/residual-contract.test.js @@ -48,13 +48,15 @@ Badge.register(TAG); /** * An interactive component, as a template literal rather than a plain string. - * The scanner-fuzz corpus sweep reads every file under `test/elision`, and - * `redactStringsAndTemplates` keeps a plain-string body VERBATIM while blanking - * a template one. A class in a plain string therefore reaches the sweep's name - * window but not its class-body extractor, the two counts disagree, and the - * internal assert throws into the miss list, which FAILS the suite. A bare - * over-match would be fine, that direction is explicitly accepted; the count - * mismatch is what reds it. Verified by reverting this fixture. + * The scanner-fuzz corpus sweep reads every file under `test/elision`. + * `redactStringsAndTemplates` keeps a plain-string body VERBATIM, so the + * embedded html-template BACKTICK survives into the mask, `matchClosingBrace` + * returns -1, and the class-body extractor yields 0 bodies against 1 + * name-window match. That internal count assert THROWS, the corpus test's + * try/catch pushes the throw into `misses`, and `misses` is asserted, so the + * suite FAILS. A bare over-match would be fine (that direction is explicitly + * accepted); this is a count mismatch, which is not the same thing. Verified + * both by reverting a fixture and by an independent check. */ const INTERACTIVE_COUNTER = ` import { WebComponent, html } from '@webjsdev/core'; From 2de3269ef3dc64c141b0d5dba9d6f831e18a0b46 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 01:45:52 +0530 Subject: [PATCH 22/24] docs: the consolidation dropped both-shapes coverage, restore it Consolidating the orphan explanation deleted the both-shapes sentence from the skill reference and the docs page while keeping the long computed-tag section, so a user hitting the far more common forgot-to-register warning was sent to a heading about computed tags that never mentions their case. The skill fix line also said to give the class a literal tag, which is not the fix when there is no registration call. Both surfaces now name both shapes and both fixes. The skill also still carried the over-broad universal corrected in the scanner last round, and that file is copied verbatim into every scaffolded app. Checked every surface mechanically for both shapes afterwards rather than by eye. That found one more real gap, the root CLI reference describing orphans without saying what one is, and showed my line-based greps were wrap-blind, which is how these sweeps kept missing things. The check is normalised now. --- .agents/skills/webjs/references/components.md | 6 ++++-- AGENTS.md | 2 +- website/app/docs/elision/page.ts | 6 +++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index cfcf119ec..abca0d8f1 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -273,7 +273,9 @@ The analyser reads source lexically, so exactly two real shapes escape it, and t - **An OBSERVER that computes the tag it waits for.** `customElements.whenDefined(TAG)` where `TAG` is a variable does not name a tag the analyser can resolve, so the observed component is elided, its `register` never runs, and the `await` never settles. Put `static interactive = true` on the OBSERVED component. - **A `:defined` rule in an external stylesheet.** `public/app.css` is not in the module graph, so a `my-badge:defined { … }` rule is invisible. Same fix, on the component the rule names. -**It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The registration still runs if the module reaches the browser, so what you ALWAYS lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades depends on one thing: the importing module has to ship WHOLE. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. Import-only is the ordinary case (any page mixing one interactive component with a display-only orphan), so assume it does not upgrade. Always pass a literal: `Badge.register('my-badge')`. `webjs dev` warns about this shape, and `webjs elision` / `webjs doctor` report it as an **orphan**. +**It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The registration still runs if the module reaches the browser, so what you ALWAYS lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades depends on one thing: the importing module has to ship WHOLE. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. A page rendering a real component alongside the orphan is import-only unless it ALSO does its own client work, so shipping whole is the narrower case: assume the element does not upgrade. Always pass a literal: `Badge.register('my-badge')`. + +`webjs dev` warns, and `webjs elision` / `webjs doctor` report it, as an **orphan**. That name covers TWO shapes and they fail differently, so read the warning carefully: a computed tag is the case above, while a class with NO registration call at all is the plainer one (someone forgot to register it), and that element never upgrades under any circumstances. Both lose the verdict, the registry entry, and the preload hint. ### Inspecting and proving the verdict @@ -299,7 +301,7 @@ webjs elision --verify --routes /,/blog/hello # add paths (the only way to cov An elided row carries no reason on purpose: elision is the ABSENCE of every signal, so there is no positive fact to report. -**What to do with each verdict.** `elided` on a component you believe is interactive is the one result worth acting on: find the signal it is missing (the list above), and if the interactivity is genuinely invisible to static analysis, add `static interactive = true`. `shipped` with an `evidence` you did not expect is usually a `closure` row, and the fix is to move the client-effecting import out of that component's path. An `orphans` row is always a bug: give the class a literal registration tag. +**What to do with each verdict.** `elided` on a component you believe is interactive is the one result worth acting on: find the signal it is missing (the list above), and if the interactivity is genuinely invisible to static analysis, add `static interactive = true`. `shipped` with an `evidence` you did not expect is usually a `closure` row, and the fix is to move the client-effecting import out of that component's path. An `orphans` row is always a bug, and the fix depends on which shape it is: give the class a literal registration tag if its tag is computed, or add the missing `Class.register('my-tag')` call if there is none at all (delete the class instead if nothing uses it). **What `--verify` proves.** It renders every static page route with elision on and off and diffs the bytes with the JS-loaded set masked out, which is the framework's own guard pointed at your app. So it proves elision did not change what your app SERVES. It does not prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Cover that half by running your own browser or e2e suite twice: diff --git a/AGENTS.md b/AGENTS.md index 8a583895a..64dc5222f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -505,7 +505,7 @@ webjs start [--port N] # prod server; source IS the runtime, plain H webjs test [--server] [--browser] [--watch] webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop webjs routes [--json] [--table] [--no-headers] # print the route table (path / owner file / methods, #975). Default tree; --json is byte-identical to the MCP list_routes tool; --no-headers drops the --table header for piping -webjs elision [--json] [--verify] [--routes ] # the elision verdict (#1308): every component module as elided or shipped (a shipped one naming the EVIDENCE that forced it and the module that did the forcing), every page/layout as inert / import-only / ships-whole, and every orphan class that gets no verdict at all. --json is byte-identical to the MCP list_elision tool. --verify renders every static page route with elision on and off and diffs the observable SSR bytes (the framework's own differential guard, pointed at your app): exit 0 on parity, non-zero on a divergence OR on a corpus where nothing could be compared. It proves elision did not change the bytes you SERVE, NOT post-hydration behaviour (a wrongly dropped module is a dead click, not different bytes), so run your browser/e2e suite twice under WEBJS_ELIDE=1 / WEBJS_ELIDE=0 for that half. Dynamic routes are skipped by name; --routes adds real paths +webjs elision [--json] [--verify] [--routes ] # the elision verdict (#1308): every component module as elided or shipped (a shipped one naming the EVIDENCE that forced it and the module that did the forcing), every page/layout as inert / import-only / ships-whole, and every orphan class that gets no verdict at all (either no registration call, or a computed tag; the scanner matches only a literal one). --json is byte-identical to the MCP list_elision tool. --verify renders every static page route with elision on and off and diffs the observable SSR bytes (the framework's own differential guard, pointed at your app): exit 0 on parity, non-zero on a divergence OR on a corpus where nothing could be compared. It proves elision did not change the bytes you SERVE, NOT post-hydration behaviour (a wrongly dropped module is a dead click, not different bytes), so run your browser/e2e suite twice under WEBJS_ELIDE=1 / WEBJS_ELIDE=0 for that half. Dynamic routes are skipped by name; --routes adds real paths webjs mcp # read-only MCP: routes, actions (RPC hashes), components, elision (what the browser drops, and why each shipped module ships), check, ui kit webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory PLUS the component-elision verdict, which warns only on an orphan (#1308); a warning when a route module writes a `` without `asset()`, #1095); non-zero exit on a hard fail OR on a check the app gated `error`. --json emits `{ results, summary }` (results is the DoctorResult[], each carrying a stable code + its effective severity; summary counts pass/warn/fail/off), plus a third `configErrors` key on the one path where a rejected `webjs.doctor` config stops any check running; --strict additionally fails on every REMAINING warning (#975). Per-check severity is CONFIG, not a flag: `webjs.doctor.gate` maps a code to `off` / `warn` / `error` so CI gates a chosen subset (#1257) webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258) diff --git a/website/app/docs/elision/page.ts b/website/app/docs/elision/page.ts index 81a62a238..16034b0da 100644 --- a/website/app/docs/elision/page.ts +++ b/website/app/docs/elision/page.ts @@ -73,7 +73,11 @@ Badge.register(TAG); // invisible Badge.register('my-badge');

    - A custom-element tag must be a literal string anyway, but the consequence here is specific: the scanner never sees that component, so it gets no elision verdict at all, nothing consults the analyser for it, and the override has nothing to attach to. The registration itself still runs if the module reaches the browser, so what you always lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades comes down to one thing: the importing module has to ship whole. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. Import-only is the ordinary case, so assume it does not upgrade. webjs dev warns about this shape, and webjs elision and webjs doctor report it as an orphan. + A custom-element tag must be a literal string anyway, but the consequence here is specific: the scanner never sees that component, so it gets no elision verdict at all, nothing consults the analyser for it, and the override has nothing to attach to. The registration itself still runs if the module reaches the browser, so what you always lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades comes down to one thing: the importing module has to ship whole. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. A page rendering a real component alongside the orphan is import-only unless it also does its own client work, so shipping whole is the narrower case: assume the element does not upgrade. +

    + +

    + webjs dev warns, and webjs elision and webjs doctor report it, as an orphan. That name covers two shapes and they fail differently. A computed tag is the case above. A class with no registration call at all is the plainer one, someone forgot to register it, and that element never upgrades under any circumstances. Both lose the verdict, the registry entry, and the preload hint.

    Inspecting the verdict

    From 748acd2d50fb170d1c8485f2e99a49fa97f46832 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 01:56:38 +0530 Subject: [PATCH 23/24] fix: the orphan scan accused sibling-registered classes findOrphanComponents computed declared-and-registered per FILE while extractComponents matches a registration in any file. A class declared in one module and registered by a sibling, which the scanner header calls equally supported, was reported as an orphan while registering fine, getting a tag, a verdict, a registry entry, a preload hint, and upgrading. Confirmed by building the case: it came back an orphan and also came back from scanComponents with its tag. This branch is what promotes an orphan to a doctor warning, so a false warning on a legitimate pattern is the same failure already fixed for docs code samples. Registration is now cross-referenced app-wide. The trade-off is recorded: matching is by class name, so two same-named classes where one is registered hides the other, which errs toward silence rather than a wrong accusation. Also drops two more over-broad universals: exactly-two-shapes is contradicted by a string-selector consumer, and the progressive-enhancement page still prescribed adding an @event for a computed registration tag, which this branch own test proves does nothing. --- .agents/skills/webjs/references/components.md | 5 ++- packages/server/index.d.ts | 6 ++- packages/server/src/component-scanner.js | 37 ++++++++++++++----- .../test/scanner/component-scanner.test.js | 24 ++++++++++++ website/app/docs/elision/page.ts | 5 ++- .../app/docs/progressive-enhancement/page.ts | 2 +- 6 files changed, 62 insertions(+), 17 deletions(-) diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index abca0d8f1..2d9457f54 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -268,14 +268,15 @@ A bare `async render()` (no other signal, light DOM) is elided too: the SSR'd da ### What `static interactive = true` does and does not rescue -The analyser reads source lexically, so exactly two real shapes escape it, and the override covers both: +The analyser reads source lexically, so a few real shapes escape it. The override covers them: - **An OBSERVER that computes the tag it waits for.** `customElements.whenDefined(TAG)` where `TAG` is a variable does not name a tag the analyser can resolve, so the observed component is elided, its `register` never runs, and the `await` never settles. Put `static interactive = true` on the OBSERVED component. - **A `:defined` rule in an external stylesheet.** `public/app.css` is not in the module graph, so a `my-badge:defined { … }` rule is invisible. Same fix, on the component the rule names. +- **A consumer that reaches the element through a string selector.** The analyser matches `whenDefined` / `:defined` / `instanceof`, so a `document.querySelector('my-wrapper')` consumer escapes all three. Same fix, on the component being reached. **It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The registration still runs if the module reaches the browser, so what you ALWAYS lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades depends on one thing: the importing module has to ship WHOLE. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. A page rendering a real component alongside the orphan is import-only unless it ALSO does its own client work, so shipping whole is the narrower case: assume the element does not upgrade. Always pass a literal: `Badge.register('my-badge')`. -`webjs dev` warns, and `webjs elision` / `webjs doctor` report it, as an **orphan**. That name covers TWO shapes and they fail differently, so read the warning carefully: a computed tag is the case above, while a class with NO registration call at all is the plainer one (someone forgot to register it), and that element never upgrades under any circumstances. Both lose the verdict, the registry entry, and the preload hint. +`webjs dev` warns, and `webjs elision` / `webjs doctor` report it, as an **orphan**. That name covers TWO shapes and they fail differently, so read the warning carefully: a computed tag is the case above, while a class with NO registration call anywhere in the app is the plainer one (someone forgot to register it), and that element never upgrades. The check is app-wide, so registering the class from a sibling module is fine and is not reported. Both lose the verdict, the registry entry, and the preload hint. ### Inspecting and proving the verdict diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts index 0d49b9476..eebb19842 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -480,8 +480,10 @@ export declare function primeComponentRegistry( export declare function extractComponents(src: string): Array<{ className: string; tag: string }>; /** * Find ORPHAN component classes: a `class X extends WebComponent` that its own - * file never registers with a LITERAL tag, either because there is no - * registration call or because the tag is computed. Neither is visible to the + * file declares and NOTHING in the app registers with a LITERAL tag, either + * because there is no registration call anywhere or because the tag is + * computed. The registration cross-reference is app-wide, so a class a sibling + * module registers is not an orphan. Neither is visible to the * scanner, so both always lose the elision verdict, the tag-to-module registry * entry, and the preload hint; assume the element does not upgrade either. * This is about REGISTRATION, never about import reachability. The full diff --git a/packages/server/src/component-scanner.js b/packages/server/src/component-scanner.js index 354a1ad7e..807f2643c 100644 --- a/packages/server/src/component-scanner.js +++ b/packages/server/src/component-scanner.js @@ -153,8 +153,10 @@ export async function primeComponentRegistry(appDir, components) { * TWO shapes land here and they fail differently, so any message about this * must cover both: * - * - No registration call at all (the forgot-to-register case). Nothing ever - * registers the tag, so the element NEVER upgrades. + * - No registration call ANYWHERE in the app (the forgot-to-register case). + * Nothing ever registers the tag, so the element NEVER upgrades. The + * cross-reference is app-wide precisely so a class registered by a sibling + * module is not accused of this. * - A registration whose tag is COMPUTED (`X.register(TAG)`). That call is * ordinary code, so it runs IF the module reaches the browser, which * requires the importing module to ship WHOLE. An inert, import-only, or @@ -185,6 +187,23 @@ export async function findOrphanComponents(appDir) { !/\.(test|spec)\.m?[jt]sx?$/.test(p) && !/\.server\.m?[jt]s$/.test(p); + // TWO passes, because registration is an APP-WIDE fact while the declaration + // is per-file. A class may legitimately be declared in one module and + // registered by a sibling (`customElements.define('my-badge', Badge)` in a + // separate file), which the scanner header calls equally supported and which + // `extractComponents` already picks up as a real component. Reporting it as + // an orphan is a FALSE positive, and a false warning on a legitimate pattern + // is exactly what makes an author stop reading the warnings. + // + // Trade-off, deliberate: the cross-reference is by class NAME, so two + // same-named classes in different files, one registered and one genuinely + // orphaned, hide the real orphan. That is rarer than the sibling-registration + // pattern and errs toward silence rather than toward a wrong accusation. + /** @type {Array<{ file: string, declared: Set }>} */ + const declaredPerFile = []; + /** @type {Set} every class name registered ANYWHERE in the app */ + const registeredAnywhere = new Set(); + for await (const file of walk(appDir, filter)) { let src; try { src = await readFile(file, 'utf8'); } catch { continue; } @@ -216,16 +235,14 @@ export async function findOrphanComponents(appDir) { const declared = new Set(); let m; while ((m = classRe.exec(redacted)) !== null) declared.add(m[1]); - if (declared.size === 0) continue; - - const registered = new Set(); - while ((m = registerRe.exec(redacted)) !== null) registered.add(m[1]); - while ((m = defineRe.exec(redacted)) !== null) registered.add(m[1]); + while ((m = registerRe.exec(redacted)) !== null) registeredAnywhere.add(m[1]); + while ((m = defineRe.exec(redacted)) !== null) registeredAnywhere.add(m[1]); + if (declared.size) declaredPerFile.push({ file, declared }); + } + for (const { file, declared } of declaredPerFile) { for (const cls of declared) { - if (!registered.has(cls)) { - orphans.push({ className: cls, file }); - } + if (!registeredAnywhere.has(cls)) orphans.push({ className: cls, file }); } } return orphans; diff --git a/packages/server/test/scanner/component-scanner.test.js b/packages/server/test/scanner/component-scanner.test.js index ed197a32a..ba519b2c7 100644 --- a/packages/server/test/scanner/component-scanner.test.js +++ b/packages/server/test/scanner/component-scanner.test.js @@ -147,6 +147,30 @@ test('findOrphanComponents: ignores files with no WebComponent subclass', async } }); +test('findOrphanComponents: a SIBLING-registered class is not an orphan (#1308)', async () => { + // Registration is an APP-WIDE fact; the declaration is per-file. A class + // declared in one module and registered by a sibling is a legitimate pattern + // (the native `customElements.define` form the scanner header calls equally + // supported), and `scanComponents` already reports it as a real component + // with a tag. Reporting it as an orphan too was a false accusation, and once + // an orphan became a `webjs doctor` warning that false warning is what makes + // an author stop reading them. + const dir = await scaffold({ + 'components/badge.ts': `export class Badge extends WebComponent {\n render() {}\n}\n`, + 'components/register.ts': `import { Badge } from './badge.ts';\ncustomElements.define('my-badge', Badge);\n`, + // A genuinely unregistered class in the same tree, so this cannot pass by + // the scan going blind. + 'components/forgotten.ts': `export class Forgotten extends WebComponent {\n render() {}\n}\n`, + }); + try { + const orphans = await findOrphanComponents(dir); + assert.deepEqual(orphans.map((o) => o.className).sort(), ['Forgotten'], + 'only the class nothing registers anywhere is an orphan'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test('findOrphanComponents: a class in a CODE SAMPLE is not an orphan (#1308)', async () => { // Every docs page writes `class X extends WebComponent` inside an `html` // template to SHOW the reader what a component looks like. Scanning raw diff --git a/website/app/docs/elision/page.ts b/website/app/docs/elision/page.ts index 16034b0da..6fd0b55cc 100644 --- a/website/app/docs/elision/page.ts +++ b/website/app/docs/elision/page.ts @@ -51,12 +51,13 @@ export default function Elision() {

    - static interactive = true is the explicit author override. It forces the module to ship when the component's interactivity is invisible to static analysis. There are exactly two such shapes: + static interactive = true is the explicit author override. It forces the module to ship when the component's interactivity is invisible to static analysis:

    • An observer that computes the tag it waits for. customElements.whenDefined(TAG) with a variable does not name a tag the analyser can resolve, so the observed component is elided, its registration never runs, and the await never settles. Put the override on the OBSERVED component.
    • A :defined rule in an external stylesheet. A public/app.css is not in the module graph, so my-badge:defined { ... } is invisible. Same fix, on the component the rule names.
    • +
    • A consumer that reaches the element through a string selector. The analyser matches whenDefined, :defined, and instanceof, so a document.querySelector('my-wrapper') consumer escapes all three. Same fix, on the component being reached.

    A computed registration tag is a different problem

    @@ -77,7 +78,7 @@ Badge.register('my-badge');

    - webjs dev warns, and webjs elision and webjs doctor report it, as an orphan. That name covers two shapes and they fail differently. A computed tag is the case above. A class with no registration call at all is the plainer one, someone forgot to register it, and that element never upgrades under any circumstances. Both lose the verdict, the registry entry, and the preload hint. + webjs dev warns, and webjs elision and webjs doctor report it, as an orphan. That name covers two shapes and they fail differently. A computed tag is the case above. A class with no registration call anywhere in the app is the plainer one, someone forgot to register it, and that element never upgrades. The check is app-wide, so registering the class from a sibling module is fine and is not reported. Both lose the verdict, the registry entry, and the preload hint.

    Inspecting the verdict

    diff --git a/website/app/docs/progressive-enhancement/page.ts b/website/app/docs/progressive-enhancement/page.ts index 2e115c25f..ef923652f 100644 --- a/website/app/docs/progressive-enhancement/page.ts +++ b/website/app/docs/progressive-enhancement/page.ts @@ -66,7 +66,7 @@ export default function ProgressiveEnhancement() {

    - One boundary to know. Eliding a module means its customElements.define never runs in the browser, so the tag stays an un-upgraded element. That is invisible for a tag that exists only as server-rendered markup, but it would matter if shipping client code observes the registration. The framework detects the statically visible forms of that observation, a literal customElements.whenDefined('the-tag'), a CSS the-tag:defined rule, or an instanceof TheClass check anywhere in your code, and automatically ships the observed component instead of eliding it. You only need to act in the cases static analysis cannot see: a tag name built from a dynamic / interpolated string, or a :defined rule in an external stylesheet outside the module graph. There, give the component an interactivity signal (an @event, a non-state reactive property, or a lifecycle hook) so it ships. This is rare in idiomatic webjs, where display-only elements are read as plain server-rendered markup. + One boundary to know. Eliding a module means its customElements.define never runs in the browser, so the tag stays an un-upgraded element. That is invisible for a tag that exists only as server-rendered markup, but it would matter if shipping client code observes the registration. The framework detects the statically visible forms of that observation, a literal customElements.whenDefined('the-tag'), a CSS the-tag:defined rule, or an instanceof TheClass check anywhere in your code, and automatically ships the observed component instead of eliding it. You only need to act where static analysis cannot see the OBSERVATION: an observer that computes the tag it waits for, a :defined rule in an external stylesheet outside the module graph, or a consumer that reaches the element through a string selector. There, put static interactive = true on the observed component. A component whose OWN registration tag is computed is a different problem and that override does not help, because the scanner never sees the component at all; give it a literal tag. See Display-Only Elision. This is rare in idiomatic webjs, where display-only elements are read as plain server-rendered markup.

    From f35c502be96a273120a4b9277dd8aa1265e93e14 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 02:51:27 +0530 Subject: [PATCH 24/24] test: the third residual had no case, and two lists still said two residual-contract.test.js exists because the escape-hatch promise was prose nothing asserted, so adding a third documented residual last commit without a case re-opened the gap the file was written to close. Verified the behaviour first, a querySelectorAll consumer leaves the badge elided and static interactive = true rescues it, then wrote the pair. All three residuals now have a case and a rescue, and the header no longer says two. The findOrphanComponents doc block contradicted itself six lines apart: the summary said whose own file never registers it while the bullet below, from the same commit, said anywhere in the app. Both AGENTS.md surfaces still enumerated exactly two residuals, one with namely which makes it closed. Sweeping found two more the review had not flagged, the data-fetching page and the component-elision field registry, so all seven surfaces now name all three. --- AGENTS.md | 2 +- packages/server/AGENTS.md | 3 +- packages/server/src/component-elision.js | 3 +- packages/server/src/component-scanner.js | 7 ++-- .../test/elision/residual-contract.test.js | 41 +++++++++++++++++-- website/app/docs/data-fetching/page.ts | 2 +- 6 files changed, 48 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 64dc5222f..0280e65a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -268,7 +268,7 @@ MyThing.register('my-thing'); **Lifecycle (lit-aligned), in order:** `shouldUpdate`, `willUpdate`, controllers' `hostUpdate()`, `update` (calls `render()` + commits), controllers' `hostUpdated()`, `firstUpdated`, `updated`, `updateComplete`, each receiving a `changedProperties` Map. **SSR runs only the constructor, attribute application, the pre-render hooks (`willUpdate` / `hostUpdate`), `reflect: true` reflection, and `render()`; it does NOT call `connectedCallback`, `firstUpdated`, `updated`, or any browser-only hook.** So defaults for first paint go in the constructor; browser-only data (localStorage, viewport, `navigator.*`) goes in `connectedCallback` writing a signal; server-known data arrives via the page function. Never ship a placeholder first paint that fetches in `connectedCallback`. A browser-only global in the constructor/`render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed). -**Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`

    ${u.name}

    \`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). A **bare** async-render component (an `async render()` with no other client signal, light DOM) is **elided** like any display-only component (#474): its SSR'd HTML is the complete output, so the framework drops the module AND the redundant on-hydration re-fetch. It SHIPS only when it also carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, the dynamic slot READ surface (`slotchange` / `assignedNodes` / `assignedElements` / `assignedSlot`; merely RENDERING a `` does not ship, since the SSR output carries the placed children), `static shadow = true`, `static interactive = true`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship: `static shadow = true` (Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run `attachShadow`) and `static interactive = true` (the explicit author override that forces a ship when the analyser cannot see a component's interactivity statically, namely an OBSERVER that computes the tag it waits for, or a `:defined` rule in an external stylesheet outside the module graph; a component's OWN registration tag must be a literal per invariant 3, and a computed one is invisible to the scanner, so it gets no verdict and the override cannot rescue it). **For SLOW data where blocking the first byte hurts, wrap the region in `` to STREAM it** (the fallback flushes on the first byte, the data streams in; multiple boundaries fetch concurrently). This is the only way to show a first-paint fallback, a deliberate choice for slow regions, and it streams progressively on soft navigation too. A throwing component inside a boundary is isolated (renders its error state, siblings stream). **The on-hydration re-fetch is itself eliminated by SSR action seeding (#472):** each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page, and the generated RPC stub reads that seed on its first client call, so a shipping async component does NOT re-issue the RPC on hydration (a later refetch / arg-change still goes to the network). Keyed by action-hash + fn + serialized args, consume-once, fail-open (a miss degrades to a normal RPC, never wrong data). Captured via a transparent server-side `'use server'` facade (no source transform, no build step; the browser source tab and on-disk files are unchanged), default on, opt out with `"webjs": { "seed": false }` or `WEBJS_SEED=0`. +**Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`

    ${u.name}

    \`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). A **bare** async-render component (an `async render()` with no other client signal, light DOM) is **elided** like any display-only component (#474): its SSR'd HTML is the complete output, so the framework drops the module AND the redundant on-hydration re-fetch. It SHIPS only when it also carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, the dynamic slot READ surface (`slotchange` / `assignedNodes` / `assignedElements` / `assignedSlot`; merely RENDERING a `` does not ship, since the SSR output carries the placed children), `static shadow = true`, `static interactive = true`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship: `static shadow = true` (Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run `attachShadow`) and `static interactive = true` (the explicit author override that forces a ship when the analyser cannot see a component's interactivity statically: an OBSERVER that computes the tag it waits for, a `:defined` rule in an external stylesheet outside the module graph, or a consumer reaching the element through a string selector; a component's OWN registration tag must be a literal per invariant 3, and a computed one is invisible to the scanner, so it gets no verdict and the override cannot rescue it). **For SLOW data where blocking the first byte hurts, wrap the region in `` to STREAM it** (the fallback flushes on the first byte, the data streams in; multiple boundaries fetch concurrently). This is the only way to show a first-paint fallback, a deliberate choice for slow regions, and it streams progressively on soft navigation too. A throwing component inside a boundary is isolated (renders its error state, siblings stream). **The on-hydration re-fetch is itself eliminated by SSR action seeding (#472):** each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page, and the generated RPC stub reads that seed on its first client call, so a shipping async component does NOT re-issue the RPC on hydration (a later refetch / arg-change still goes to the network). Keyed by action-hash + fn + serialized args, consume-once, fail-open (a miss degrades to a normal RPC, never wrong data). Captured via a transparent server-side `'use server'` facade (no source transform, no build step; the browser source tab and on-disk files are unchanged), default on, opt out with `"webjs": { "seed": false }` or `WEBJS_SEED=0`. **Light DOM (default) vs Shadow DOM.** Light DOM applies global CSS and Tailwind directly (default; for Tailwind/global CSS + simple composition). Shadow DOM (`static shadow = true`) is for `static styles` scoped CSS and third-party isolation; `` works in either. **Light-DOM slots ARE the native DOM slot API (#1021, full shadow parity):** `` works identically in light and shadow DOM, so post-mount native writes are LIVE (`appendChild`, `insertBefore`, `removeChild`, `el.remove()`, `innerHTML`, `el.slot=` flips, `HTMLSlotElement.assign()`) and the reads (`assignedNodes` / `assignedElements` / `{flatten}` / `assignedSlot` / `slotchange`, with native async-coalesced timing) match. Flip `static shadow` and nothing else changes; there is NO WebJs-specific slot API. The one write that does NOT flip is `assign()`: the light-DOM version is an extension (element-bound overlay alongside name matching), while native shadow `assign()` needs `slotAssignment: 'manual'`, which WebJs does not set, so avoid `assign()` in a component meant to flip modes. A FORWARDED slot (a template forwarding `` into a nested component) projects its content on the client and through hydration (#1023): the renderer stamps each slot with its template owner (`SLOT_OWNER`, carried across SSR as `data-wj-slot-owner`) so it routes to the OUTER host that rendered it, and a layout's `${children}` inside a slotted shell keeps its named slots in sync across a soft-nav swap (#1024, the swap resyncs every own slot of the enclosing host). Four inherent gaps, all a consequence of light DOM having no shadow boundary: structural host reads (`host.children` / the `innerHTML` getter show the rendered template, not the authored children, so read slotted content with `assignedNodes()`), `assignedChild.parentNode` is the ``, `::slotted()` CSS (style slotted content with normal selectors / Tailwind), and initial-projection lifecycle timing (`firstUpdated` sees the `` element with EMPTY `assignedNodes()`, the projection lands one microtask later; read assigned content from `slotchange` or after a microtask). Live writes need the component's JS on the page, so a display-only slotted wrapper elides (its writes are inert like anything elided; force a ship with `static interactive = true` for an imperative consumer the analyser cannot see). A light-DOM component authoring custom CSS MUST prefix every class selector with its tag name (invariant 7); prefer Tailwind. **Light-DOM component hosts default to `display: block`**: a custom element is `display: inline` in plain CSS, so the framework marks every LIGHT-DOM host `data-wj-host` and injects one head rule in a low-priority cascade layer, `@layer webjs-host { :where([data-wj-host]) { display: block } }`, so a container component does not collapse; the layer keeps it overridable by any author style INCLUDING Tailwind utilities (`class="flex"`/`grid`/`hidden` win, because their layer is ordered after `webjs-host`), a `[hidden]` carve-out keeps `?hidden` working, and an inline light component opts out with `my-tag { display: inline }`. **Shadow-DOM hosts are NOT marked** (a document rule would override the shadow tree's `:host`), so a shadow component sets its host display via `:host { display: block }` in `static styles` (respected because the host is unmarked; set it for a shadow block container). See the even-grid / no-reflow layout recipes in `references/styling.md`. **Never interpolate into a component's `\``): the server emits it but the client drops the raw-text hole, so it paints at SSR then wipes to empty on hydrate. Use `static styles` or Tailwind instead (flagged by `no-interpolation-in-raw-text-element`). A page/layout, which never hydrates, may interpolate a `css` result into `