diff --git a/.claude/hooks/require-scaffold-with-src.sh b/.claude/hooks/require-scaffold-with-src.sh
index f76909af..e4fd1f3b 100755
--- a/.claude/hooks/require-scaffold-with-src.sh
+++ b/.claude/hooks/require-scaffold-with-src.sh
@@ -14,10 +14,19 @@
#
# What a hook CANNOT do: know WHETHER a given change actually needs a
# scaffold demo (a bug fix, an internal perf change, or a tweak to an
-# already-demoed feature may not). So it enforces the floor (a
-# feature-source commit must stage SOME scaffold surface OR consciously opt
-# out) and points at the webjs-scaffold-sync skill for the per-surface walk
-# and the mandatory generate-boot-check.
+# already-demoed feature may not), nor tell a real demo from a doc bullet.
+# So it enforces only the FLOOR (a feature-source commit must stage SOME
+# scaffold surface OR consciously opt out) and points at the
+# webjs-scaffold-sync skill for the per-surface walk and the mandatory
+# generate-boot-check.
+#
+# The TIER-2 teeth live in CI, not here: test/scaffolds/gallery-coverage.test.js
+# reconciles the live framework surface (@webjsdev/core + @webjsdev/server exports
+# AND the routing convention files the router parses) against
+# test/scaffolds/gallery-coverage.json and FAILS when a new one is neither
+# demonstrated by the scaffold nor consciously exempted. That is the un-skippable
+# gate (the analogue of a test that must exist AND pass); this hook is the fast
+# commit-time reminder in front of it.
#
# Scope: only fires on `git commit` Bash calls. Inspects the STAGED diff,
# so `git add` choices drive it.
diff --git a/.claude/skills/webjs-scaffold-sync/SKILL.md b/.claude/skills/webjs-scaffold-sync/SKILL.md
index b3471e17..5a3af38f 100644
--- a/.claude/skills/webjs-scaffold-sync/SKILL.md
+++ b/.claude/skills/webjs-scaffold-sync/SKILL.md
@@ -36,13 +36,36 @@ They overlap on two surfaces (the scaffold's per-agent rule files, and the
template matrix in the framework docs/README). Whichever skill reaches that
surface must update it; when in doubt run both.
-A hard commit gate enforces the FLOOR: `.claude/hooks/require-scaffold-with-src.sh`
-BLOCKS a commit that stages framework-feature source (`packages/(core|server|cli)/src`)
-with no scaffold surface (`packages/cli/templates` or `packages/cli/lib`) in the
-same commit (escape hatch `WEBJS_NO_SCAFFOLD_GATE=1` for a change that genuinely
-needs no scaffold update). The hook can only enforce "stage SOME scaffold
-surface"; THIS skill does the substantive per-surface judgment and the
-generate-boot-check verification.
+Enforcement is TWO tiers, deliberately mirroring how tests are enforced (a
+commit-time floor plus an un-skippable CI gate):
+
+- **Tier 1, the commit floor.** `.claude/hooks/require-scaffold-with-src.sh`
+ BLOCKS a commit that stages framework-feature source (`packages/(core|server|cli)/src`)
+ with no scaffold surface (`packages/cli/templates` or `packages/cli/lib`) in the
+ same commit (escape hatch `WEBJS_NO_SCAFFOLD_GATE=1`). Like the test commit
+ gate, it only proves you *touched* a scaffold file; it cannot tell a real demo
+ from a doc bullet, which is exactly how #848 slipped (forbidden()/unauthorized()
+ staged doc bullets, shipped no gallery demo).
+
+- **Tier 2, the CI coverage gate.** `test/scaffolds/gallery-coverage.test.js`
+ reconciles the LIVE framework surface against the hand-curated
+ `test/scaffolds/gallery-coverage.json` manifest and FAILS when a new surface is
+ neither demoed nor exempted. It gates THREE surfaces: **`@webjsdev/core`
+ exports** (a `{ demo }` pointing at a gallery file that references it),
+ **`@webjsdev/server` exports** (`{ demoed: true }`, verified by a generated app
+ importing it), and **routing convention files** (the stems the router parses,
+ DERIVED from `packages/server/src/router.js` so a new `stem === '...'` branch
+ auto-appears, each demonstrated by a file in a generated app). Every entry is
+ `demo`/`demoed` or `{ exempt }` with a reason (`internal: ...` for plumbing,
+ `deferred: ...` for an agent-facing surface not yet demoed). It runs on every
+ `npm test` and in CI, so it cannot be skipped with a local `--no-verify`, the
+ analogue of "a test must exist AND pass": a new export or convention turns CI red
+ until it is classified. **When you add or rename a core/server export, or add a
+ routing convention file the router parses, update the manifest** (a demo, or an
+ honest exemption), the same reflex as writing a test.
+
+THIS skill does the substantive per-surface judgment and the generate-boot-check
+verification that neither tier can automate.
## The complete scaffold surface map
@@ -94,6 +117,7 @@ it applies, then update or consciously skip each.
| New / changed **gallery or showcase demo** | the template file(s) or generator strings for the demo + the home-page `features`/index array + the scaffold AGENTS.md gallery list + `test/scaffolds/*` FEATURES/assertions + **generate + boot the affected template** |
| New / removed **template** | the `create.js` template branch (+ a `*-template.js` if large) + the "only N templates exist" list in EVERY per-agent rule file + the framework `AGENTS.md`/getting-started/README template matrix + the CLI `--template` validation + `--help` + `test/scaffolds/*` |
| New **control-flow throw or routing boundary file** (`notFound` / `redirect` / `forbidden` / `unauthorized` and their `not-found` / `forbidden` / `unauthorized` / `error` / `loading` / `global-error` / `global-not-found` boundary files) | a runnable **gallery demo** that exercises it (a route that throws it plus the nearest boundary file), NOT just an app-tree bullet in the rule files + the home-page `features` array + `test/scaffolds/*` FEATURES/boundary-file asserts + **generate + boot + hit the route**. A doc bullet in `AGENTS.md` / `CONVENTIONS.md` is necessary but NOT sufficient: the gallery is the primary teaching surface, so an undemoed thrower is invisible to a scaffolding agent (the #848 gap). Carve-out: a **root-only** boundary (`global-error` / `global-not-found`) cannot mount under `app/features/` without clashing with the generated app root, so teach those in the demo's PROSE rather than as a live route. |
+| New / renamed **public `@webjsdev/core` or `@webjsdev/server` export, or a new routing convention file** the router parses | `test/scaffolds/gallery-coverage.json` MUST classify it (a `{ demo }` / `{ demoed: true }`, or `{ exempt }` with an `internal:` / `deferred:` reason) or the tier-2 CI gate (`gallery-coverage.test.js`) FAILS. Prefer a real demo; `deferred:` is a conscious, reviewer-visible exemption tracked for later. This is the coverage-gate teeth described above. |
| New **convention/rule** for generated apps | ALL per-agent rule files in lockstep (surface 3) + repo `CONVENTIONS.md` if the repo demonstrates it + `agent-docs` only if it also changes a framework API |
| Changed **generated file** (layout, theme, home, schema, middleware) | the generator (`create.js`/`*-template.js`) + any scaffold test asserting it + any doc/preview describing it + **regenerate + boot** |
| New **scaffold-shipped config/hook** (`.hooks/`, `webjs.*` in the generated `package.json`, a check rule) | `templates/**` + `webjs doctor`/`check` that reads it + the per-agent rule files if agents must know it |
diff --git a/agent-docs/framework-dev.md b/agent-docs/framework-dev.md
index 5ef708bc..d23b4b59 100644
--- a/agent-docs/framework-dev.md
+++ b/agent-docs/framework-dev.md
@@ -38,6 +38,18 @@ The fix only repairs the LOCAL checkout. Commits and branches are always safe on
---
+### Scaffold teaching-coverage gate (`gallery-coverage.test.js`)
+
+The scaffold is webjs's primary teaching surface for AI agents, so a new framework feature must ship a runnable gallery demo, not just a doc bullet. Enforcement is two tiers, mirroring how tests are enforced:
+
+- **Tier 1 (commit floor):** `.claude/hooks/require-scaffold-with-src.sh` blocks a commit that stages `packages/(core|server|cli)/src` with no scaffold surface. It only proves you touched a scaffold file, so a documented-but-undemoed feature can still pass (this is exactly how #848 shipped `forbidden()` / `unauthorized()` with app-tree bullets and no demo).
+
+- **Tier 2 (CI gate):** `test/scaffolds/gallery-coverage.test.js` reconciles the LIVE framework surface against `test/scaffolds/gallery-coverage.json` and FAILS when something new is neither demoed nor exempted. It gates three surfaces: `@webjsdev/core` exports (a `{ demo }` gallery-file pointer), `@webjsdev/server` exports (`{ demoed: true }`, verified by a generated app importing it), and routing convention files (the stems DERIVED from `packages/server/src/router.js`, each demonstrated by a file in a generated app). It runs under `npm test`, so a local `--no-verify` cannot skip it: a new export or convention turns CI red until classified. The `reconcile()` / `reconcileSet()` cores are pure and their failure modes (new name, stale key, missing/over-claimed demo, empty reason) are proven with synthetic inputs alongside the real-surface assertions. The deferred backlog is tracked in #859.
+
+**When you add or rename a `@webjsdev/core` export, update the manifest** the same way you write a test: add a demo pointer, or an honest exemption. The gate covers the export surface (where the #848-class throwers live); a new routing convention FILE type (a `*.ts` boundary) is not an export, so it stays tier-1-only for now.
+
+---
+
### Changelog: per-package, per-version, auto-generated
WebJs ships per-package per-version changelogs under `changelog//.md`. The model: **a version bump is the trigger**. When any commit on `main` changes the `version` field in `packages//package.json`, the scripts/backfill-changelog.js generator emits a new `changelog//.md` summarising every conventional-commit (`feat:` / `fix:` / `breaking:` / `perf:`) that landed in that package since the prior bump. The website renders the union of all packages' files at `/changelog`.
diff --git a/packages/cli/templates/gallery/app/features/routing/[id]/page.ts b/packages/cli/templates/gallery/app/features/routing/[id]/page.ts
index ac348d6d..4f8ca9ee 100644
--- a/packages/cli/templates/gallery/app/features/routing/[id]/page.ts
+++ b/packages/cli/templates/gallery/app/features/routing/[id]/page.ts
@@ -12,12 +12,16 @@
// params, so PageProps<'/features/routing/[id]'>['params'] narrows to
// { id: string } automatically. Rename the folder and the type follows; pass a
// route literal that does not exist and it is a compile error.
-import { html } from '@webjsdev/core';
+import { html, notFound } from '@webjsdev/core';
import type { PageProps } from '@webjsdev/core';
export default async function RoutingParam({ params }: PageProps<'/features/routing/[id]'>) {
// The Next-style await also works; `params.id` sync would be identical.
const { id } = await params;
+ // Throw notFound() to short-circuit into the nearest not-found boundary (404).
+ // Here the reserved id "missing" stands in for "no such record"; in a real app
+ // you throw this after a DB lookup returns nothing. Try /features/routing/missing.
+ if (id === 'missing') notFound();
return html`
Route param
The [id] segment is: ${id}
@@ -28,6 +32,14 @@ export default async function RoutingParam({ params }: PageProps<'/features/rout
params is awaitable too:
const { id } = await params works, same value.
+
+ Throwing wins over rendering: /features/routing/missing
+ throws notFound() and renders the nearest
+ not-found boundary at 404. See the
+ Boundaries demo for
+ forbidden() and
+ unauthorized().
+
Back
`;
}
diff --git a/test/scaffolds/gallery-coverage.json b/test/scaffolds/gallery-coverage.json
new file mode 100644
index 00000000..bbc44d58
--- /dev/null
+++ b/test/scaffolds/gallery-coverage.json
@@ -0,0 +1,752 @@
+{
+ "$comment": "Source of truth for scaffold teaching coverage. gallery-coverage.test.js reconciles this against the LIVE framework surface and FAILS CI when something new is unclassified. Three sections: exports (@webjsdev/core; { demo } points at a gallery file that references it), serverExports (@webjsdev/server; { demoed: true } verified by a generated app importing it), conventions (routing stems parsed by packages/server/src/router.js; { demoed: true } verified by a file of that stem in a generated app). Classify a new entry with a demo or an { exempt } reason (internal: plumbing, deferred: agent-facing not yet demoed). See the webjs-scaffold-sync skill.",
+ "specifier": "@webjsdev/core",
+ "exports": {
+ "ContextConsumer": {
+ "exempt": "deferred: context API; agent-docs/components.md (#859)"
+ },
+ "ContextProvider": {
+ "exempt": "deferred: context API; agent-docs/components.md (#859)"
+ },
+ "ContextRequestEvent": {
+ "exempt": "deferred: context API; agent-docs/components.md (#859)"
+ },
+ "FRAME_CHUNK": {
+ "exempt": "internal: wire/stream protocol constant"
+ },
+ "FRAME_END": {
+ "exempt": "internal: wire/stream protocol constant"
+ },
+ "FRAME_ERROR": {
+ "exempt": "internal: wire/stream protocol constant"
+ },
+ "MARKER": {
+ "exempt": "internal: SSR hydration marker constant"
+ },
+ "SEED_MISS": {
+ "exempt": "internal: SSR action-seed sentinel"
+ },
+ "STREAM_CONTENT_TYPE": {
+ "exempt": "internal: stream-action content-type constant"
+ },
+ "Signal": {
+ "exempt": "internal: Signal class; app code uses signal() / computed()"
+ },
+ "Suspense": {
+ "exempt": "deferred: page-level streaming boundary; documented in agent-docs/advanced.md (#859)"
+ },
+ "Task": {
+ "exempt": "deferred: client async primitive; the gallery teaches async render() instead, agent-docs/advanced.md (#859)"
+ },
+ "TaskStatus": {
+ "exempt": "deferred: Task status enum; pairs with Task (#859)"
+ },
+ "WebComponent": {
+ "demo": "modules/async-render/components/server-clock.ts"
+ },
+ "WebjsFrame": {
+ "exempt": "internal: auto-registered element class; app uses the tag"
+ },
+ "WebjsStream": {
+ "exempt": "internal: auto-registered element class; app uses the tag"
+ },
+ "activeActionSignal": {
+ "exempt": "internal: action-signal plumbing; app code uses actionSignal() from @webjsdev/server"
+ },
+ "adoptStyles": {
+ "exempt": "internal: style-adoption plumbing"
+ },
+ "allTags": {
+ "exempt": "internal: component-registry plumbing"
+ },
+ "asyncAppend": {
+ "exempt": "deferred: asyncAppend directive; agent-docs/components.md (#859)"
+ },
+ "asyncReplace": {
+ "exempt": "deferred: asyncReplace directive; agent-docs/components.md (#859)"
+ },
+ "batch": {
+ "exempt": "deferred: signal batching; agent-docs/components.md (#859)"
+ },
+ "cache": {
+ "exempt": "deferred: lit cache directive (distinct from the server cache() helper); agent-docs/advanced.md (#859)"
+ },
+ "computed": {
+ "exempt": "deferred: derived signal; agent-docs/components.md (#859)"
+ },
+ "connectWS": {
+ "demo": "modules/broadcast/components/broadcast-feed.ts"
+ },
+ "consumeStale": {
+ "exempt": "internal: stale-while-revalidate plumbing"
+ },
+ "createContext": {
+ "exempt": "deferred: context API; agent-docs/components.md (#859)"
+ },
+ "createFrameDecoder": {
+ "exempt": "internal: frame codec used by the client router"
+ },
+ "createRef": {
+ "exempt": "deferred: element ref directive; agent-docs/components.md (#859)"
+ },
+ "cspNonce": {
+ "exempt": "deferred: CSP nonce accessor; agent-docs/configuration.md (#859)"
+ },
+ "css": {
+ "exempt": "deferred: shadow-DOM scoped styles; documented in agent-docs/styling.md (#859)"
+ },
+ "deserialize": {
+ "exempt": "internal: wire serializer; app code round-trips via server actions"
+ },
+ "disableClientRouter": {
+ "exempt": "deferred: client-router opt-out; agent-docs/configuration.md (#859)"
+ },
+ "effect": {
+ "exempt": "deferred: signal effect; agent-docs/components.md (#859)"
+ },
+ "enableClientRouter": {
+ "exempt": "deferred: client-router opt-in; agent-docs/configuration.md (#859)"
+ },
+ "encodeFrame": {
+ "exempt": "internal: frame codec used by the client router"
+ },
+ "escapeAttr": {
+ "exempt": "internal: SSR escaping primitive"
+ },
+ "escapeText": {
+ "exempt": "internal: SSR escaping primitive"
+ },
+ "fetchMark": {
+ "exempt": "internal: stale-while-revalidate plumbing"
+ },
+ "forbidden": {
+ "demo": "app/features/boundaries/gated/page.ts"
+ },
+ "guard": {
+ "exempt": "deferred: guard directive; agent-docs/components.md (#859)"
+ },
+ "html": {
+ "demo": "app/features/async-render/page.ts"
+ },
+ "isAsyncAppend": {
+ "exempt": "internal: directive type guard used by the renderer"
+ },
+ "isAsyncReplace": {
+ "exempt": "internal: directive type guard used by the renderer"
+ },
+ "isCSS": {
+ "exempt": "internal: value type guard used by the renderer"
+ },
+ "isCache": {
+ "exempt": "internal: directive type guard used by the renderer"
+ },
+ "isForbidden": {
+ "exempt": "internal: control-flow throw guard used by the renderer"
+ },
+ "isGuard": {
+ "exempt": "internal: directive type guard used by the renderer"
+ },
+ "isKeyed": {
+ "exempt": "internal: directive type guard used by the renderer"
+ },
+ "isLazy": {
+ "exempt": "internal: directive type guard used by the renderer"
+ },
+ "isLive": {
+ "exempt": "internal: directive type guard used by the renderer"
+ },
+ "isNotFound": {
+ "exempt": "internal: control-flow throw guard used by the renderer"
+ },
+ "isRedirect": {
+ "exempt": "internal: control-flow throw guard used by the renderer"
+ },
+ "isRef": {
+ "exempt": "internal: directive type guard used by the renderer"
+ },
+ "isRepeat": {
+ "exempt": "internal: directive type guard used by the renderer"
+ },
+ "isSignal": {
+ "exempt": "internal: value type guard used by the renderer"
+ },
+ "isSuspense": {
+ "exempt": "internal: value type guard used by the renderer"
+ },
+ "isTemplate": {
+ "exempt": "internal: value type guard used by the renderer"
+ },
+ "isTemplateContent": {
+ "exempt": "internal: directive type guard used by the renderer"
+ },
+ "isUnauthorized": {
+ "exempt": "internal: control-flow throw guard used by the renderer"
+ },
+ "isUnsafeHTML": {
+ "exempt": "internal: directive type guard used by the renderer"
+ },
+ "isUntil": {
+ "exempt": "internal: directive type guard used by the renderer"
+ },
+ "isWatch": {
+ "exempt": "internal: directive type guard used by the renderer"
+ },
+ "keyed": {
+ "exempt": "deferred: keyed directive; agent-docs/components.md (#859)"
+ },
+ "live": {
+ "exempt": "deferred: live directive; agent-docs/components.md (#859)"
+ },
+ "lookup": {
+ "exempt": "internal: component-registry plumbing"
+ },
+ "lookupModuleUrl": {
+ "exempt": "internal: module-url plumbing"
+ },
+ "markStale": {
+ "exempt": "internal: stale-while-revalidate plumbing"
+ },
+ "navigate": {
+ "exempt": "deferred: programmatic client navigation; mentioned in the routing demo prose (#859)"
+ },
+ "notFound": {
+ "demo": "app/features/routing/[id]/page.ts"
+ },
+ "optimistic": {
+ "demo": "modules/optimistic-ui/components/like-button.ts"
+ },
+ "parse": {
+ "exempt": "internal: wire serializer; app code round-trips via server actions"
+ },
+ "parseTagHeader": {
+ "exempt": "internal: partial-nav header plumbing"
+ },
+ "primeModuleUrl": {
+ "exempt": "internal: module-url plumbing"
+ },
+ "prop": {
+ "demo": "modules/components/components/counter-card.ts"
+ },
+ "redirect": {
+ "exempt": "deferred: server redirect throw; mentioned in the boundaries demo prose (#859)"
+ },
+ "ref": {
+ "exempt": "deferred: element ref directive; agent-docs/components.md (#859)"
+ },
+ "register": {
+ "demo": "modules/components/components/counter-card.ts"
+ },
+ "registerKeyTags": {
+ "exempt": "internal: keyed-list registry plumbing"
+ },
+ "render": {
+ "exempt": "internal: client render entry; the framework calls it, app code writes components"
+ },
+ "renderStream": {
+ "exempt": "internal: the applier is auto-wired by the client router; app uses the tag / connectWS"
+ },
+ "renderToStream": {
+ "exempt": "internal: server render entry; used by the framework and tests, not authored in app pages"
+ },
+ "renderToString": {
+ "exempt": "internal: server render entry; used by the framework and tests, not authored in app pages"
+ },
+ "repeat": {
+ "demo": "modules/directives/components/directive-demo.ts"
+ },
+ "revalidate": {
+ "exempt": "deferred: client snapshot eviction; agent-docs/built-ins.md (#859)"
+ },
+ "richFetch": {
+ "exempt": "deferred: content-negotiated rich fetch; agent-docs/advanced.md (#859)"
+ },
+ "scanSeeds": {
+ "exempt": "internal: SSR action-seeding plumbing"
+ },
+ "serialize": {
+ "exempt": "internal: wire serializer; app code round-trips via server actions"
+ },
+ "setActiveActionSignal": {
+ "exempt": "internal: action-signal plumbing; app code uses actionSignal() from @webjsdev/server"
+ },
+ "setCspNonceProvider": {
+ "exempt": "internal: server-only CSP nonce provider setup"
+ },
+ "signal": {
+ "demo": "modules/broadcast/components/broadcast-feed.ts"
+ },
+ "stringify": {
+ "exempt": "internal: wire serializer; app code round-trips via server actions"
+ },
+ "stylesToString": {
+ "exempt": "internal: style-adoption plumbing"
+ },
+ "tagOf": {
+ "exempt": "internal: component-registry plumbing"
+ },
+ "takeSeed": {
+ "exempt": "internal: SSR action-seeding plumbing"
+ },
+ "templateContent": {
+ "exempt": "deferred: templateContent directive; agent-docs/components.md (#859)"
+ },
+ "unauthorized": {
+ "demo": "app/features/boundaries/private/page.ts"
+ },
+ "unsafeHTML": {
+ "exempt": "deferred: trusted raw-HTML directive; agent-docs/components.md (#859)"
+ },
+ "until": {
+ "exempt": "deferred: until directive; agent-docs/components.md (#859)"
+ },
+ "watch": {
+ "demo": "modules/directives/components/directive-demo.ts"
+ }
+ },
+ "serverSpecifier": "@webjsdev/server",
+ "serverExports": {
+ "Credentials": {
+ "demoed": true
+ },
+ "DEFAULT_UPLOAD_DIR": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "GitHub": {
+ "exempt": "deferred: OAuth provider preset (#865)"
+ },
+ "Google": {
+ "exempt": "deferred: OAuth provider preset (#865)"
+ },
+ "STREAM_MIME": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "SUPPORTED_PROVIDERS": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "Session": {
+ "demoed": true
+ },
+ "acceptsStream": {
+ "demoed": true
+ },
+ "actionContext": {
+ "exempt": "deferred: per-action middleware context accessor (#865)"
+ },
+ "actionEndpoint": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "actionSignal": {
+ "exempt": "deferred: action AbortSignal accessor (#865)"
+ },
+ "analyzeAppElision": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "applyCorsHeaders": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "applyEnvValidation": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "assertNodeVersion": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "assertSafeKey": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "attachWebSocket": {
+ "exempt": "deferred: WS upgrade helper (#865)"
+ },
+ "auditPinned": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "broadcast": {
+ "demoed": true
+ },
+ "buildActionIndex": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "buildImportMap": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "buildModuleGraph": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "buildRouteTable": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "cache": {
+ "demoed": true
+ },
+ "checkImportmapCoherence": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "checkNodeVersion": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "clearVendorCache": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "clientIp": {
+ "exempt": "deferred: client IP accessor (#865)"
+ },
+ "cookieSession": {
+ "exempt": "deferred: cookie session strategy (#865)"
+ },
+ "cookieSessionStorage": {
+ "exempt": "deferred: cookie session storage (#865)"
+ },
+ "cookies": {
+ "exempt": "deferred: request cookie accessor (#865)"
+ },
+ "cookiesToHeader": {
+ "exempt": "deferred: cookie serializer (#865)"
+ },
+ "cors": {
+ "demoed": true
+ },
+ "createAuth": {
+ "demoed": true
+ },
+ "createBrowserTestHandler": {
+ "demoed": true
+ },
+ "createRequestHandler": {
+ "demoed": true
+ },
+ "cspNonce": {
+ "exempt": "deferred: CSP nonce accessor (#865)"
+ },
+ "defaultLogger": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "defaultSerializer": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "diskStore": {
+ "exempt": "deferred: disk file store (#865)"
+ },
+ "ensureVendorCommittable": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "extractComponents": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "extractPackageName": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "extractPinnedVersions": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "findOrphanComponents": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "findOutdated": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "formatEnvErrors": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "generateKey": {
+ "demoed": true
+ },
+ "generateRouteTypes": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "getFileStore": {
+ "demoed": true
+ },
+ "getPackageManifest": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "getPackageVersion": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "getRequest": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "getSerializer": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "getSession": {
+ "exempt": "deferred: session read (#865)"
+ },
+ "getSetCookies": {
+ "exempt": "deferred: Set-Cookie reader (#865)"
+ },
+ "getStore": {
+ "exempt": "deferred: active store accessor (#865)"
+ },
+ "handleApi": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "hasVendorPin": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "hashFile": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "headers": {
+ "exempt": "deferred: security headers helper (#865)"
+ },
+ "importMapTag": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "invokeAction": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "invokeActionForTest": {
+ "demoed": true
+ },
+ "isServerFile": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "json": {
+ "exempt": "deferred: JSON response helper (#865)"
+ },
+ "jspmGenerate": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "listPinned": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "loadEnvSchema": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "loginAndGetCookies": {
+ "demoed": true
+ },
+ "matchApi": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "matchPage": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "memoryStore": {
+ "exempt": "deferred: in-memory store (#865)"
+ },
+ "normalizeProvider": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "parseMajor": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "parseRequiredMajor": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "parseWindow": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "pinAll": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "primeComponentRegistry": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "rateLimit": {
+ "demoed": true
+ },
+ "rawActionRequest": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "readBody": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "readPinFile": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "redisStore": {
+ "demoed": true
+ },
+ "requestId": {
+ "exempt": "deferred: request id accessor (#865)"
+ },
+ "requiredNodeMajor": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "resolveOrigin": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "resolveServerModule": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "resolveVendorImports": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "revalidateAll": {
+ "exempt": "deferred: cache flush (#865)"
+ },
+ "revalidatePath": {
+ "exempt": "deferred: path cache eviction (#865)"
+ },
+ "revalidateTag": {
+ "exempt": "deferred: cache tag eviction (#865)"
+ },
+ "revalidateTags": {
+ "exempt": "deferred: cache tags eviction (#865)"
+ },
+ "route": {
+ "demoed": true
+ },
+ "runInstrumentation": {
+ "exempt": "deferred: instrumentation runner (#865)"
+ },
+ "runWithActionSignal": {
+ "exempt": "deferred: action-signal scope helper (#865)"
+ },
+ "satisfiesSemverRange": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "scanBareImports": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "scanComponents": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "serveActionStub": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "serveDownloadedBundle": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "session": {
+ "exempt": "deferred: session helper (#865)"
+ },
+ "setFileStore": {
+ "exempt": "deferred: file-store setter (#865)"
+ },
+ "setOnError": {
+ "exempt": "deferred: APM error hook (#865)"
+ },
+ "setSerializer": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "setStore": {
+ "demoed": true
+ },
+ "setVendorEntries": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "signedUrl": {
+ "exempt": "deferred: signed URL builder (#865)"
+ },
+ "sitemap": {
+ "exempt": "deferred: sitemap serializer (#865)"
+ },
+ "sitemapIndex": {
+ "exempt": "deferred: sitemap index serializer (#865)"
+ },
+ "ssrNotFound": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "ssrPage": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "stampRemoteIp": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "startServer": {
+ "exempt": "deferred: programmatic server boot (#865)"
+ },
+ "storeSession": {
+ "exempt": "deferred: session persistence (#865)"
+ },
+ "storeSessionStorage": {
+ "exempt": "deferred: session storage (#865)"
+ },
+ "stream": {
+ "demoed": true
+ },
+ "streamResponse": {
+ "demoed": true
+ },
+ "testRequest": {
+ "demoed": true
+ },
+ "toRequest": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "transitiveDeps": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "unpinPackage": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "updatePinned": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "validateEnv": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "vendorImportMapEntries": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "verifySignedUrl": {
+ "exempt": "deferred: signed URL verifier (#865)"
+ },
+ "withCookies": {
+ "exempt": "deferred: cookie response helper (#865)"
+ },
+ "withRequest": {
+ "exempt": "internal: framework/CLI plumbing"
+ },
+ "withSessionCookie": {
+ "demoed": true
+ }
+ },
+ "conventions": {
+ "page": {
+ "demoed": true
+ },
+ "layout": {
+ "demoed": true
+ },
+ "error": {
+ "exempt": "deferred: no example error file shipped by the scaffold yet (#865)"
+ },
+ "loading": {
+ "exempt": "deferred: no example loading file shipped by the scaffold yet (#865)"
+ },
+ "middleware": {
+ "demoed": true
+ },
+ "not-found": {
+ "exempt": "deferred: no example not-found file shipped by the scaffold yet (#865)"
+ },
+ "forbidden": {
+ "demoed": true
+ },
+ "unauthorized": {
+ "demoed": true
+ },
+ "global-error": {
+ "exempt": "deferred: no example global-error file shipped by the scaffold yet (#865)"
+ },
+ "global-not-found": {
+ "exempt": "deferred: no example global-not-found file shipped by the scaffold yet (#865)"
+ },
+ "route": {
+ "demoed": true
+ },
+ "sitemap": {
+ "exempt": "deferred: no example sitemap file shipped by the scaffold yet (#865)"
+ },
+ "robots": {
+ "exempt": "deferred: no example robots file shipped by the scaffold yet (#865)"
+ },
+ "manifest": {
+ "exempt": "deferred: no example manifest file shipped by the scaffold yet (#865)"
+ },
+ "icon": {
+ "exempt": "deferred: no example icon file shipped by the scaffold yet (#865)"
+ },
+ "apple-icon": {
+ "exempt": "deferred: no example apple-icon file shipped by the scaffold yet (#865)"
+ },
+ "opengraph-image": {
+ "exempt": "deferred: no example opengraph-image file shipped by the scaffold yet (#865)"
+ },
+ "twitter-image": {
+ "exempt": "deferred: no example twitter-image file shipped by the scaffold yet (#865)"
+ }
+ }
+}
diff --git a/test/scaffolds/gallery-coverage.test.js b/test/scaffolds/gallery-coverage.test.js
new file mode 100644
index 00000000..3016b4c9
--- /dev/null
+++ b/test/scaffolds/gallery-coverage.test.js
@@ -0,0 +1,324 @@
+// Scaffold teaching-coverage gate (the tier-2, un-skippable enforcement).
+//
+// The `require-scaffold-with-src.sh` commit hook is only a FLOOR: it blocks a
+// feature commit that stages NO scaffold surface, but it cannot tell a real
+// demo from a doc bullet, so a new API can ship documented-but-undemoed (the
+// #848 gap: forbidden()/unauthorized() got app-tree bullets, no gallery demo).
+//
+// This test is the missing tier-2: it runs on every `npm test` (and in CI),
+// reconciles the LIVE framework surface against the hand-curated
+// test/scaffolds/gallery-coverage.json manifest, and FAILS when something new is
+// neither demoed nor exempted. So the moment someone adds a surface, CI is red
+// until they either add a demo or consciously exempt it with a reason. That is
+// the same shape as tests: existence is machine-enforced here, and whether the
+// demo/exemption is honest is a code-review concern on the PR.
+//
+// Three surfaces are gated:
+// 1. @webjsdev/core exports -> a { demo } pointing at a gallery file that references it.
+// 2. @webjsdev/server exports -> { demoed: true } verified by a generated app importing it.
+// 3. routing convention files -> the stems the router parses (page/layout/error/
+// loading/not-found/forbidden/unauthorized/global-*/route + metadata), each
+// demonstrated by a file in a generated app, or exempted.
+// The convention stems are DERIVED from packages/server/src/router.js source, so a
+// new `stem === '...'` branch auto-appears and forces classification.
+//
+// The reconcile*() cores are pure functions so the failure modes are proven with
+// SYNTHETIC inputs (a new name, a stale key, a missing demo, an empty reason)
+// without mutating the framework, plus assertions over the REAL surfaces.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync, existsSync, readdirSync, mkdtempSync, rmSync } from 'node:fs';
+import { join, dirname, basename } from 'node:path';
+import { tmpdir } from 'node:os';
+import { fileURLToPath } from 'node:url';
+import * as core from '@webjsdev/core';
+import * as server from '@webjsdev/server';
+import { scaffoldApp } from '../../packages/cli/lib/create.js';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const REPO = join(__dirname, '..', '..');
+const GALLERY = join(REPO, 'packages', 'cli', 'templates', 'gallery');
+const MANIFEST_PATH = join(__dirname, 'gallery-coverage.json');
+const ROUTER_SRC = join(REPO, 'packages', 'server', 'src', 'router.js');
+
+/**
+ * Pure reconciliation. Returns a list of human-readable error strings; empty
+ * means fully covered. Injectable file predicates so synthetic cases need no fs.
+ *
+ * @param {string[]} liveExports the real export names
+ * @param {{ exports: Record }} manifest
+ * @param {(rel: string) => boolean} demoFileExists gallery-relative path -> exists?
+ * @param {(rel: string, sym: string) => boolean} demoFileTeaches file mentions the symbol as a word?
+ */
+export function reconcile(liveExports, manifest, demoFileExists, demoFileTeaches) {
+ const errors = [];
+ const entries = manifest.exports || {};
+ const classified = new Set(Object.keys(entries));
+ const live = new Set(liveExports);
+
+ // 1. Every live export MUST be classified (the tier-2 teeth: a new export fails).
+ for (const name of liveExports) {
+ if (!classified.has(name)) {
+ errors.push(
+ `unclassified export "${name}": add a { demo } (a runnable gallery example) ` +
+ `or an { exempt } (reason "internal: ..." or "deferred: ...") to ` +
+ `test/scaffolds/gallery-coverage.json. The scaffold is the primary teaching ` +
+ `surface, so a new @webjsdev/core export must be demoed or consciously exempted.`,
+ );
+ }
+ }
+
+ // 2. No stale manifest keys (export removed or renamed).
+ for (const name of classified) {
+ if (!live.has(name)) {
+ errors.push(`stale manifest entry "${name}": no longer a @webjsdev/core export, remove or rename it.`);
+ }
+ }
+
+ // 3. Per-entry shape + demo integrity.
+ for (const [name, entry] of Object.entries(entries)) {
+ if (!live.has(name)) continue; // already reported as stale
+ const hasDemo = typeof entry.demo === 'string' && entry.demo.length > 0;
+ const hasExempt = typeof entry.exempt === 'string' && entry.exempt.trim().length > 0;
+ if (hasDemo === hasExempt) {
+ errors.push(`entry "${name}" must have exactly one of { demo } or a non-empty { exempt }.`);
+ continue;
+ }
+ if (hasDemo) {
+ if (!demoFileExists(entry.demo)) {
+ errors.push(`demo for "${name}" points at "${entry.demo}", which does not exist under the gallery.`);
+ } else if (!demoFileTeaches(entry.demo, name)) {
+ errors.push(`demo for "${name}" ("${entry.demo}") does not reference "${name}"; point at a file that actually uses it.`);
+ }
+ }
+ }
+ return errors;
+}
+
+/**
+ * Reconcile a { demoed: true | exempt } section against the set of names the
+ * scaffold actually demonstrates. Used for @webjsdev/server exports (demonstrated
+ * = imported by a generated app) and routing convention files (demonstrated = a
+ * file of that stem exists in a generated app).
+ *
+ * @param {string[]} liveNames
+ * @param {Record} entries
+ * @param {Set} demonstrated names the generated apps actually show
+ * @param {string} kind label for error messages
+ */
+export function reconcileSet(liveNames, entries, demonstrated, kind) {
+ const errors = [];
+ const classified = new Set(Object.keys(entries));
+ const live = new Set(liveNames);
+
+ // 1. Every live name MUST be classified (the tier-2 teeth).
+ for (const name of liveNames) {
+ if (!classified.has(name)) {
+ errors.push(
+ `unclassified ${kind} "${name}": add { demoed: true } (the scaffold demonstrates it) ` +
+ `or { exempt } (reason "internal: ..." or "deferred: ...") to test/scaffolds/gallery-coverage.json.`,
+ );
+ }
+ }
+ // 2. No stale entries.
+ for (const name of classified) {
+ if (!live.has(name)) errors.push(`stale ${kind} entry "${name}": no longer present, remove or rename it.`);
+ }
+ // 3. Shape + over-claim check. A { demoed: true } that the scaffold does NOT
+ // actually demonstrate is a false claim and fails; an exempt name the
+ // scaffold happens to demonstrate is a safe under-claim (not failed).
+ for (const [name, entry] of Object.entries(entries)) {
+ if (!live.has(name)) continue;
+ const hasDemoed = entry.demoed === true;
+ const hasExempt = typeof entry.exempt === 'string' && entry.exempt.trim().length > 0;
+ if (hasDemoed === hasExempt) {
+ errors.push(`${kind} "${name}" must have exactly one of { demoed: true } or a non-empty { exempt }.`);
+ continue;
+ }
+ if (hasDemoed && !demonstrated.has(name)) {
+ errors.push(`${kind} "${name}" is marked demoed but no generated app demonstrates it; add a real demo or exempt it.`);
+ }
+ }
+ return errors;
+}
+
+// Derive the routing convention stems from the router source, so a new
+// `stem === '...'` branch (or METADATA_STEMS entry) auto-appears and must be
+// classified. This is the convention-file analogue of Object.keys(core).
+function deriveConventionStems() {
+ const src = readFileSync(ROUTER_SRC, 'utf8');
+ const stems = new Set([...src.matchAll(/stem === '([a-z-]+)'/g)].map((m) => m[1]));
+ const meta = (src.match(/METADATA_STEMS = new Set\(\[([^\]]*)\]/) || [])[1] || '';
+ for (const m of meta.matchAll(/'([a-z-]+)'/g)) stems.add(m[1]);
+ return [...stems].sort();
+}
+
+// Generate one app per template (files only) and report which @webjsdev/server
+// names they import and which convention-file stems they contain. Memoized so
+// the three generations happen once for the whole suite.
+let _analysis = null;
+function scaffoldAnalysis() {
+ if (_analysis) return _analysis;
+ _analysis = (async () => {
+ const base = mkdtempSync(join(tmpdir(), 'webjs-coverage-'));
+ const serverNames = new Set();
+ const conventions = new Set();
+ const importRe = /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+['"]@webjsdev\/server[^'"]*['"]/g;
+ const walk = (d) => {
+ for (const e of readdirSync(d, { withFileTypes: true })) {
+ if (e.name === 'node_modules' || e.name === '.git') continue;
+ const f = join(d, e.name);
+ if (e.isDirectory()) { walk(f); continue; }
+ conventions.add(basename(f).replace(/\.(ts|js|mts|mjs)$/, ''));
+ let src;
+ try { src = readFileSync(f, 'utf8'); } catch { continue; }
+ let m;
+ while ((m = importRe.exec(src))) {
+ for (let n of m[1].split(',')) {
+ n = n.trim().split(/\s+as\s+/)[0].trim();
+ if (n) serverNames.add(n);
+ }
+ }
+ }
+ };
+ try {
+ for (const t of ['full-stack', 'api', 'saas']) {
+ // scaffoldApp(name, parentDir) writes parentDir/name.
+ await scaffoldApp(t, base, { template: t, install: false });
+ walk(join(base, t));
+ }
+ } finally {
+ rmSync(base, { recursive: true, force: true });
+ }
+ return { serverNames, conventions };
+ })();
+ return _analysis;
+}
+
+const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
+const liveExports = Object.keys(core);
+
+const realDemoExists = (rel) => existsSync(join(GALLERY, rel));
+const wordRe = (sym) => new RegExp(`\\b${sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
+const realDemoTeaches = (rel, sym) => {
+ try { return wordRe(sym).test(readFileSync(join(GALLERY, rel), 'utf8')); }
+ catch { return false; }
+};
+
+test('every @webjsdev/core export is demoed in the gallery or consciously exempted', () => {
+ const errors = reconcile(liveExports, manifest, realDemoExists, realDemoTeaches);
+ const demoed = Object.values(manifest.exports).filter((e) => e.demo).length;
+ const internal = Object.values(manifest.exports).filter((e) => e.exempt?.startsWith('internal:')).length;
+ const deferred = Object.entries(manifest.exports).filter(([, e]) => e.exempt?.startsWith('deferred:'));
+ // Visibility: print the coverage split and the deferred (agent-facing, not-yet-demoed) list every run.
+ console.log(
+ `[scaffold-coverage] ${liveExports.length} @webjsdev/core exports: ` +
+ `${demoed} demoed, ${internal} internal-exempt, ${deferred.length} deferred (agent-facing, not yet demoed).`,
+ );
+ if (deferred.length) console.log('[scaffold-coverage] deferred: ' + deferred.map(([n]) => n).sort().join(', '));
+ assert.deepEqual(errors, [], `scaffold teaching-coverage gaps:\n - ${errors.join('\n - ')}`);
+});
+
+test('reconcile FAILS on a new unclassified export (the tier-2 teeth)', () => {
+ const errors = reconcile(
+ [...liveExports, 'brandNewFeature'],
+ manifest,
+ realDemoExists,
+ realDemoTeaches,
+ );
+ assert.equal(errors.length, 1);
+ assert.match(errors[0], /unclassified export "brandNewFeature"/);
+});
+
+test('reconcile FAILS on a stale manifest entry (export removed/renamed)', () => {
+ const shrunk = liveExports.filter((n) => n !== 'html');
+ const errors = reconcile(shrunk, manifest, realDemoExists, realDemoTeaches);
+ assert.ok(errors.some((e) => /stale manifest entry "html"/.test(e)), errors.join('\n'));
+});
+
+test('reconcile FAILS when a demo pointer references a missing file', () => {
+ const m = { exports: { html: { demo: 'app/features/gone/page.ts' } } };
+ const errors = reconcile(['html'], m, () => false, () => false);
+ assert.equal(errors.length, 1);
+ assert.match(errors[0], /does not exist under the gallery/);
+});
+
+test('reconcile FAILS when a demo file does not actually reference the symbol', () => {
+ const m = { exports: { html: { demo: 'app/features/x/page.ts' } } };
+ const errors = reconcile(['html'], m, () => true, () => false);
+ assert.equal(errors.length, 1);
+ assert.match(errors[0], /does not reference "html"/);
+});
+
+test('reconcile FAILS on an empty exemption reason', () => {
+ const m = { exports: { html: { exempt: ' ' } } };
+ const errors = reconcile(['html'], m, () => true, () => true);
+ assert.equal(errors.length, 1);
+ assert.match(errors[0], /exactly one of \{ demo \} or a non-empty \{ exempt \}/);
+});
+
+// ---------------------------------------------------------------------------
+// Gap 2: @webjsdev/server exports. Demonstrated = imported by a generated app.
+// ---------------------------------------------------------------------------
+
+const liveServer = Object.keys(server);
+
+test('every @webjsdev/server export is demonstrated by the scaffold or exempted', async () => {
+ const { serverNames } = await scaffoldAnalysis();
+ const entries = manifest.serverExports || {};
+ const errors = reconcileSet(liveServer, entries, serverNames, 'server export');
+ const demoed = Object.values(entries).filter((e) => e.demoed).length;
+ const internal = Object.values(entries).filter((e) => e.exempt?.startsWith('internal:')).length;
+ const deferred = Object.entries(entries).filter(([, e]) => e.exempt?.startsWith('deferred:'));
+ console.log(
+ `[scaffold-coverage] ${liveServer.length} @webjsdev/server exports: ` +
+ `${demoed} demoed, ${internal} internal-exempt, ${deferred.length} deferred.`,
+ );
+ if (deferred.length) console.log('[scaffold-coverage] server deferred: ' + deferred.map(([n]) => n).sort().join(', '));
+ assert.deepEqual(errors, [], `server-export coverage gaps:\n - ${errors.join('\n - ')}`);
+});
+
+// ---------------------------------------------------------------------------
+// Gap 1: routing convention files. Stems derived from router.js source;
+// demonstrated = a file of that stem exists in a generated app.
+// ---------------------------------------------------------------------------
+
+const conventionStems = deriveConventionStems();
+
+test('every routing convention file the router parses is demonstrated or exempted', async () => {
+ const { conventions } = await scaffoldAnalysis();
+ const entries = manifest.conventions || {};
+ const errors = reconcileSet(conventionStems, entries, conventions, 'convention file');
+ const demoed = Object.values(entries).filter((e) => e.demoed).length;
+ const deferred = Object.entries(entries).filter(([, e]) => e.exempt?.startsWith('deferred:'));
+ console.log(
+ `[scaffold-coverage] ${conventionStems.length} routing convention files: ` +
+ `${demoed} demonstrated, ${deferred.length} deferred.`,
+ );
+ if (deferred.length) console.log('[scaffold-coverage] convention deferred: ' + deferred.map(([n]) => n).sort().join(', '));
+ assert.deepEqual(errors, [], `convention-file coverage gaps:\n - ${errors.join('\n - ')}`);
+});
+
+test('deriveConventionStems finds the #848 boundary stems (source is parseable)', () => {
+ for (const stem of ['page', 'layout', 'not-found', 'forbidden', 'unauthorized', 'global-error', 'global-not-found']) {
+ assert.ok(conventionStems.includes(stem), `expected router to parse "${stem}" (got: ${conventionStems.join(', ')})`);
+ }
+});
+
+test('reconcileSet FAILS on a new unclassified name (the tier-2 teeth)', () => {
+ const errors = reconcileSet(['broadcast', 'brandNewApi'], { broadcast: { demoed: true } }, new Set(['broadcast']), 'server export');
+ assert.ok(errors.some((e) => /unclassified server export "brandNewApi"/.test(e)), errors.join('\n'));
+});
+
+test('reconcileSet FAILS when a name is marked demoed but nothing demonstrates it', () => {
+ const errors = reconcileSet(['broadcast'], { broadcast: { demoed: true } }, new Set(), 'server export');
+ assert.equal(errors.length, 1);
+ assert.match(errors[0], /marked demoed but no generated app demonstrates it/);
+});
+
+test('reconcileSet FAILS on a stale entry and on an empty exemption', () => {
+ const stale = reconcileSet([], { gone: { exempt: 'internal: x' } }, new Set(), 'server export');
+ assert.ok(stale.some((e) => /stale server export entry "gone"/.test(e)), stale.join('\n'));
+ const empty = reconcileSet(['x'], { x: { exempt: ' ' } }, new Set(), 'server export');
+ assert.match(empty[0], /exactly one of \{ demoed: true \}/);
+});