Skip to content

Commit 9979191

Browse files
author
Евгений Балякин
committed
improvements for python
1 parent 1dd789a commit 9979191

7 files changed

Lines changed: 59 additions & 14 deletions

File tree

src/core/context.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export interface ContextOptions {
1818
budget?: number;
1919
includeTests?: boolean;
2020
changedOnly?: boolean;
21-
mode?: 'full' | 'architecture' | 'overview' | 'edit_prep' | 'composition';
21+
mode?: 'full' | 'architecture' | 'overview' | 'edit_prep' | 'composition' | 'test_impact';
2222
productionOnly?: boolean;
2323
testsOnly?: boolean;
2424
includeMocks?: boolean;
@@ -108,14 +108,16 @@ export async function buildContext(root: string, options: ContextOptions) {
108108
const items = [] as Array<{ type: 'skeleton' | 'symbol_body'; path: string; score: number; reason: string; content: string; symbolId?: string }>;
109109
let usedTokens = 0;
110110
const testRelations = inferTestRelations(fileRecords, graph.edges).slice(0, 30);
111-
if (options.mode === 'architecture' || options.mode === 'overview' || options.mode === 'edit_prep' || options.mode === 'composition') {
111+
if (options.mode === 'architecture' || options.mode === 'overview' || options.mode === 'edit_prep' || options.mode === 'composition' || options.mode === 'test_impact') {
112112
const architecture = buildArchitectureSummary(fileRecords, graph.edges, testRelations, changedFiles);
113113
const content = options.mode === 'overview'
114114
? renderOverviewSummary(architecture)
115115
: options.mode === 'edit_prep'
116116
? renderEditPrepSummary(architecture, options.goal)
117117
: options.mode === 'composition'
118118
? renderCompositionSummary(architecture, budget)
119+
: options.mode === 'test_impact'
120+
? renderTestImpactSummary(architecture, options.goal)
119121
: renderArchitectureSummary(architecture, budget);
120122
const omitted = omittedFiles.slice(0, 20);
121123
const data = { schemaVersion: SCHEMA_VERSION, goal: options.goal, mode: options.mode, budget, usedTokens: estimateTokens(content), items: [{ type: `${options.mode}_summary` as const, path: options.path ?? '.', score: 1, reason: `compact ${options.mode} summary`, content }], omitted, nextReads: ranked.slice(0, 10).map((item) => ({ command: 'skeleton', path: item.path, symbolId: item.symbolId })), architecture, testRelations, warnings, truncated: omitted.length > 0 || estimateTokens(renderArchitectureSummary(architecture)) > budget, tokenEstimate: estimateTokens(content) };
@@ -247,7 +249,10 @@ function renderArchitectureSummary(summary: ReturnType<typeof buildArchitectureS
247249
if (summary.dependencies.length) sections.push(`App dependency graph:\n${summary.dependencies.map((item) => {
248250
const writes = item.writes.length ? ` created: ${item.writes.map((usage) => `${usage.file}:${usage.line}${usage.value ? ` = ${usage.value}` : ''}`).join(', ')}` : '';
249251
const reads = item.reads.length ? ` read: ${item.reads.slice(0, 6).map((usage) => `${usage.file}:${usage.line}`).join(', ')}` : '';
250-
return ` app["${item.key}"]${writes}${reads}`;
252+
const starts = item.starts.length ? ` start: ${item.starts.map((usage) => `${usage.file}:${usage.line}`).join(', ')}` : '';
253+
const stops = item.stops.length ? ` stop: ${item.stops.map((usage) => `${usage.file}:${usage.line}`).join(', ')}` : '';
254+
const risk = item.starts.length && !item.stops.length ? ' [risk: started but no stop found]' : '';
255+
return ` app["${item.key}"]${writes}${reads}${starts}${stops}${risk}`;
251256
}).join('\n')}`);
252257
if (summary.tables.length) sections.push(`SQLAlchemy tables:\n${summary.tables.map((item) => ` ${item.name} (${item.file}:${item.line})`).join('\n')}`);
253258
if (summary.changed.length) sections.push(`Changed files impact:\n${summary.changed.map((item) => ` ${item.path}${item.tests.length ? ` -> tests: ${item.tests.join(', ')}` : ''}`).join('\n')}`);
@@ -297,13 +302,31 @@ function renderCompositionSummary(summary: ReturnType<typeof buildArchitectureSu
297302
if (summary.dependencies.length) sections.push(`App dependencies:\n${summary.dependencies.map((item) => {
298303
const writes = item.writes.length ? `created ${item.writes.map((usage) => `${usage.file}:${usage.line}${usage.value ? ` = ${usage.value}` : ''}`).join(', ')}` : 'no creator found';
299304
const reads = item.reads.length ? `; read ${item.reads.slice(0, 5).map((usage) => `${usage.file}:${usage.line}`).join(', ')}` : '';
300-
return ` app["${item.key}"] ${writes}${reads}`;
305+
const lifecycle = `${item.starts.length ? `; start ${item.starts.map((usage) => `${usage.file}:${usage.line}`).join(', ')}` : ''}${item.stops.length ? `; stop ${item.stops.map((usage) => `${usage.file}:${usage.line}`).join(', ')}` : ''}${item.starts.length && !item.stops.length ? '; risk: started but no stop found' : ''}`;
306+
return ` app["${item.key}"] ${writes}${reads}${lifecycle}`;
301307
}).join('\n')}`);
302308
if (summary.layers.integrations.length) sections.push(`External integrations:\n${summary.layers.integrations.slice(0, 15).map((file) => ` ${file}`).join('\n')}`);
303309
if (summary.layers.background.length) sections.push(`Background jobs/consumers:\n${summary.layers.background.slice(0, 15).map((file) => ` ${file}`).join('\n')}`);
304310
return fitSections(sections, budget);
305311
}
306312

313+
function renderTestImpactSummary(summary: ReturnType<typeof buildArchitectureSummary>, goal: string): string {
314+
const query = goal.toLowerCase().split(/[^a-z0-9_./]+/).filter((term) => term.length > 2);
315+
const scored = summary.testRelations.map((relation) => {
316+
const haystack = `${relation.source} ${relation.test}`.toLowerCase();
317+
const score = query.reduce((sum, term) => sum + (haystack.includes(term) ? 1 : 0), 0) + (relation.reason === 'import' ? 2 : relation.reason === 'symbol_mention' ? 1.5 : 1);
318+
return { ...relation, score };
319+
}).filter((relation) => relation.score > 0).sort((a, b) => b.score - a.score || a.test.localeCompare(b.test)).slice(0, 12);
320+
const sections = [`Test impact for: ${goal}`];
321+
if (scored.length) sections.push(`Pytest candidates:\n${scored.map((item) => ` ${item.test} (covers ${item.source}; ${item.reason})`).join('\n')}`);
322+
const fixtureHints = Array.from(new Set(scored.flatMap((item) => [item.test, item.source]).filter((file) => /fixture|mock|fake|conftest/i.test(file))));
323+
const external = Array.from(new Set(scored.flatMap((item) => [item.test, item.source]).join(' ').match(/redis|rabbit|postgres|mongo|kafka/gi) ?? [])).map((item) => item.toLowerCase());
324+
if (fixtureHints.length) sections.push(`Fixture/mock hints:\n${fixtureHints.map((item) => ` ${item}`).join('\n')}`);
325+
if (external.length) sections.push(`External services hinted by paths: ${external.join(', ')}`);
326+
if (!scored.length) sections.push('No direct test relation found. Use codebone_symbols for the changed symbol, then run nearest API/task tests by path proximity.');
327+
return sections.join('\n\n');
328+
}
329+
307330
function summarizeLayers(fileRecords: Array<{ path: string }>) {
308331
const layers: Record<string, string[]> = { entrypoints: [], api: [], services: [], persistence: [], background: [], integrations: [], tests: [] };
309332
for (const record of fileRecords) {
@@ -330,12 +353,14 @@ function fitSections(sections: string[], budget: number): string {
330353
}
331354

332355
function buildAppDependencyGraph(fileRecords: Array<{ path: string; symbols: ReturnType<typeof flattenSymbols> }>) {
333-
const byKey = new Map<string, { key: string; writes: Array<{ file: string; line: number; value?: string }>; reads: Array<{ file: string; line: number }> }>();
356+
const byKey = new Map<string, { key: string; writes: Array<{ file: string; line: number; value?: string }>; reads: Array<{ file: string; line: number }>; starts: Array<{ file: string; line: number }>; stops: Array<{ file: string; line: number }> }>();
334357
for (const record of fileRecords) {
335358
for (const symbol of record.symbols.filter((item) => item.kind === 'dependency')) {
336-
const entry = byKey.get(symbol.name) ?? { key: symbol.name, writes: [], reads: [] };
359+
const entry = byKey.get(symbol.name) ?? { key: symbol.name, writes: [], reads: [], starts: [], stops: [] };
337360
const usage = { file: record.path, line: symbol.startLine };
338361
if (/\b(?:app|request\.app)\[['"][^'"]+['"]\]\s*=/.test(symbol.signature)) entry.writes.push({ ...usage, value: symbol.signature.split('=').slice(1).join('=').trim().slice(0, 80) });
362+
else if (/\[['"][^'"]+['"]\]\.(?:start|startup)\s*\(/.test(symbol.signature)) entry.starts.push(usage);
363+
else if (/\[['"][^'"]+['"]\]\.(?:stop|close|shutdown|cleanup)\s*\(/.test(symbol.signature)) entry.stops.push(usage);
339364
else entry.reads.push(usage);
340365
byKey.set(symbol.name, entry);
341366
}

src/core/directory-skeleton.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import { estimateTokens } from './budget.js';
66

77
const execFileAsync = promisify(execFile);
88

9-
export async function skeletonDirectory(root: string, inputPath: string, options: { maxFiles?: number; budget?: number; publicOnly?: boolean; publicApiOnly?: boolean; symbolsOnly?: boolean; includePrivate?: boolean; includeRoutes?: boolean; include?: string[]; exclude?: string[]; sort?: string; changedOnly?: boolean; respectAiIgnore?: boolean; mode?: 'full' | 'summary' | 'public_api' } = {}) {
9+
export async function skeletonDirectory(root: string, inputPath: string, options: { maxFiles?: number; budget?: number; publicOnly?: boolean; publicApiOnly?: boolean; symbolsOnly?: boolean; includePrivate?: boolean; includeRoutes?: boolean; detail?: 'rpc_api' | 'lifecycle' | 'app_dependencies' | 'public_methods'; include?: string[]; exclude?: string[]; sort?: string; changedOnly?: boolean; respectAiIgnore?: boolean; mode?: 'full' | 'summary' | 'public_api' } = {}) {
1010
const files = await sortFiles(root, await walkSourceFiles(root, inputPath, { maxFiles: options.maxFiles ?? 100, include: options.include, exclude: options.exclude, respectAiIgnore: options.respectAiIgnore }), options.sort ?? 'path', Boolean(options.changedOnly));
1111
const skeletons = [];
1212
let used = 0;
1313
let truncated = false;
1414
for (const file of files) {
15-
const skeleton = await skeletonPath(root, file.relativePath, { publicOnly: options.publicOnly, publicApiOnly: options.publicApiOnly || options.mode === 'public_api', symbolsOnly: options.symbolsOnly, includePrivate: options.includePrivate, includeRoutes: options.includeRoutes, noImports: options.mode === 'public_api', budget: options.budget });
15+
const skeleton = await skeletonPath(root, file.relativePath, { publicOnly: options.publicOnly, publicApiOnly: options.publicApiOnly || options.mode === 'public_api', symbolsOnly: options.symbolsOnly, includePrivate: options.includePrivate, includeRoutes: options.includeRoutes, detail: options.detail, noImports: options.mode === 'public_api', budget: options.budget });
1616
const cost = skeleton.tokenEstimate;
1717
if (options.budget && skeletons.length > 0 && used + cost > options.budget) {
1818
truncated = true;

src/core/skeleton.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ interface ExtractOptions {
3333
symbolsOnly?: boolean;
3434
includePrivate?: boolean;
3535
includeRoutes?: boolean;
36+
detail?: 'rpc_api' | 'lifecycle' | 'app_dependencies' | 'public_methods';
3637
budget?: number;
3738
}
3839

@@ -551,6 +552,10 @@ function filterPublicApi(symbols: CodeSymbol[], publicApiOnly: boolean): CodeSym
551552

552553
function filterDetailLevel(symbols: CodeSymbol[], options: ExtractOptions): CodeSymbol[] {
553554
return symbols.filter((symbol) => {
555+
if (options.detail === 'rpc_api') return symbol.kind === 'route' || (['function', 'method'].includes(symbol.kind) && symbol.name.startsWith('rpc_'));
556+
if (options.detail === 'lifecycle') return ['function', 'method'].includes(symbol.kind) && /startup|shutdown|cleanup|setup|init|destroy|create_app|make_app|start|stop/i.test(symbol.name);
557+
if (options.detail === 'app_dependencies') return symbol.kind === 'dependency';
558+
if (options.detail === 'public_methods') return ['class', 'function', 'method'].includes(symbol.kind) && !symbol.name.startsWith('_');
554559
if (options.symbolsOnly && ['import', 'variable', 'constant', 'property'].includes(symbol.kind)) return false;
555560
if (options.includeRoutes === false && symbol.kind === 'route') return false;
556561
if (options.includePrivate === false && symbol.name.startsWith('_') && !symbol.name.startsWith('rpc_')) return false;

src/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,12 @@ program.command('skeleton')
5454
const stat = await fs.stat(absolutePath);
5555
if (stat.isDirectory()) {
5656
const mode = options.mode === 'summary' || options.mode === 'public_api' ? options.mode : 'full';
57-
const data = await skeletonDirectory(root, inputPath, { publicOnly: Boolean(options.publicOnly), publicApiOnly: mode === 'public_api', symbolsOnly: Boolean(options.symbolsOnly), includePrivate: Boolean(options.includePrivate), includeRoutes: options.includeRoutes !== false, maxFiles: Number(options.maxFiles), budget: globals.budget ? Number(globals.budget) : 12000, include: options.include, exclude: options.exclude, sort: options.sort, changedOnly: Boolean(options.changed), respectAiIgnore: options.respectAiIgnore, mode });
57+
const detail = ['rpc_api', 'lifecycle', 'app_dependencies', 'public_methods'].includes(options.mode) ? options.mode : undefined;
58+
const data = await skeletonDirectory(root, inputPath, { publicOnly: Boolean(options.publicOnly), publicApiOnly: mode === 'public_api', symbolsOnly: Boolean(options.symbolsOnly), includePrivate: Boolean(options.includePrivate), includeRoutes: options.includeRoutes !== false, detail, maxFiles: Number(options.maxFiles), budget: globals.budget ? Number(globals.budget) : 12000, include: options.include, exclude: options.exclude, sort: options.sort, changedOnly: Boolean(options.changed), respectAiIgnore: options.respectAiIgnore, mode });
5859
printResult(format, renderDirectorySkeleton(data), envelope(root, { command: 'skeleton', path: inputPath }, data, startedAt));
5960
} else {
60-
const data = await skeletonPath(root, inputPath, { publicOnly: Boolean(options.publicOnly), publicApiOnly: options.mode === 'public_api', symbolsOnly: Boolean(options.symbolsOnly), includePrivate: Boolean(options.includePrivate), includeRoutes: options.includeRoutes !== false, noImports: Boolean(options.noImports) || options.mode === 'public_api', budget: globals.budget ? Number(globals.budget) : undefined });
61+
const detail = ['rpc_api', 'lifecycle', 'app_dependencies', 'public_methods'].includes(options.mode) ? options.mode : undefined;
62+
const data = await skeletonPath(root, inputPath, { publicOnly: Boolean(options.publicOnly), publicApiOnly: options.mode === 'public_api', symbolsOnly: Boolean(options.symbolsOnly), includePrivate: Boolean(options.includePrivate), includeRoutes: options.includeRoutes !== false, detail, noImports: Boolean(options.noImports) || options.mode === 'public_api', budget: globals.budget ? Number(globals.budget) : undefined });
6163
printResult(format, renderSkeleton(data), envelope(root, { command: 'skeleton', path: inputPath }, data, startedAt));
6264
}
6365
}));
@@ -101,7 +103,7 @@ program.command('context')
101103
.option('--include-config', 'include config files')
102104
.option('--include-migrations', 'include migration files')
103105
.action(async (options) => run('context', async (root, format, startedAt) => {
104-
const mode = ['architecture', 'overview', 'edit_prep', 'composition'].includes(options.mode) ? options.mode : 'full';
106+
const mode = ['architecture', 'overview', 'edit_prep', 'composition', 'test_impact'].includes(options.mode) ? options.mode : 'full';
105107
const data = await buildContext(root, { goal: options.goal, path: options.path, budget: Number(options.budget), includeTests: options.includeTests, changedOnly: options.changedOnly, mode, productionOnly: Boolean(options.productionOnly), testsOnly: Boolean(options.testsOnly), includeMocks: Boolean(options.includeMocks), includeConfig: Boolean(options.includeConfig), includeMigrations: Boolean(options.includeMigrations) });
106108
printResult(format, renderContext(data), envelope(root, { command: 'context', goal: options.goal }, data, startedAt));
107109
}));

src/mcp-server.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,13 @@ async function callTool(projectRoot: string, name: string, args: Record<string,
106106
const target = String(args.path);
107107
const stat = await fs.stat(resolveInsideRoot(projectRoot, target));
108108
const mode = args.mode === 'summary' || args.mode === 'public_api' ? args.mode : 'full';
109-
if (stat.isDirectory()) return skeletonDirectory(projectRoot, target, { publicOnly: Boolean(args.publicOnly), publicApiOnly: mode === 'public_api', symbolsOnly: Boolean(args.symbolsOnly), includePrivate: Boolean(args.includePrivate), includeRoutes: args.includeRoutes !== false, maxFiles: Number(args.maxFiles ?? 50), budget: Number(args.budget ?? 12000), changedOnly: Boolean(args.changedOnly), mode });
110-
return skeletonPath(projectRoot, target, { publicOnly: Boolean(args.publicOnly), publicApiOnly: mode === 'public_api', symbolsOnly: Boolean(args.symbolsOnly), includePrivate: Boolean(args.includePrivate), includeRoutes: args.includeRoutes !== false, noImports: Boolean(args.noImports) || args.includeImports === false || mode === 'public_api', budget: Number(args.budget ?? 12000) });
109+
const detail = ['rpc_api', 'lifecycle', 'app_dependencies', 'public_methods'].includes(String(args.mode)) ? String(args.mode) as 'rpc_api' | 'lifecycle' | 'app_dependencies' | 'public_methods' : undefined;
110+
if (stat.isDirectory()) return skeletonDirectory(projectRoot, target, { publicOnly: Boolean(args.publicOnly), publicApiOnly: mode === 'public_api', symbolsOnly: Boolean(args.symbolsOnly), includePrivate: Boolean(args.includePrivate), includeRoutes: args.includeRoutes !== false, detail, maxFiles: Number(args.maxFiles ?? 50), budget: Number(args.budget ?? 12000), changedOnly: Boolean(args.changedOnly), mode });
111+
return skeletonPath(projectRoot, target, { publicOnly: Boolean(args.publicOnly), publicApiOnly: mode === 'public_api', symbolsOnly: Boolean(args.symbolsOnly), includePrivate: Boolean(args.includePrivate), includeRoutes: args.includeRoutes !== false, detail, noImports: Boolean(args.noImports) || args.includeImports === false || mode === 'public_api', budget: Number(args.budget ?? 12000) });
111112
}
112113
if (name === 'codebone_symbols') return findSymbols(projectRoot, String(args.path ?? '.'), { query: String(args.query), kind: String(args.kind ?? 'all') as never, exact: args.exact === undefined ? undefined : Boolean(args.exact), fuzzy: Boolean(args.fuzzy), limit: Number(args.limit ?? 100), includeImports: args.includeImports !== false });
113114
if (name === 'codebone_read') return readCode(projectRoot, String(args.path), { symbolId: args.symbolId as string | undefined, symbol: args.symbol as string | undefined, lines: args.lines as string | undefined, context: Number(args.context ?? 0), maxBytes: Number(args.maxBytes ?? 65536) });
114-
if (name === 'codebone_context') return buildContext(projectRoot, { goal: String(args.goal), path: String(args.path ?? '.'), budget: Number(args.budget ?? 8000), includeTests: args.includeTests !== false, changedOnly: Boolean(args.changedOnly), mode: ['architecture', 'overview', 'edit_prep', 'composition'].includes(String(args.mode)) ? String(args.mode) as 'architecture' | 'overview' | 'edit_prep' | 'composition' : 'full', productionOnly: Boolean(args.productionOnly), testsOnly: Boolean(args.testsOnly), includeMocks: Boolean(args.includeMocks), includeConfig: Boolean(args.includeConfig), includeMigrations: Boolean(args.includeMigrations) });
115+
if (name === 'codebone_context') return buildContext(projectRoot, { goal: String(args.goal), path: String(args.path ?? '.'), budget: Number(args.budget ?? 8000), includeTests: args.includeTests !== false, changedOnly: Boolean(args.changedOnly), mode: ['architecture', 'overview', 'edit_prep', 'composition', 'test_impact'].includes(String(args.mode)) ? String(args.mode) as 'architecture' | 'overview' | 'edit_prep' | 'composition' | 'test_impact' : 'full', productionOnly: Boolean(args.productionOnly), testsOnly: Boolean(args.testsOnly), includeMocks: Boolean(args.includeMocks), includeConfig: Boolean(args.includeConfig), includeMigrations: Boolean(args.includeMigrations) });
115116
if (name === 'codebone_batch') return runBatch(projectRoot, (args.operations ?? []) as Array<Record<string, unknown>>);
116117
if (name === 'codebone_index') return buildIndex(projectRoot, String(args.path ?? '.'), { clear: Boolean(args.clear) });
117118
if (name === 'codebone_doctor') return doctor(projectRoot);

0 commit comments

Comments
 (0)