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
24 changes: 23 additions & 1 deletion packages/blast/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';

Expand Down
4 changes: 3 additions & 1 deletion packages/blast/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
103 changes: 102 additions & 1 deletion packages/blast/src/__tests__/blast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
});
});
103 changes: 99 additions & 4 deletions packages/blast/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
* 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.
*/

import * as fs from 'fs';
import * as path from 'path';
import { z } from 'zod';

// ============================================================================
// Types
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<typeof BlastInputSchema>;

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<typeof BlastOutputSchema>;

// ============================================================================
// 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,
};
}
Loading
Loading