Skip to content

Commit 3852ecf

Browse files
author
Евгений Балякин
committed
improvements for python
1 parent 680a794 commit 3852ecf

10 files changed

Lines changed: 90 additions & 20 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebone",
3-
"version": "0.1.1",
3+
"version": "0.1.2",
44
"description": "Agent-native CLI and MCP server for compact code context.",
55
"type": "module",
66
"bin": {

src/core/context.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,9 @@ function isGeneratedOrVendor(filePath: string): boolean {
162162
}
163163

164164
function inferTestRelations(fileRecords: Array<{ path: string; imports: string[] }>, edges: Array<{ from: string; resolved?: string }>) {
165-
const sourceFiles = new Set(fileRecords.map((record) => record.path).filter((filePath) => !isTestPath(filePath)));
165+
const sourceFiles = new Set(fileRecords.map((record) => record.path).filter((filePath) => !isTestPath(filePath) && !isInitFile(filePath)));
166166
const relations: Array<{ test: string; source: string; reason: string }> = [];
167-
for (const test of fileRecords.filter((record) => isTestPath(record.path))) {
167+
for (const test of fileRecords.filter((record) => isTestPath(record.path) && !isInitFile(record.path))) {
168168
for (const edge of edges.filter((item) => item.from === test.path && item.resolved && sourceFiles.has(item.resolved))) {
169169
relations.push({ test: test.path, source: edge.resolved!, reason: 'import' });
170170
}
@@ -187,6 +187,10 @@ function isTestPath(filePath: string): boolean {
187187
return /(^|\/)(test|tests|spec|__tests__)(\/|$)|\.(test|spec)\.|(^|\/)test_[^/]+\.py$|(^|\/)[^/]+_test\.py$/.test(filePath);
188188
}
189189

190+
function isInitFile(filePath: string): boolean {
191+
return /(^|\/)__init__\.py$/.test(filePath);
192+
}
193+
190194
function buildArchitectureSummary(fileRecords: Array<{ path: string; imports: string[]; symbols: ReturnType<typeof flattenSymbols> }>, edges: Array<{ from: string; source: string; resolved?: string }>, testRelations: Array<{ test: string; source: string; reason: string }>) {
191195
const files = fileRecords.map((record) => ({
192196
path: record.path,
@@ -199,20 +203,26 @@ function buildArchitectureSummary(fileRecords: Array<{ path: string; imports: st
199203
const handler = record.symbols
200204
.filter((symbol) => ['function', 'method'].includes(symbol.kind) && symbol.startLine > route.startLine)
201205
.sort((a, b) => a.startLine - b.startLine)[0];
202-
return { file: record.path, route: route.name, line: route.startLine, handler: handler?.qualifiedName };
206+
return { file: record.path, route: route.name, line: route.startLine, handler: route.source ?? handler?.qualifiedName };
203207
}));
204208

205-
const dependencies = uniqueBy(fileRecords.flatMap((record) => record.symbols.filter((symbol) => symbol.kind === 'dependency').map((symbol) => ({ file: record.path, key: symbol.name, line: symbol.startLine }))), (item) => `${item.file}:${item.key}:${item.line}`);
209+
const dependencies = buildAppDependencyGraph(fileRecords);
206210
const tables = uniqueBy(fileRecords.flatMap((record) => record.symbols.filter((symbol) => symbol.kind === 'table').map((symbol) => ({ file: record.path, name: symbol.name, line: symbol.startLine }))), (item) => `${item.file}:${item.name}`);
207211
const localImports = edges.filter((edge) => edge.resolved).map((edge) => ({ from: edge.from, to: edge.resolved!, source: edge.source }));
208212
const serviceEdges = localImports.filter((edge) => /service|api|route|handler|view|dao|task|worker|creator/i.test(`${edge.from} ${edge.to} ${edge.source}`));
209-
return { files, routes, dependencies, tables, serviceEdges: serviceEdges.slice(0, 30), testRelations };
213+
const rpc = buildRpcSummary(fileRecords, dependencies);
214+
return { files, routes, rpc, dependencies, tables, serviceEdges: serviceEdges.slice(0, 20), testRelations };
210215
}
211216

212217
function renderArchitectureSummary(summary: ReturnType<typeof buildArchitectureSummary>): string {
213218
const sections: string[] = ['Architecture summary'];
214219
if (summary.routes.length) sections.push(`Routes -> handlers:\n${summary.routes.map((item) => ` ${item.route} -> ${item.handler ?? 'unknown'} (${item.file}:${item.line})`).join('\n')}`);
215-
if (summary.dependencies.length) sections.push(`App dependencies:\n${summary.dependencies.map((item) => ` app["${item.key}"] (${item.file}:${item.line})`).join('\n')}`);
220+
if (summary.rpc.length) sections.push(`RPC summary:\n${summary.rpc.map((item) => ` ${item.name} (${item.file}:${item.line})${item.dependencies.length ? ` uses app[${item.dependencies.map((key) => `"${key}"`).join(', ')}]` : ''}`).join('\n')}`);
221+
if (summary.dependencies.length) sections.push(`App dependency graph:\n${summary.dependencies.map((item) => {
222+
const writes = item.writes.length ? ` created: ${item.writes.map((usage) => `${usage.file}:${usage.line}`).join(', ')}` : '';
223+
const reads = item.reads.length ? ` read: ${item.reads.slice(0, 6).map((usage) => `${usage.file}:${usage.line}`).join(', ')}` : '';
224+
return ` app["${item.key}"]${writes}${reads}`;
225+
}).join('\n')}`);
216226
if (summary.tables.length) sections.push(`SQLAlchemy tables:\n${summary.tables.map((item) => ` ${item.name} (${item.file}:${item.line})`).join('\n')}`);
217227
if (summary.serviceEdges.length) sections.push(`Local dependency flow:\n${summary.serviceEdges.map((item) => ` ${item.from} -> ${item.to}`).join('\n')}`);
218228
if (summary.files.length) sections.push(`Key files:\n${summary.files.slice(0, 30).map((item) => {
@@ -227,6 +237,30 @@ function renderArchitectureSummary(summary: ReturnType<typeof buildArchitectureS
227237
return sections.join('\n\n');
228238
}
229239

240+
function buildAppDependencyGraph(fileRecords: Array<{ path: string; symbols: ReturnType<typeof flattenSymbols> }>) {
241+
const byKey = new Map<string, { key: string; writes: Array<{ file: string; line: number }>; reads: Array<{ file: string; line: number }> }>();
242+
for (const record of fileRecords) {
243+
for (const symbol of record.symbols.filter((item) => item.kind === 'dependency')) {
244+
const entry = byKey.get(symbol.name) ?? { key: symbol.name, writes: [], reads: [] };
245+
const usage = { file: record.path, line: symbol.startLine };
246+
if (/\b(?:app|request\.app)\[['"][^'"]+['"]\]\s*=/.test(symbol.signature)) entry.writes.push(usage);
247+
else entry.reads.push(usage);
248+
byKey.set(symbol.name, entry);
249+
}
250+
}
251+
return [...byKey.values()].sort((a, b) => Number(b.writes.length > 0) - Number(a.writes.length > 0) || a.key.localeCompare(b.key));
252+
}
253+
254+
function buildRpcSummary(fileRecords: Array<{ path: string; symbols: ReturnType<typeof flattenSymbols> }>, dependencies: ReturnType<typeof buildAppDependencyGraph>) {
255+
const dependencyKeys = new Set(dependencies.map((dependency) => dependency.key));
256+
return fileRecords.flatMap((record) => record.symbols.filter((symbol) => ['function', 'method'].includes(symbol.kind) && symbol.name.startsWith('rpc_')).map((symbol) => {
257+
const deps = record.symbols
258+
.filter((item) => item.kind === 'dependency' && item.startLine >= symbol.startLine && item.endLine <= symbol.endLine && dependencyKeys.has(item.name))
259+
.map((item) => item.name);
260+
return { file: record.path, name: symbol.qualifiedName, line: symbol.startLine, dependencies: Array.from(new Set(deps)) };
261+
}));
262+
}
263+
230264
function uniqueBy<T>(items: T[], key: (item: T) => string): T[] {
231265
const seen = new Set<string>();
232266
return items.filter((item) => {

src/core/doctor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export async function doctor(root: string) {
1818
warnings.push(...missing.map((grammar) => `grammar_missing:${grammar.language}`));
1919
return {
2020
schemaVersion: SCHEMA_VERSION,
21-
version: '0.1.1',
21+
version: '0.1.2',
2222
node: process.version,
2323
grammars: { mode: 'tree-sitter-wasm+syntax-fallback', loaded: loaded.length, fallback: 14 - loaded.length, missing: missing.length, gold: loaded.map((grammar) => grammar.language), missingLanguages: missing.map((grammar) => grammar.language) },
2424
indexDirectory: warnings.includes('index_not_writable') ? 'failed' : 'ok',

src/core/graph.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,11 @@ function resolvePythonImport(from: string, source: string, fileSet: Set<string>)
7676
const direct = directCandidates.find((candidate) => fileSet.has(candidate));
7777
if (direct) return direct;
7878

79+
if (!source.startsWith('.') && !source.includes('.')) return undefined;
80+
7981
const suffixMatches = [...fileSet]
8082
.filter((filePath) => filePath === `${modulePath}.py` || filePath.endsWith(`/${modulePath}.py`) || filePath === path.posix.join(modulePath, '__init__.py') || filePath.endsWith(`/${modulePath}/__init__.py`))
83+
.filter((filePath) => !/(^|\/)tests?\//.test(filePath))
8184
.sort((a, b) => a.length - b.length || a.localeCompare(b));
8285
return suffixMatches[0];
8386
}

src/core/skeleton.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,12 +232,8 @@ function extractPythonLandmarks(lines: string[]): Candidate[] {
232232
for (let index = 0; index < lines.length; index += 1) {
233233
const trimmed = lines[index].trim();
234234
const lineNo = index + 1;
235-
const routeMatch = trimmed.match(/^(?:@\w+(?:\.\w+)*\.(get|post|put|patch|delete|route)\(|(?:\w+\.)?router\.add_(get|post|put|patch|delete|route)\(|web\.(get|post|put|patch|delete|route)\()\s*['"]([^'"]+)['"]/) ?? trimmed.match(/add_routes\(\s*\[\s*web\.(get|post|put|patch|delete|route)\(\s*['"]([^'"]+)['"]/);
236-
if (routeMatch) {
237-
const method = (routeMatch[1] ?? routeMatch[2] ?? routeMatch[3] ?? routeMatch[5] ?? 'route').toUpperCase();
238-
const routePath = routeMatch[4] ?? routeMatch[6];
239-
out.push({ kind: 'route', name: `${method} ${routePath}`, signature: trimmed, startLine: lineNo, endLine: lineNo, exported: true });
240-
}
235+
const route = pythonRoute(trimmed);
236+
if (route) out.push({ kind: 'route', name: `${route.method} ${route.path}`, source: route.handler, signature: trimmed, startLine: lineNo, endLine: lineNo, exported: true });
241237
const dependencyMatch = trimmed.match(/\bapp\[['"]([^'"]+)['"]\]|\brequest\.app\[['"]([^'"]+)['"]\]/);
242238
if (dependencyMatch) out.push({ kind: 'dependency', name: dependencyMatch[1] ?? dependencyMatch[2], signature: trimmed, startLine: lineNo, endLine: lineNo, exported: true });
243239
const tableName = pythonTableName(lines, index);
@@ -246,6 +242,19 @@ function extractPythonLandmarks(lines: string[]): Candidate[] {
246242
return out;
247243
}
248244

245+
function pythonRoute(trimmed: string): { method: string; path: string; handler?: string } | undefined {
246+
const decorator = trimmed.match(/^@\w+(?:\.\w+)*\.(get|post|put|patch|delete|route)\(\s*['"]([^'"]+)['"]/);
247+
if (decorator) return { method: decorator[1].toUpperCase(), path: decorator[2] };
248+
249+
const addRoute = trimmed.match(/(?:\w+\.)?router\.add_(get|post|put|patch|delete|route)\(\s*['"]([^'"]+)['"]\s*(?:,\s*([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?))?/);
250+
if (addRoute) return { method: addRoute[1].toUpperCase(), path: addRoute[2], handler: addRoute[3] };
251+
252+
const webRoute = trimmed.match(/\bweb\.(get|post|put|patch|delete|route)\(\s*['"]([^'"]+)['"]\s*(?:,\s*([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?))?/);
253+
if (webRoute) return { method: webRoute[1].toUpperCase(), path: webRoute[2], handler: webRoute[3] };
254+
255+
return undefined;
256+
}
257+
249258
function pythonTableName(lines: string[], index: number): string | undefined {
250259
const text = lines.slice(index, Math.min(lines.length, index + 5)).map((line) => line.trim()).join(' ');
251260
return text.match(/(?:^|=\s*)(?:\w+\.)?Table\(\s*['"]([^'"]+)['"]/)?.[1]

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const program = new Command();
1818
program
1919
.name('codebone')
2020
.description('Agent-native CLI and MCP server for compact code context')
21-
.version('0.1.1')
21+
.version('0.1.2')
2222
.option('--root <path>', 'project root', '.')
2323
.option('--format <format>', 'text or json', 'text')
2424
.option('--budget <tokens>', 'token budget')

src/mcp-server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function startMcpServer(): void {
4242
}
4343

4444
export function createSdkServer(projectRoot: string): Server {
45-
const server = new Server({ name: 'codebone', version: '0.1.1' }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
45+
const server = new Server({ name: 'codebone', version: '0.1.2' }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
4646
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools }));
4747
server.setRequestHandler(CallToolRequestSchema, async (request) => {
4848
try {
@@ -67,7 +67,7 @@ export async function handleMcpRequest(projectRoot: string, message: JsonRpc): P
6767
if (!message.method) return;
6868
try {
6969
if (message.method === 'initialize') {
70-
return { id: message.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'codebone', version: '0.1.1' } } };
70+
return { id: message.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'codebone', version: '0.1.2' } } };
7171
} else if (message.method === 'notifications/initialized') {
7272
return;
7373
} else if (message.method === 'tools/list') {

tests/context.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,14 @@ describe('context ranking', () => {
6161
' request.app["dao"]',
6262
' return None',
6363
'',
64+
'def setup(app):',
65+
' app["dao"] = UserDao()',
66+
' app.router.add_routes([',
67+
' web.post("/rpc", rpc_get_user),',
68+
' ])',
69+
'',
6470
].join('\n'));
71+
await fs.writeFile(path.join(root, 'tests/__init__.py'), '');
6572
await fs.writeFile(path.join(root, 'tests/test_user.py'), 'from app.dao.user import UserDao\n\ndef test_user():\n assert UserDao\n');
6673

6774
const data = await buildContext(root, { goal: 'user api architecture', mode: 'architecture', budget: 1000 });
@@ -70,9 +77,14 @@ describe('context ranking', () => {
7077
expect(data.mode).toBe('architecture');
7178
expect(content).toContain('Routes -> handlers');
7279
expect(content).toContain('GET /users/{user_id} -> rpc_get_user');
73-
expect(content).toContain('app["dao"]');
80+
expect(content).toContain('POST /rpc -> rpc_get_user');
81+
expect(content).toContain('RPC summary');
82+
expect(content).toContain('rpc_get_user');
83+
expect(content).toContain('App dependency graph');
84+
expect(content).toContain('app["dao"] created: app/api/users.py');
7485
expect(content).toContain('SQLAlchemy tables:\n users');
7586
expect((content.match(/users \(app\/db\.py/g) ?? [])).toHaveLength(1);
7687
expect(content).toContain('Suggested tests');
88+
expect(content).not.toContain('tests/__init__.py');
7789
});
7890
});

tests/graph.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,16 @@ describe('import/export graph', () => {
5050
expect(graph.edges).toContainEqual(expect.objectContaining({ from: 'app/api/users.py', source: 'app.tasks.worker', resolved: 'app/tasks/worker.py' }));
5151
expect(graph.summary.resolvedImports).toBe(3);
5252
});
53+
54+
it('does not resolve Python stdlib imports to test package name collisions', async () => {
55+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'codebone-python-stdlib-'));
56+
await fs.mkdir(path.join(root, 'app'), { recursive: true });
57+
await fs.mkdir(path.join(root, 'tests/logging'), { recursive: true });
58+
await fs.writeFile(path.join(root, 'app/service.py'), 'import logging\n\ndef run():\n return logging.getLogger(__name__)\n');
59+
await fs.writeFile(path.join(root, 'tests/logging/__init__.py'), '');
60+
61+
const graph = await buildImportGraph(root, '.');
62+
63+
expect(graph.edges).toContainEqual(expect.objectContaining({ from: 'app/service.py', source: 'logging', resolved: undefined }));
64+
});
5365
});

0 commit comments

Comments
 (0)