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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ The format is based on Keep a Changelog and follows Semantic Versioning.

## [Unreleased]

## [1.6.0] - 2026-06-23

### Added

- **`charter surface --format markdown`** (`cli#236`) — `--format markdown` is now a valid format value for `charter surface`. Previously the flag was rejected with "Invalid --format value: markdown. Use text or json." Passing `--format markdown` now routes to the same `formatSurfaceMarkdown()` output as the existing `--markdown`/`--md` shorthand flags, producing a markdown table of routes and schema tables suitable for piping into documentation tools, vault sync scripts, or AI context files. `--format markdown` is gated to the `surface` subcommand — other subcommands continue to reject it with a descriptive error.
- **`charter surface --exclude <glob>`** (`cli#237`) — New `--exclude <path>` flag filters paths from the surface scan. Accepts any number of `--exclude` invocations per command. Supports plain path prefixes (`packages/scaffold-core`), within-segment wildcards (`src/gen*`), and multi-segment `**` wildcards (`**/codegen/**`, `packages/**`). Fixes false-positive routes when running `charter surface` inside repos that contain scaffold template source files (template route strings match the route extraction regex). Implemented as `excludeGlobs` in `SurfaceInputSchema`, `ExtractOptions`, and `analyze()` — programmatic callers can pass the field directly without the CLI flag.

### Changed

- `CLIOptions.format` type extended from `'text' | 'json'` to `'text' | 'json' | 'markdown'` — consumers that switch exhaustively on this type should add a `'markdown'` branch.
- `SurfaceInputSchema` gains `excludeGlobs: z.array(z.string()).default([])`. Additive only — existing calls without the field continue to work unchanged.
- `extractSurface` / `ExtractOptions` gain `excludeGlobs?: string[]`. Additive only.
- `walkFiles` internal signature updated to accept `root` and `excludeGlobs` parameters. Not a public export; callers using only `extractSurface` or `analyze` are unaffected.

## [1.5.0] - 2026-06-20

### Added
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,8 @@ charter serve # MCP server for Claude Code, Codex, Cu
charter blast src/foo.ts # Blast radius: files that transitively import the seed
charter blast src/a.ts src/b.ts --depth 4 # Multi-seed, custom BFS depth
charter surface # Extract routes (Hono/Express) + D1 schema
charter surface --markdown # Emit as markdown for .ai/surface.adf or AI context
charter surface --format markdown # Emit as markdown for .ai/surface.adf or AI context
charter surface --exclude packages/scaffold-core # Skip a directory (supports ** globs)
```

Deterministic codebase analysis — no LLM calls, zero runtime dependencies. `blast` warns on large radiuses (≥20 files) as a CROSS_CUTTING signal; `surface` is a lightweight alternative to full AST walks for Cloudflare Worker projects.
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@stackbilt/cli",
"sideEffects": false,
"version": "1.5.0",
"version": "1.6.0",
"description": "Charter CLI — repo-level governance toolkit",
"bin": {
"charter": "./dist/bin.js"
Expand Down
12 changes: 11 additions & 1 deletion packages/cli/src/commands/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,23 @@ import { z } from 'zod';
export async function surfaceCommand(options: CLIOptions, args: string[]): Promise<number> {
const rootArg = getFlag(args, '--root') || '.';
const schemaFlag = getFlag(args, '--schema');
const asMarkdown = args.includes('--markdown') || args.includes('--md');
const asMarkdown = args.includes('--markdown') || args.includes('--md') || options.format === 'markdown';

// Collect all --exclude values (flag may appear multiple times).
// Guard against adjacent flags being consumed as values (e.g. --root --exclude src).
const excludeGlobs: string[] = [];
for (let i = 0; i < args.length - 1; i++) {
if (args[i] === '--exclude' && !args[i + 1].startsWith('--')) {
excludeGlobs.push(args[i + 1]);
}
}

let input;
try {
input = SurfaceInputSchema.parse({
root: rootArg,
schemaPaths: schemaFlag ? [path.resolve(schemaFlag)] : undefined,
excludeGlobs: excludeGlobs.length > 0 ? excludeGlobs : undefined,
});
} catch (err) {
if (err instanceof z.ZodError) {
Expand Down
19 changes: 14 additions & 5 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,10 @@ Usage:
charter score [--ai-dir <dir>] AI-readiness audit for the current repo
charter blast <file> [<file> ...] [--root <dir>] [--depth <n>]
Compute blast radius: which files transitively depend on the seeds
charter surface [--root <dir>] [--schema <path>]
charter surface [--root <dir>] [--schema <path>] [--format markdown] [--exclude <glob>]
Extract API surface: routes (Hono/Express) + D1 schema tables
--format markdown: emit as markdown (--markdown/--md also accepted)
--exclude <glob>: skip paths matching glob (repeatable; supports ** and *)
charter telemetry report Local telemetry summary (passive CLI observability)
charter why Explain why teams adopt Charter and expected ROI
charter doctor [--adf-only] Check CLI + config health (or ADF-only wiring checks)
Expand All @@ -81,7 +83,7 @@ Usage:

Options:
--config <path> Path to .charter/ directory (default: .charter/)
--format <type> Output format: text, json (default: text)
--format <type> Output format: text, json, markdown (default: text)
--ci CI mode: exit non-zero on WARN or FAIL
--yes Non-interactive bootstrap; accept default actions
--force Overwrite existing custom files during bootstrap (.ai/*.adf are backed up first)
Expand Down Expand Up @@ -109,7 +111,7 @@ export class CLIError extends Error {

export interface CLIOptions {
configPath: string;
format: 'text' | 'json';
format: 'text' | 'json' | 'markdown';
ciMode: boolean;
yes: boolean;
}
Expand Down Expand Up @@ -145,8 +147,15 @@ export async function run(args: string[]): Promise<number> {
return EXIT_CODE.SUCCESS;
}

if (rawFormat !== 'text' && rawFormat !== 'json') {
throw new CLIError(`Invalid --format value: ${rawFormat}. Use text or json.`);
if (rawFormat !== 'text' && rawFormat !== 'json' && rawFormat !== 'markdown') {
throw new CLIError(`Invalid --format value: ${rawFormat}. Use text, json, or markdown.`);
}

if (rawFormat === 'markdown') {
const subcommand = args.length > 0 && !args[0].startsWith('-') ? args[0] : null;
if (subcommand !== 'surface') {
throw new CLIError(`--format markdown is only supported by 'charter surface'. Use text or json.`);
}
}

const options: CLIOptions = {
Expand Down
13 changes: 10 additions & 3 deletions packages/surface/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ Via the Charter CLI:
```bash
charter surface # Text summary
charter surface --format json # JSON for tooling
charter surface --markdown # Markdown for .ai/surface.adf
charter surface --format markdown # Markdown for .ai/surface.adf (--markdown/--md also accepted)
charter surface --root ./packages/worker # Scan a subdirectory
charter surface --schema db/schema.sql # Explicit schema path
charter surface --exclude packages/scaffold-core # Skip a path prefix
charter surface --exclude "**/codegen/**" # Skip any codegen/ directory (** glob)
charter surface --exclude src/templates --exclude fixtures # Multiple excludes
```

## Programmatic Usage
Expand All @@ -38,7 +41,10 @@ import { analyze, SurfaceInputSchema } from '@stackbilt/surface';

// `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 input = SurfaceInputSchema.parse({
root: './packages/worker',
excludeGlobs: ['**/codegen/**'], // optional: skip scaffold template directories
});
const result = analyze(input);

console.log(result.summary);
Expand Down Expand Up @@ -68,7 +74,8 @@ Scans a project directory and returns its full API surface (routes + schemas).
**Options:**
- `root` — project root (default: `cwd`)
- `extensions` — file extensions to scan for routes (default: `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`)
- `ignoreDirs` — additional directories to skip
- `ignoreDirs` — additional directory names to skip (basename match only)
- `excludeGlobs` — path patterns relative to root to exclude; supports `*` (within-segment) and `**` (multi-segment) wildcards (e.g. `['**/codegen/**', 'packages/scaffold-core']`)
- `schemaPaths` — explicit schema file paths (default: auto-detect `*schema*.sql` under root)

**Returns:** `{ root, routes, schemas, summary }`
Expand Down
2 changes: 1 addition & 1 deletion packages/surface/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@stackbilt/surface",
"sideEffects": false,
"version": "1.5.0",
"version": "1.6.0",
"description": "API surface extraction: routes (Hono/Express) + D1 SQL schema",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
35 changes: 35 additions & 0 deletions packages/surface/src/__tests__/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ describe('SurfaceInputSchema', () => {
expect(parsed.root).toBe('.');
expect(parsed.extensions).toEqual([...DEFAULT_SURFACE_EXTENSIONS]);
expect(parsed.ignoreDirs).toEqual([]);
expect(parsed.excludeGlobs).toEqual([]);
expect(parsed.schemaPaths).toBeUndefined();
});

Expand All @@ -246,11 +247,13 @@ describe('SurfaceInputSchema', () => {
extensions: ['.ts'],
ignoreDirs: ['private'],
schemaPaths: ['db/schema.sql'],
excludeGlobs: ['src/codegen'],
});
expect(parsed.root).toBe('./packages/worker');
expect(parsed.extensions).toEqual(['.ts']);
expect(parsed.ignoreDirs).toEqual(['private']);
expect(parsed.schemaPaths).toEqual(['db/schema.sql']);
expect(parsed.excludeGlobs).toEqual(['src/codegen']);
});

it('rejects non-array extensions', () => {
Expand Down Expand Up @@ -308,6 +311,38 @@ app.post('/api/users', createUser);
expect(result.routes[0].path).toBe('/kept');
});

it('honors excludeGlobs — plain path prefix', () => {
write('src/app.ts', `import { Hono } from 'hono';\napp.get('/real', h);`);
write('packages/codegen/routes.ts', `app.post('/template', h);`);

const input = SurfaceInputSchema.parse({ root: tmpRoot, excludeGlobs: ['packages/codegen'] });
const result = analyze(input);
expect(result.summary.routeCount).toBe(1);
expect(result.routes[0].path).toBe('/real');
});

it('honors excludeGlobs — ** wildcard pattern excludes nested directories', () => {
write('src/app.ts', `import { Hono } from 'hono';\napp.get('/real', h);`);
write('packages/scaffold-core/src/codegen/routes.ts', `app.post('/template', h);`);
write('other/codegen/more.ts', `app.delete('/also-template', h);`);

const input = SurfaceInputSchema.parse({ root: tmpRoot, excludeGlobs: ['**/codegen/**'] });
const result = analyze(input);
expect(result.summary.routeCount).toBe(1);
expect(result.routes[0].path).toBe('/real');
});

it('honors excludeGlobs — trailing ** excludes all files under a directory', () => {
write('src/app.ts', `import { Hono } from 'hono';\napp.get('/real', h);`);
write('packages/scaffold-core/src/routes.ts', `app.post('/template', h);`);
write('packages/scaffold-core/src/handler.ts', `app.get('/also-template', h);`);

const input = SurfaceInputSchema.parse({ root: tmpRoot, excludeGlobs: ['packages/**'] });
const result = analyze(input);
expect(result.summary.routeCount).toBe(1);
expect(result.routes[0].path).toBe('/real');
});

it('honors an explicit schemaPaths list', () => {
const schemaFile = write(
'db/custom-name.sql',
Expand Down
44 changes: 41 additions & 3 deletions packages/surface/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,16 +111,45 @@ export interface ExtractOptions {
ignoreDirs?: string[];
/** explicit schema file path(s); default: auto-detect schema.sql anywhere under root */
schemaPaths?: string[];
/** path patterns (relative to root, supports * and **) to exclude from scanning */
excludeGlobs?: string[];
}

// ============================================================================
// File walking
// ============================================================================

/**
* Match a relative path against a glob pattern.
* Supports `*` (within a path segment) and `**` (across segments).
* Plain strings without wildcards match if the relPath equals or is nested
* under the pattern (i.e., prefix + `/`).
*/
function matchGlob(pattern: string, relPath: string): boolean {
const p = relPath.replace(/\\/g, '/');
const pat = pattern.replace(/\\/g, '/').replace(/\/$/, '');

if (!pat.includes('*')) {
return p === pat || p.startsWith(pat + '/');
}

// Escape regex metacharacters except * which we handle specially.
const regexStr = pat
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '\x00') // placeholder for **
.replace(/\*/g, '[^/]*') // * → within-segment wildcard
.replace(/\x00\//g, '(?:[^/]+/)*') // **/ → zero-or-more path segments
.replace(/\x00/g, '.*'); // trailing ** → anything

return new RegExp(`^(?:${regexStr})(?:/.*)?$`).test(p);
}

function walkFiles(
dir: string,
root: string,
extensions: Set<string>,
ignoreDirs: Set<string>,
excludeGlobs: string[],
out: string[] = []
): string[] {
let entries: fs.Dirent[];
Expand All @@ -132,8 +161,10 @@ function walkFiles(
for (const entry of entries) {
if (ignoreDirs.has(entry.name)) continue;
const full = path.join(dir, entry.name);
const rel = path.relative(root, full).replace(/\\/g, '/');
if (excludeGlobs.length > 0 && excludeGlobs.some((g) => matchGlob(g, rel))) continue;
if (entry.isDirectory()) {
walkFiles(full, extensions, ignoreDirs, out);
walkFiles(full, root, extensions, ignoreDirs, excludeGlobs, out);
} else if (entry.isFile()) {
if (extensions.has(path.extname(entry.name))) out.push(full);
}
Expand Down Expand Up @@ -474,9 +505,10 @@ export function extractSurface(options: ExtractOptions = {}): Surface {
...DEFAULT_SURFACE_IGNORE_DIRS,
...(options.ignoreDirs ?? []),
]);
const excludeGlobs = options.excludeGlobs ?? [];

// Routes
const sourceFiles = walkFiles(root, extensions, ignoreDirs);
const sourceFiles = walkFiles(root, root, extensions, ignoreDirs, excludeGlobs);
const routes: Route[] = [];
for (const file of sourceFiles) {
// Skip test/spec files — their route strings are fixtures, not real routes
Expand All @@ -498,7 +530,7 @@ export function extractSurface(options: ExtractOptions = {}): Surface {
// Schemas
const schemaFiles =
options.schemaPaths ??
walkFiles(root, new Set(['.sql']), ignoreDirs).filter((f) =>
walkFiles(root, root, new Set(['.sql']), ignoreDirs, excludeGlobs).filter((f) =>
path.basename(f).toLowerCase().includes('schema')
);
const schemas: SchemaTable[] = [];
Expand Down Expand Up @@ -609,6 +641,11 @@ export const SurfaceInputSchema = z.object({
.array(z.string())
.optional()
.describe('Explicit paths to SQL schema files. When omitted, schema files are auto-detected under the scan root.'),
excludeGlobs: z
.array(z.string())
.optional()
.default([])
.describe('Path patterns relative to root to exclude from scanning. Supports * (within-segment) and ** (multi-segment) wildcards.'),
});

export type SurfaceInput = z.infer<typeof SurfaceInputSchema>;
Expand Down Expand Up @@ -650,5 +687,6 @@ export function analyze(input: SurfaceInput): SurfaceOutput {
extensions: input.extensions,
ignoreDirs: input.ignoreDirs,
schemaPaths: input.schemaPaths,
excludeGlobs: input.excludeGlobs,
});
}