From 05632c64688a5f37b3908781a95e0c1d820289af Mon Sep 17 00:00:00 2001
From: Vivek
Date: Thu, 9 Jul 2026 11:46:19 +0530
Subject: [PATCH 1/3] feat: add tier-2 scaffold teaching-coverage gate for core
exports
The require-scaffold-with-src commit hook is only a floor: it proves a
feature commit touched SOME scaffold file, but cannot tell a real demo
from a doc bullet, which is how #848 shipped forbidden()/unauthorized()
documented but undemoed.
Add the missing tier-2, mirroring how tests are enforced (a commit floor
plus an un-skippable CI gate). gallery-coverage.test.js reconciles the
live @webjsdev/core export surface against a hand-curated
gallery-coverage.json manifest and FAILS when a new export is neither
demoed in the gallery nor consciously exempted (internal plumbing, or a
deferred agent-facing API). It runs under npm test, so a local
--no-verify cannot skip it: a new export turns CI red until classified.
The reconcile() core is pure; its failure modes (new export, stale key,
missing demo file, empty reason) are proven with synthetic inputs beside
the real-surface assertion. Seed the manifest across all 100 current
exports (12 demoed, 59 internal, 29 deferred, tracked in #859), and make
notFound genuinely demoed by having routing/[id] throw it for a reserved
id (verified: /features/routing/missing renders 404).
---
.../gallery/app/features/routing/[id]/page.ts | 14 +-
test/scaffolds/gallery-coverage.json | 306 ++++++++++++++++++
test/scaffolds/gallery-coverage.test.js | 147 +++++++++
3 files changed, 466 insertions(+), 1 deletion(-)
create mode 100644 test/scaffolds/gallery-coverage.json
create mode 100644 test/scaffolds/gallery-coverage.test.js
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..7e7df0a1
--- /dev/null
+++ b/test/scaffolds/gallery-coverage.json
@@ -0,0 +1,306 @@
+{
+ "$comment": "Source of truth for scaffold teaching coverage of @webjsdev/core. The gallery-coverage.test.js gate reconciles this against the LIVE export surface and the gallery template files, and FAILS CI when a new export is neither demoed nor exempted. Add a { demo } (a runnable gallery example) or an { exempt } (with a reason: \"internal: ...\" for plumbing, \"deferred: ...\" for an agent-facing API not yet demoed) when you add a core export. 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"
+ }
+ }
+}
diff --git a/test/scaffolds/gallery-coverage.test.js b/test/scaffolds/gallery-coverage.test.js
new file mode 100644
index 00000000..04bea852
--- /dev/null
+++ b/test/scaffolds/gallery-coverage.test.js
@@ -0,0 +1,147 @@
+// 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 @webjsdev/core export surface against the hand-curated
+// test/scaffolds/gallery-coverage.json manifest, and FAILS when a new export is
+// neither demoed nor exempted. So the moment someone adds a core export, CI is
+// red until they either add a runnable gallery demo (a { demo } pointer) or
+// consciously exempt it with a reason (an { exempt } string). 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.
+//
+// The reconcile() core is a pure function so the failure modes are proven with
+// SYNTHETIC inputs (a new export, a stale key, a missing demo file, an empty
+// reason) without mutating the real framework, plus one assertion over the REAL
+// surface that must stay green.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync, existsSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import * as core from '@webjsdev/core';
+
+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');
+
+/**
+ * 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;
+}
+
+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 \}/);
+});
From 62ca1fa6d1aa8b051434f2d2fb32fb85eac3707a Mon Sep 17 00:00:00 2001
From: Vivek
Date: Thu, 9 Jul 2026 11:46:26 +0530
Subject: [PATCH 2/3] docs: document the tier-2 scaffold-coverage gate across
enforcement surfaces
Wire the new gate into the surfaces that describe scaffold enforcement:
the webjs-scaffold-sync skill (a two-tier enforcement section plus a
change-type row requiring a manifest entry for every new core export),
the require-scaffold-with-src hook header (cross-reference the CI gate as
the real teeth), and agent-docs/framework-dev.md (a section explaining
both tiers and the manifest workflow).
---
.claude/hooks/require-scaffold-with-src.sh | 16 +++++++---
.claude/skills/webjs-scaffold-sync/SKILL.md | 35 ++++++++++++++++-----
agent-docs/framework-dev.md | 12 +++++++
3 files changed, 52 insertions(+), 11 deletions(-)
diff --git a/.claude/hooks/require-scaffold-with-src.sh b/.claude/hooks/require-scaffold-with-src.sh
index f76909af..671fd254 100755
--- a/.claude/hooks/require-scaffold-with-src.sh
+++ b/.claude/hooks/require-scaffold-with-src.sh
@@ -14,10 +14,18 @@
#
# 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 @webjsdev/core export surface against
+# test/scaffolds/gallery-coverage.json and FAILS when a new export is neither
+# demoed in the gallery 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..3dfe280d 100644
--- a/.claude/skills/webjs-scaffold-sync/SKILL.md
+++ b/.claude/skills/webjs-scaffold-sync/SKILL.md
@@ -36,13 +36,33 @@ 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 `@webjsdev/core` export surface against the hand-curated
+ `test/scaffolds/gallery-coverage.json` manifest and FAILS when a new export is
+ neither `{ demo }`-ed (a runnable gallery example that references it) nor
+ `{ exempt }`-ed (with a reason: `internal: ...` for plumbing, `deferred: ...`
+ for an agent-facing API not yet demoed). It runs on every `npm test` and in CI,
+ so it cannot be skipped with a local `--no-verify`. This is the analogue of "a
+ test must exist AND pass": a new export turns CI red until it is classified.
+ **When you add or rename a `@webjsdev/core` export, update the manifest** (add a
+ demo pointer, or an honest exemption), the same reflex as writing a test. The
+ gate deliberately covers core's export surface (where the #848-class throwers
+ live); routing convention FILES (`forbidden.ts` and friends) are not exports, so
+ a new boundary-file TYPE is still tier-1-only for now.
+
+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 +114,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` export** | `test/scaffolds/gallery-coverage.json` MUST classify it (`{ demo }` pointing at a runnable gallery file that references it, 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 export-surface 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..be432c32 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 `@webjsdev/core` export surface against `test/scaffolds/gallery-coverage.json` and FAILS when an export is neither `{ demo }`-ed (a gallery file that references it) nor `{ exempt }`-ed (reason `internal: ...` for plumbing, `deferred: ...` for an agent-facing API not yet demoed). It runs under `npm test`, so a local `--no-verify` cannot skip it: a new export turns CI red until classified. The `reconcile()` core is pure and its failure modes (new export, stale key, missing demo file, empty reason) are proven with synthetic inputs alongside the real-surface assertion. 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`.
From 308b2e1992033034e771b3994f54d13dbae73011 Mon Sep 17 00:00:00 2001
From: Vivek
Date: Thu, 9 Jul 2026 13:27:32 +0530
Subject: [PATCH 3/3] feat: extend coverage gate to server exports + routing
convention files
Close the two enforcement gaps the core-only gate left open, in the same
tier-2 model. The gate now reconciles THREE live surfaces, not one:
- @webjsdev/core exports (unchanged): a { demo } gallery-file pointer.
- @webjsdev/server exports (new, 129): { demoed: true } verified by a
generated full-stack/api/saas app importing it, else an { exempt }
reason. 21 demoed, 72 internal, 36 deferred.
- routing convention files (new, 18): the stems DERIVED from
packages/server/src/router.js source (so a new stem === '...' branch
auto-appears and forces classification), each demonstrated by a file
in a generated app. 6 demonstrated, 12 deferred (error/loading/
not-found boundaries + metadata routes the scaffold does not yet ship).
A pure reconcileSet() handles the demoed/exempt model; its failure modes
(unclassified, stale, demoed-but-not-demonstrated, empty reason) are
proven with synthetic inputs beside the real-surface assertions. Docs
(skill two-tier section + change-type row, hook header, framework-dev)
updated to the broadened scope. Full scaffold suite 44/44.
---
.claude/hooks/require-scaffold-with-src.sh | 9 +-
.claude/skills/webjs-scaffold-sync/SKILL.md | 29 +-
agent-docs/framework-dev.md | 2 +-
test/scaffolds/gallery-coverage.json | 448 +++++++++++++++++++-
test/scaffolds/gallery-coverage.test.js | 203 ++++++++-
5 files changed, 659 insertions(+), 32 deletions(-)
diff --git a/.claude/hooks/require-scaffold-with-src.sh b/.claude/hooks/require-scaffold-with-src.sh
index 671fd254..e4fd1f3b 100755
--- a/.claude/hooks/require-scaffold-with-src.sh
+++ b/.claude/hooks/require-scaffold-with-src.sh
@@ -21,10 +21,11 @@
# generate-boot-check.
#
# The TIER-2 teeth live in CI, not here: test/scaffolds/gallery-coverage.test.js
-# reconciles the live @webjsdev/core export surface against
-# test/scaffolds/gallery-coverage.json and FAILS when a new export is neither
-# demoed in the gallery nor consciously exempted. That is the un-skippable gate
-# (the analogue of a test that must exist AND pass); this hook is the fast
+# 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,
diff --git a/.claude/skills/webjs-scaffold-sync/SKILL.md b/.claude/skills/webjs-scaffold-sync/SKILL.md
index 3dfe280d..5a3af38f 100644
--- a/.claude/skills/webjs-scaffold-sync/SKILL.md
+++ b/.claude/skills/webjs-scaffold-sync/SKILL.md
@@ -48,18 +48,21 @@ commit-time floor plus an un-skippable CI gate):
staged doc bullets, shipped no gallery demo).
- **Tier 2, the CI coverage gate.** `test/scaffolds/gallery-coverage.test.js`
- reconciles the LIVE `@webjsdev/core` export surface against the hand-curated
- `test/scaffolds/gallery-coverage.json` manifest and FAILS when a new export is
- neither `{ demo }`-ed (a runnable gallery example that references it) nor
- `{ exempt }`-ed (with a reason: `internal: ...` for plumbing, `deferred: ...`
- for an agent-facing API not yet demoed). It runs on every `npm test` and in CI,
- so it cannot be skipped with a local `--no-verify`. This is the analogue of "a
- test must exist AND pass": a new export turns CI red until it is classified.
- **When you add or rename a `@webjsdev/core` export, update the manifest** (add a
- demo pointer, or an honest exemption), the same reflex as writing a test. The
- gate deliberately covers core's export surface (where the #848-class throwers
- live); routing convention FILES (`forbidden.ts` and friends) are not exports, so
- a new boundary-file TYPE is still tier-1-only for now.
+ 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.
@@ -114,7 +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` export** | `test/scaffolds/gallery-coverage.json` MUST classify it (`{ demo }` pointing at a runnable gallery file that references it, 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 export-surface teeth described above. |
+| 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 be432c32..d23b4b59 100644
--- a/agent-docs/framework-dev.md
+++ b/agent-docs/framework-dev.md
@@ -44,7 +44,7 @@ The scaffold is webjs's primary teaching surface for AI agents, so a new framewo
- **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 `@webjsdev/core` export surface against `test/scaffolds/gallery-coverage.json` and FAILS when an export is neither `{ demo }`-ed (a gallery file that references it) nor `{ exempt }`-ed (reason `internal: ...` for plumbing, `deferred: ...` for an agent-facing API not yet demoed). It runs under `npm test`, so a local `--no-verify` cannot skip it: a new export turns CI red until classified. The `reconcile()` core is pure and its failure modes (new export, stale key, missing demo file, empty reason) are proven with synthetic inputs alongside the real-surface assertion. The deferred backlog is tracked in #859.
+- **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.
diff --git a/test/scaffolds/gallery-coverage.json b/test/scaffolds/gallery-coverage.json
index 7e7df0a1..bbc44d58 100644
--- a/test/scaffolds/gallery-coverage.json
+++ b/test/scaffolds/gallery-coverage.json
@@ -1,5 +1,5 @@
{
- "$comment": "Source of truth for scaffold teaching coverage of @webjsdev/core. The gallery-coverage.test.js gate reconciles this against the LIVE export surface and the gallery template files, and FAILS CI when a new export is neither demoed nor exempted. Add a { demo } (a runnable gallery example) or an { exempt } (with a reason: \"internal: ...\" for plumbing, \"deferred: ...\" for an agent-facing API not yet demoed) when you add a core export. See the webjs-scaffold-sync skill.",
+ "$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": {
@@ -302,5 +302,451 @@
"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
index 04bea852..3016b4c9 100644
--- a/test/scaffolds/gallery-coverage.test.js
+++ b/test/scaffolds/gallery-coverage.test.js
@@ -6,29 +6,40 @@
// #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 @webjsdev/core export surface against the hand-curated
-// test/scaffolds/gallery-coverage.json manifest, and FAILS when a new export is
-// neither demoed nor exempted. So the moment someone adds a core export, CI is
-// red until they either add a runnable gallery demo (a { demo } pointer) or
-// consciously exempt it with a reason (an { exempt } string). 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.
+// 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.
//
-// The reconcile() core is a pure function so the failure modes are proven with
-// SYNTHETIC inputs (a new export, a stale key, a missing demo file, an empty
-// reason) without mutating the real framework, plus one assertion over the REAL
-// surface that must stay green.
+// 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 } from 'node:fs';
-import { join, dirname } from 'node:path';
+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
@@ -84,6 +95,106 @@ export function reconcile(liveExports, manifest, demoFileExists, demoFileTeaches
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);
@@ -145,3 +256,69 @@ test('reconcile FAILS on an empty exemption reason', () => {
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 \}/);
+});