From bedbf32093657a562a84f022f63a3efdb65e52b7 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Thu, 16 Apr 2026 07:00:15 -0500 Subject: [PATCH] refactor(blast,cli,serve): Zod-Core-Out vertical slice (#109) Prove the shared-schema pattern on blast end-to-end: @stackbilt/blast - Add zod@^3.24.1 as a runtime dependency - Add BlastInputSchema and BlastOutputSchema with .describe() on every field - Add high-level analyze(input) that composes buildGraph + blastRadius - Extract DEFAULT_MAX_DEPTH constant; schema and blastRadius both reference it - Make topHotFiles tie-break deterministic (secondary sort by filename) - analyze validates seed existence with a descriptive error CLI blast.ts - Route argv through BlastInputSchema.parse; Zod owns the --depth rules - Call analyze() instead of buildGraph + blastRadius directly - Export detectTsconfigAliases for reuse in serve.ts MCP serve.ts - Register charter_blast tool; handler parses raw args through BlastInputSchema, auto-detects tsconfig aliases, calls analyze, returns structured JSON - Advertised inputSchema uses a simple raw shape (SDK's ZodRawShapeCompat rejects .default() chains); defaults are applied inside the handler Tests - Structural assertions (no byte-identical snapshots) - Defaults verified, invalid maxDepth/empty seeds rejected - analyze agrees with low-level API on affected files - hotFiles tie-break determinism verified Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/blast/README.md | 24 ++++- packages/blast/package.json | 4 +- packages/blast/src/__tests__/blast.test.ts | 103 ++++++++++++++++++++- packages/blast/src/index.ts | 103 ++++++++++++++++++++- packages/cli/src/commands/blast.ts | 62 ++++++++----- packages/cli/src/commands/serve.ts | 59 ++++++++++++ pnpm-lock.yaml | 6 +- 7 files changed, 330 insertions(+), 31 deletions(-) diff --git a/packages/blast/README.md b/packages/blast/README.md index b6ad182..d864559 100644 --- a/packages/blast/README.md +++ b/packages/blast/README.md @@ -2,7 +2,7 @@ Blast radius analysis for [Charter Kit](https://github.com/Stackbilt-dev/charter) — a local-first governance toolkit for software repos. Builds a reverse dependency graph from TypeScript/JavaScript source files and answers the question: **"if I change this file, what else breaks?"** -Pure heuristic — no LLM calls, no TypeScript compiler API, zero runtime dependencies. Uses regex-based import extraction, which trades some precision for universality and speed. +Pure heuristic — no LLM calls, no TypeScript compiler API. Uses regex-based import extraction, which trades some precision for universality and speed. The only runtime dependency is Zod, which provides the authoritative input/output schemas (`BlastInputSchema`, `BlastOutputSchema`) shared between the CLI and MCP tool adapters. > **Want the full toolkit?** Just install the CLI — it includes everything: > ```bash @@ -31,6 +31,28 @@ Blast radius ≥20 files triggers a `CROSS_CUTTING` warning — a signal for gov ## Programmatic Usage +### High-level: `analyze` + +The Core-Out entry point used by both the Charter CLI and the `charter_blast` MCP tool. Takes a Zod-validated input object, returns a structured `BlastOutput`: + +```ts +import { analyze, BlastInputSchema } from '@stackbilt/blast'; + +const input = BlastInputSchema.parse({ + seeds: ['./src/kernel/dispatch.ts'], + root: './', + maxDepth: 3, + // aliases: { '@/': 'src/' }, // optional, CLI auto-detects from tsconfig +}); + +const result = analyze(input); +console.log(result.summary.totalAffected); +``` + +### Low-level: `buildGraph` + `blastRadius` + +Useful when you want to reuse a graph across multiple seeds: + ```ts import { buildGraph, blastRadius, topHotFiles } from '@stackbilt/blast'; diff --git a/packages/blast/package.json b/packages/blast/package.json index 44d523d..40d892b 100644 --- a/packages/blast/package.json +++ b/packages/blast/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/blast/src/__tests__/blast.test.ts b/packages/blast/src/__tests__/blast.test.ts index dfd1c87..0169a6b 100644 --- a/packages/blast/src/__tests__/blast.test.ts +++ b/packages/blast/src/__tests__/blast.test.ts @@ -2,7 +2,16 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { buildGraph, blastRadius, extractImports, topHotFiles } from '../index'; +import { + buildGraph, + blastRadius, + extractImports, + topHotFiles, + analyze, + BlastInputSchema, + BlastOutputSchema, + DEFAULT_MAX_DEPTH, +} from '../index'; let tmpRoot: string; @@ -217,4 +226,96 @@ describe('topHotFiles', () => { expect(hot[0].file).toBe(path.join(tmpRoot, 'shared.ts')); expect(hot[0].importers).toBe(3); }); + + it('breaks ties deterministically by filename', () => { + // Three leaves, each with exactly one importer — all tied at importers=1. + // Filenames are crafted to test sort stability: z > m > a lexicographically. + write('leaf_z.ts', `export const z = 1;`); + write('leaf_m.ts', `export const m = 1;`); + write('leaf_a.ts', `export const a = 1;`); + write('use_z.ts', `import { z } from './leaf_z';`); + write('use_m.ts', `import { m } from './leaf_m';`); + write('use_a.ts', `import { a } from './leaf_a';`); + + const graph = buildGraph(tmpRoot); + const hot = topHotFiles(graph, 10); + + // Only the leaf files have importers > 0. Ties break by filename ascending. + const tiedLeaves = hot.filter((h) => h.importers === 1).map((h) => path.basename(h.file)); + expect(tiedLeaves).toEqual(['leaf_a.ts', 'leaf_m.ts', 'leaf_z.ts']); + }); +}); + +// ============================================================================ +// Zod schemas + analyze — Core-Out contract +// ============================================================================ + +describe('BlastInputSchema', () => { + it('applies default maxDepth when omitted', () => { + const parsed = BlastInputSchema.parse({ seeds: ['src/x.ts'] }); + expect(parsed.maxDepth).toBe(DEFAULT_MAX_DEPTH); + expect(parsed.root).toBe('.'); + expect(parsed.aliases).toEqual({}); + }); + + it('rejects maxDepth < 1', () => { + expect(() => BlastInputSchema.parse({ seeds: ['x'], maxDepth: 0 })).toThrow(); + expect(() => BlastInputSchema.parse({ seeds: ['x'], maxDepth: -2 })).toThrow(); + }); + + it('rejects non-integer maxDepth', () => { + expect(() => BlastInputSchema.parse({ seeds: ['x'], maxDepth: 1.5 })).toThrow(); + }); + + it('rejects empty seeds array', () => { + expect(() => BlastInputSchema.parse({ seeds: [] })).toThrow(); + }); +}); + +describe('analyze', () => { + it('returns a shape that matches BlastOutputSchema', () => { + write('leaf.ts', `export const x = 1;`); + write('importer.ts', `import { x } from './leaf';`); + + const input = BlastInputSchema.parse({ + seeds: [path.join(tmpRoot, 'leaf.ts')], + root: tmpRoot, + }); + const result = analyze(input); + + // Structural assertion — no snapshot flakiness. + expect(() => BlastOutputSchema.parse(result)).not.toThrow(); + expect(result.summary.totalAffected).toBe(1); + expect(result.affected).toContain('importer.ts'); + }); + + it('throws a descriptive error when a seed is missing', () => { + expect(() => + analyze( + BlastInputSchema.parse({ + seeds: [path.join(tmpRoot, 'does-not-exist.ts')], + root: tmpRoot, + }), + ), + ).toThrow(/Seed file\(s\) not found/); + }); + + it('agrees with buildGraph + blastRadius on affected files', () => { + write('a.ts', `export const a = 1;`); + write('b.ts', `import { a } from './a';\nexport const b = a;`); + write('c.ts', `import { b } from './b';\nexport const c = b;`); + + const input = BlastInputSchema.parse({ + seeds: [path.join(tmpRoot, 'a.ts')], + root: tmpRoot, + maxDepth: 2, + }); + const fromAnalyze = analyze(input); + + const graph = buildGraph(tmpRoot); + const fromLowLevel = blastRadius(graph, [path.join(tmpRoot, 'a.ts')], { maxDepth: 2 }); + + expect(fromAnalyze.affected.sort()).toEqual(fromLowLevel.affected.sort()); + expect(fromAnalyze.summary.totalAffected).toBe(fromLowLevel.summary.totalAffected); + }); }); diff --git a/packages/blast/src/index.ts b/packages/blast/src/index.ts index ef55821..4f59bd7 100644 --- a/packages/blast/src/index.ts +++ b/packages/blast/src/index.ts @@ -5,8 +5,10 @@ * and performs BFS traversal to determine which files are affected by * changes to a given set of seed files. * - * Zero dependencies — pure Node.js APIs. AST-free: uses regex-based import - * extraction, which trades some accuracy for universality and speed. + * AST-free: uses regex-based import extraction, which trades some accuracy + * for universality and speed. Runtime dependency on Zod only — the schemas + * below are the authoritative input/output contract shared by the CLI and + * MCP tool adapters. * * Inspired by the CodeSight project's blast-radius algorithm, adapted for * the Charter governance workflow. @@ -14,6 +16,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import { z } from 'zod'; // ============================================================================ // Types @@ -65,6 +68,10 @@ export interface BlastOptions { // Constants // ============================================================================ +/** Default BFS traversal depth. Referenced by both the schema default and + * blastRadius's in-function default so they cannot drift. */ +export const DEFAULT_MAX_DEPTH = 3; + const DEFAULT_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']; const DEFAULT_IGNORE_DIRS = new Set([ 'node_modules', @@ -319,7 +326,7 @@ export function blastRadius( seeds: string[], options: BlastOptions = {} ): BlastRadiusResult { - const maxDepth = options.maxDepth ?? 3; + const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; const absSeeds = seeds.map((s) => path.resolve(s)); const seedSet = new Set(absSeeds); @@ -383,6 +390,94 @@ export function topHotFiles( if (parents.size === 0) continue; ranked.push({ file, importers: parents.size }); } - ranked.sort((a, b) => b.importers - a.importers); + // Primary: descending importer count. Secondary: ascending filename, so + // ties are deterministic across Node majors and filesystem scan order. + ranked.sort((a, b) => b.importers - a.importers || a.file.localeCompare(b.file)); return ranked.slice(0, limit); } + +// ============================================================================ +// Zod schemas — authoritative input/output contract +// ============================================================================ + +export const BlastInputSchema = z.object({ + seeds: z + .array(z.string().min(1)) + .min(1) + .describe('One or more file paths whose blast radius should be computed. Paths may be absolute or relative to the process cwd.'), + root: z + .string() + .optional() + .default('.') + .describe('Directory to scan for the dependency graph. Defaults to the current working directory.'), + maxDepth: z + .number() + .int() + .min(1) + .optional() + .default(DEFAULT_MAX_DEPTH) + .describe('Maximum BFS depth when traversing reverse dependencies. 1 = direct importers only.'), + aliases: z + .record(z.string(), z.string()) + .optional() + .default({}) + .describe('Optional tsconfig-style path alias map (e.g. { "@/": "src/" }). The CLI auto-detects these from tsconfig.json; programmatic callers must supply them explicitly.'), +}); + +export type BlastInput = z.infer; + +export const BlastOutputSchema = z.object({ + root: z.string().describe('Resolved absolute root directory the graph was built from.'), + fileCount: z.number().int().nonnegative().describe('Total source files scanned under root.'), + seeds: z.array(z.string()).describe('Seed files, as paths relative to root.'), + affected: z.array(z.string()).describe('Files that transitively import any seed, excluding seeds themselves, as paths relative to root.'), + maxDepth: z.number().int().nonnegative().describe('Deepest BFS level actually reached.'), + hotFiles: z + .array( + z.object({ + file: z.string(), + importers: z.number().int().nonnegative(), + }), + ) + .describe('Top 20 most-imported files in the whole graph (not just the blast radius). Sorted by importer count descending, with filename as deterministic tie-breaker.'), + summary: z.object({ + totalAffected: z.number().int().nonnegative(), + seedCount: z.number().int().nonnegative(), + depthHistogram: z.record(z.string(), z.number().int().nonnegative()) + .describe('Count of files reached at each BFS depth. Keys are stringified depths.'), + }), +}); + +export type BlastOutput = z.infer; + +// ============================================================================ +// High-level analyze — the Core-Out entry point for CLI and MCP adapters +// ============================================================================ + +/** + * Compose buildGraph + blastRadius from a validated input. + * + * This is the function both the CLI and the MCP tool adapter call. Low-level + * consumers can still use buildGraph and blastRadius directly. + */ +export function analyze(input: BlastInput): BlastOutput { + const absRoot = path.resolve(input.root); + const graph = buildGraph(absRoot, { aliases: input.aliases }); + + const absSeeds = input.seeds.map((s) => path.resolve(s)); + const missing = absSeeds.filter((s) => !fs.existsSync(s)); + if (missing.length > 0) { + throw new Error(`Seed file(s) not found: ${missing.join(', ')}`); + } + const result = blastRadius(graph, absSeeds, { maxDepth: input.maxDepth }); + + return { + root: absRoot, + fileCount: graph.fileCount, + seeds: result.seeds, + affected: result.affected, + maxDepth: result.maxDepth, + hotFiles: result.hotFiles, + summary: result.summary, + }; +} diff --git a/packages/cli/src/commands/blast.ts b/packages/cli/src/commands/blast.ts index 72c567d..d4fe327 100644 --- a/packages/cli/src/commands/blast.ts +++ b/packages/cli/src/commands/blast.ts @@ -13,7 +13,8 @@ import * as fs from 'fs'; import type { CLIOptions } from '../index'; import { CLIError, EXIT_CODE } from '../index'; import { getFlag } from '../flags'; -import { buildGraph, blastRadius } from '@stackbilt/blast'; +import { analyze, BlastInputSchema } from '@stackbilt/blast'; +import { z } from 'zod'; // Flags that consume a value (so the next positional should not be treated as a seed file). // Includes local flags (--root, --depth) and global CLI flags (--format, --config). @@ -36,36 +37,51 @@ export async function blastCommand(options: CLIOptions, args: string[]): Promise ); } - const root = path.resolve(getFlag(args, '--root') || '.'); + const rootArg = getFlag(args, '--root') || '.'; const depthStr = getFlag(args, '--depth'); - const maxDepth = depthStr ? parseInt(depthStr, 10) : 3; - if (!Number.isFinite(maxDepth) || maxDepth < 1) { - throw new CLIError(`Invalid --depth value: ${depthStr}. Must be a positive integer.`); + const root = path.resolve(rootArg); + const aliases = detectTsconfigAliases(root); + + // Route argv through the schema. BlastInputSchema owns the depth default + // and the "positive integer" rule. + let input; + try { + input = BlastInputSchema.parse({ + seeds: seedArgs, + root: rootArg, + maxDepth: depthStr !== undefined ? Number(depthStr) : undefined, + aliases, + }); + } 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; } - // Validate seeds exist - const seeds: string[] = []; - for (const seed of seedArgs) { - const abs = path.resolve(seed); - if (!fs.existsSync(abs)) { + // Pre-flight existence check — matches prior CLI error text. + for (const seed of input.seeds) { + if (!fs.existsSync(path.resolve(seed))) { throw new CLIError(`Seed file not found: ${seed}`); } - seeds.push(abs); } - // Auto-detect path aliases from tsconfig.json if present (best-effort) - const aliases = detectTsconfigAliases(root); - - const graph = buildGraph(root, { aliases }); - const result = blastRadius(graph, seeds, { maxDepth }); + const result = analyze(input); if (options.format === 'json') { console.log( JSON.stringify( { - root: path.relative(process.cwd(), root), - fileCount: graph.fileCount, - ...result, + root: path.relative(process.cwd(), result.root), + fileCount: result.fileCount, + seeds: result.seeds, + affected: result.affected, + maxDepth: result.maxDepth, + hotFiles: result.hotFiles, + summary: result.summary, }, null, 2 @@ -76,13 +92,13 @@ export async function blastCommand(options: CLIOptions, args: string[]): Promise console.log(''); console.log(` Blast radius analysis`); - console.log(` root: ${path.relative(process.cwd(), root) || '.'}`); - console.log(` scanned: ${graph.fileCount} files`); + console.log(` root: ${path.relative(process.cwd(), result.root) || '.'}`); + console.log(` scanned: ${result.fileCount} files`); console.log(` seeds: ${result.seeds.length}`); for (const seed of result.seeds) { console.log(` - ${seed}`); } - console.log(` max depth: ${maxDepth} (reached: ${result.maxDepth})`); + console.log(` max depth: ${input.maxDepth} (reached: ${result.maxDepth})`); console.log(` affected: ${result.summary.totalAffected} file(s)`); console.log(''); @@ -186,7 +202,7 @@ function loadTsconfigChain(tsconfigPath: string, seen = new Set()): Mini return merged; } -function detectTsconfigAliases(root: string): Record { +export function detectTsconfigAliases(root: string): Record { const tsconfigPath = path.join(root, 'tsconfig.json'); const merged = loadTsconfigChain(tsconfigPath); const paths = merged.compilerOptions?.paths; diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index a053943..7ea3755 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -26,9 +26,11 @@ import { resolveModules, validateConstraints, } from '@stackbilt/adf'; +import { analyze, BlastInputSchema } from '@stackbilt/blast'; import type { CLIOptions } from '../index'; import { CLIError, EXIT_CODE } from '../index'; import { getFlag } from '../flags'; +import { detectTsconfigAliases } from './blast'; // ============================================================================ // Constants @@ -172,6 +174,63 @@ function registerTools(server: McpServer, aiDir: string): void { }, ); + // Advertised input shape is a plain ZodRawShape with no `.default()` / + // chained refinements — the SDK's type compatibility layer only accepts + // simple Zod types. Authoritative validation (defaults, min lengths, etc.) + // lives in BlastInputSchema.parse inside the handler; the descriptions + // here document those rules for agents. + const charterBlastInput = { + seeds: z.array(z.string()).describe( + 'One or more file paths whose blast radius should be computed (at least one required). Paths may be absolute or relative to the server cwd.', + ), + root: z.string().optional().describe( + 'Directory to scan for the dependency graph. Defaults to "." (server cwd).', + ), + maxDepth: z.number().optional().describe( + 'Maximum BFS depth when traversing reverse dependencies. Positive integer, defaults to 3. 1 = direct importers only.', + ), + aliases: z.record(z.string()).optional().describe( + 'Optional tsconfig-style path alias map (e.g. { "@/": "src/" }). If omitted, aliases are auto-detected from tsconfig.json at the scan root.', + ), + }; + + // Cast matches the other inputSchema-bearing tools in this file. The SDK's + // `ZodRawShapeCompat` overload resolution triggers TS2589 on any + // multi-field raw shape. Follow-up: remove all three casts once the SDK + // ships a better type signature (or once Charter upgrades zod). + (server.registerTool as Function)( + 'charter_blast', + { + description: + 'Compute blast radius for one or more source files — which other files transitively import them, up to a configurable BFS depth. Returns structured JSON including affected files, hot files, and a depth histogram. A totalAffected >= 20 is the governance signal to classify a change as CROSS_CUTTING.', + inputSchema: charterBlastInput, + }, + async (rawInput: unknown) => { + try { + const parsed = BlastInputSchema.parse(rawInput); + // Auto-detect tsconfig aliases if the caller didn't supply any. + const aliases = + Object.keys(parsed.aliases).length > 0 + ? parsed.aliases + : detectTsconfigAliases(path.resolve(parsed.root)); + const result = analyze({ ...parsed, aliases }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } 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/pnpm-lock.yaml b/pnpm-lock.yaml index 2024b46..18c9169 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,11 @@ importers: packages/adf: {} - packages/blast: {} + packages/blast: + dependencies: + zod: + specifier: ^3.24.1 + version: 3.25.76 packages/ci: dependencies: