Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
67 changes: 65 additions & 2 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) }],
};
Expand All @@ -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',
{
Expand Down
26 changes: 21 additions & 5 deletions packages/cli/src/commands/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
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('.') || '<root>'}: ${i.message}`)
.join('; ');
throw new CLIError(`Invalid arguments: ${msg}`);
}
throw err;
}

const surface = analyze(input);

if (asMarkdown) {
console.log(formatSurfaceMarkdown(surface));
Expand All @@ -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('');
Expand Down
21 changes: 16 additions & 5 deletions packages/surface/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion packages/surface/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
105 changes: 104 additions & 1 deletion packages/surface/src/__tests__/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');
});
});
Loading
Loading