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 \}/); +});