From 4d0479645618b3bb7795a54465e72fe0dfbf9ba2 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 8 Oct 2026 03:01:36 +0530 Subject: [PATCH 1/2] feat: webjs source prints one export's signature and doc An agent that needs one framework contract today greps or prints whole files under node_modules/@webjsdev/*. The unit it wants is one export: its signature and the doc comment above it. lookupExport() in @webjsdev/mcp finds the export in the installed packages' typed declarations first (index.d.ts, src/*.d.ts), falls back to the authored JSDoc in src/ when no typed hit carries a doc, prints every overload and names the searched packages on a miss. The MCP source tool takes it as `export`, and `webjs source [--pkg ]` prints the same text for a Bash-only agent (exit 1 on a miss). The skill, the scaffold AGENTS.md, the CLI reference and the docs site teach it; the source stays the source of truth, this is the cheap first step. Closes #1623 Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo --- .agents/skills/webjs/SKILL.md | 2 +- AGENTS.md | 1 + packages/cli/bin/webjs.js | 38 +++++++ packages/cli/templates/AGENTS.md | 11 +- packages/mcp/AGENTS.md | 7 +- packages/mcp/src/mcp-source.js | 149 ++++++++++++++++++++++++- packages/mcp/src/mcp.js | 9 +- packages/mcp/test/mcp-source.test.mjs | 107 ++++++++++++++++++ test/cli/source.test.mjs | 45 ++++++++ website/app/docs/configuration/page.ts | 5 + 10 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 test/cli/source.test.mjs diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index cd92824bd..29108b07f 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -9,7 +9,7 @@ Use this skill for end-to-end WebJs app work. It helps you choose the right laye ## Full Documentation -This skill is the quick guide. When you need the full API reference for a surface, load the matching file in `references/` (listed below). For even deeper framework detail, WebJs ships buildless, so the source you run IS the source you read: look in `node_modules/@webjsdev/{core,server,cli}/` (each package ships its own `AGENTS.md`). The complete hosted docs live at https://webjs.dev/docs. +This skill is the quick guide. When you need the full API reference for a surface, load the matching file in `references/` (listed below). For even deeper framework detail, WebJs ships buildless, so the source you run IS the source you read: look in `node_modules/@webjsdev/{core,server,cli}/` (each package ships its own `AGENTS.md`). To check ONE export's contract, run `npx webjs source ` (for example `npx webjs source createAuth`, or `--pkg core` to narrow): it prints that export's signature and the doc comment above it from the installed package, so you read the declaration instead of the file. The MCP `source` tool does the same with `export`. Open the source file when the question is about behaviour the signature does not state. The complete hosted docs live at https://webjs.dev/docs. ## What WebJs Is diff --git a/AGENTS.md b/AGENTS.md index 0dabfb4b2..0b73ef44d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -580,6 +580,7 @@ webjs ci [-f|--fail-fast] [--only ]... [--json] [--signoff] # local webjs routes [--json] [--table] [--no-headers] # print the route table (path / owner file / methods, #975). Default tree; --json is byte-identical to the MCP list_routes tool; --no-headers drops the --table header for piping webjs elision [--json] [--verify] [--routes <paths>] # the elision verdict (#1308): every component module as elided or shipped (a shipped one naming the EVIDENCE that forced it and the module that did the forcing), every page/layout as inert / import-only / ships-whole, and every orphan class that gets no verdict at all (either no registration call, or a computed tag; the scanner matches only a literal one). --json is byte-identical to the MCP list_elision tool. --verify renders every static page route with elision on and off and diffs the observable SSR bytes (the framework's own differential guard, pointed at your app): exit 0 on parity, non-zero on a divergence OR on a corpus where nothing could be compared. It proves elision did not change the bytes you SERVE, NOT post-hydration behaviour (a wrongly dropped module is a dead click, not different bytes), so run your browser/e2e suite twice under WEBJS_ELIDE=1 / WEBJS_ELIDE=0 for that half. Dynamic routes are skipped by name; --routes adds real paths webjs mcp # read-only MCP: routes, actions (RPC hashes), components, elision (what the browser drops, and why each shipped module ships), check, ui kit +webjs source <Export> [--pkg <name>] # one export's signature plus its doc comment from the installed @webjsdev/* (the typed declarations, the authored JSDoc as the fallback; overloads all print; a miss names the searched packages). The MCP `source` tool's `export` argument is the same lookup. Exit 1 on a miss webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory PLUS the component-elision verdict, which warns only on an orphan (#1308); a `framework-links` check that warns when the `@webjsdev/core` entry is a symlink that DANGLES or resolves outside the checkout owning its `node_modules`, which is what an install inside a linked worktree leaves behind and what a plain resolve probe cannot see, #1442; a warning when a route module writes a `<link rel="stylesheet">` without `asset()`, #1095; a `WORKSPACE_OVERRIDES` warning when a workspace MEMBER's package.json carries `overrides` / `resolutions`, which npm and bun honour only at the workspace root, #1492); non-zero exit on a hard fail OR on a check the app gated `error`. --json emits `{ results, summary }` (results is the DoctorResult[], each carrying a stable code + its effective severity; summary counts pass/warn/fail/off), plus a third `configErrors` key on the one path where a rejected `webjs.doctor` config stops any check running; --strict additionally fails on every REMAINING warning (#975). Per-check severity is CONFIG, not a flag: `webjs.doctor.gate` maps a code to `off` / `warn` / `error` so CI gates a chosen subset (#1257) webjs audit [--json] # the dependency audit with a reviewable allowlist (#1492): runs `npm audit` or `bun audit` (nearest lockfile, so a workspace member uses the root's) and fails on any advisory at or above `webjs.audit.level` (default high) that `webjs.audit.ignore` does not list. Each ignore entry is `{ id, reason }`, the one place an accepted advisory is recorded; a malformed block exits 1 and an id the audit stopped reporting prints as stale. The scaffold's CI audit step runs it webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258) diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 3c5421c48..fd16be74a 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -99,6 +99,8 @@ const USAGE = `webjs commands: webjs elision [--json] [--verify] Report which component modules are elided and why each shipped one ships; --verify diffs SSR output with elision on vs off (exits non-zero on a divergence) webjs mcp Start the read-only MCP server (routes / actions / components / elision / check) + webjs source <Export> [--pkg <name>] Print the signature plus doc comment of one export from the installed @webjsdev/* + packages (typed declarations first, the authored JSDoc as the fallback); exit 1 on a miss webjs doctor [--json] [--strict] Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision, component elision, un-versioned stylesheet links). --json emits the structured results (with stable codes). --strict additionally fails on every remaining warning. Per-check severity is CONFIG: map a code to off/warn/error under "webjs": { "doctor": { "gate": {...} } } @@ -326,6 +328,19 @@ const HELP = { summary: 'Start the read-only MCP server (routes / actions / components / elision / check + a docs/source knowledge layer).', examples: ['webjs mcp'], }, + source: { + usage: 'webjs source <Export> [--pkg <name>]', + summary: 'Print one framework export\'s signature plus the doc comment above it, read from the installed @webjsdev/* packages (the typed declarations first, the authored JSDoc when none carries a doc; every overload). The cheap way to check a contract before opening the source file.', + options: [ + { flag: '--pkg <name>', description: 'Search one package only: core, server, cli, ui or intellisense.' }, + ], + notesTitle: 'Exit status', + notes: [ + '0 when at least one declaration printed; 1 on a miss (the message names the packages that were searched).', + 'The read-only MCP `source` tool returns the same text for { export: "<Export>" }.', + ], + examples: ['webjs source createAuth', 'webjs source optimistic --pkg core', 'webjs source getFileStore'], + }, version: { usage: 'webjs version', summary: 'Print the installed @webjsdev/cli version. Also available as webjs --version / -v.', @@ -1968,6 +1983,29 @@ Full docs: https://webjs.dev/docs`); ` --from PROVIDER CDN to resolve through. One of: ${[...SUPPORTED_PROVIDERS].join(', ')}. Default: jspm.`); process.exit(1); } + case 'source': { + // One export's signature plus its doc comment from the installed + // @webjsdev/* packages (#1623): the same lookup the MCP `source` tool + // runs for its `export` arg, so a Bash-only agent (no MCP) checks a + // contract for the price of the declaration instead of the whole file. + // Read-only: it reads the resolved package roots and loads no module. + const name = rest.find((a) => !a.startsWith('--')); + const pkgAt = rest.indexOf('--pkg'); + const pkg = pkgAt >= 0 ? rest[pkgAt + 1] : undefined; + if (!name) { + console.error('usage: webjs source <Export> [--pkg core|server|cli|ui|intellisense]'); + process.exit(1); + } + const { lookupExport, resolveFrameworkRoots } = await import('@webjsdev/mcp'); + const { existsSync, readdirSync } = await import('node:fs'); + const { readFile } = await import('node:fs/promises'); + const roots = resolveFrameworkRoots(process.cwd(), { exists: existsSync }); + const readdir = (d) => readdirSync(d, { withFileTypes: true }).map((e) => ({ name: e.name, isDir: e.isDirectory() })); + const out = await lookupExport({ roots, readFile, readdir }, name, pkg); + console.log(out); + if (!/^@webjsdev\//m.test(out)) process.exit(1); + break; + } case 'mcp': { // Read-only MCP server (#262, #415) over stdio. STDOUT is the JSON-RPC // channel, so nothing here may write to stdout: the data functions are diff --git a/packages/cli/templates/AGENTS.md b/packages/cli/templates/AGENTS.md index 7da873cae..ceb7a9efa 100644 --- a/packages/cli/templates/AGENTS.md +++ b/packages/cli/templates/AGENTS.md @@ -22,10 +22,13 @@ This is what separates a working app from a broken one. rules (git, tests, review) are in `.agents/rules/workflow.md`; follow them too. 3. **Read the framework source for exact contracts.** WebJs is 100% buildless - native ES modules, so the source you run IS the source you read. When you - need a precise API signature or behavior, open the package source under - `node_modules/@webjsdev/*` directly (each package ships its own `AGENTS.md`). - The full hosted docs are at https://webjs.dev/docs. + native ES modules, so the source you run IS the source you read. For one + export's signature and doc comment run `npx webjs source <Export>` (for + example `npx webjs source createAuth`); it reads the installed package and + prints the declaration, not the file. When you need the behaviour behind a + signature, open the package source under `node_modules/@webjsdev/*` directly + (each package ships its own `AGENTS.md`). The full hosted docs are at + https://webjs.dev/docs. {{PLAYBOOK}} diff --git a/packages/mcp/AGENTS.md b/packages/mcp/AGENTS.md index 653add0e7..9c7947a64 100644 --- a/packages/mcp/AGENTS.md +++ b/packages/mcp/AGENTS.md @@ -77,7 +77,12 @@ src/ source from node_modules/@webjsdev/*/src (read-only, traversal-guarded via realpath, loads no module). resolveFrameworkRoots locates each package by probing - the require.resolve node_modules dirs. + the require.resolve node_modules dirs. lookupExport + (the tool's `export` arg and `webjs source <Export>`) + returns ONE export's signature plus the doc above it: + the .d.ts declarations under the package root first, + the authored src JSDoc as the fallback, overloads all, + a miss naming the searched packages (#837). check-report.js projectCheck(violations) -> { violations, summary }. The shared shape returned by BOTH the MCP `check` tool and `webjs check --json` (the CLI imports it from diff --git a/packages/mcp/src/mcp-source.js b/packages/mcp/src/mcp-source.js index 3e77a7851..e62cce687 100644 --- a/packages/mcp/src/mcp-source.js +++ b/packages/mcp/src/mcp-source.js @@ -238,17 +238,158 @@ export async function readSource(deps, path) { } } +/** Bound one export's rendered declaration (a long interface body is cut here). */ +const MAX_DECL_LINES = 40; +/** Bound the hits one lookup returns (overloads plus a JS fallback stay well under this). */ +const MAX_EXPORT_HITS = 8; + +/** + * Collect the JSDoc block (or the `//` run) that ends on the line right above + * `line`, as the lines themselves, or `[]` when the declaration has no comment. + * @param {string[]} lines + * @param {number} line index of the declaration line + * @returns {string[]} + */ +function docAbove(lines, line) { + let i = line - 1; + if (i < 0) return []; + if (lines[i].trim().endsWith('*/')) { + const end = i; + while (i >= 0 && !lines[i].trim().startsWith('/**')) { + if (lines[i].trim().startsWith('/*') && !lines[i].trim().startsWith('/**')) return []; + i--; + } + return i < 0 ? [] : lines.slice(i, end + 1); + } + const end = i; + while (i >= 0 && lines[i].trim().startsWith('//')) i--; + return i === end ? [] : lines.slice(i + 1, end + 1); +} + +/** + * The declaration that starts on `line`: a `function` / `const` / `type` + * statement runs to its terminating `;` (a `.js` function stops at its opening + * brace, so the body is never printed); an `interface` / `class` runs to the + * brace that closes it. Capped at {@link MAX_DECL_LINES}. + * @param {string[]} lines + * @param {number} line + * @param {boolean} isTypes true for a `.d.ts` file + * @returns {string[]} + */ +function declarationFrom(lines, line, isTypes) { + const first = lines[line]; + const braced = /^export\s+(?:declare\s+)?(?:abstract\s+)?(?:interface|class|enum)\b/.test(first) + || (/^export\s+(?:declare\s+)?(?:const|let|var|type)\b/.test(first) && /[{(]\s*$/.test(first) && !/;\s*$/.test(first)); + const out = []; + let depth = 0; + // An authored `export const x = (...) => {` is its first line: the value is + // the implementation, and the doc above it is the contract. + if (!isTypes && /^export\s+(?:const|let|var)\b/.test(first)) return [first]; + for (let i = line; i < lines.length && out.length < MAX_DECL_LINES; i++) { + const l = lines[i]; + if (!isTypes && /^export\s+(?:async\s+)?function\b/.test(first)) { + // A JS function: the signature only, cut at the body's opening brace. + const cut = l.indexOf('{'); + if (cut >= 0 && (i > line || !/^\s*\/\//.test(l))) { out.push(l.slice(0, cut).trimEnd()); return out; } + out.push(l); + continue; + } + out.push(l); + if (braced) { + for (const ch of l) { if (ch === '{') depth++; else if (ch === '}') depth--; } + if (depth <= 0 && /}/.test(l) && i > line) return out; + if (depth <= 0 && i === line && /}\s*;?\s*$/.test(l)) return out; + } else { + // Balanced parens and braces matter for a multi-line signature; angle + // brackets are not counted (an arrow's `=>` would unbalance them). + for (const ch of l) { if (ch === '(' || ch === '{') depth++; else if (ch === ')' || ch === '}') depth--; } + if (depth <= 0 && /;\s*$/.test(l)) return out; + if (depth <= 0 && !isTypes && /=\s*\(.*\)\s*=>/.test(first) && i === line) return out; + } + } + if (out.length >= MAX_DECL_LINES) out.push(` ... (cut at ${MAX_DECL_LINES} lines; read the file with \`path\` for the rest)`); + return out; +} + +/** + * `export` mode: ONE export's signature plus its doc comment, so an agent + * checking a contract pays for the declaration instead of the whole file. The + * typed declarations win (`index.d.ts` and `src/**\/*.d.ts` under each package + * root, where `@webjsdev/server` keeps every export with its doc and + * `@webjsdev/core` keeps them beside the source); the authored `.js` is the + * fallback when no typed hit exists or none of them carries a doc, since the + * JSDoc there is the behaviour contract. Overloads are consecutive hits and + * all print. A miss discloses the searched packages (the #837 rule): nothing + * resolvable means NOTHING was searched, never "not found". + * + * @param {{ roots: Array<{ pkg: string, root: string, src: string }>, readFile: Function, readdir: Function }} deps + * @param {string} name an export name, e.g. `createAuth` + * @param {string} [pkgFilter] one of the framework packages, e.g. `server` + * @returns {Promise<string>} + */ +export async function lookupExport(deps, name, pkgFilter) { + const n = String(name || '').trim(); + if (!/^[A-Za-z_$][\w$]*$/.test(n)) return 'Pass an export name (letters, digits, _ or $), e.g. `createAuth`.'; + const roots = pkgFilter ? deps.roots.filter((r) => r.pkg === pkgFilter) : deps.roots; + if (!deps.roots.length) { + return 'No @webjsdev/* source is resolvable here, so NOTHING was searched (run inside a webjs app or the monorepo). This is not the same as "not found".'; + } + if (!roots.length) { + return `@webjsdev/${pkgFilter} is not installed/resolvable here. Resolvable: ${deps.roots.map((r) => r.pkg).join(', ')}.`; + } + const typesRe = new RegExp(`^export\\s+(?:declare\\s+)?(?:abstract\\s+)?(?:async\\s+)?(?:function\\*?|const|let|var|class|interface|type|enum)\\s+${n.replace(/\$/g, '\\$')}\\b`); + const jsRe = new RegExp(`^export\\s+(?:async\\s+)?(?:function\\*?|const|let|var|class)\\s+${n.replace(/\$/g, '\\$')}\\b`); + /** @type {Array<{ pkg: string, rel: string, line: number, doc: string[], decl: string[], types: boolean }>} */ + const hits = []; + for (const r of roots) { + const typeFiles = walkSource(r.root, deps).filter((f) => /\.d\.(?:ts|mts|cts)$/.test(f)); + const jsFiles = walkSource(r.src, deps).filter((f) => /\.(?:js|mjs)$/.test(f)); + for (const [files, isTypes] of [[typeFiles, true], [jsFiles, false]]) { + for (const file of files) { + let text = ''; + try { text = await deps.readFile(file, 'utf8'); } catch { continue; } + if (!text.includes(n)) continue; + const lines = text.split('\n'); + const rel = relative(r.root, file).split(sep).join('/'); + for (let i = 0; i < lines.length; i++) { + if (!(isTypes ? typesRe : jsRe).test(lines[i])) continue; + hits.push({ pkg: r.pkg, rel, line: i + 1, doc: docAbove(lines, i), decl: declarationFrom(lines, i, isTypes), types: isTypes }); + } + } + } + } + const searched = deps.roots.map((r) => r.pkg).join(', '); + if (!hits.length) { + return `No export named "${n}" in the searched @webjsdev/* packages (${roots.map((r) => r.pkg).join(', ')}${pkgFilter ? `; resolvable: ${searched}` : ''}). Try \`query\` for a substring search, or \`path\` to read a file.`; + } + // The typed declarations are the answer; the JS doc is added only when the + // typed hits say nothing about behaviour (no doc on any of them), or there + // is no typed hit at all. + const typed = hits.filter((h) => h.types); + const typedHasDoc = typed.some((h) => h.doc.length); + const shown = typed.length && typedHasDoc ? typed : typed.length ? [...typed, ...hits.filter((h) => !h.types && h.doc.length)] : hits; + const out = []; + for (const h of shown.slice(0, MAX_EXPORT_HITS)) { + out.push(`@webjsdev/${h.pkg} ${h.rel}:${h.line}`); + out.push(...h.doc, ...h.decl, ''); + } + if (shown.length > MAX_EXPORT_HITS) out.push(`... (${shown.length - MAX_EXPORT_HITS} more declarations; narrow with \`package\`)`); + return out.join('\n').trimEnd(); +} + /** - * The `source` tool entry point. Dispatches on the args: `path` reads a file, - * `query` greps, otherwise (or with `package`) lists the packages. PURE given - * `deps` (`{ roots, readFile, readdir }`). + * The `source` tool entry point. Dispatches on the args: `export` looks up one + * export's signature and doc, `path` reads a file, `query` greps, otherwise + * (or with `package`) lists the packages. PURE given `deps` + * (`{ roots, readFile, readdir }`). * * @param {object} deps - * @param {{ query?: string, path?: string, package?: string }} [args] + * @param {{ export?: string, query?: string, path?: string, package?: string }} [args] * @returns {Promise<string>} */ export async function runSourceTool(deps, args) { const a = args || {}; + if (a.export) return lookupExport(deps, a.export, a.package); if (a.path) return readSource(deps, a.path); if (a.query) return grepSources(deps, a.query); return listSources(deps, a.package); diff --git a/packages/mcp/src/mcp.js b/packages/mcp/src/mcp.js index 20c185d45..88ef81f62 100644 --- a/packages/mcp/src/mcp.js +++ b/packages/mcp/src/mcp.js @@ -33,6 +33,9 @@ import { getPrompt, } from './mcp-docs.js'; import { resolveFrameworkRoots, runSourceTool } from './mcp-source.js'; +// The export lookup behind the `source` tool's `export` arg, re-exported for +// `webjs source <Export>` in the CLI (#1623), which shells the same text. +export { lookupExport, resolveFrameworkRoots } from './mcp-source.js'; import { projectRoutes } from './routes-report.js'; const PROTOCOL_VERSION = '2024-11-05'; @@ -118,6 +121,10 @@ const UI_SCHEMA = { const SOURCE_SCHEMA = { type: 'object', properties: { + export: { + type: 'string', + description: 'One export name, e.g. createAuth or optimistic: returns its signature plus the doc comment above it (from the typed declarations, with the authored JSDoc as the fallback), not the whole file. The cheapest way to check a contract.', + }, path: { type: 'string', description: 'A framework source file to read, e.g. server/src/ssr/head.js or @webjsdev/core/src/render-client/parts.js. The top-level ssr.js / render-client.js paths are barrels; the code is in the sibling directory.', @@ -157,7 +164,7 @@ const TOOL_DEFS = [ { name: 'source', description: - 'Read the FRAMEWORK authored source (webjs is buildless: node_modules/@webjsdev/*/src is the JSDoc source, run directly server-side; only the core browser bundle is built into dist/, which this skips). Pass `path` to read a file (e.g. server/src/ssr/head.js; the bare server/src/ssr.js is a barrel that re-exports from the sibling directory), `query` to grep the @webjsdev/* src trees, or no args to list the packages + entry points. Use when the docs do not answer something. Read-only.', + 'Read the FRAMEWORK authored source (webjs is buildless: node_modules/@webjsdev/*/src is the JSDoc source, run directly server-side; only the core browser bundle is built into dist/, which this skips). Pass `export` for the signature plus doc comment of ONE export (the cheap way to check a contract: `export: "createAuth"`), `path` to read a file (e.g. server/src/ssr/head.js; the bare server/src/ssr.js is a barrel that re-exports from the sibling directory), `query` to grep the @webjsdev/* src trees, or no args to list the packages + entry points. Use when the docs do not answer something. Read-only.', inputSchema: SOURCE_SCHEMA, }, { diff --git a/packages/mcp/test/mcp-source.test.mjs b/packages/mcp/test/mcp-source.test.mjs index 5e7161867..4333fcdd8 100644 --- a/packages/mcp/test/mcp-source.test.mjs +++ b/packages/mcp/test/mcp-source.test.mjs @@ -175,3 +175,110 @@ test('resolveFrameworkRoots: fail-soft when nothing resolves', () => { const roots = resolveFrameworkRoots('/nonexistent-xyz', { exists: () => false }); assert.deepEqual(roots, []); }); + +const { lookupExport } = await import(resolve(REPO, 'packages', 'mcp', 'src', 'mcp-source.js')); + +/** A tree with typed declarations beside the authored source, the real layout. */ +function exportTree() { + return fakeTree({ + '/fw/server/index.d.ts': [ + '/** Auth configuration for `createAuth`. */', + 'export interface AuthConfig {', + ' providers: unknown[];', + ' secret: string;', + '}', + '', + '/**', + ' * Create the auth system.', + ' */', + 'export declare function createAuth<TUser = unknown>(config: AuthConfig): AuthInstance<TUser>;', + '// Overload for a bare store.', + 'export declare function diskStore(opts?: { dir?: string }): FileStore;', + 'export declare function diskStore(dir: string): FileStore;', + 'export declare const DEFAULT_UPLOAD_DIR: string;', + '', + ].join('\n'), + '/fw/server/src/auth.js': [ + '/**', + ' * Create the auth system. Long behaviour notes live here.', + ' * @param {AuthConfig} config', + ' */', + 'export function createAuth(config) {', + ' return config;', + '}', + '', + ].join('\n'), + '/fw/core/src/html.d.ts': 'export function html(strings: TemplateStringsArray, ...values: unknown[]): TemplateResult;\n', + '/fw/core/src/html.js': [ + '/**', + ' * Tagged template literal producing a TemplateResult.', + ' */', + 'export function html(strings, ...values) {', + ' return { strings, values };', + '}', + '', + ].join('\n'), + '/fw/core/src/opt.js': 'export const optimistic = (host, options) => {\n return host;\n};\n', + }); +} + +test('lookupExport: a typed declaration with its doc wins; the file body is not printed', async () => { + const out = await lookupExport(exportTree(), 'createAuth'); + assert.match(out, /^@webjsdev\/server index\.d\.ts:10$/m, 'names the package, file and line'); + assert.match(out, /Create the auth system\.\n \*\/\nexport declare function createAuth<TUser = unknown>\(config: AuthConfig\): AuthInstance<TUser>;/); + assert.doesNotMatch(out, /Long behaviour notes/, 'the JS doc is not added when the typed hit carries one'); + assert.doesNotMatch(out, /providers: unknown/, 'the sibling interface is not printed'); +}); + +test('lookupExport: an interface runs to its closing brace; a const is one line', async () => { + const out = await lookupExport(exportTree(), 'AuthConfig'); + assert.match(out, /export interface AuthConfig \{\n providers: unknown\[\];\n secret: string;\n\}/); + assert.doesNotMatch(out, /createAuth</, 'stops at the closing brace'); + const c = await lookupExport(exportTree(), 'DEFAULT_UPLOAD_DIR'); + assert.match(c, /export declare const DEFAULT_UPLOAD_DIR: string;/); +}); + +test('lookupExport: overloads all print, each with the comment above it', async () => { + const out = await lookupExport(exportTree(), 'diskStore'); + assert.equal((out.match(/export declare function diskStore/g) || []).length, 2); + assert.match(out, /Overload for a bare store/); +}); + +test('lookupExport: the authored JS doc is the fallback when the typed hit has no doc, cut at the body', async () => { + const out = await lookupExport(exportTree(), 'html'); + assert.match(out, /@webjsdev\/core src\/html\.d\.ts:1\nexport function html\(strings: TemplateStringsArray/); + assert.match(out, /Tagged template literal producing a TemplateResult\.\n \*\/\nexport function html\(strings, \.\.\.values\)$/m, 'the JS signature stops at the opening brace'); + assert.doesNotMatch(out, /return \{ strings, values \}/, 'no function body'); + const arrow = await lookupExport(exportTree(), 'optimistic'); + assert.match(arrow, /export const optimistic = \(host, options\) => \{/); + assert.doesNotMatch(arrow, /return host/, 'an arrow const stops at its first line'); +}); + +test('lookupExport: a miss discloses the searched packages; empty roots say nothing was searched (#837)', async () => { + const out = await lookupExport(exportTree(), 'nopeNothing'); + assert.match(out, /No export named "nopeNothing" in the searched @webjsdev\/\* packages \(core, server, cli\)/); + const filtered = await lookupExport(exportTree(), 'createAuth', 'core'); + assert.match(filtered, /No export named "createAuth".*\(core; resolvable: core, server, cli\)/); + const none = await lookupExport({ roots: [], readFile: async () => '', readdir: () => [] }, 'createAuth'); + assert.match(none, /NOTHING was searched/); + assert.match(await lookupExport(exportTree(), 'not a name'), /Pass an export name/); +}); + +test('lookupExport: the package filter narrows, and runSourceTool dispatches `export`', async () => { + const out = await lookupExport(exportTree(), 'createAuth', 'server'); + assert.match(out, /^@webjsdev\/server index\.d\.ts:10$/m); + const viaTool = await runSourceTool(exportTree(), { export: 'createAuth' }); + assert.equal(viaTool, out, 'runSourceTool routes `export` to lookupExport'); + assert.match(await runSourceTool(exportTree(), { export: 'createAuth', package: 'core' }), /No export named/); +}); + +test('lookupExport: finds createAuth in the real @webjsdev/server typed declarations', async () => { + const { existsSync, readdirSync } = await import('node:fs'); + const { readFile } = await import('node:fs/promises'); + const roots = resolveFrameworkRoots(resolve(REPO, 'gallery'), { exists: existsSync }); + const readdir = (d) => readdirSync(d, { withFileTypes: true }).map((e) => ({ name: e.name, isDir: e.isDirectory() })); + const out = await lookupExport({ roots, readFile, readdir }, 'createAuth', 'server'); + assert.match(out, /@webjsdev\/server index\.d\.ts:\d+/); + assert.match(out, /export declare function createAuth</); + assert.ok(out.split('\n').length < 40, `one export, not the file: ${out.split('\n').length} lines`); +}); diff --git a/test/cli/source.test.mjs b/test/cli/source.test.mjs new file mode 100644 index 000000000..ead719ea3 --- /dev/null +++ b/test/cli/source.test.mjs @@ -0,0 +1,45 @@ +/** + * `webjs source <Export>` (#1623): one framework export's signature plus its doc + * comment, read from the installed @webjsdev/* packages, so an agent checks a + * contract without printing the file. The lookup itself is unit-tested in + * packages/mcp/test/mcp-source.test.mjs; this proves the CLI front door: the + * argument parsing, the exit status, and that it runs from inside an app. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(__dirname, '..', '..'); +const CLI = resolve(REPO, 'packages', 'cli', 'bin', 'webjs.js'); +const run = (...args) => spawnSync(process.execPath, [CLI, 'source', ...args], { cwd: resolve(REPO, 'gallery'), encoding: 'utf8' }); + +test('webjs source <Export> prints the declaration and its doc, not the file', () => { + const r = run('createAuth'); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^@webjsdev\/server index\.d\.ts:\d+$/m, 'names the package, file and line'); + assert.match(r.stdout, /export declare function createAuth</, 'the typed signature'); + assert.match(r.stdout, /\/\*\*[\s\S]*\*\/\nexport declare function createAuth</, 'the doc comment directly above it'); + assert.ok(r.stdout.split('\n').length < 40, `one declaration, not the file (${r.stdout.split('\n').length} lines)`); +}); + +test('webjs source --pkg narrows the search; a miss exits 1 and names the searched packages', () => { + const ok = run('optimistic', '--pkg', 'core'); + assert.equal(ok.status, 0, ok.stderr); + assert.match(ok.stdout, /@webjsdev\/core /); + const miss = run('createAuth', '--pkg', 'core'); + assert.equal(miss.status, 1, 'a miss is exit 1'); + assert.match(miss.stdout, /No export named "createAuth"/); + // Counterfactual: a name no package exports. + const none = run('definitelyNotAnExport'); + assert.equal(none.status, 1); + assert.match(none.stdout, /searched @webjsdev\/\* packages \(core, server/); +}); + +test('webjs source with no name prints usage and exits 1', () => { + const r = run(); + assert.equal(r.status, 1); + assert.match(r.stderr, /usage: webjs source <Export>/); +}); diff --git a/website/app/docs/configuration/page.ts b/website/app/docs/configuration/page.ts index 381d04bfb..a6d96c1e5 100644 --- a/website/app/docs/configuration/page.ts +++ b/website/app/docs/configuration/page.ts @@ -49,6 +49,11 @@ webjs routes --table --no-headers # same, without the header row (pipe-friendl webjs routes --json # structured JSON (matches the MCP list_routes tool)</code-block> <p>Prints the route table to stdout: every page (path, owner file, dynamic params) and every <code>route.{js,ts}</code> handler (path, owner file, HTTP methods). It reuses the same route walker that backs the typed-routes generator and the dev server, so it always reflects exactly what the framework will serve. The <code>--json</code> shape is byte-identical to the read-only MCP <code>list_routes</code> tool, so an agent gets the same data whether it shells out or calls the MCP.</p> + <h3>webjs source</h3> + <code-block>webjs source createAuth # one export: its signature plus the doc comment above it +webjs source optimistic --pkg core # narrow to one installed @webjsdev/* package</code-block> + <p>Prints one export's declaration from the installed framework packages instead of the file around it: the typed declaration (<code>index.d.ts</code> or <code>src/*.d.ts</code>) with the doc comment above it, the authored JSDoc from <code>src/</code> when no typed declaration carries one, and every overload. A miss names the packages that were searched. The read-only MCP <code>source</code> tool returns the same text for <code>{ export: "createAuth" }</code>, so an agent checks a contract for the price of the declaration, and opens the source file only for behaviour the signature does not state.</p> + <h3>webjs doctor</h3> <code-block>webjs doctor # human-readable project-health checklist webjs doctor --json # structured results (each with a stable code) + a summary From a4d2efb9c63ba0121a813e7a0e6d6a3ce495cf89 Mon Sep 17 00:00:00 2001 From: t <t@t> Date: Thu, 8 Oct 2026 10:29:48 +0530 Subject: [PATCH 2/2] fix: teach the prose hook the new source subcommand The hook's drift guard requires every CLI subcommand in its list, so the new webjs source command failed the unit and Bun matrices. Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo --- .claude/hooks/block-prose-punctuation.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/hooks/block-prose-punctuation.sh b/.claude/hooks/block-prose-punctuation.sh index a4e741b96..5fb45654d 100755 --- a/.claude/hooks/block-prose-punctuation.sh +++ b/.claude/hooks/block-prose-punctuation.sh @@ -306,7 +306,7 @@ fi # positives (a wrongly blocked write), the same tradeoff as the rules above: # e.g. a sentence-ending "built on webjs." is not flagged (trailing period), # and the `bin/webjs.js` "webjs commands:" usage banner may rarely trip it. -webjs_cli='create|dev|start|test|check|ci|routes|elision|db|ui|doctor|types|typecheck|mcp|vendor|help|version|add|init|generate|migrate|push|studio|seed|pin|unpin|list|audit|outdated|update|view|diff|info|build' +webjs_cli='create|dev|start|test|check|ci|routes|elision|db|ui|doctor|types|typecheck|mcp|vendor|help|version|add|init|generate|migrate|push|studio|seed|pin|unpin|list|audit|outdated|update|view|diff|info|build|source' # Scan copy: drop fenced code blocks, inline code spans, and emphasis markers # so a `webjs` inside code is never considered and **webjs** still matches.