Skip to content

Commit b135a9a

Browse files
Kurt Overmierclaude
andcommitted
fix(blast,surface): dogfooding finds real bugs; add comment-strip, extends chain, src/ fallback
Discovered by meta-testing blast+surface against the charter monorepo itself. blast: - Resolve directory aliases via src/index.* FIRST (monorepo source layout) - Fall back to package.json source/types/main for legacy packages - Follow tsconfig extends chain when loading aliases (baseUrl resolution is per-file, not global). Charter uses tsconfig.base.json which my old code never read, so @stackbilt/types was unresolvable. - Prefer source over types/dist so the dep graph edges point at .ts files, not compiled .d.ts declarations. - Add --format, --config to VALUE_FLAGS so global CLI flags don't get swept into the positional seeds list. surface: - Strip block and line comments before scanning for routes. jsdoc examples in surface's own source were matching as real routes during meta-testing. Comment stripping preserves line numbers. - Ignore __tests__, __mocks__, __fixtures__ directories and *.test.*, *.spec.* files. Test fixtures contain route-like strings that aren't real routes. Real-world validation: - `charter blast packages/types/src/index.ts` on charter now correctly reports 27 affected files (was 0 before fixes). types.ts shows up in hot files with 14 importers pointing at .ts source, not .d.ts. - `charter surface` on charter correctly returns 0 routes (CLI project, not a Worker) instead of 15 false positives from test fixtures. - All 341 existing tests still pass. 30 → 33 tests in blast+surface. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5135319 commit b135a9a

File tree

5 files changed

+230
-25
lines changed

5 files changed

+230
-25
lines changed

packages/blast/src/__tests__/blast.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,30 @@ describe('buildGraph', () => {
7474
expect(graph.imports.get(consumerPath)?.has(libIndex)).toBe(true);
7575
});
7676

77+
it('resolves alias to package dir via src/index.ts fallback', () => {
78+
// Simulates monorepo layout: @stackbilt/types → packages/types
79+
write('packages/types/src/index.ts', `export type X = number;`);
80+
write('packages/cli/src/index.ts', `import type { X } from '@stackbilt/types';`);
81+
82+
const graph = buildGraph(tmpRoot, { aliases: { '@stackbilt/types': 'packages/types' } });
83+
const consumerPath = path.join(tmpRoot, 'packages', 'cli', 'src', 'index.ts');
84+
const libPath = path.join(tmpRoot, 'packages', 'types', 'src', 'index.ts');
85+
86+
expect(graph.imports.get(consumerPath)?.has(libPath)).toBe(true);
87+
});
88+
89+
it('resolves alias to package dir via package.json main', () => {
90+
write('packages/lib/package.json', `{"main":"./lib/entry.ts"}`);
91+
write('packages/lib/lib/entry.ts', `export const x = 1;`);
92+
write('consumer.ts', `import { x } from '@scope/lib';`);
93+
94+
const graph = buildGraph(tmpRoot, { aliases: { '@scope/lib': 'packages/lib' } });
95+
const consumerPath = path.join(tmpRoot, 'consumer.ts');
96+
const entryPath = path.join(tmpRoot, 'packages', 'lib', 'lib', 'entry.ts');
97+
98+
expect(graph.imports.get(consumerPath)?.has(entryPath)).toBe(true);
99+
});
100+
77101
it('resolves path aliases', () => {
78102
write('src/consumer.ts', `import { x } from '@/lib';`);
79103
write('src/lib.ts', `export const x = 1;`);

packages/blast/src/index.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,13 +208,39 @@ function resolveWithExtensions(base: string, extensions: string[]): string | nul
208208
const candidate = base + ext;
209209
if (fs.existsSync(candidate)) return candidate;
210210
}
211-
// Index file in directory
211+
// Directory resolution. Source-first, then package.json as fallback.
212+
// Rationale: in monorepos with TS workspaces, we want dep-graph edges
213+
// pointing at .ts source files, not compiled .d.ts declarations.
212214
try {
213215
if (fs.statSync(base).isDirectory()) {
216+
// 1. src/index.* — standard monorepo source layout
217+
for (const ext of extensions) {
218+
const srcIndex = path.join(base, 'src', 'index' + ext);
219+
if (fs.existsSync(srcIndex)) return srcIndex;
220+
}
221+
// 2. bare index.*
214222
for (const ext of extensions) {
215223
const candidate = path.join(base, 'index' + ext);
216224
if (fs.existsSync(candidate)) return candidate;
217225
}
226+
// 3. package.json source/types/main (for packages without src/)
227+
const pkgPath = path.join(base, 'package.json');
228+
if (fs.existsSync(pkgPath)) {
229+
try {
230+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as {
231+
main?: string;
232+
types?: string;
233+
source?: string;
234+
};
235+
const candidates = [pkg.source, pkg.types, pkg.main].filter(Boolean) as string[];
236+
for (const c of candidates) {
237+
const resolved = resolveWithExtensions(path.join(base, c), extensions);
238+
if (resolved) return resolved;
239+
}
240+
} catch {
241+
/* malformed package.json */
242+
}
243+
}
218244
}
219245
} catch {
220246
/* not a directory */

packages/cli/src/commands/blast.ts

Lines changed: 81 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ import { CLIError, EXIT_CODE } from '../index';
1515
import { getFlag } from '../flags';
1616
import { buildGraph, blastRadius } from '@stackbilt/blast';
1717

18-
// Flags that consume a value (so the next positional should not be treated as a seed file)
19-
const VALUE_FLAGS = new Set(['--root', '--depth']);
18+
// Flags that consume a value (so the next positional should not be treated as a seed file).
19+
// Includes local flags (--root, --depth) and global CLI flags (--format, --config).
20+
const VALUE_FLAGS = new Set(['--root', '--depth', '--format', '--config']);
2021

2122
export async function blastCommand(options: CLIOptions, args: string[]): Promise<number> {
2223
const seedArgs: string[] = [];
@@ -121,29 +122,88 @@ export async function blastCommand(options: CLIOptions, args: string[]): Promise
121122
// tsconfig path alias detection
122123
// ============================================================================
123124

124-
function detectTsconfigAliases(root: string): Record<string, string> {
125-
const tsconfigPath = path.join(root, 'tsconfig.json');
126-
if (!fs.existsSync(tsconfigPath)) return {};
125+
interface MinimalTsconfig {
126+
extends?: string | string[];
127+
compilerOptions?: { baseUrl?: string; paths?: Record<string, string[]> };
128+
}
129+
130+
function readTsconfig(tsconfigPath: string): MinimalTsconfig | null {
131+
if (!fs.existsSync(tsconfigPath)) return null;
127132
try {
128133
const raw = fs.readFileSync(tsconfigPath, 'utf8');
129134
// Strip JSON comments (tsconfig allows them)
130135
const cleaned = raw.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
131-
const parsed = JSON.parse(cleaned) as {
132-
compilerOptions?: { baseUrl?: string; paths?: Record<string, string[]> };
133-
};
134-
const paths = parsed.compilerOptions?.paths;
135-
const baseUrl = parsed.compilerOptions?.baseUrl ?? '.';
136-
if (!paths) return {};
137-
const aliases: Record<string, string> = {};
138-
for (const [key, targets] of Object.entries(paths)) {
139-
if (!targets || targets.length === 0) continue;
140-
// Normalize "@/*" -> "@/", "src/*" -> "src/"
141-
const aliasKey = key.replace(/\*$/, '');
142-
const targetPath = targets[0].replace(/\*$/, '');
143-
aliases[aliasKey] = path.join(baseUrl, targetPath);
144-
}
145-
return aliases;
136+
return JSON.parse(cleaned) as MinimalTsconfig;
146137
} catch {
147-
return {};
138+
return null;
139+
}
140+
}
141+
142+
/**
143+
* Walk the tsconfig `extends` chain and merge compilerOptions.paths from
144+
* parents into the child. Shallower (closer to the child) wins on conflicts.
145+
*/
146+
function loadTsconfigChain(tsconfigPath: string, seen = new Set<string>()): MinimalTsconfig {
147+
const abs = path.resolve(tsconfigPath);
148+
if (seen.has(abs)) return {};
149+
seen.add(abs);
150+
151+
const parsed = readTsconfig(abs);
152+
if (!parsed) return {};
153+
154+
const merged: MinimalTsconfig = { compilerOptions: { baseUrl: '.', paths: {} } };
155+
156+
// Resolve extends first, then overlay current file's options on top
157+
const extendsRaw = parsed.extends;
158+
const extendsList = Array.isArray(extendsRaw) ? extendsRaw : extendsRaw ? [extendsRaw] : [];
159+
for (const ext of extendsList) {
160+
let extPath = ext;
161+
if (!extPath.endsWith('.json')) extPath += '.json';
162+
if (!path.isAbsolute(extPath)) extPath = path.resolve(path.dirname(abs), extPath);
163+
const parent = loadTsconfigChain(extPath, seen);
164+
if (parent.compilerOptions?.paths) {
165+
Object.assign(merged.compilerOptions!.paths!, parent.compilerOptions.paths);
166+
}
167+
if (parent.compilerOptions?.baseUrl) {
168+
// baseUrl is relative to the tsconfig that declared it
169+
merged.compilerOptions!.baseUrl = path.resolve(
170+
path.dirname(extPath),
171+
parent.compilerOptions.baseUrl
172+
);
173+
}
174+
}
175+
176+
if (parsed.compilerOptions?.paths) {
177+
Object.assign(merged.compilerOptions!.paths!, parsed.compilerOptions.paths);
178+
}
179+
if (parsed.compilerOptions?.baseUrl) {
180+
merged.compilerOptions!.baseUrl = path.resolve(
181+
path.dirname(abs),
182+
parsed.compilerOptions.baseUrl
183+
);
184+
}
185+
186+
return merged;
187+
}
188+
189+
function detectTsconfigAliases(root: string): Record<string, string> {
190+
const tsconfigPath = path.join(root, 'tsconfig.json');
191+
const merged = loadTsconfigChain(tsconfigPath);
192+
const paths = merged.compilerOptions?.paths;
193+
const baseUrl = merged.compilerOptions?.baseUrl ?? root;
194+
if (!paths || Object.keys(paths).length === 0) return {};
195+
const aliases: Record<string, string> = {};
196+
for (const [key, targets] of Object.entries(paths)) {
197+
if (!targets || targets.length === 0) continue;
198+
// Normalize "@/*" -> "@/", "src/*" -> "src/"
199+
const aliasKey = key.replace(/\*$/, '');
200+
const targetPath = targets[0].replace(/\*$/, '');
201+
// baseUrl is already absolute after loadTsconfigChain; produce an absolute alias target
202+
const absoluteTarget = path.isAbsolute(targetPath)
203+
? targetPath
204+
: path.resolve(baseUrl, targetPath);
205+
// Express target as relative to root so buildGraph's resolveSpecifier can join it
206+
aliases[aliasKey] = path.relative(root, absoluteTarget) || '.';
148207
}
208+
return aliases;
149209
}

packages/surface/src/__tests__/surface.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ app.get('/users', h);
6060
expect(routes[0].prefix).toBe('/api/v1');
6161
});
6262

63+
it('ignores route patterns inside comments', () => {
64+
const src = `
65+
import { Hono } from 'hono';
66+
const app = new Hono();
67+
// Example usage: app.get('/fake-from-comment', handler)
68+
/* Another example:
69+
* app.post('/also-fake', h);
70+
* router.delete('/nope', h);
71+
*/
72+
app.get('/real', handler);
73+
`;
74+
const routes = extractRoutes(src, 'app.ts');
75+
expect(routes).toHaveLength(1);
76+
expect(routes[0].path).toBe('/real');
77+
});
78+
6379
it('handles router. prefix', () => {
6480
const src = `
6581
const router = new Hono();
@@ -158,6 +174,17 @@ app.post('/api/users', createUser);
158174
expect(surface.schemas[0].name).toBe('users');
159175
});
160176

177+
it('ignores test/spec files and __tests__ dirs', () => {
178+
write('src/app.ts', `import { Hono } from 'hono';\napp.get('/real', h);`);
179+
write('src/app.test.ts', `app.get('/fake-from-test', h);`);
180+
write('src/__tests__/fixtures.ts', `app.get('/fake-from-tests-dir', h);`);
181+
write('src/handler.spec.ts', `app.post('/fake-from-spec', h);`);
182+
183+
const surface = extractSurface({ root: tmpRoot });
184+
expect(surface.summary.routeCount).toBe(1);
185+
expect(surface.routes[0].path).toBe('/real');
186+
});
187+
161188
it('ignores node_modules and dist', () => {
162189
write('src/app.ts', `app.get('/a', h);`);
163190
write('node_modules/bad/src/x.ts', `app.get('/evil', h);`);

packages/surface/src/index.ts

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ const DEFAULT_IGNORE_DIRS = new Set([
8282
'.turbo',
8383
'.wrangler',
8484
'coverage',
85+
'__tests__',
86+
'__mocks__',
87+
'__fixtures__',
8588
]);
8689

8790
const HTTP_METHODS = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'all'];
@@ -130,14 +133,18 @@ function walkFiles(
130133
*/
131134
export function extractRoutes(source: string, filePath: string): Route[] {
132135
const routes: Route[] = [];
133-
const lines = source.split('\n');
136+
// Strip block comments and line comments before scanning.
137+
// Preserves line numbers by replacing comment bodies with spaces.
138+
const stripped = stripComments(source);
139+
const lines = stripped.split('\n');
134140

135141
// Detect basePath (applies to this router within the file)
136142
let basePath: string | undefined;
137-
const basePathMatch = source.match(/\.basePath\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/);
143+
const basePathMatch = stripped.match(/\.basePath\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/);
138144
if (basePathMatch) basePath = basePathMatch[1];
139145

140-
// Framework detection from import statements
146+
// Framework detection from import statements (use original source —
147+
// detectFramework only looks at import lines which aren't commented out)
141148
const framework = detectFramework(source);
142149

143150
// Pattern: <identifier>.METHOD('/path', ...)
@@ -169,6 +176,63 @@ export function extractRoutes(source: string, filePath: string): Route[] {
169176
return routes;
170177
}
171178

179+
/**
180+
* Strip block and line comments from source, preserving line numbers
181+
* by replacing stripped content with equivalent whitespace.
182+
*/
183+
function stripComments(source: string): string {
184+
let result = '';
185+
let i = 0;
186+
const n = source.length;
187+
while (i < n) {
188+
const ch = source[i];
189+
const next = source[i + 1];
190+
// Block comment
191+
if (ch === '/' && next === '*') {
192+
result += ' ';
193+
i += 2;
194+
while (i < n - 1 && !(source[i] === '*' && source[i + 1] === '/')) {
195+
result += source[i] === '\n' ? '\n' : ' ';
196+
i++;
197+
}
198+
result += ' ';
199+
i += 2;
200+
continue;
201+
}
202+
// Line comment
203+
if (ch === '/' && next === '/') {
204+
while (i < n && source[i] !== '\n') {
205+
result += ' ';
206+
i++;
207+
}
208+
continue;
209+
}
210+
// String literal — preserve contents (contains real route paths)
211+
if (ch === '"' || ch === "'" || ch === '`') {
212+
const quote = ch;
213+
result += ch;
214+
i++;
215+
while (i < n && source[i] !== quote) {
216+
if (source[i] === '\\' && i + 1 < n) {
217+
result += source[i] + source[i + 1];
218+
i += 2;
219+
continue;
220+
}
221+
result += source[i];
222+
i++;
223+
}
224+
if (i < n) {
225+
result += source[i];
226+
i++;
227+
}
228+
continue;
229+
}
230+
result += ch;
231+
i++;
232+
}
233+
return result;
234+
}
235+
172236
function detectFramework(source: string): Route['framework'] {
173237
if (/from\s+['"`]hono['"`]/.test(source) || /require\s*\(\s*['"`]hono['"`]/.test(source)) {
174238
return 'hono';
@@ -320,6 +384,10 @@ export function extractSurface(options: ExtractOptions = {}): Surface {
320384
const sourceFiles = walkFiles(root, extensions, ignoreDirs);
321385
const routes: Route[] = [];
322386
for (const file of sourceFiles) {
387+
// Skip test/spec files — their route strings are fixtures, not real routes
388+
const base = path.basename(file);
389+
if (/\.(test|spec)\.[mc]?[tj]sx?$/i.test(base)) continue;
390+
323391
let content: string;
324392
try {
325393
content = fs.readFileSync(file, 'utf8');

0 commit comments

Comments
 (0)