Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8eb2fa8
feat(server): thread the per-component elision verdict into the app r…
vivek7405 Aug 6, 2026
5ec592d
feat(cli): add webjs elision, the per-module verdict and its differen…
vivek7405 Aug 6, 2026
d39be6c
feat: surface the elision verdict through MCP, dev, and the contract …
vivek7405 Aug 6, 2026
fe8a64c
test: cover the elision CLI, the doctor check, the MCP tool, and the …
vivek7405 Aug 6, 2026
ef13733
docs: give display-only elision its own page, and stop the false orphans
vivek7405 Aug 6, 2026
a88b54e
test: classify the two new server exports and keep the fuzz corpus clean
vivek7405 Aug 6, 2026
45d9120
fix(cli): let a help entry title its own prose block, and widen the f…
vivek7405 Aug 6, 2026
f499ed4
fix: --verify forces elision on, and the e2e control can actually fail
vivek7405 Aug 6, 2026
4eb1a5c
docs: finish the list_elision sweep and the blog fixture inventory
vivek7405 Aug 6, 2026
d74540a
docs: drop the comment-scanning claim #179 already closed
vivek7405 Aug 6, 2026
55708fa
docs: finish removing the comment-scanning claim from the sibling fix…
vivek7405 Aug 6, 2026
e74249d
docs: the MCP post said four tools, and there are five
vivek7405 Aug 6, 2026
4369eaa
fix: correct two comments I got wrong while correcting the first one
vivek7405 Aug 6, 2026
6437576
fix: an orphan is two shapes, and both messages only described one
vivek7405 Aug 6, 2026
1e90bac
fix: the dev orphan warning described one shape and named the wrong API
vivek7405 Aug 6, 2026
6fe962d
fix: a computed-tag orphan does not always fail to upgrade
vivek7405 Aug 6, 2026
642e3ba
docs: sweep the retracted orphan claim instead of patching it piecemeal
vivek7405 Aug 6, 2026
8c2871d
fix: import-only drops the orphan too, and it is the ordinary case
vivek7405 Aug 6, 2026
20e7477
docs: keep the orphan explanation in one place instead of eight
vivek7405 Aug 6, 2026
62301a9
fix: the type declaration missed the consolidation, and another unive…
vivek7405 Aug 6, 2026
55c88f5
docs: name the exact mechanism behind the template-literal fixture rule
vivek7405 Aug 6, 2026
2de3269
docs: the consolidation dropped both-shapes coverage, restore it
vivek7405 Aug 6, 2026
748acd2
fix: the orphan scan accused sibling-registered classes
vivek7405 Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/webjs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
51 changes: 50 additions & 1 deletion .agents/skills/webjs/references/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,56 @@ A component that does no client-side work renders the same SSR'd HTML with or wi
- the dynamic slot READ surface (`slotchange`, `assignedNodes` / `assignedElements` / `assignedSlot`); merely RENDERING a `<slot>` does not ship (the SSR output carries the placed children, so a display-only slotted wrapper is byte-identical without its JS; native-write liveness is consumer-driven and the consumer's tag reference forces the ship)
- being rendered by a component that itself ships

A bare `async render()` (no other signal, light DOM) is elided too: the SSR'd data is the complete first paint. Force shipping with `static interactive = true` when interactivity is invisible to static analysis (a dynamically-built tag string, a `:defined` rule in an external stylesheet). `static shadow = true` always ships (Declarative Shadow DOM re-attaches only during parsing). Turn elision off app-wide with `{ "webjs": { "elide": false } }` or `WEBJS_ELIDE=0`.
A bare `async render()` (no other signal, light DOM) is elided too: the SSR'd data is the complete first paint. Force shipping with `static interactive = true` when interactivity is invisible to static analysis. `static shadow = true` always ships (Declarative Shadow DOM re-attaches only during parsing). Turn elision off app-wide with `{ "webjs": { "elide": false } }` or `WEBJS_ELIDE=0`.

### What `static interactive = true` does and does not rescue

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

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.

```sh
webjs elision # per-module verdict, and the evidence behind every ship
webjs elision --json # the same object, for a tool or an agent
webjs elision --verify # prove elision changed nothing your app serves
webjs elision --verify --routes /,/blog/hello # add paths (the only way to cover a dynamic route)
```

**Reading the report.** Every component is `elided` or `shipped`. A shipped one carries the `evidence` that forced it, first match wins:

| `evidence` | Means | `by` |
|---|---|---|
| `own` | its own source carries a signal; `reason` is the exact one | null |
| `observed` | another module observes its registration (`whenDefined` / `:defined` / `instanceof`) | the observer |
| `closure` | something it imports does client work | the import |
| `render` | a shipping component can render its tag | that component |
| `import` | a shipping component imports it | that component |
| `unreadable` | its source could not be read, so it ships conservatively | null |

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, 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:

```sh
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. 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.

## Members app code must not shadow

Expand Down
19 changes: 19 additions & 0 deletions .agents/skills/webjs/references/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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:

```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.
Expand Down
2 changes: 1 addition & 1 deletion .claude/hooks/block-prose-punctuation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ fi
# positives (a wrongly blocked write), the same tradeoff as the rules above:
# e.g. a sentence-ending "built on webjs." is not flagged (trailing period),
# and the `bin/webjs.js` "webjs commands:" usage banner may rarely trip it.
webjs_cli='create|dev|start|test|check|routes|db|ui|doctor|types|typecheck|mcp|vendor|help|version|add|init|generate|migrate|push|studio|seed|pin|unpin|list|audit|outdated|update|view|diff|info|build'
webjs_cli='create|dev|start|test|check|routes|elision|db|ui|doctor|types|typecheck|mcp|vendor|help|version|add|init|generate|migrate|push|studio|seed|pin|unpin|list|audit|outdated|update|view|diff|info|build'

# Scan copy: drop fenced code blocks, inline code spans, and emphasis markers
# so a `webjs` inside code is never considered and **webjs** still matches.
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/webjs-start-work/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<topic>/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 <reason>" 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:
Expand Down
Loading