From 067852324dc5658a76242fb83e8a741b8e604f36 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Thu, 16 Apr 2026 19:16:59 -0500 Subject: [PATCH] refactor(surface,cli,serve): Zod-Core-Out vertical slice for surface (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the blast Core-Out refactor (#110) on @stackbilt/surface: SurfaceInputSchema + SurfaceOutputSchema become the authoritative contract, analyze(input) composes extractSurface, and `charter_surface` joins `charter_blast` as an MCP tool in `charter serve`. Motivation: prerequisite for the #113 repo-brief RFC. The brief's Surface section consumes analyze() output; without Zod-validated schemas the brief shape would reshape on every surface primitive refactor. - @stackbilt/surface: add zod runtime dep, RouteSchema / SchemaTableSchema / SchemaColumnSchema / SurfaceInputSchema / SurfaceOutputSchema, analyze(), DEFAULT_SURFACE_EXTENSIONS / DEFAULT_SURFACE_IGNORE_DIRS exports - @stackbilt/cli: route `charter surface` argv through SurfaceInputSchema, map ZodError to CLIError; register `charter_surface` MCP tool with format: "json" | "markdown" input - Route/SchemaTable/SchemaColumn become z.infer<> aliases, structurally identical to the prior interfaces — OSS additive-only policy preserved - Tests: schema validation + analyze structural assertions via SurfaceOutputSchema.parse on fixtures Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 11 + packages/cli/src/commands/serve.ts | 67 +++++- packages/cli/src/commands/surface.ts | 26 ++- packages/surface/README.md | 21 +- packages/surface/package.json | 4 +- .../surface/src/__tests__/surface.test.ts | 105 +++++++++- packages/surface/src/index.ts | 191 +++++++++++++----- pnpm-lock.yaml | 6 +- 8 files changed, 368 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ede6c..9cf7c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project are documented in this file. The format is based on Keep a Changelog and follows Semantic Versioning. +## [Unreleased] + +### Added +- **`analyze()` + Zod schemas for `@stackbilt/surface`** — new high-level `analyze(input: SurfaceInput): SurfaceOutput` entry point, plus `SurfaceInputSchema`, `SurfaceOutputSchema`, `RouteSchema`, `SchemaTableSchema`, `SchemaColumnSchema`, `DEFAULT_SURFACE_EXTENSIONS`, and `DEFAULT_SURFACE_IGNORE_DIRS` exports. The Zod schemas are the authoritative input/output contract shared by the CLI and MCP tool adapters. Existing `extractSurface` / `extractRoutes` / `extractSchema` / `formatSurfaceMarkdown` exports preserved. `Route`, `SchemaTable`, and `SchemaColumn` are now `z.infer<>` aliases of their schemas — structurally identical to the prior interfaces, so consumer code is unaffected. +- **`charter_surface` MCP tool** — `charter serve` now registers a callable tool for API surface extraction. Supports a `format: "json" | "markdown"` input for agents that want a compact human-readable summary instead of the structured payload. The tool description leads with "use this instead of grepping for route handlers" to nudge cold-boot usage. + +### Changed +- `@stackbilt/surface` gains `zod` (`^3.24.1`) as a runtime dependency. The "zero runtime dependencies" README claim is updated — Zod is the authoritative contract at the package boundary. +- `charter surface` CLI routes argv through `SurfaceInputSchema` — invalid arguments surface as a structured Zod validation error instead of silently defaulting. +- `extractSurface` now references the exported `DEFAULT_SURFACE_EXTENSIONS` / `DEFAULT_SURFACE_IGNORE_DIRS` constants so schema defaults and in-function fallbacks cannot drift (same pattern as `DEFAULT_MAX_DEPTH` in blast). + ## [0.11.0] - 2026-04-16 Synchronized version bump for all `@stackbilt/*` packages to 0.11.0. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 7ea3755..46e0cb0 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -26,7 +26,12 @@ import { resolveModules, validateConstraints, } from '@stackbilt/adf'; -import { analyze, BlastInputSchema } from '@stackbilt/blast'; +import { analyze as analyzeBlast, BlastInputSchema } from '@stackbilt/blast'; +import { + analyze as analyzeSurface, + SurfaceInputSchema, + formatSurfaceMarkdown, +} from '@stackbilt/surface'; import type { CLIOptions } from '../index'; import { CLIError, EXIT_CODE } from '../index'; import { getFlag } from '../flags'; @@ -213,7 +218,7 @@ function registerTools(server: McpServer, aiDir: string): void { Object.keys(parsed.aliases).length > 0 ? parsed.aliases : detectTsconfigAliases(path.resolve(parsed.root)); - const result = analyze({ ...parsed, aliases }); + const result = analyzeBlast({ ...parsed, aliases }); return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], }; @@ -231,6 +236,64 @@ function registerTools(server: McpServer, aiDir: string): void { }, ); + // Advertised shape mirrors the blast pattern: plain ZodRawShape with + // `.optional()` (no `.default()` chaining) so the SDK's type compatibility + // layer accepts it. Authoritative validation (defaults, structure) lives + // in SurfaceInputSchema.parse inside the handler. + const charterSurfaceInput = { + root: z.string().optional().describe( + 'Directory to scan. Defaults to "." (server cwd).', + ), + extensions: z.array(z.string()).optional().describe( + 'File extensions scanned for HTTP route registrations (each with a leading dot). Defaults to .ts/.tsx/.js/.jsx/.mjs.', + ), + ignoreDirs: z.array(z.string()).optional().describe( + 'Extra directory names to skip in addition to the built-in ignore list (node_modules, dist, build, .git, .next, .turbo, .wrangler, coverage, __tests__, __mocks__, __fixtures__).', + ), + schemaPaths: z.array(z.string()).optional().describe( + 'Explicit paths to SQL schema files. When omitted, schema files are auto-detected under the scan root.', + ), + format: z.enum(['json', 'markdown']).optional().describe( + 'Response format. "json" (default) returns structured output; "markdown" returns a compact human/agent-friendly summary suitable for direct prompt injection.', + ), + }; + + // Cast matches the other inputSchema-bearing tools in this file — see the + // charter_blast registration above for context. + (server.registerTool as Function)( + 'charter_surface', + { + description: + "Extract the project's API surface — HTTP routes (Hono/Express/itty-router) and D1/SQLite schema tables. Returns structured JSON by default, or a compact markdown summary when format=\"markdown\". Use this instead of grepping for route handlers — it's the pre-digested map of what the repo exposes.", + inputSchema: charterSurfaceInput, + }, + async (rawInput: unknown) => { + try { + const raw = (rawInput ?? {}) as { format?: 'json' | 'markdown' }; + const format = raw.format ?? 'json'; + const parsed = SurfaceInputSchema.parse(rawInput); + const result = analyzeSurface(parsed); + const text = + format === 'markdown' + ? formatSurfaceMarkdown(result) + : JSON.stringify(result, null, 2); + return { + content: [{ type: 'text' as const, text }], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Error: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + }, + ); + (server.registerTool as Function)( 'getRecentChanges', { diff --git a/packages/cli/src/commands/surface.ts b/packages/cli/src/commands/surface.ts index a2c4200..d472ced 100644 --- a/packages/cli/src/commands/surface.ts +++ b/packages/cli/src/commands/surface.ts @@ -15,15 +15,31 @@ import * as path from 'path'; import type { CLIOptions } from '../index'; import { CLIError, EXIT_CODE } from '../index'; import { getFlag } from '../flags'; -import { extractSurface, formatSurfaceMarkdown } from '@stackbilt/surface'; +import { analyze, SurfaceInputSchema, formatSurfaceMarkdown } from '@stackbilt/surface'; +import { z } from 'zod'; export async function surfaceCommand(options: CLIOptions, args: string[]): Promise { - const root = path.resolve(getFlag(args, '--root') || '.'); + const rootArg = getFlag(args, '--root') || '.'; const schemaFlag = getFlag(args, '--schema'); - const schemaPaths = schemaFlag ? [path.resolve(schemaFlag)] : undefined; const asMarkdown = args.includes('--markdown') || args.includes('--md'); - const surface = extractSurface({ root, schemaPaths }); + let input; + try { + input = SurfaceInputSchema.parse({ + root: rootArg, + schemaPaths: schemaFlag ? [path.resolve(schemaFlag)] : undefined, + }); + } catch (err) { + if (err instanceof z.ZodError) { + const msg = err.issues + .map((i) => `${i.path.join('.') || ''}: ${i.message}`) + .join('; '); + throw new CLIError(`Invalid arguments: ${msg}`); + } + throw err; + } + + const surface = analyze(input); if (asMarkdown) { console.log(formatSurfaceMarkdown(surface)); @@ -46,7 +62,7 @@ export async function surfaceCommand(options: CLIOptions, args: string[]): Promi console.log(''); console.log(` API Surface`); - console.log(` root: ${path.relative(process.cwd(), root) || '.'}`); + console.log(` root: ${path.relative(process.cwd(), surface.root) || '.'}`); console.log(` routes: ${surface.summary.routeCount}`); console.log(` tables: ${surface.summary.schemaTableCount}`); console.log(''); diff --git a/packages/surface/README.md b/packages/surface/README.md index e0c42a9..bd3c7c6 100644 --- a/packages/surface/README.md +++ b/packages/surface/README.md @@ -5,7 +5,7 @@ API surface extraction for [Charter Kit](https://github.com/Stackbilt-dev/charte 1. **HTTP routes** — Hono, Express, itty-router 2. **Database schema** — D1 / SQLite `CREATE TABLE` statements -Pure heuristic — no LLM calls, no AST, zero runtime dependencies. Designed for Cloudflare Worker projects, but works on any Node.js HTTP backend with compatible routing conventions. +Pure heuristic — no LLM calls, no AST. Zod is the only runtime dependency; it carries the package's authoritative input/output schemas so CLI and MCP adapters validate against the same contract. Designed for Cloudflare Worker projects, but works on any Node.js HTTP backend with compatible routing conventions. > **Want the full toolkit?** Just install the CLI — it includes everything: > ```bash @@ -34,13 +34,24 @@ charter surface --schema db/schema.sql # Explicit schema path ## Programmatic Usage ```ts -import { extractSurface, formatSurfaceMarkdown } from '@stackbilt/surface'; +import { analyze, SurfaceInputSchema } from '@stackbilt/surface'; -const surface = extractSurface({ root: './packages/worker' }); +// `analyze` is the Core-Out entry point — validates input via Zod, +// composes extractSurface, returns a SurfaceOutput-shaped result. +const input = SurfaceInputSchema.parse({ root: './packages/worker' }); +const result = analyze(input); -console.log(surface.summary); +console.log(result.summary); // { routeCount: 95, schemaTableCount: 50, routesByMethod: {...}, routesByFramework: {...} } +``` + +The lower-level primitives are still exported for callers that don't need +the schema layer: +```ts +import { extractSurface, formatSurfaceMarkdown } from '@stackbilt/surface'; + +const surface = extractSurface({ root: './packages/worker' }); console.log(formatSurfaceMarkdown(surface)); // # API Surface // **Routes:** 95 @@ -126,7 +137,7 @@ See [cc-taskrunner/taskrunner.sh](https://github.com/Stackbilt-dev/cc-taskrunner ## Requirements - Node >= 18 -- Zero runtime dependencies +- One runtime dependency: [zod](https://zod.dev) (authoritative input/output schemas) ## License diff --git a/packages/surface/package.json b/packages/surface/package.json index cdb49a8..bb7699f 100644 --- a/packages/surface/package.json +++ b/packages/surface/package.json @@ -28,7 +28,9 @@ "url": "https://github.com/Stackbilt-dev/charter/issues" }, "homepage": "https://github.com/Stackbilt-dev/charter#readme", - "dependencies": {}, + "dependencies": { + "zod": "^3.24.1" + }, "publishConfig": { "access": "public" }, diff --git a/packages/surface/src/__tests__/surface.test.ts b/packages/surface/src/__tests__/surface.test.ts index 157eef0..f26d0fc 100644 --- a/packages/surface/src/__tests__/surface.test.ts +++ b/packages/surface/src/__tests__/surface.test.ts @@ -2,7 +2,17 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { extractRoutes, extractSchema, extractSurface, formatSurfaceMarkdown } from '../index'; +import { + extractRoutes, + extractSchema, + extractSurface, + formatSurfaceMarkdown, + analyze, + SurfaceInputSchema, + SurfaceOutputSchema, + DEFAULT_SURFACE_EXTENSIONS, + DEFAULT_SURFACE_IGNORE_DIRS, +} from '../index'; let tmpRoot: string; @@ -206,3 +216,96 @@ app.post('/api/users', createUser); expect(md).toContain('### t'); }); }); + +describe('SurfaceInputSchema', () => { + it('applies defaults for all optional fields', () => { + const parsed = SurfaceInputSchema.parse({}); + expect(parsed.root).toBe('.'); + expect(parsed.extensions).toEqual([...DEFAULT_SURFACE_EXTENSIONS]); + expect(parsed.ignoreDirs).toEqual([]); + expect(parsed.schemaPaths).toBeUndefined(); + }); + + it('passes user-supplied values through', () => { + const parsed = SurfaceInputSchema.parse({ + root: './packages/worker', + extensions: ['.ts'], + ignoreDirs: ['private'], + schemaPaths: ['db/schema.sql'], + }); + expect(parsed.root).toBe('./packages/worker'); + expect(parsed.extensions).toEqual(['.ts']); + expect(parsed.ignoreDirs).toEqual(['private']); + expect(parsed.schemaPaths).toEqual(['db/schema.sql']); + }); + + it('rejects non-array extensions', () => { + expect(() => + SurfaceInputSchema.parse({ extensions: '.ts' as unknown as string[] }), + ).toThrow(); + }); + + it('rejects non-string root', () => { + expect(() => + SurfaceInputSchema.parse({ root: 42 as unknown as string }), + ).toThrow(); + }); +}); + +describe('analyze', () => { + it('returns a SurfaceOutputSchema-shaped result for a mock project', () => { + write( + 'src/app.ts', + `import { Hono } from 'hono'; +const app = new Hono(); +app.get('/health', () => new Response('ok')); +app.post('/api/users', createUser); +`, + ); + write('schema.sql', `CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT NOT NULL);`); + + const input = SurfaceInputSchema.parse({ root: tmpRoot }); + const result = analyze(input); + + // Structural assertion — the schema is the contract. + const checked = SurfaceOutputSchema.parse(result); + expect(checked.summary.routeCount).toBe(2); + expect(checked.summary.schemaTableCount).toBe(1); + expect(checked.summary.routesByFramework.hono).toBe(2); + expect(checked.schemas[0].name).toBe('users'); + }); + + it('returns empty routes + schemas on an empty directory', () => { + const input = SurfaceInputSchema.parse({ root: tmpRoot }); + const result = analyze(input); + const checked = SurfaceOutputSchema.parse(result); + expect(checked.routes).toEqual([]); + expect(checked.schemas).toEqual([]); + expect(checked.summary.routeCount).toBe(0); + }); + + it('honors ignoreDirs from the input', () => { + write('src/app.ts', `app.get('/kept', h);`); + write('vendor/lib.ts', `app.get('/skipped', h);`); + + const input = SurfaceInputSchema.parse({ root: tmpRoot, ignoreDirs: ['vendor'] }); + const result = analyze(input); + expect(result.summary.routeCount).toBe(1); + expect(result.routes[0].path).toBe('/kept'); + }); + + it('honors an explicit schemaPaths list', () => { + const schemaFile = write( + 'db/custom-name.sql', + `CREATE TABLE widgets (id INTEGER PRIMARY KEY);`, + ); + + const input = SurfaceInputSchema.parse({ + root: tmpRoot, + schemaPaths: [schemaFile], + }); + const result = analyze(input); + expect(result.summary.schemaTableCount).toBe(1); + expect(result.schemas[0].name).toBe('widgets'); + }); +}); diff --git a/packages/surface/src/index.ts b/packages/surface/src/index.ts index 404decb..bb3f4b9 100644 --- a/packages/surface/src/index.ts +++ b/packages/surface/src/index.ts @@ -5,7 +5,8 @@ * 1. HTTP routes (Hono, Express, itty-router) via regex matching * 2. Database schema (D1 schema.sql CREATE TABLE statements) * - * Zero dependencies. Pure heuristic — no TypeScript compiler API, no AST. + * Runtime dependency on Zod only — the schemas below are the authoritative + * input/output contract shared by the CLI and MCP tool adapters. * * Trade-off: misses exotic patterns (dynamic route registration, * programmatic middleware chains). Captures the 95% case for Cloudflare @@ -14,36 +15,80 @@ import * as fs from 'fs'; import * as path from 'path'; +import { z } from 'zod'; // ============================================================================ -// Types +// Constants — exported so schema defaults and in-function fallbacks share +// the same source of truth (same pattern as DEFAULT_MAX_DEPTH in blast). // ============================================================================ -export interface Route { - method: string; - path: string; - file: string; - line: number; - framework: 'hono' | 'express' | 'itty' | 'unknown'; - /** Router prefix if detected (e.g. '/api') */ - prefix?: string; -} +/** Default source file extensions scanned for HTTP route registrations. */ +export const DEFAULT_SURFACE_EXTENSIONS: readonly string[] = [ + '.ts', + '.tsx', + '.js', + '.jsx', + '.mjs', +]; + +/** Default directories skipped when walking the source tree. */ +export const DEFAULT_SURFACE_IGNORE_DIRS: readonly string[] = [ + 'node_modules', + 'dist', + 'build', + '.git', + '.next', + '.turbo', + '.wrangler', + 'coverage', + '__tests__', + '__mocks__', + '__fixtures__', +]; -export interface SchemaTable { - name: string; - columns: SchemaColumn[]; - file: string; - line: number; -} +const HTTP_METHODS = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'all']; -export interface SchemaColumn { - name: string; - type: string; - nullable: boolean; - primaryKey: boolean; - unique: boolean; - defaultValue?: string; -} +// ============================================================================ +// Zod schemas — authoritative runtime contract shared with CLI + MCP adapters +// ============================================================================ + +export const RouteSchema = z.object({ + method: z.string().describe('HTTP method, uppercased (GET, POST, …).'), + path: z.string().describe('Route path as written in source, starting with `/`.'), + file: z.string().describe('Source file, relative to the scan root.'), + line: z.number().int().nonnegative().describe('1-based line number of the registration.'), + framework: z + .enum(['hono', 'express', 'itty', 'unknown']) + .describe('Detected framework based on import statements in the file.'), + prefix: z + .string() + .optional() + .describe('Router prefix if detected via `.basePath(...)`.'), +}); + +export const SchemaColumnSchema = z.object({ + name: z.string(), + type: z.string().describe('Column type as written, uppercased with whitespace removed (e.g. VARCHAR(255)).'), + nullable: z.boolean(), + primaryKey: z.boolean(), + unique: z.boolean(), + defaultValue: z.string().optional(), +}); + +export const SchemaTableSchema = z.object({ + name: z.string(), + columns: z.array(SchemaColumnSchema), + file: z.string().describe('Source SQL file, relative to the scan root.'), + line: z.number().int().positive().describe('1-based line number of the CREATE TABLE statement.'), +}); + +export type Route = z.infer; +export type SchemaColumn = z.infer; +export type SchemaTable = z.infer; + +// ============================================================================ +// Types +// ============================================================================ export interface Surface { root: string; @@ -68,27 +113,6 @@ export interface ExtractOptions { schemaPaths?: string[]; } -// ============================================================================ -// Constants -// ============================================================================ - -const DEFAULT_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs']; -const DEFAULT_IGNORE_DIRS = new Set([ - 'node_modules', - 'dist', - 'build', - '.git', - '.next', - '.turbo', - '.wrangler', - 'coverage', - '__tests__', - '__mocks__', - '__fixtures__', -]); - -const HTTP_METHODS = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'all']; - // ============================================================================ // File walking // ============================================================================ @@ -377,8 +401,11 @@ function splitTopLevelCommas(input: string): string[] { */ export function extractSurface(options: ExtractOptions = {}): Surface { const root = path.resolve(options.root ?? '.'); - const extensions = new Set(options.extensions ?? DEFAULT_EXTENSIONS); - const ignoreDirs = new Set([...DEFAULT_IGNORE_DIRS, ...(options.ignoreDirs ?? [])]); + const extensions = new Set(options.extensions ?? DEFAULT_SURFACE_EXTENSIONS); + const ignoreDirs = new Set([ + ...DEFAULT_SURFACE_IGNORE_DIRS, + ...(options.ignoreDirs ?? []), + ]); // Routes const sourceFiles = walkFiles(root, extensions, ignoreDirs); @@ -489,3 +516,71 @@ export function formatSurfaceMarkdown(surface: Surface): string { return lines.join('\n'); } + +// ============================================================================ +// Zod schemas — Input / Output contract for analyze() +// ============================================================================ + +export const SurfaceInputSchema = z.object({ + root: z + .string() + .optional() + .default('.') + .describe('Directory to scan. Defaults to the current working directory.'), + extensions: z + .array(z.string()) + .optional() + .default([...DEFAULT_SURFACE_EXTENSIONS]) + .describe('File extensions scanned for HTTP route registrations (each with a leading dot).'), + ignoreDirs: z + .array(z.string()) + .optional() + .default([]) + .describe('Extra directory names to skip in addition to the built-in ignore list.'), + schemaPaths: z + .array(z.string()) + .optional() + .describe('Explicit paths to SQL schema files. When omitted, schema files are auto-detected under the scan root.'), +}); + +export type SurfaceInput = z.infer; + +export const SurfaceOutputSchema = z.object({ + root: z.string().describe('Resolved absolute root directory the scan was performed from.'), + routes: z.array(RouteSchema).describe('All HTTP routes discovered in the scanned source files.'), + schemas: z.array(SchemaTableSchema).describe('All D1/SQLite tables discovered in schema SQL files.'), + summary: z + .object({ + routeCount: z.number().int().nonnegative(), + schemaTableCount: z.number().int().nonnegative(), + routesByMethod: z + .record(z.string(), z.number().int().nonnegative()) + .describe('Count of routes grouped by uppercased HTTP method.'), + routesByFramework: z + .record(z.string(), z.number().int().nonnegative()) + .describe('Count of routes grouped by detected framework (hono/express/itty/unknown).'), + }) + .describe('Aggregate counts across the scanned project.'), +}); + +export type SurfaceOutput = z.infer; + +// ============================================================================ +// High-level analyze — the Core-Out entry point for CLI and MCP adapters +// ============================================================================ + +/** + * Extract a project's API surface from a validated input. + * + * This is the function both the CLI and the MCP tool adapter call. Low-level + * consumers can still use extractSurface / extractRoutes / extractSchema + * directly. + */ +export function analyze(input: SurfaceInput): SurfaceOutput { + return extractSurface({ + root: input.root, + extensions: input.extensions, + ignoreDirs: input.ignoreDirs, + schemaPaths: input.schemaPaths, + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae20fbe..2c14243 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,7 +98,11 @@ importers: specifier: workspace:^ version: link:../types - packages/surface: {} + packages/surface: + dependencies: + zod: + specifier: ^3.24.1 + version: 3.25.76 packages/types: {}