Skip to content

refactor(framework): overhaul WebJs framework architecture following SOLID, KISS, and DRY principles #1365

Description

@vivek7405

Plan written against HEAD 509d8809. Every LOC figure, line anchor, export count, and byte size below was measured at that commit. Re-derive anchors before editing.

Problem

Ten source files across @webjsdev/core, @webjsdev/server, and @webjsdev/cli have grown into single-file monoliths. Every figure below was re-measured with wc -l at HEAD 509d8809 and every one in the original body was correct.

File LOC What it holds
packages/core/src/router-client.js 5400 Client routing, prefetch, DOM diffing, stream parsing, forms, scroll restoration
packages/server/src/dev.js 3070 Boot config readers, request handler, listener shell, static serving, watcher, HMR client, dev error overlay
packages/core/src/render-client.js 2939 Template compiler, expression binder, part appliers, directive runtime
packages/server/src/vendor.js 2430 Bare specifier scan, JSPM client, pin file, download cache, semver, coherence
packages/core/src/render-server.js 2427 SSR string render, DSD injection, SSR slot projection, entity decode, streaming render
packages/server/src/ssr.js 2394 Layout chain, head builder, preload emission, metadata, streaming response
packages/core/src/slot.js 2282 Light and shadow slot projection, polyfills, sensors, native write interception
packages/core/src/component.js 1896 WebComponent base class, lifecycle, reflection, server element shim
packages/server/src/check.js 1805 The webjs check rule engine and all 18 rules inline in one function
packages/cli/lib/doctor.js 1686 13 project-health probes, the severity policy, the runner

Total 26,329 lines. Runtime export counts, measured by importing each module at HEAD, are the contract any split has to preserve.

Module Named exports Of which _ test seams
router-client.js 68 63
slot.js 31 0
vendor.js 25 0
ssr.js 18 9
dev.js 16 0
doctor.js 9 0
render-server.js 2 0
component.js 2 0
check.js 2 0
render-client.js 1 0

The largest hidden cost is the relative-path import surface. Counting from '<path>/<file>.js' sites across packages/*/test, test/, packages/*/src, and the in-repo apps gives 214 import sites for the ten files. Per file, counting distinct TEST files only.

Module Test files importing it by relative path
dev.js 62
component.js 36
router-client.js 32
render-client.js 22
render-server.js 12
check.js 11
vendor.js 6
slot.js 5
ssr.js 3
doctor.js 2

Many of those import INTERNAL symbols, not the public API. The full internal-symbol contract, extracted mechanically from the import clauses, is recorded per phase below.

What was stale or wrong in the previous body

  1. The 800-LOC acceptance criterion contradicted itself. It said "no single source file in the core packages exceeds 800 lines" while four files outside the list of ten already break it at HEAD, and the issue proposed no work on them. Verified counts: packages/core/src/html-entities.js 2179, packages/cli/bin/webjs.js 1665, packages/cli/lib/create.js 1636, packages/editors/intellisense/src/index.js 1493, packages/server/src/component-elision.js 1374, packages/core/src/form-action.js 1258. The criterion is restated in a mechanically checkable form under Design.
  2. The comment proposing a rename to src/client-router.js is superseded. It contradicts packages/core/package.json, whose ./client-router subpath maps "source": "./src/router-client.js" and "types": "./src/router-client.d.ts". Reasoning under Design.
  3. The prior-art table overstated three of its five claims. Corrections under Design, each cited by the file actually read in the clone.
  4. src/dev/ is a misleading directory name for what dev.js holds, because startServer (line 1729) is the PRODUCTION entry too and the 16 read*FromApp config readers (lines 181-535) run in both modes. The name is kept anyway for a stated mechanical reason, with the caveat recorded.
  5. The issue's proposed module lists were guesses. Every one is replaced below with a list derived from the functions actually in each file, with current line ranges.

Design / approach

D1. This ships as nine independently mergeable PRs, all closing this issue

A ten-file, 26,000-line refactor in one PR is unreviewable and unbisectable. If any one of the ten splits regresses SSR bytes or drops an export, a single PR gives no way to bisect which. One subsystem per PR is the norm for this shape and is what every cited prior art did incrementally.

Every PR body carries Closes #1365, so the last one to merge closes it. Do NOT file follow-up issues for anything found on the way. Report it in the PR body and leave it.

Order, by ascending blast radius, with each phase establishing mechanics the next one reuses.

# Subsystem Package Why here
1 lib/doctor.js cli 2 test import sites, 13 already-independent probe functions, never in a browser bundle, never on a request path. The cheapest place to establish the barrel pattern, the export-count floor, and the cycle check
2 src/vendor.js server 6 test import sites, clean function seams, server-only, no request-path coupling. Establishes the pattern on a 2400-line file
3 src/check.js server 11 test import sites but only 2 exports. The one phase that is a real restructure rather than a move, so it goes before anything with SSR-byte or bundle risk
4 src/router-client.js core Self-contained (nothing in packages/core/src imports it except the two index entries), but 32 test files, 63 _ seams, and the first phase with browser-bundle exposure
5 src/render-client.js core 22 test files, 1 export, and the hardest cycle problem in the repo. Goes after the router so the cycle discipline is already proven
6 src/slot.js core 5 test files, 31 exports, imported by render-client.js, component.js, and router-client.js. Goes after both consumers are split so its import surface is already stable
7 src/component.js core 36 test files and one 1334-line class, which cannot be split by moving functions. Needs the method-body extraction described in P7
8 src/render-server.js + src/ssr.js core + server Coupled through the SSR byte contract and the streaming protocol. Move together
9 src/dev.js server Largest blast radius by far (62 test files), the boot path of every app, runtime-sensitive on Bun, and the only file whose main function must be decomposed rather than moved. Last

Phases 8 and 9 are the only pair that must move together, and they are 8. packages/server/src/ssr.js:3 imports renderToString from the @webjsdev/core package specifier, not from a relative path, so the two are not coupled at module level. They are coupled through the bytes: ssr.js drives renderToString / renderToStream and then post-processes the HTML those produce (DSD injection, <webjs-suspense> processing, head hoisting, boundary markers). One PR gives one SSR-bytes verification pass over both halves instead of two passes that each leave the other half unproven.

D2. Naming principle, settled once for all ten

The monolith's path stays and becomes the barrel. New modules live in a sibling directory named after the barrel's basename. So packages/core/src/router-client.js remains, and its parts land in packages/core/src/router-client/.

Alternative REJECTED: rename to the public-export name (src/client-router.js), which is what the single existing comment on this issue proposed. Rejected on measured cost.

  • packages/core/package.json maps ./client-router to "source": "./src/router-client.js" and "types": "./src/router-client.d.ts". A rename changes the published manifest for zero behaviour.
  • There are 26 hand-written .d.ts overlays in packages/core/src/, and BOTH type guards derive the runtime sibling from the overlay path by the sibling rule (foo.d.ts overlays foo.js), stated in the header of test/types/dts-export-coverage.test.mjs and again in test/types/dts-no-phantom-exports.test.mjs. Renaming the .js without the .d.ts breaks that derivation silently.
  • packages/core/test/routing/client-router-export-parity.test.js imports '../../src/router-client.js' AND reads src/router-client.d.ts off disk by name.
  • website/app/docs/no-build/page.ts:51 prints the literal string "@webjsdev/core/client-router": "/__webjs/core/src/router-client.js" as a docs example of the emitted importmap.
  • packages/core/AGENTS.md names src/router-client.js at lines 84 and 274, and framework-dev.md:176 names it as the home of the 63 _ seams.
  • 214 in-repo import sites across the ten files, 32 of them for this file alone.

Keeping the path makes every phase's diff purely additive at the import layer. No existing import, .d.ts, manifest entry, docs sample, or type guard has to change.

The barrels are not compatibility shims. WebJs has no users, so a shim would be theatre. Each barrel is justified by the import sites it keeps working inside this repo (62, 36, 32, 22, 12, 11, 6, 5, 3, 2 test files respectively) and by the published exports manifest, which names four of the ten paths directly. Beyond the barrel, ship NO compatibility layer: no deprecated aliases, no old-path stubs, no re-export of a name the split renamed.

packages/core/package.json files needs no change. It lists "src" wholesale, so a new subdirectory publishes automatically. Same for packages/server ("src") and packages/cli ("lib"). Verified against all three manifests.

One caveat, recorded rather than fixed. packages/server/src/dev/ reads as dev-only and is not, since startServer and the 16 boot config readers live there and run in production. Renaming to src/server/ would cost 62 test import sites and a package.json-adjacent rename for zero behaviour, so the mechanical rule wins. Say so in a banner comment at the top of the barrel.

D3. The 800-LOC criterion, settled

Scope. The criterion binds ONLY the ten files this issue splits and the modules produced from them. No other file in the repo is bound by it, and none of them is in scope.

Rule. A produced module targets 800 lines and MUST NOT exceed 1000. A barrel is exempt (its length is a function of its export count, and router-client.js alone re-exports 68 names). A module that would need an artificial seam to get under 1000 is exempt only if it is named in this plan with its measured size and its reason. Exactly one such exemption is anticipated, packages/core/src/component/base.js, and P7 states the fallback that avoids it.

Mechanically checkable form, run once at the end of the sequence.

find packages/core/src/router-client packages/core/src/render-client packages/core/src/render-server \
     packages/core/src/component packages/core/src/slot packages/server/src/dev \
     packages/server/src/vendor packages/server/src/ssr packages/server/src/check packages/cli/lib/doctor \
     -name '*.js' -exec wc -l {} + | awk '$1 > 1000 && $2 != "total"'

Empty output passes.

Why not a hard 800, and why no CI guard. Every framework this issue cites as its model exceeds 800 in its own core modules, measured in the local clones: lit/packages/reactive-element/src/reactive-element.ts is 1754, lit/packages/lit-html/src/lit-html.ts is 2303, vite/packages/vite/src/node/server/index.ts is 1447, vite/packages/vite/src/node/optimizer/index.ts is 1487. Those projects draw their seams by responsibility, not by line count. A LOC gate in CI is a proxy metric that fights cohesion: it would red on packages/core/src/html-entities.js forever, and it would have to encode an exemption list that rots. Do not add one. The criterion is a one-time acceptance check, verified by the command above.

Data tables are exempt by nature. packages/core/src/html-entities.js (2179 lines) is the WHATWG named-character-reference table plus the legacy semicolon-less names. It has no seams, splitting it changes no byte, and it is not in scope.

D4. Cycle and TDZ discipline

Splitting a file whose functions call each other in both directions produces ESM cycles. Node tolerates a cycle, but reading a const or class binding during the cycle's evaluation phase throws a TDZ ReferenceError at module load, and in the minified browser bundle that surfaces as a blank page rather than a test failure.

Baseline, measured. There are currently zero import cycles across all 119 modules in packages/core/src, packages/server/src, and packages/cli/lib. So the invariant is "still zero", which is checkable and currently true.

Discipline.

  1. Layer the modules and never import upward. Leaf layer holds constants and module state with no sibling imports. Middle layer holds pure helpers. Top layer holds orchestration. The barrel sits above everything.
  2. Where a genuine back edge exists, resolve it by late binding through a function call at call time, never a top-level read. A const X = other.thing or a class A extends other.B at module scope is the shape that throws.
  3. Where mutual recursion is inherent (the render-client.js apply and instance group is the one real case), introduce an explicit dispatch registry module that both sides import, with the implementations registered at module load. P5 specifies it.

Per-phase check. Write this to your scratchpad and run it after each phase. It takes no dependency and writes nothing into the repo.

// find-cycles.mjs  (run: node find-cycles.mjs packages/core/src packages/server/src packages/cli/lib)
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
const roots = process.argv.slice(2).map((d) => resolve(d));
const files = [];
function walk(d) {
  for (const e of readdirSync(d, { withFileTypes: true })) {
    const p = join(d, e.name);
    if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== 'dist') walk(p); }
    else if (/\.m?js$/.test(e.name)) files.push(p);
  }
}
for (const r of roots) walk(r);
const RE = /(?:^|\n)\s*(?:import|export)[\s\S]{0,400}?from\s*['"](\.[^'"]+)['"]|(?:^|\n)\s*import\s*['"](\.[^'"]+)['"]/g;
const graph = new Map();
for (const f of files) {
  const deps = new Set();
  for (const m of readFileSync(f, 'utf8').matchAll(RE)) {
    const spec = m[1] || m[2];
    if (!spec) continue;
    const abs = resolve(dirname(f), spec);
    try { if (statSync(abs).isFile()) deps.add(abs); } catch {}
  }
  graph.set(f, [...deps]);
}
const color = new Map(files.map((f) => [f, 0]));
const stack = [], cycles = [];
function dfs(n) {
  color.set(n, 1); stack.push(n);
  for (const d of graph.get(n) || []) {
    if (!color.has(d)) continue;
    if (color.get(d) === 1) cycles.push([...stack.slice(stack.indexOf(d)), d]);
    else if (color.get(d) === 0) dfs(d);
  }
  stack.pop(); color.set(n, 2);
}
for (const f of files) if (color.get(f) === 0) dfs(f);
const rel = (p) => p.replace(process.cwd() + '/', '');
if (!cycles.length) { console.log(`no import cycles across ${files.length} modules`); process.exit(0); }
console.log(`${cycles.length} import cycle(s):`);
for (const c of cycles) console.log('  ' + c.map(rel).join(' -> '));
process.exit(1);

Load smoke, per phase. Both core browser entries load cleanly under Node today (verified), so a TDZ throw introduced by a split shows up immediately.

node --input-type=module -e "await import('./packages/core/index-browser.js'); console.log('ok')"
node --input-type=module -e "await import('./packages/core/index.js'); console.log('ok')"

The MINIFIED-bundle half of the risk is covered by npm run test:browser, which boots the built bundle in real Chromium. That is why the browser suite is mandatory on every core phase even though the change is behaviour-preserving.

D5. The published dist/ bundle is a hard gate

scripts/build-framework-dist.js bundles four entries (index.js, index-browser.js, src/lazy-loader.js, src/testing.js) with esbuild at bundle: true, splitting: false, treeShaking: true, minify: true, plus its own sanity check that every entry produced its expected output filename.

Three consequences the implementer must encode.

(a) e2e and Bun tests resolve the BUILT bundle. A src-only edit is invisible to them until dist is rebuilt, so a counterfactual passes vacuously. Every core phase rebuilds first.

node scripts/build-framework-dist.js

(b) Bundle size is a gate with a stated tolerance. Record webjs-core-browser.js byte size before and after each core phase.

stat -c%s packages/core/dist/webjs-core-browser.js packages/core/dist/webjs-core.js

Baseline at HEAD 509d8809 is 137,038 bytes for webjs-core-browser.js and 189,088 for webjs-core.js.

Tolerance is +1.0% per core phase and +2.0% cumulative across phases 4 through 8. Reasoning: a pure module split should be byte-neutral after bundling and minification, since the statements are identical and only their file boundaries moved. The two legitimate sources of drift are esbuild disambiguating an internal identifier that now collides across modules, and code that esbuild previously dropped inside one module but can no longer drop once it crosses a module boundary as an export. The second is exactly the regression worth catching, so the tolerance has to be tight enough to surface it. One percent of 137 KB is about 1.4 KB, roughly one medium function, which is tight enough to catch a defeated tree-shake and loose enough not to red on identifier renaming. A phase over tolerance must explain the delta in its PR body before merging.

(c) The build script's own sanity check must still pass. It throws when an entry name goes missing. Since no entry path changes in this plan, a failure here means a barrel stopped resolving.

D6. Dev-time serving needs no change (investigated, evidence recorded)

In dev with no built dist, the browser fetches core source files individually rather than the bundle. Do not go hunting; this was resolved.

  • packages/server/src/importmap.js maps the prefix '@webjsdev/core/' to '/__webjs/core/src/' (lines 306 and 397). It is a PREFIX mapping, not a file enumeration.
  • packages/server/src/dev.js:2011 serves /__webjs/core/* by resolving the remainder against the resolved core directory behind a trailing-separator traversal guard. A nested path such as /__webjs/core/src/router-client/state.js is served with no code change.
  • Relative imports inside the split modules resolve against the served URL, so no importmap entry, no scope, and no module-graph change is needed.
  • Exactly ONE modulepreload hint is emitted for core (packages/server/src/ssr.js:1718-1729, the bare @webjsdev/core importmap target), not one per file, so nothing enumerates core's file list.

The one real dev-only cost. Splitting a core file into N modules adds N requests at one extra graph depth on a dev page load, because ES module resolution is a waterfall by depth. This is localhost, dev-only, and disappears in production, where dist mode serves one bundle. It is accepted, not mitigated. Do not add a dev-mode bundler.

D7. Elision is both a risk surface and the best verification tool

packages/server/src/component-elision.js and packages/server/src/js-scan.js decide what ships to the browser. Checked, and the finding is narrow: neither matches on any core source filename. The single occurrence of component.js in component-elision.js is at line 35 inside a prose doc comment, and js-scan.js contains no reference to a core src filename at all. The analysers work on the APP's module graph and on @webjsdev/core as a package specifier, which no phase changes.

Elision is therefore used here as the verification tool it is. npx webjs elision --verify renders every static page route with elision on and off and diffs the observable SSR bytes. Run it in all three in-repo apps on every phase that touches SSR, rendering, or the client bundle (phases 4 through 9).

( cd gallery        && npx webjs elision --verify )
( cd examples/blog  && npx webjs elision --verify )
( cd website        && npx webjs elision --verify )

What it does NOT cover. It proves the bytes you SERVE did not change. It says nothing about post-hydration behaviour, because a wrongly dropped module is a dead click, not different bytes. That gap is why npm run test:browser and the e2e suite still have to run on every core phase.

D8. Prior art, verified against the clones and corrected

Clones live at ~/Documents/Projects/frameworks/. Every claim below was read, not assumed.

Claim in the old table Verdict What the clone actually contains
Turbo turbo/src/core/drive/ splits into visit.js, navigator.js, page_renderer.js, snapshot_cache.js, form_submission.js, head_snapshot.js Confirmed, and understated All six exist. The directory also holds prefetch_cache.js, preloader.js, view_transitioner.js, progress_bar.js, history.js, page_view.js, page_snapshot.js, error_renderer.js, morphing_page_renderer.js, limited_set.js, which map one-to-one onto WebJs router concerns the old table omitted (prefetch, view transitions, nav progress, DOM morphing). Largest module is visit.js at 419 lines; the median is under 150. This is the closest match to the P4 target
Next router-reducer/ splits into navigate-reducer.ts, restore-reducer.ts, server-action-reducer.ts Confirmed, path corrected The clone is next.js/, not next/, and the three files live under router-reducer/reducers/, alongside refresh-reducer.ts, server-patch-reducer.ts, hmr-refresh-reducer.ts, find-head-in-cache.ts, committed-state.ts. navigate-reducer.ts is 56 lines and router-reducer.ts is 69. The seam is a pure state-transition dispatch, which WebJs has no equivalent of (its router mutates the DOM directly), so this informs the state.js and navigator.js seam in P4 and nothing else
Lit decouples reactive-element (lifecycle, props, controllers) from lit-html (templates, parts) and directives/ Confirmed at PACKAGE level, and it contradicts the 800-line criterion lit/packages/reactive-element/src/ holds reactive-element.ts (1754 lines), reactive-controller.ts, css-tag.ts, decorators/. lit/packages/lit-html/src/ holds lit-html.ts (2303 lines), directive.ts, async-directive.ts, directive-helpers.ts, directives/. Lit draws the seam by RESPONSIBILITY across packages and leaves each core module large. This is the direct evidence behind D3
Vite server/ is modular (index.ts, hmr.ts, ws.ts, moduleGraph.ts, transformRequest.ts, send.ts, middlewares/) Confirmed All present, plus environments/, pluginContainer.ts, warmup.ts, sourcemap.ts. index.ts is 1447 lines and hmr.ts 1191, so again the orchestration entry stays large while the concerns split out. That is precisely the shape P9 targets
Vite optimizer decouples "scan, resolution, import map generation, and cache storage" Two of four wrong vite/packages/vite/src/node/optimizer/ holds index.ts (1487), optimizer.ts (808), scan.ts (797), resolve.ts, pluginConverter.ts, rolldownDepPlugin.ts. Scan and resolution are separate as claimed. There is no importmap generation in Vite's optimizer at all (Vite rewrites specifiers at transform time), and cache storage is not its own module, it lives inside index.ts and optimizer.ts. The corrected lesson for P2 is narrower: split the SCANNER from the RESOLVER, and expect the orchestration entry to stay the largest module
Remix @remix-run/server-runtime separates "layout matching, boundary stream construction, and head metadata extraction" One of three wrong remix-v2/packages/remix-server-runtime/ holds routeMatching.ts (29 lines), routeModules.ts (268), links.ts (192), entry.ts (58), markup.ts (19), serverHandoff.ts (30), with server.ts at 813 as the orchestrator. Matching and head metadata are separate as claimed. Boundary stream construction is not in that package, it lives in remix-v2/packages/remix-react/components.tsx (1356 lines) and server.tsx. The corrected lesson for P8 is that tiny single-purpose modules (routeMatching, markup, serverHandoff) around a large orchestrator is the achievable shape, not an evenly-sized fan

D9. The export contract, and the one new test

The failure mode this refactor introduces is a silently dropped export. Three guards already exist and cover most of it.

  • test/types/dts-no-phantom-exports.test.mjs proves every value an overlay declares EXISTS at runtime, per published exports subpath. A dropped PUBLIC export fails here by name.
  • test/types/dts-export-coverage.test.mjs proves the reverse per subpath.
  • packages/core/test/routing/client-router-export-parity.test.js derives the public router surface from src/router-client.d.ts and asserts it on the module, on index.js, and on index-browser.js.
  • Every internal _ seam is guarded implicitly, because the test importing it fails at LINK time with "does not provide an export named" when it disappears.

What none of them catches is a barrel that quietly carries FEWER seams than before while every currently-written test still happens to pass. Add one test, test/architecture/barrel-surface.test.mjs, holding a per-barrel export-COUNT floor. A count floor is not a name list, so it does not rot as exports are added, and it is the same anti-vacuum device dts-export-coverage.test.mjs already uses for its minNames floors. Floors are the counts measured at HEAD.

Barrel Floor
packages/core/src/router-client.js 68
packages/core/src/slot.js 31
packages/server/src/vendor.js 25
packages/server/src/ssr.js 18
packages/server/src/dev.js 16
packages/cli/lib/doctor.js 9
packages/core/src/render-server.js 2
packages/core/src/component.js 2
packages/server/src/check.js 2
packages/core/src/render-client.js 1

The test is added in phase 1 with all ten entries live from the start, since all ten modules exist at HEAD and the floors hold before any split. That way every later phase inherits the guard rather than adding it.

Per-phase mechanical proof, in addition to the test. Before opening each PR, diff the barrel's export list against the pre-split module's and paste the (empty) diff into the PR body.

git show origin/main:packages/core/src/router-client.js > /tmp/before.js   # adjust path per phase
node --input-type=module -e "
  const a = await import('/tmp/before.js');
  const b = await import('./packages/core/src/router-client.js');
  const A = Object.keys(a).sort(), B = Object.keys(b).sort();
  const missing = A.filter(k => !B.includes(k)), added = B.filter(k => !A.includes(k));
  console.log(JSON.stringify({ missing, added }));
"

missing must be empty. added must be empty too, because a behaviour-preserving split adds no public surface.

D10. Two enforcement hooks need edits, one does not

.claude/hooks/require-bun-parity-with-runtime-src.sh decides that a staged path is runtime-sensitive by matching this regex against the path.

serialize|/json\.js|file-storage|listener|ts-strip|action|render-server|/ssr\.js|conditional-get|websocket|node-version|csrf|/auth\.js|/session\.js|/cors\.js|crypto|compression|body-limit|/dev\.js|stream

packages/server/src/dev/app-config.js matches none of those alternatives, and neither does packages/server/src/ssr/head.js. Splitting either file would silently disable the Bun parity gate for the resulting modules. Fix it in the phase that causes it, by widening the two exact-file alternatives so they also match a directory.

  • In phase 8, change /ssr\.js to /ssr[./].
  • In phase 9, change /dev\.js to /dev[./].

Neither widening captures an unrelated sibling (dev-error.js has a - after dev, not a . or /). render-server already matches without anchors, so packages/core/src/render-server/dsd.js stays covered with no edit.

.claude/hooks/require-docs-with-src.sh matches ^packages/([^/]+/src|editors/[^/]+/src|cli/lib)/, which already matches every nested path. No edit.

.claude/hooks/require-tests-with-src.sh is satisfied by every phase, since each one touches test files.


Implementation plan

Before every phase, without exception.

git fetch origin
git worktree add -b refactor/1365-<slug> ../webjs-1365-<slug> origin/main
cd ../webjs-1365-<slug>
npm run worktree:link

The line anchors in this plan are dated to HEAD 509d8809. Re-derive every range with grep -n in your worktree before editing. A phase that lands weeks after this was written will find them moved.

Push the branch and open a DRAFT PR immediately, with Closes #1365 in the body. Mark it ready only after the phase's full verification set is green and pasted.

Phase 1. packages/cli/lib/doctor.js (1686 LOC)

Barrel packages/cli/lib/doctor.js. Modules in packages/cli/lib/doctor/.

Current internal structure and the seams it dictates.

Current lines Functions Goes to Target LOC
79-126 DOCTOR_SEVERITIES, DOCTOR_CODES, codeForName doctor/codes.js 90
127-245 isPlainObject, readDoctorPolicy, applyDoctorPolicy doctor/policy.js 130
246-336, 1184-1214, 1460-1474 readEngines, stripJsonc, parseEnvKeys, newestMtimeMs, isCommentedOut doctor/util.js 190
615-720, 878-923 satisfiesRange, makeInstalledManifestReader, formatConflicts, readInstalledVersion doctor/manifest.js 180
1257-1304, 1411-1459, 1475-1531 ROUTE_WALK_IGNORE, ROUTE_MODULE_RE, ROOT_ONLY_MODULE_RE, readAppBasePath, collectRouteModules doctor/route-modules.js 150
337-366 checkNode doctor/probes/node.js 60
367-414 checkTsconfig doctor/probes/tsconfig.js 55
415-464 checkEnv doctor/probes/env.js 60
465-550 checkVendorPin doctor/probes/vendor-pin.js 95
551-614 checkVendorGitignore doctor/probes/vendor-gitignore.js 70
721-877 checkImportmapCoherence doctor/probes/importmap-coherence.js 165
924-1000 checkWebjsVersions doctor/probes/webjs-versions.js 85
1001-1075 checkGitHook doctor/probes/git-hook.js 80
1076-1172 checkElisionCarriers, checkElisionComponents doctor/probes/elision.js 105
1173-1256 FRESHNESS_IGNORE, checkStaticAssetFreshness doctor/probes/static-asset-freshness.js 90
1287-1410, 1532-1586 LINK_TAG_RE, ATTR_RE, parseTagAttrs, unmarkedStylesheetHref, checkUnmarkedAssetLinks doctor/probes/unmarked-asset-links.js 190
1587-1654 frameworkResolves, checkFrameworkResolves doctor/probes/framework-resolves.js 75
1655-1686 runDoctorChecks doctor/runner.js 60
header 1-52 the file banner stays on the barrel 55

Barrel re-exports the 9 current exports: DOCTOR_SEVERITIES, DOCTOR_CODES, codeForName, readDoctorPolicy, applyDoctorPolicy, readAppBasePath, frameworkResolves, checkFrameworkResolves, runDoctorChecks.

Layering, bottom to top: codes.js and util.js are leaves; policy.js, manifest.js, route-modules.js sit above them; every probes/*.js imports downward only; runner.js imports the probes and codes.js; the barrel imports everything.

The one non-move step. runDoctorChecks currently attaches r.code = codeForName(r.name) after the Promise.all. Keep that in runner.js exactly as is. Do NOT move code assignment into the probes, which would be a behaviour change disguised as a refactor.

The internal symbol the tests import is readAppBasePath only (packages/cli/test/), so the barrel's contract is small. packages/cli/bin/webjs.js:431 and :782 dynamically import '../lib/doctor.js', which the barrel keeps working unchanged.

Also in this PR: add test/architecture/barrel-surface.test.mjs with all ten floors from D9.

Verification for phase 1.

node scripts/find-cycles.mjs packages/cli/lib          # the D4 script, from your scratchpad
npm test
( cd gallery       && npx webjs doctor && npx webjs check )
( cd examples/blog && npx webjs doctor && npx webjs check )
( cd website       && npx webjs doctor && npx webjs check )
node --test packages/cli/test/**/*.test.js

Browser, e2e, Bun, and elision do NOT apply. lib/doctor.js never reaches a browser, never runs on a request path, and is not on the Bun parity hook's runtime-sensitive list.

Phase 2. packages/server/src/vendor.js (2430 LOC)

Barrel packages/server/src/vendor.js. Modules in packages/server/src/vendor/.

Current lines Functions Goes to Target LOC
67-99, 100-237 BUILTIN, FRAMEWORK_SERVER_ONLY, scanBareImports, extractPackageName, IMPORT_RE, DYNAMIC_IMPORT_RE, stripComments, isServerOnlyFile, CONFIG_FILE_RE, walk vendor/scan.js 190
238-328 resolvePackageDir, getPackageVersion, getPackageManifest vendor/manifest.js 95
329-706 jspmCache, lastLiveResolveFailed, the endpoint and timeout constants, SUPPORTED_PROVIDERS, normalizeProvider, jspmCall, jspmResolveOne, jspmProbeOne, jspmGenerate, mergePerInstall, vendorImportMapEntries, clearVendorCache vendor/jspm.js 380
732-1117 derivePinParts, PIN_DIR_REL, PIN_FILE, pinDir, pinFilePath, VENDOR_GITIGNORE_LINES, vendorPinIsIgnored, ensureVendorCommittable, hasVendorPin, bundleFilenameWithSubpath, sha384Integrity, readPinFile, writePinFile vendor/pin-file.js 390
1118-1269 downloadBundle, fetchIntegrity, pruneOrphans vendor/download.js 155
1270-1557 pinAll, unpinPackage, listPinned vendor/pin-commands.js 290
1558-1795 NPM_REGISTRY, NPM_TIMEOUT_MS, fetchNpmJson, groupPinnedByPackage, auditPinned, findOutdated, updatePinned vendor/registry.js 240
1796-1868, 1981-2160 compareSemver, maxSemverVersion, basePackage, satisfiesSemverRange, parseSemver, cmpSemver vendor/semver.js 255
1869-1980 prunePinToReachable, extractPinnedVersions vendor/pin-prune.js 115
2161-2344 checkImportmapCoherence, liveIntegrityCache, INTEGRITY_FETCH_CONCURRENCY, INTEGRITY_TOTAL_BUDGET_MS, fetchLiveIntegrity, computeLiveIntegrity vendor/coherence.js 185
2345-2430 resolveVendorImports, serveDownloadedBundle vendor/serve.js 90
header 1-66 the file banner and the module doc stays on the barrel 70

Correction to the issue's proposed list. The issue proposed resolver.js, jspm.js, importmap-builder.js, disk-cache.js. There is no importmap BUILDER in this file (packages/server/src/importmap.js is a separate module that builds the map; vendor.js only produces the vendor ENTRIES that feed it, in vendorImportMapEntries). And "disk cache" is really two unrelated things, the pin FILE at .webjs/vendor/importmap.json and the downloaded BUNDLE files, which have different lifecycles. The list above replaces both names. This matches the corrected Vite lesson in D8, where the real seam is scanner-versus-resolver and the orchestration entry stays largest.

Barrel re-exports the 25 current exports. The test import surface is exactly those 25 names across 6 test files, so the barrel is a pure re-export list.

Layering: semver.js and scan.js are leaves; manifest.js, pin-file.js, pin-prune.js above them; jspm.js, download.js, registry.js, coherence.js above those; pin-commands.js and serve.js on top.

Watch for one real coupling. clearVendorCache (line 707) clears jspmCache and is imported by packages/server/src/dev.js:51. It must stay in the module that OWNS jspmCache (vendor/jspm.js), not in a general cache module, or the clear silently stops clearing.

Verification for phase 2.

node scripts/find-cycles.mjs packages/server/src
npm test
node --test packages/server/test/**/*.test.js
( cd gallery && npx webjs doctor )   # exercises checkImportmapCoherence + checkVendorPin
( cd examples/blog && npx webjs doctor )
( cd website && npx webjs doctor )

Browser and e2e do NOT apply (server-only, no request-path behaviour). Bun parity does NOT apply (no path in this phase matches the hook's runtime-sensitive regex, and the vendor engine runs identically on both runtimes through plain fetch and node:fs). Elision does NOT apply.

The vendor suite uses a JSPM double (framework-dev.md:204), which records refusals rather than throwing, because every fetch caller in this file catches. A split that misroutes a call site therefore shows up as a recorded refusal, not a thrown test. Read the recorded refusals in the test output, do not just check the exit code.

Phase 3. packages/server/src/check.js (1805 LOC)

Barrel packages/server/src/check.js. Modules in packages/server/src/check/.

This is the only phase that is a genuine restructure. checkConventions spans lines 547-1471 (925 lines) with all 18 rules inline, delimited by // --- Rule: <name> --- comments. The RULES array at 57-160 is metadata only; it does not dispatch. So the target architecture is the ESLint shape: one shared scan context built once, then one module per rule group that receives the context and pushes violations.

Current lines Content Goes to Target LOC
57-169 RULES, RULE_NAMES check/rules-manifest.js 115
170-225, 1719-1805 hasUseServerDirective, isServerActionFile, isComponentFile, isUseServerActionFile, readElideEnabledForCheck, pathExists, gitIgnoredSet check/context.js (plus the corpus builder lifted out of 547-571) 240
226-546 arrayPropUsesObject, isArrayTypeText, findFieldInitializers, BROWSER_GLOBALS, HTMLELEMENT_MEMBERS, methodBodyOf, findBrowserMemberUses, hasMultiDeclaratorExport, enumerableExports, importedLocalNames, importedValueNames check/ast-helpers.js 325
572-771, 833-905 components-have-register, no-static-properties, reactive-props-no-class-field, array-prop-uses-array-type, no-browser-globals-in-render, no-shadowed-native-member, no-interpolation-in-raw-text-element, no-server-env-in-components check/rules/components.js 290
772-832, 906-942, 1373-1471 no-redirect-in-api-route, shell-in-non-root-layout, form-action-not-a-get-action check/rules/routing.js 200
943-1081 erasable-typescript-only, no-non-erasable-typescript check/rules/typescript.js 145
1082-1209 use-server-needs-extension, use-server-exports-callable, one-action-per-configured-file check/rules/server-actions.js 135
1210-1298 tag-name-has-hyphen, no-duplicate-tag check/rules/registration.js 95
1299-1372, 1472-1718 no-server-import-in-browser-module (with findImportChain and checkServerImportInBrowserModule), no-missing-local-import check/rules/imports.js 330
547-571 plus the dispatch checkConventions orchestration check/run.js 140
header 1-56 imports and the file banner stays on the barrel 30

Correction to the issue's proposed list. The issue proposed rules/, ast-scanner.js, reporter.js. There is no reporter in this file. Reporting lives in packages/cli/bin/webjs.js, which formats what checkConventions returns. And the scanning is not an AST scanner, it is regex and text scanning over source built on packages/server/src/js-scan.js, which is already a separate module. check/ast-helpers.js above is named for what it is, a set of source-shape predicates, and js-scan.js is left alone.

The invariant to preserve. checkConventions returns a flat violations array in a stable order. Rule modules must be invoked in the SAME order they run today, or a snapshot-shaped assertion in the check suite reorders and fails for a non-reason. Record the order in check/run.js with a comment naming this constraint.

RULES is imported by test/knowledge/knowledge-coverage.test.js:22, so it must stay on the barrel.

Verification for phase 3.

node scripts/find-cycles.mjs packages/server/src
npm test
node --test packages/server/test/**/*.test.js
node --test test/knowledge/knowledge-coverage.test.js
npx webjs check --rules                      # from any app dir, prints the rule list
( cd gallery       && npx webjs check )
( cd examples/blog && npx webjs check )
( cd website       && npx webjs check )

npx webjs check --rules output must be byte-identical to origin/main. Capture both and diff.

Browser, e2e, Bun, and elision do NOT apply. check.js is a static analyser invoked by the CLI; it is not on a request path and not on the Bun parity list.

Phase 4. packages/core/src/router-client.js (5400 LOC)

Barrel packages/core/src/router-client.js. Modules in packages/core/src/router-client/.

This is the first phase with browser-bundle exposure. Nothing inside packages/core/src imports this file (only index.js:31 and index-browser.js:55,63 do), so it is self-contained in the import graph, which is why it goes before the other core files.

Current lines Functions and bindings Goes to Target LOC
30, 311-349, 767, 1100, 1279, 1712, 2175-2200, 4486, 4715 STREAM_MIME, ANCHOR_SUPPRESS_CEILING_MS, ANCHOR_SUPPRESS_FLOOR_MS, ANCHOR_RELEASE_EVENTS, NON_HTML_EXTENSIONS, FRAME_TOP, FALLBACK_MARKER_KEY, SNAPSHOT_CAP, the six PREFETCH_* constants, LIVE_ATTRS, META_KEY_CSP_NONCE router-client/constants.js 140
233-310, 788, 3556 enabled, activeAbortController, currentNavigationToken, upgradeObserver, ensureUpgradeObserver, currentPageUrl, hardNavigate, _swapCommit, and their accessors router-client/state.js 150
121-232 parseHTML, parseHTMLUnsafePreservesComments, resetParseProbe, parseDocumentPreservingComments router-client/dom-parse.js 115
303-583 prevScrollRestoration, releaseScrollAnchor, suppressScrollAnchoring, cancelScrollCatchUp, restoreGeneration, catchUpToRestoredScroll, afterTwoFrames router-client/scroll.js 270
1167-1508 warnOnce, warnIfActionSubmissionCannotDeliver, shouldFullLoadDuringParse, isPreBootNavigation, reportPreBootNavigation, reportFallback, warnDropped, warnIfSmoothScrollOnHtml, setNavigating router-client/diagnostics.js 340
928-1057 getSubmitMethod, getSubmitAction, normalizeEnctype, getSubmitEnctype, encodeSubmitBody, buildSubmitFormData router-client/form-encoder.js 135
1058-1166, 3126-3335 findAnchorInPath, activeFrameId, resolveTargetFrameId, markFrameBusy, clearFrameBusy, markFormBusy, clearFormBusy, trackedReloadSignature router-client/frames.js 320
1509-1711 collectBoundaries, planBoundarySwap router-client/boundaries.js 200
1712-1787 snapshotCache, snapshotCurrent, snapshotGet, cacheKey router-client/snapshot-cache.js 80
2175-2747 the whole prefetch engine (prefetchSaysSaveData through refreshPrefetchObservers) router-client/prefetch.js 575
3336-3555 viewTransitionsEnabled, runWithTransition, regraftPermanentElements, regraftPermanentInSlice, findInSlice, upgradeCustomElementsInRange router-client/view-transition.js 220
3901-4611 blurOutgoingFocus, replaceBoundaryRange, swapMarkerRange, reconcileSiblings, diffElementInPlace, isHydratedComponent, isOwnLightSlot, ownActualLightSlots, resyncEnclosingSlotRecord, resyncEnclosingHostSlots, reprojectSlottedContent, reconcileChildren, keyOf, applyOptimisticLoading, restoreOptimistic, diffChildren router-client/dom-differ.js 710
4612-4939 getCspNonce, cloneScriptWithCorrectNonce, cloneElementWithCorrectNonce, outerHTMLForDiff, metaIdentity, reconcileHeadMetas, addNewHeadElements, isPersistentHeadStyle, mergeHead router-client/head-merge.js 330
4940-4964, 5199-5280 upgradeCustomElements, upgradeTree, reactivateScripts, activateSwappedRange router-client/upgrade.js 110
3558-3900 applySwap router-client/swap.js 345
4965-5198 forwardSuspenseResolvers, readStreamedShell, takeResolveUnit, applyStreamedResolve, streamBoundariesProgressively router-client/stream.js 235
2748-2851 renderInPlaceNavError, handleNavigationError router-client/nav-error.js 105
2852-3125 fetchAndApply router-client/fetch-apply.js 275
584-766, 1788-2174 enableClientRouter, disableClientRouter, navigate, loadFrame, revalidate, performNavigation, performSubmission, buildHaveHeader router-client/navigator.js 570
767-927 onClick, onPopState, onSubmit, closestAnchor (2656) router-client/events.js 175
1-29, 5281-5400 the imports, the export block, the 63 _ seams, the module-scope auto-enable stays on the barrel 175

Twenty modules plus the barrel. Largest is dom-differ.js at about 710.

Correction to the issue's proposed list. The issue proposed ten modules and named form-encoder.js, dom-parse.js, scroll.js, prefetch.js, dom-differ.js, stream-processor.js, navigator.js, events.js, constants.js, state.js. Eight of those are right and kept. stream-processor.js is renamed to stream.js, since it does not process a stream so much as read the shell and apply resolve units. The list was missing six real concerns the code actually contains, each large enough to matter: the swap (345), head merging (330), diagnostics and the fallback event (340), frames and busy markers (320), view transitions and permanent regraft (220), and the boundary scan (200). Those are added above. Turbo's drive/ directory, verified in D8, has a directly comparable module for four of the six (view_transitioner.js, head_snapshot.js, page_renderer.js, progress_bar.js), which is the evidence they are real seams rather than invented ones.

Layering, strictly downward. constants.js is the only true leaf. state.js imports constants.js. Then dom-parse.js, scroll.js, snapshot-cache.js, form-encoder.js, boundaries.js, upgrade.js. Then diagnostics.js, frames.js, head-merge.js, dom-differ.js, view-transition.js, stream.js, nav-error.js. Then swap.js, which is the one that pulls the widest. Then fetch-apply.js, then navigator.js, then events.js. The barrel imports navigator.js and events.js and re-exports everything.

The one back edge to plan for. applySwap calls into upgradeCustomElementsInRange (view transitions) and mergeHead, and fetchAndApply calls applySwap, while performNavigation calls fetchAndApply and the prefetch engine calls back into the snapshot cache. Every one of those points downward in the layering above. The single edge that does NOT is reportFallback in diagnostics.js reaching hardNavigate, which lives in state.js, one layer below it. That resolves cleanly because hardNavigate is a mutable binding read through an accessor, so state.js exposes getHardNavigate() and never a top-level const.

The 63 _ seams stay on the barrel, unchanged in name. framework-dev.md:176 and packages/core/AGENTS.md:84 both name this file as their home, and the _ prefix is what exempts them from the two .d.ts guards. Do not move them into sub-modules and do not rename them.

Verification for phase 4.

stat -c%s packages/core/dist/webjs-core-browser.js       # record BEFORE
node scripts/find-cycles.mjs packages/core/src
node --input-type=module -e "await import('./packages/core/index-browser.js'); console.log('ok')"
node scripts/build-framework-dist.js
stat -c%s packages/core/dist/webjs-core-browser.js       # record AFTER, tolerance +1.0%
npm test
npm run test:browser
WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs test/e2e/dev-seed-observability.test.mjs \
  test/e2e/nested-layout-partial-swap.test.mjs test/e2e/form-submission-and-race.test.mjs \
  test/e2e/dev-overlay-nav.test.mjs test/e2e/browser-harness.test.mjs
node scripts/run-bun-tests.js
( cd gallery       && npx webjs elision --verify && npx webjs check )
( cd examples/blog && npx webjs elision --verify && npx webjs check )
( cd website       && npx webjs elision --verify && npx webjs check )

Phase 5. packages/core/src/render-client.js (2939 LOC)

Barrel packages/core/src/render-client.js. Modules in packages/core/src/render-client/.

This file exports exactly ONE name, render, so the export contract is trivial. The difficulty is entirely internal: the apply and instance functions are mutually recursive in a genuine cycle.

Current lines Functions Goes to Target LOC
38-198, 1000-1005 commitInto, templateCache, submitterActionBindings, INSTANCE, currentRenderRoot, boundaryOwnerOf, commitOutOfBand, COMMIT_FAILED render-client/commit.js 180
272-643 compile, discoverSlots, assignPaths render-client/compile.js 375
644-910, 1108-1136 buildFormActionRecord, reconcileFormActions, releaseSubmitterAction, reconcileSubmitterAction, effectiveFormAttr render-client/form-parts.js 300
911-1107, 1724-1806 createInstance, bindPart, updateInstance, clearInstance, buildDetached, disposeInstance render-client/instance.js 285
1137-1442 resolveHoleValue, applyPart, findSlotHost, isInShadowRootEl, applyElement, applyChild, applyChildInner render-client/apply-part.js 310
1443-1723 applyChildInnerRaw, nodesToFrag, removeBetween render-client/apply-child.js 285
1807-1982 applyRepeatFresh, reconcileRepeat, teardownRepeat render-client/part-repeat.js 180
1983-2264 arrayItemFirstNode, removeArrayItem, buildArrayItem, applyArrayFresh, reconcileArray, nextArrayAnchor, teardownArray, moveRange, shallowEqualArray, teardownChild render-client/part-array.js 285
2265-2678 clearStaleDirectiveState, applyCache, applyUntil, reportOutOfBandCommitError, teardownUntil, applyWatch, teardownWatch render-client/part-directives.js 415
2679-2939 applyAsyncAppend, applyAsyncReplace, consumeAsyncStream, renderToNodes, teardownAsyncStream render-client/part-async.js 265
new the late-binding registry (see below) render-client/dispatch.js 60
1-37, 199-271 the imports and render itself stays on the barrel 110

Correction to the issue's proposed list. The issue proposed template.js, expressions.js, directive-runtime.js. Those three names cover perhaps a third of the file and hide the two largest concerns, the part-type appliers (repeat, array, cache, until, watch, async, about 1145 lines together) and the form-action binding machinery (about 300 lines, which exists because of invariant 12 and has no analogue in Lit). The list above replaces them. The Lit seam cited in the old table is a PACKAGE-level split between element and template, not a within-template-engine split, so it does not prescribe these names (see D8).

The cycle, and the fix. applyPart calls applyChildInner, which calls applyChildInnerRaw, which calls buildDetached and createInstance, which call bindPart and updateInstance, which call applyPart. Every part-type applier (reconcileRepeat, reconcileArray, applyCache, applyUntil, applyWatch, the two async ones) also calls back into applyChildInner or buildDetached. Moving these into separate modules by hand produces a dense cycle.

Introduce render-client/dispatch.js, a tiny module holding mutable function slots and a registerRenderInternals({ applyPart, applyChildInner, buildDetached, createInstance, disposeInstance }) setter, plus thin call-through wrappers. Leaf modules import the WRAPPERS from dispatch.js and call them; instance.js and apply-part.js call registerRenderInternals at module load. Every edge into dispatch.js is downward, every read happens at call time rather than at module evaluation, and the TDZ class disappears. This is the same late-binding shape Lit uses for private-ssr-support.ts, which exists precisely so lit-html internals can be reached without an import cycle.

Do not change what render does. The barrel keeps render as its own body rather than re-exporting it, so the one public name has one definition site.

Verification for phase 5: identical command set to phase 4. The browser suite matters most here, since a TDZ fault in the minified bundle is invisible to Node.

Phase 6. packages/core/src/slot.js (2282 LOC)

Barrel packages/core/src/slot.js. Modules in packages/core/src/slot/.

Current lines Functions and bindings Goes to Target LOC
84-142, 786-812 detectBrowser, inBrowser, SLOT_STATE, LIGHT_SLOT_ATTR, PROJECTION_ATTR, PROJECTION_ACTUAL, PROJECTION_FALLBACK, SLOT_FALLBACK_FRAG, SLOT_OWNER, SLOT_OWNER_ATTR, FLATTEN_MAX_DEPTH, RENDERING, INTERCEPTED, PARK, FRAMEWORK_DETACHED slot/symbols.js 110
143-494 NATIVE_* captures, installSlotPolyfills, manualSlotFor, hostOfSlot, isInShadowRoot, lightAssignedNodes, flattenAssignedNodes, findLightAssignedSlot slot/polyfills.js 350
495-732 ensureSlotState, hasSlotState, captureAuthoredChildren, repartition, effectiveKeyOf, adoptSSRAssignments, keyOfName, slotNameOf, appendToMap slot/state.js 240
733-785, 1649-2008 projectAuthored, applySlotAssignments, resyncActualSlots, pruneAuthored slot/project.js 415
813-1094 captureNatives, withRendererWrites, installSlotSensors, processBackstop, drainRendererBackstop, processFlip, teardownSlotSensors, reconnectSweep, instanceOwns slot/sensors.js 285
1095-1648 parkFor, isRealmNode, expandArg, guardInsertable, guardCycle, authoredSplice, isAuthoredContentSlot, isInsideAuthored, isVirtualChild, EMPTY_NODE_SET, convertVariadicArgs, commitAuthored, installSlotInterception slot/interception.js 555
2009-2282 hasFrameworkRenderedSubtree, isOwnSlot, ownerHostFor, applyActualAssignment, applyFallback, restoreFallbackInto, rescueAssignedNodes, fireSlotChange, queueSlotChange, arraysEqual slot/assignment.js 275
1-83 the file banner stays on the barrel 145

Correction to the issue's proposed list. The issue folded slot.js into src/component/slot-engine.js, a single module. That is wrong on two counts. slot.js is imported by render-client.js:16, router-client.js:24, AND component.js:10, so it is not a component-private concern, and putting it under component/ inverts the dependency (the renderer would import from the component tree). It also cannot be one module at 2282 lines. It gets its own directory, as above.

Barrel re-exports all 31 current exports. The test import surface is 24 named symbols across 5 test files plus 3 in-package importers, all covered by the barrel.

Layering: symbols.js is the leaf, then polyfills.js and state.js, then sensors.js and assignment.js, then project.js, then interception.js.

Verification for phase 6: identical command set to phase 4. The slot browser suites (packages/core/test/slots/browser/*, 10 files) are the load-bearing layer here, and the native-parity contract test in particular is the counterfactual for any dropped write interception.

Phase 7. packages/core/src/component.js (1896 LOC)

Barrel packages/core/src/component.js. Modules in packages/core/src/component/.

class WebComponentBase spans lines 518-1851, which is 1334 lines in ONE class body. A class body cannot be split across modules, so this phase is not a move. The approach is to extract method BODIES into free functions that take the host element as their first argument, leaving the class body as thin delegating methods. This is behaviour-preserving as long as every extracted function keeps the exact this semantics it had.

Current lines Content Goes to Target LOC
22-104, 105-270 isBrowser, defaultHasChanged, safeString, warnFunctionReflection, warnUnserializableReflection component/reflect-warnings.js 195
271-504 makeServerInternals, class ServerElement, ARIA_IDL_PROPS component/server-element.js 240
1852-1867 hyphenate, camelCase component/naming.js 30
586-592, 668-757, 758-842, 911-933, 1146-1180, 1450-1479 the observedAttributes getter body, _assertFactoryProperties, _initializeProperties, _reflectAttribute, _hydratePropAttrs, attributeChangedCallback body, _reflectDeclaredAttributes component/properties.js 340
1181-1234, 1235-1449, 1561-1607, 1638-1671, 1711-1764 requestUpdate, _scheduleUpdate, _performRender, _resolveUpdate, performServerUpdate, _commitAsync, _postCommit, updateComplete, getUpdateComplete component/updates.js 480
843-1145 connectedCallback, _activate, __isHydrating, disconnectedCallback component/lifecycle.js 305
1608-1637, 1817-1851 _overridesRenderFallback, _handleRenderError, renderError, renderFallback defaults component/render-states.js 70
1765-1791 addController, removeController component/controllers.js 40
505-517, 1868-1896 Base, FACTORY_PROPS, _propsChecked, WebComponent, prop component/factory.js 95
518-1851 residue the class shell: static fields, the constructor, the thin lifecycle hooks (shouldUpdate, willUpdate, update, updated, firstUpdated, render), and the delegating one-line methods component/base.js 370

Barrel re-exports WebComponent and prop.

Correction to the issue's proposed list. The issue proposed base.js, properties.js, controllers.js, slot-engine.js. Three of those survive. slot-engine.js is wrong and is handled in phase 6 (see the correction there). The list is missing the update scheduler, which is the single largest concern in the class at about 480 lines, and the server element shim at 240, which exists because SSR runs the constructor against a ServerElement rather than an HTMLElement and is entirely separable.

Stated fallback. If component/base.js still exceeds the 1000-line ceiling after the extraction, record it as the single named exemption under D3 rather than inventing a mixin chain. lit/packages/reactive-element/src/reactive-element.ts is 1754 lines for exactly this reason (verified in D8), so a large single class at this seam is the industry outcome, not a failure.

The invariant that makes or breaks this phase. packages/server/src/check.js enforces reactive-props-no-class-field and no-static-properties against user code, and component.js itself throws at runtime when an app declares static properties. The extraction must not change WHEN _assertFactoryProperties runs relative to _initializeProperties, or the throw moves and the check-rule tests disagree with the runtime.

Verification for phase 7: identical command set to phase 4, plus the type fixtures, which pin WebComponent's signature.

node --test test/types/type-fixtures.test.mjs test/types/dts-export-coverage.test.mjs test/types/dts-no-phantom-exports.test.mjs

Phase 8. packages/core/src/render-server.js (2427) plus packages/server/src/ssr.js (2394)

Two barrels, two directories, one PR. They are not coupled by import (ssr.js:3 reaches core through the package specifier), they are coupled through the SSR byte contract and the streaming protocol. One PR gives one verification pass over both halves.

packages/core/src/render-server.js, modules in packages/core/src/render-server/.

Current lines Functions Goes to Target LOC
1774-1946 CHAR_REF, NAMED, LEGACY, decodeAttrEntities, decodeNamed, codePointsToString, fromCodePoint, consumePropAttrs render-server/entities.js 180
618-908, 1213-1247, 1338-1385, 1580-1628 endOfComment, endOfScriptContent, inertRanges, inRanges, inertAt, VOID_ELEMENTS, isVoidElement, findClosingTagInString, escapeRegex, isRawtextTag, isRcdataTag, isTextOnlyTag render-server/html-scan.js 385
1629-1773 parseAttrs, seedServerAttrs, withHostMarker, appendReflectedAttrs, applyAttrsToInstance, camelCase, kebabCase render-server/instance.js 155
536-617 SSR_BROWSER_GLOBALS, SSR_HTMLELEMENT_METHODS, browserMemberHint, isProd, defaultSSRErrorTemplate render-server/ssr-errors.js 90
909-1212 injectDSD render-server/dsd.js 305
1386-1579 extractSlotAttr, partitionAuthoredBySlot, appendStringToMap, substituteSlotsInRender render-server/slots.js 195
1248-1337 processSuspenseElements render-server/suspense.js 95
50-535 renderToString, render, renderTemplate render-server/string.js 490
1947-2427 renderToStream, streamRender, streamTemplate, streamSuspenseBoundaries render-server/stream.js 485
1-49 the imports and the banner stays on the barrel 55

Barrel re-exports renderToString and renderToStream.

Correction to the issue's proposed list. The issue folded both files into one packages/server/src/ssr/ directory with layout-chain.js, boundary-emitter.js, metadata.js, stream-builder.js, dsd-serializer.js. That is wrong on package boundaries: render-server.js is in @webjsdev/core and is reached through @webjsdev/core/server, so it cannot move into @webjsdev/server. The two directories above replace the single one. Within ssr.js, boundary-emitter.js does not correspond to anything, since boundary markers are emitted by wrapWithChildrenMarker inside the segment-path helpers, not by a dedicated emitter.

packages/server/src/ssr.js, modules in packages/server/src/ssr/.

Current lines Functions Goes to Target LOC
845-1030 loadingSegmentPath, layoutSegmentPath, pageSegmentPath, regionRouteKey, wrapWithChildrenMarker and their export block ssr/segments.js 190
662-844 nearest, ssrBoundaryHtml, ssrNotFoundHtml, renderChain, loadingTemplates ssr/layout-chain.js 190
1031-1145, 2376-2394 collectMetadata, hoistHeadTags, serializeViewport ssr/metadata.js 195
1146-1358 extractUserShell, buildHeadInner, buildDocumentParts, wrapInDocument, collectHoistedHeadTags, publicEnvShim ssr/document.js 220
1359-1950 setClientRouterEnabled, clientRouterEnabled, wrapHead ssr/head.js 595
1951-2119, 2341-2375 componentPreloads, deduplicatedPreloads, reachedVendorSpecifiers, preloadCrossOriginAttr, integrityAttr ssr/preloads.js 215
554-661, 2120-2225 privateFragment, htmlResponse, cachedHtmlResponse, streamingHtmlResponse ssr/response.js 215
2226-2340 loadModule, toUrlPath, getNonce, escapeHtml, escapeAttr, escapeJsonLd, jsonLdScript ssr/util.js 120
36-553 ssrPage, ssrNotFound, ssrForbidden, ssrUnauthorized ssr/page.js 520
1-35 the imports and the banner stays on the barrel 55

Barrel re-exports all 18 current exports including the 9 _ seams (_hoistHeadTags, _pageSegmentPath, _regionRouteKey, _wrapWithChildrenMarker, _extractUserShell, _buildDocumentParts, _escapeJsonLd, _jsonLdScript, and the fourth in the wrapWithChildrenMarker block).

Layering on the server side: util.js is the leaf, then segments.js, metadata.js, preloads.js, then document.js and head.js, then response.js, then layout-chain.js, then page.js.

Also in this PR. Widen the Bun parity hook regex from /ssr\.js to /ssr[./] per D10, or every future edit under packages/server/src/ssr/ silently escapes the gate.

The invariant to preserve. The keyed boundary comment pairs (<!--wj:children:<segment>:<route-key>-->) are the client router's strict scan target, and a single byte of drift degrades every soft navigation to a full page load. wrapWithChildrenMarker and regionRouteKey must produce byte-identical output. webjs elision --verify covers this directly, and so does test/bun/keyed-boundaries.mjs.

Verification for phase 8.

stat -c%s packages/core/dist/webjs-core-browser.js packages/core/dist/webjs-core.js   # BEFORE
node scripts/find-cycles.mjs packages/core/src packages/server/src
node --input-type=module -e "await import('./packages/core/index.js'); console.log('ok')"
node scripts/build-framework-dist.js
stat -c%s packages/core/dist/webjs-core-browser.js packages/core/dist/webjs-core.js   # AFTER
npm test
node --test test/ssr/*.test.js
npm run test:browser
WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs test/e2e/dev-seed-observability.test.mjs \
  test/e2e/nested-layout-partial-swap.test.mjs test/e2e/form-submission-and-race.test.mjs
node scripts/run-bun-tests.js
( cd gallery       && npx webjs elision --verify && npx webjs check && npx webjs doctor )
( cd examples/blog && npx webjs elision --verify && npx webjs check && npx webjs doctor )
( cd website       && npx webjs elision --verify && npx webjs check && npx webjs doctor )

webjs-core-browser.js should not move at all in this phase, since render-server.js is excluded from the browser entry by design (index-browser.js:5). A non-zero delta there means the split leaked a server module into the browser bundle, which is a hard fail, not a tolerance question.

Phase 9. packages/server/src/dev.js (3070 LOC)

Barrel packages/server/src/dev.js. Modules in packages/server/src/dev/.

Largest blast radius in the sequence: 62 test files import it by relative path, it is the boot path of every app, and it is runtime-sensitive on Bun. It goes last so every mechanic in this plan is already proven.

This is the one file whose main function must be decomposed rather than moved. createRequestHandler spans lines 536-1682, which is 1147 lines in one closure. It already funnels its mutable data through a state object (state.routeTable, state.lastDevError, state.regenerateRules) and already hands a ctx record to startNodeListener, so the pattern is half present. Finish it: build one explicit boot record plus the existing state, and pass both to the extracted phase functions instead of capturing them.

Current lines Content Goes to Target LOC
113-180 MIME, TS_CACHE_MAX, kebab, resolveRequestId, shouldAccessLog dev/mime.js 95
181-535 loadAppEnv, elideEnvOverride, readElideEnabled, seedEnvOverride, readSeedEnabled, readClientRouterEnabled, readHeaderRules, readRedirectRules, readTrailingSlashFromApp, readBasePathFromApp, warnOnInvalidWebjsConfig, readAllowedOriginsFromApp, readCspConfigFromApp, readBodyLimitsFromApp, readDevWatchPathsFromApp, readServerTimeoutsFromApp dev/app-config.js 360
536-750 the boot sequence of createRequestHandler (env load, config reads, provider wiring, importmap binding, the action load hook, reportError) dev/handler/boot.js 240
752-866, 903-1252 emitRouteTypes, ensureReady, resolveAndApplyVendor, rebuild, doRebuild dev/handler/analysis.js 430
867-902, 1168-1192 reportDevError, getReadinessCheck dev/handler/readiness.js 110
1253-1640 handle, produce, routeFor dev/handler/dispatch.js 395
1641-1682 the returned API object, plus the ctx construction dev/request-handler.js 250
1683-1728, 2804-2855 shouldIgnoreWatchPath, fileByteHash, frameworkServerVersion, debounce dev/watch.js 105
1729-1833 startServer dev/start-server.js 115
1834-1979 startNodeListener dev/listener-node.js 150
1980-2055 tryServeFrameworkStatic dev/framework-static.js 85
2056-2408 handleCore, wantsJson dev/internal-routes.js 360
2409-2484 runWithSegmentMiddleware, ROOT_MIDDLEWARE_FILES, loadMiddleware dev/middleware-chain.js 85
2485-2596 makeHttpServer, toWebRequest, sendWebResponse dev/http-io.js 120
2597-2803 fileResponse, jsModuleResponse, exists, stripTs, tsResponse dev/static-serve.js 215
2856-2963 collectRouteModules, computeBrowserBoundFiles, appTopLevelDirs, locateCoreDir, locatePackageDir dev/app-analysis.js 115
2964-3070 DEV_OVERLAY_SRC, RELOAD_WORKER_SRC, reloadClientJs, __webjsApplyError, __webjsReloadWhenReady, __webjsDirectEvents, reloadWorkerJs dev/reload-client.js 115
1-112 the imports and the banner stays on the barrel 130

Barrel re-exports the 16 current exports. The internal symbols the tests import are createRequestHandler, startServer, readDevWatchPathsFromApp, shouldIgnoreWatchPath, readElideEnabled, readSeedEnabled, all covered.

Correction to the issue's proposed list. The issue proposed config-reader.js, http-server.js, watcher.js, hmr.js, error-overlay.js, route-dispatcher.js. Four of those map onto real seams and are kept under different names. hmr.js does not exist as a concept in this file: WebJs has no HMR, it has a full-reload SSE channel, and the code for it is the reload-client.js generator plus the SSE hub in listener-core.js, which is already a separate module. error-overlay.js is likewise already separate (packages/server/src/dev-error.js builds the frame, dev-overlay.js is the client script), and what lives in dev.js is only reportDevError, which is 35 lines and belongs with readiness. The six missing concerns the list above adds are the boot sequence, the readiness and rebuild machinery, the internal /__webjs/* route table, the node listener shell, the TypeScript-stripping static serve, and the app analysis helpers.

The naming caveat, recorded. dev/ is inherited mechanically from the barrel's basename per D2, and it is a poor description of the contents, since start-server.js and app-config.js run in production. Put a banner comment saying so at the top of packages/server/src/dev.js. Renaming to src/server/ would cost 62 test import sites for zero behaviour.

Also in this PR. Widen the Bun parity hook regex from /dev\.js to /dev[./] per D10.

The three invariants to preserve.

  1. Ordering inside createRequestHandler is load-bearing and documented in the comments. loadAppEnv must run before applyEnvValidation, which must run before runInstrumentation, which must run before the route table and action index are built, and the 'use server' load hook must install before any action module is imported (ESM caches by URL). Extracting the boot sequence must preserve that exact order, and the extracted function must document it.
  2. setBasePath runs before setCoreInstall and setVendorEntries, which run before setAssetRoots, so the published build id is a stable deploy fingerprint. Same constraint, different chain.
  3. startServer picks the listener shell by runtime (serverRuntime() === 'bun' dynamically imports listener-bun.js, otherwise startNodeListener). Do not move the dynamic import to a static one, or the Bun.* global is referenced on Node.

Verification for phase 9.

node scripts/find-cycles.mjs packages/server/src
npm test
node --test packages/server/test/**/*.test.js
node --test packages/cli/test/**/*.test.js
npm run test:browser
WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs test/e2e/dev-seed-observability.test.mjs \
  test/e2e/dev-overlay-nav.test.mjs test/e2e/browser-harness.test.mjs \
  test/e2e/nested-layout-partial-swap.test.mjs test/e2e/form-submission-and-race.test.mjs
node scripts/run-bun-tests.js
( cd gallery       && npx webjs elision --verify && npx webjs check && npx webjs doctor )
( cd examples/blog && npx webjs elision --verify && npx webjs check && npx webjs doctor )
( cd website       && npx webjs elision --verify && npx webjs check && npx webjs doctor )
( cd gallery && npm run dev )        # boot smoke, then Ctrl-C
( cd examples/blog && npm start )    # prod boot smoke, then Ctrl-C

Tests

This refactor is behaviour-preserving, so the usual "add a test that fails when reverted" rule inverts. The existing suites ARE the counterfactual. A behaviour-preserving move that breaks something breaks an existing test; a move that breaks nothing needs no new assertion of its own. What the split DOES introduce as a new failure mode is a silently dropped export, and that is covered by the one new test in D9 plus the per-phase export diff.

The one new test. test/architecture/barrel-surface.test.mjs, added in phase 1, holding the ten export-count floors from D9. It imports each barrel and asserts Object.keys(mod).filter(k => k !== 'default').length >= floor. Rationale and the anti-rot argument are in D9.

Per-phase layer applicability.

Phase Unit Browser e2e Bun Smoke and apps Elision verify
1 doctor yes no no no webjs doctor in 3 apps no
2 vendor yes no no no webjs doctor in 3 apps no
3 check yes no no no webjs check in 3 apps no
4 router-client yes yes yes yes 3 apps yes
5 render-client yes yes yes yes 3 apps yes
6 slot yes yes yes yes 3 apps yes
7 component yes yes yes yes 3 apps yes
8 render-server + ssr yes yes yes yes 3 apps yes
9 dev yes yes yes yes 3 apps plus a boot smoke yes

Why the first three skip four layers. lib/doctor.js, src/vendor.js, and src/check.js never reach a browser, never run on a request path, and none of their paths matches the Bun parity hook's runtime-sensitive regex. webjs elision --verify has nothing to compare, since none of them participates in SSR. Running those layers anyway costs about forty minutes per phase for zero signal. This is a decision, not an omission.

Bun parity is mandatory for phases 4 through 9 and is enforced, not merely expected: .claude/hooks/require-bun-parity-with-runtime-src.sh BLOCKS the commit when a runtime-sensitive path is staged with no test/bun/** file alongside. The escape hatch is WEBJS_BUN_VERIFIED=1 and it is not the honest answer here, because these are exactly the surfaces the gate exists for. The test/bun/** files already in play, none of which needs a new sibling since the behaviour does not change.

Phase Existing Bun tests that must stay green
4 router-client keyed-boundaries.mjs, nav-sentinels.mjs, form-action-dispatch.mjs, form-action-submitter-parity.test.mjs, routing-boundaries.mjs
5 render-client binding-prefixes.mjs, comment-not-an-element.mjs, form-action-guard.mjs, host-display-default.mjs
6 slot slot-ssr-parity.mjs
7 component attribute-converter-parity.mjs, attribute-reader-parity.mjs, reflect-function-guard.mjs, reflect-unserializable.mjs, host-display-default.mjs
8 render-server + ssr seed.mjs, action-seed-circular.test.mjs, keyed-boundaries.mjs, core-modulepreload.mjs, asset-url.mjs, slot-ssr-parity.mjs
9 dev listener.mjs, listener-overhead.mjs, app-boot.mjs, dev-hot-reload.mjs, dev-extra-watch.mjs, dev-regenerate.mjs, dev-reload-retry.mjs, dev-overlay-scope.mjs, path-alias.mjs, root-middleware.mjs, timeouts.mjs, webjs-config-validate.mjs, runtime-smoke.test.mjs, compression.mjs, forwarded-proto.mjs, forwarded-trust.mjs

The whole set runs with node scripts/run-bun-tests.js. Satisfy the hook by touching the relevant test/bun/** file with the phase's assertion re-run, or by staging a comment recording which Bun tests were run and their result. Do not reach for the escape hatch.

Two known-flaky-in-a-worktree results, so they are not chased. Five tests fail in a worktree:link worktree and pass in the primary checkout and in CI (the listener and listener-overhead pair plus three elision assertions). If exactly those five fail, it is the worktree, not the phase. Confirm by re-running them in a worktree cut from origin/main with no changes.

Rebuild dist before e2e and Bun, every core phase. node scripts/build-framework-dist.js. Skipping it makes the whole e2e and Bun run vacuous, since both resolve the built bundle rather than src.


Docs

Every path below was checked at HEAD 509d8809. Where nothing changes, that is stated so the implementer does not go looking.

Real doc surfaces that MUST change.

Path Phase What changes
AGENTS.md:162 ("Framework source: where to find it") 3, 4, 8 It names four starting points by exact path (@webjsdev/server/src/ssr.js, @webjsdev/core/src/render-client.js, @webjsdev/core/src/router-client.js, @webjsdev/server/src/check.js). All four still RESOLVE after the split, since the barrel keeps the path. Add one sentence saying each is now a barrel over a sibling directory, so an agent reading it knows to look one level down
packages/core/AGENTS.md:84 and :274 4 Both name src/router-client.js as the home of the 63 _ seams. The seams stay on the barrel, so the claim stays true. Add the directory pointer
packages/core/AGENTS.md:113 8 Names packages/server/src/ssr.js as what the metadata surface is derived from. Point at ssr/metadata.js
packages/server/AGENTS.md 9 Holds the canonical webjs.* config-reader inventory, all of which moves to dev/app-config.js. Update the paths in place
packages/cli/AGENTS.md:193 1 Names readDoctorPolicy and applyDoctorPolicy as living in lib/doctor.js. They move to lib/doctor/policy.js and are re-exported. Update the sentence
framework-dev.md:176 4 Names src/router-client.js as the home of the 63 _ seams for the .d.ts guard exemption. Still true, add the directory pointer
framework-dev.md:204 2 Names packages/server/src/vendor.js as the file whose fetch callers all catch, which is why the JSPM double records refusals. Point at vendor/jspm.js
framework-dev.md:244 9 Names packages/server/src/dev.js as the file that statically imports core symbols, which forces the core-publishes-first order. Point at dev/handler/boot.js
framework-dev.md:259 9 Names reportDevError plus the SSE push in packages/server/src/dev.js. Point at dev/handler/readiness.js
packages/mcp/src/mcp.js:123 and :160, packages/mcp/src/mcp-source.js:16 and :212 8 These use server/src/ssr.js as an EXAMPLE path in a tool description and an error message. The source tool reads any path under the @webjsdev/* src trees behind a traversal guard, so it keeps working with no code change and a nested path is readable. Refresh the example strings so an agent following them lands on something informative
.claude/hooks/require-bun-parity-with-runtime-src.sh 8, 9 The regex widenings from D10. Not a doc surface, but it ships in the same commits

Surfaces that do NOT change, checked.

  • The docs site (website/app/docs/**) documents the framework's public API, not its file layout. The one exception is website/app/docs/no-build/page.ts:51, which prints the literal "@webjsdev/core/client-router": "/__webjs/core/src/router-client.js" as an importmap example. Because D2 keeps the path, that string stays correct. This is the single strongest reason the rename was rejected.
  • website/app/why-webjs/page.ts:150-151 prints core/src/render-server.js: export async function renderToString( and server/src/ssr.js: const html = await renderToString(tree) as illustration. Both stay correct, since both barrels keep their path and renderToString stays exported from render-server.js.
  • .agents/skills/webjs/** names exactly one framework source path, node_modules/@webjsdev/core/src/component.js in references/components.md:417, as a place to grep the base surface. The barrel keeps that path resolvable. Add "and its sibling component/ directory" in phase 7 so the grep advice stays useful.
  • The scaffold templates (packages/cli/templates/**) name no framework source path. Nothing to sync, so the webjs-scaffold-sync skill does not apply to any phase.
  • README.md documents capabilities, not file layout. No change.
  • CONVENTIONS.md covers app conventions. No change.

The doc gate. .claude/hooks/require-docs-with-src.sh BLOCKS a commit that stages packages/*/src or packages/cli/lib with no doc surface alongside. Every phase in this plan stages a real doc surface (the table above assigns at least one to each of phases 1, 2, 3, 4, 8, 9, and phases 5, 6, 7 stage packages/core/AGENTS.md and .agents/skills/webjs/references/components.md). So WEBJS_NO_DOC_GATE=1 is NOT the honest answer for any phase and must not be used. A pure internal move that genuinely changed no documented surface would be the case for it, and this refactor is not that: the "Framework source" sections in AGENTS.md, framework-dev.md, and all three per-package AGENTS.md files exist specifically to tell a cold agent where to look, and this refactor changes where to look. Leaving them stale reproduces the #488 gap the hook was written for.

Invoke the webjs-doc-sync skill on each phase to confirm no surface was missed.


Acceptance criteria

  • Nine PRs merged, each closing this issue, in the order given in D1, each rebased on origin/main immediately before opening.
  • All ten monoliths are barrels over sibling directories, keeping their current paths per D2. No package.json exports, files, or types entry changed in any of the three packages.
  • LOC criterion, in its settled form. The command below prints nothing.
    sh find packages/core/src/router-client packages/core/src/render-client packages/core/src/render-server \ packages/core/src/component packages/core/src/slot packages/server/src/dev \ packages/server/src/vendor packages/server/src/ssr packages/server/src/check packages/cli/lib/doctor \ -name '*.js' -exec wc -l {} + | awk '$1 > 1000 && $2 != "total"'
    It binds only modules produced by this issue. Barrels are exempt. Any exemption beyond packages/core/src/component/base.js must be named in the merging PR with its measured size and reason. No CI guard is added (see D3).
  • Every barrel's runtime export set is byte-identical to its pre-split module's, proved per phase by the D9 export diff with empty missing and empty added, pasted in each PR body.
  • test/architecture/barrel-surface.test.mjs exists with all ten floors and passes.
  • node find-cycles.mjs packages/core/src packages/server/src packages/cli/lib reports zero cycles after every phase, as it does at HEAD.
  • node --input-type=module -e "await import('./packages/core/index.js')" and the same for index-browser.js both load cleanly after every core phase.
  • packages/core/dist/webjs-core-browser.js grew by no more than 1.0% in any single core phase and no more than 2.0% cumulatively from its 137,038-byte baseline. Sizes recorded before and after in each PR body.
  • packages/core/dist/webjs-core.js did not grow at all in phase 8, since render-server.js is excluded from the browser entry and its split must not leak anywhere.
  • npm test, npm run test:browser, the six e2e files under WEBJS_E2E=1, and node scripts/run-bun-tests.js are green on the phases the Tests table marks, with no new skips and no WEBJS_BUN_VERIFIED=1.
  • npx webjs elision --verify reports parity in gallery, examples/blog, and website on phases 4 through 9, with a non-zero compared count in each (a zero-compared run is a vacuous pass and fails this criterion).
  • npx webjs check reports zero violations and npx webjs doctor exits zero in all three apps, on every phase.
  • npx webjs check --rules output is byte-identical to origin/main after phase 3.
  • .claude/hooks/require-bun-parity-with-runtime-src.sh matches every new path under packages/server/src/ssr/ and packages/server/src/dev/, per D10, verified by staging one file from each directory and confirming the hook fires.
  • Every doc surface in the Docs table is updated in the phase it is assigned to. WEBJS_NO_DOC_GATE=1 appears in no commit.
  • No behaviour changed. Any behaviour difference found mid-refactor is reported in the PR body and NOT fixed inside the move commit.

Out of scope

Behaviour-preserving move only. The implementer must not widen into any of the following.

  • No bug fixes. If a split surfaces a real defect, report it in the PR body and leave the code as it was. Fixing it inside a move commit destroys the one property that makes this refactor reviewable, that the diff is a pure relocation.
  • No API changes. No renamed export, no changed signature, no new option, no removed dead export. added in the D9 export diff must be empty, which mechanically forbids this.
  • No new features. In particular no bundler, no webjs build, no dev-mode bundling to claw back the dev-only request count from D6. Those are deliberately deferred per AGENTS.md.
  • No back-compat shims beyond the barrels. No deprecated aliases, no old-path stubs, no re-export of a renamed name. WebJs has no users, so a shim is pure cost. Each barrel is justified by the import sites and the published exports entries measured in D2, not by compatibility.
  • No reformatting of untouched code. Move a function with its comments byte-identical. A whitespace or comment-rewrap pass inside a moved block makes the relocation unreviewable. Prettier or any formatter run across a moved file is a hard no.
  • No splitting of files outside the ten. Specifically NOT packages/core/src/html-entities.js (2179, a generated data table), packages/cli/bin/webjs.js (1665), packages/cli/lib/create.js (1636), packages/editors/intellisense/src/index.js (1493), packages/server/src/component-elision.js (1374), packages/core/src/form-action.js (1258). They are over 800 lines and they are not this issue's business.
  • No LOC CI guard. Reasoned and rejected in D3.
  • No renaming packages/server/src/dev/ to something more accurate. The caveat is recorded in P9 and the 62 import sites are the reason.
  • No follow-up issues. Report findings in the PR body. Fold a small tweak in a file the PR already touches into the PR. Anything genuinely separate goes here, in this section, in the PR that found it.
  • No merging without approval. Each phase asks, and waits for both answers.

Staleness discipline. Every line anchor in this plan is dated to HEAD 509d8809. This issue's file surface overlaps essentially every other in-flight change in the repo, so anchors age fast. Each phase MUST git fetch origin and cut its worktree from origin/main immediately before starting, and MUST re-derive its ranges with grep -n rather than trusting the numbers here. A phase that opens more than a few days after the previous one merged should re-run the structural greps in full before writing a line.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions