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/.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. diff --git a/AGENTS.md b/AGENTS.md index af9ff5968..f855820e5 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 d72f2a277..624b459a2 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