iteration 0

This commit is contained in:
Harivansh Rathi 2026-01-11 16:58:40 -05:00
commit 4b24606d0e
25 changed files with 7843 additions and 0 deletions

118
src/cli/commands/intro.ts Normal file
View file

@ -0,0 +1,118 @@
import { Command } from 'commander';
import * as path from 'node:path';
import { analyze, treeToString } from '../../introspector/index.js';
export const introCommand = new Command('intro')
.description('Introspect a codebase and output its structure (tree-sitter analysis)')
.argument('[path]', 'Path to the repository to analyze', '.')
.option('-o, --output <file>', 'Output file for the RepoSummary JSON')
.option('--json', 'Output as JSON (default)')
.option('--summary', 'Output a human-readable summary instead of JSON')
.option('--tree', 'Show file tree structure')
.action(async (repoPath: string, options: { output?: string; json?: boolean; summary?: boolean; tree?: boolean }) => {
const absolutePath = path.resolve(repoPath);
console.log(`\n🔍 Analyzing: ${absolutePath}\n`);
try {
const summary = await analyze({
root: absolutePath,
onProgress: (msg) => console.log(` ${msg}`),
});
console.log('');
if (options.tree && summary.tree) {
console.log('📁 File Tree:\n');
console.log(treeToString(summary.tree));
console.log('');
} else if (options.summary) {
printHumanSummary(summary);
} else {
const json = JSON.stringify(summary, null, 2);
if (options.output) {
const fs = await import('node:fs/promises');
await fs.writeFile(options.output, json);
console.log(`📄 Written to: ${options.output}`);
} else {
console.log(json);
}
}
} catch (error) {
console.error('❌ Error analyzing repository:', error);
process.exit(1);
}
});
function printHumanSummary(summary: import('../../introspector/types.js').RepoSummary): void {
console.log('📊 Repository Summary');
console.log('─'.repeat(50));
console.log(`📁 Root: ${summary.root}`);
console.log(`🗓️ Analyzed: ${summary.analyzedAt}`);
console.log(`🔤 Languages: ${summary.languages.join(', ') || 'none detected'}`);
console.log('\n📂 Files:');
console.log(` Total: ${summary.files.length}`);
console.log(` Source: ${summary.files.filter(f => f.role === 'source').length}`);
console.log(` Test: ${summary.files.filter(f => f.role === 'test').length}`);
console.log(` Config: ${summary.files.filter(f => f.role === 'config').length}`);
console.log('\n📦 Modules:');
console.log(` Total: ${summary.modules.length}`);
const totalExports = summary.modules.reduce((sum, m) => sum + m.exports.length, 0);
const functions = summary.modules.flatMap(m => m.exports.filter(e => e.kind === 'function'));
const classes = summary.modules.flatMap(m => m.exports.filter(e => e.kind === 'class'));
console.log(` Functions: ${functions.length}`);
console.log(` Classes: ${classes.length}`);
console.log(` Total exports: ${totalExports}`);
if (summary.config.python) {
console.log('\n🐍 Python:');
console.log(` Test framework: ${summary.config.python.testFramework}`);
console.log(` pyproject.toml: ${summary.config.python.pyprojectToml ? '✓' : '✗'}`);
console.log(` setup.py: ${summary.config.python.setupPy ? '✓' : '✗'}`);
}
if (summary.config.typescript) {
console.log('\n📘 TypeScript:');
console.log(` Test framework: ${summary.config.typescript.testFramework}`);
console.log(` package.json: ${summary.config.typescript.packageJson ? '✓' : '✗'}`);
console.log(` tsconfig.json: ${summary.config.typescript.tsconfig ? '✓' : '✗'}`);
}
if (summary.git) {
console.log('\n📌 Git:');
console.log(` Branch: ${summary.git.branch}`);
console.log(` Commit: ${summary.git.currentCommit.slice(0, 8)}`);
if (summary.git.recentCommits && summary.git.recentCommits.length > 0) {
console.log('\n📜 Recent Commits:');
for (const commit of summary.git.recentCommits.slice(0, 5)) {
const date = new Date(commit.date).toLocaleDateString();
console.log(` ${commit.shortHash} ${date} - ${commit.message.slice(0, 50)}${commit.message.length > 50 ? '...' : ''}`);
}
}
if (summary.git.fileHistory && summary.git.fileHistory.length > 0) {
console.log('\n🔥 Most Active Files (by commit count):');
for (const file of summary.git.fileHistory.slice(0, 5)) {
console.log(` ${file.path} (${file.commitCount} commits)`);
}
}
}
// Show top modules by export count
const topModules = [...summary.modules]
.sort((a, b) => b.exports.length - a.exports.length)
.slice(0, 5);
if (topModules.length > 0) {
console.log('\n🏆 Top modules by exports:');
for (const mod of topModules) {
console.log(` ${mod.path}: ${mod.exports.length} exports`);
}
}
}

15
src/cli/index.ts Normal file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { introCommand } from './commands/intro.js';
const program = new Command();
program
.name('evaluclaude')
.description('Zero-to-evals in one command. Claude analyzes codebases and generates functional tests.')
.version('0.1.0');
program.addCommand(introCommand);
program.parse(process.argv);

1
src/index.ts Normal file
View file

@ -0,0 +1 @@
export * from './introspector/index.js';

199
src/introspector/git.ts Normal file
View file

@ -0,0 +1,199 @@
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import type { GitInfo, CommitInfo, FileHistoryInfo } from './types.js';
const execAsync = promisify(exec);
const MAX_COMMITS = 20;
const MAX_FILE_HISTORY = 50;
export async function getGitInfo(root: string, lastCommit?: string): Promise<GitInfo | undefined> {
try {
// Check if it's a git repo
await execAsync('git rev-parse --git-dir', { cwd: root });
} catch {
return undefined;
}
try {
const [currentCommitResult, branchResult] = await Promise.all([
execAsync('git rev-parse HEAD', { cwd: root }),
execAsync('git branch --show-current', { cwd: root }),
]);
const currentCommit = currentCommitResult.stdout.trim();
const branch = branchResult.stdout.trim() || 'HEAD';
let changedSince: string[] = [];
if (lastCommit && lastCommit !== currentCommit) {
changedSince = await getChangedFiles(root, lastCommit);
}
// Fetch recent commits
const recentCommits = await getRecentCommits(root);
// Fetch file history (most frequently changed files)
const fileHistory = await getFileHistory(root);
return {
currentCommit,
lastAnalyzedCommit: lastCommit || currentCommit,
changedSince,
branch,
recentCommits,
fileHistory,
};
} catch {
return undefined;
}
}
export async function getChangedFiles(root: string, since: string): Promise<string[]> {
try {
const { stdout } = await execAsync(`git diff --name-only ${since}`, { cwd: root });
return stdout
.split('\n')
.filter(f => f && isSourceFile(f));
} catch {
return [];
}
}
export async function getCurrentCommit(root: string): Promise<string | undefined> {
try {
const { stdout } = await execAsync('git rev-parse HEAD', { cwd: root });
return stdout.trim();
} catch {
return undefined;
}
}
export async function isGitRepo(root: string): Promise<boolean> {
try {
await execAsync('git rev-parse --git-dir', { cwd: root });
return true;
} catch {
return false;
}
}
function isSourceFile(filePath: string): boolean {
return /\.(py|ts|tsx|js|jsx)$/.test(filePath);
}
export async function getRecentCommits(root: string, limit: number = MAX_COMMITS): Promise<CommitInfo[]> {
try {
// Format: hash|short|author|date|message|filesChanged
const { stdout } = await execAsync(
`git log -${limit} --pretty=format:"%H|%h|%an|%aI|%s" --shortstat`,
{ cwd: root, maxBuffer: 1024 * 1024 }
);
const commits: CommitInfo[] = [];
const lines = stdout.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i]?.trim();
if (!line) {
i++;
continue;
}
const parts = line.split('|');
if (parts.length >= 5) {
// Parse the commit line
const [hash, shortHash, author, date, ...messageParts] = parts;
const message = messageParts.join('|'); // In case message contains |
// Look for stats line (next non-empty line)
let filesChanged = 0;
if (i + 1 < lines.length) {
const statsLine = lines[i + 1]?.trim();
if (statsLine) {
const match = statsLine.match(/(\d+) files? changed/);
if (match) {
filesChanged = parseInt(match[1], 10);
i++; // Skip stats line
}
}
}
commits.push({
hash,
shortHash,
author,
date,
message,
filesChanged,
});
}
i++;
}
return commits;
} catch {
return [];
}
}
export async function getFileHistory(root: string, limit: number = MAX_FILE_HISTORY): Promise<FileHistoryInfo[]> {
try {
// Get the most frequently modified source files
const { stdout } = await execAsync(
`git log --pretty=format: --name-only | grep -E '\\.(py|ts|tsx|js|jsx)$' | sort | uniq -c | sort -rn | head -${limit}`,
{ cwd: root, maxBuffer: 1024 * 1024, shell: '/bin/bash' }
);
const files: FileHistoryInfo[] = [];
for (const line of stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^\s*(\d+)\s+(.+)$/);
if (match) {
const commitCount = parseInt(match[1], 10);
const filePath = match[2];
// Get contributors for this file
const contributors = await getFileContributors(root, filePath);
const lastModified = await getFileLastModified(root, filePath);
files.push({
path: filePath,
commitCount,
lastModified,
contributors,
});
}
}
return files;
} catch {
return [];
}
}
async function getFileContributors(root: string, filePath: string): Promise<string[]> {
try {
const { stdout } = await execAsync(
`git log --pretty=format:"%an" -- "${filePath}" | sort -u | head -5`,
{ cwd: root, shell: '/bin/bash' }
);
return stdout.split('\n').filter(s => s.trim()).slice(0, 5);
} catch {
return [];
}
}
async function getFileLastModified(root: string, filePath: string): Promise<string> {
try {
const { stdout } = await execAsync(
`git log -1 --pretty=format:"%aI" -- "${filePath}"`,
{ cwd: root }
);
return stdout.trim();
} catch {
return '';
}
}

25
src/introspector/index.ts Normal file
View file

@ -0,0 +1,25 @@
export { analyze, analyzeIncremental } from './summarizer.js';
export { scanDirectory, detectConfig } from './scanner.js';
export { getGitInfo, getChangedFiles, getCurrentCommit, isGitRepo, getRecentCommits, getFileHistory } from './git.js';
export { buildFileTree, treeToString, getTreeStats } from './tree.js';
export { PythonParser } from './parsers/python.js';
export { TypeScriptParser } from './parsers/typescript.js';
export type {
RepoSummary,
FileInfo,
ModuleInfo,
ExportInfo,
ConfigInfo,
GitInfo,
CommitInfo,
FileHistoryInfo,
FileTreeNode,
Language,
} from './types.js';
import { analyze as analyzeRepo } from './summarizer.js';
export async function introspect(repoPath: string): Promise<import('./types.js').RepoSummary> {
return analyzeRepo({ root: repoPath });
}

View file

@ -0,0 +1,29 @@
import type { ModuleInfo, ExportInfo } from '../types.js';
export interface ParserResult {
exports: ExportInfo[];
imports: string[];
}
export abstract class BaseParser {
abstract readonly language: string;
abstract parse(source: string, filePath: string): ModuleInfo;
protected getText(source: string, startIndex: number, endIndex: number): string {
return source.slice(startIndex, endIndex);
}
protected calculateComplexity(exportCount: number): ModuleInfo['complexity'] {
if (exportCount <= 5) return 'low';
if (exportCount <= 15) return 'medium';
return 'high';
}
protected extractFirstLineOfDocstring(docstring: string | undefined): string | undefined {
if (!docstring) return undefined;
const trimmed = docstring.trim();
const firstLine = trimmed.split('\n')[0];
return firstLine.replace(/^["']{1,3}|["']{1,3}$/g, '').trim() || undefined;
}
}

View file

@ -0,0 +1,167 @@
import Parser from 'tree-sitter';
import Python from 'tree-sitter-python';
import { BaseParser } from './base.js';
import type { ModuleInfo, ExportInfo } from '../types.js';
export class PythonParser extends BaseParser {
readonly language = 'python';
private parser: Parser;
constructor() {
super();
this.parser = new Parser();
this.parser.setLanguage(Python);
}
parse(source: string, filePath: string): ModuleInfo {
const tree = this.parser.parse(source);
const rootNode = tree.rootNode;
const exports: ExportInfo[] = [];
const imports: string[] = [];
// Walk the tree to extract functions, classes, and imports
this.walkNode(rootNode, source, exports, imports);
return {
path: filePath,
exports,
imports: [...new Set(imports)],
complexity: this.calculateComplexity(exports.length),
};
}
private walkNode(
node: Parser.SyntaxNode,
source: string,
exports: ExportInfo[],
imports: string[]
): void {
switch (node.type) {
case 'function_definition':
exports.push(this.extractFunction(node, source));
break;
case 'class_definition':
exports.push(this.extractClass(node, source));
break;
case 'import_statement':
imports.push(...this.extractImport(node, source));
break;
case 'import_from_statement':
imports.push(...this.extractFromImport(node, source));
break;
default:
// Recurse into children for top-level nodes
if (node.type === 'module' || node.type === 'decorated_definition') {
for (const child of node.children) {
this.walkNode(child, source, exports, imports);
}
}
}
}
private extractFunction(node: Parser.SyntaxNode, source: string): ExportInfo {
const nameNode = node.childForFieldName('name');
const paramsNode = node.childForFieldName('parameters');
const returnTypeNode = node.childForFieldName('return_type');
const bodyNode = node.childForFieldName('body');
const name = nameNode ? this.getText(source, nameNode.startIndex, nameNode.endIndex) : 'unknown';
// Build signature
let signature = '';
if (paramsNode) {
signature = this.getText(source, paramsNode.startIndex, paramsNode.endIndex);
}
if (returnTypeNode) {
signature += ` -> ${this.getText(source, returnTypeNode.startIndex, returnTypeNode.endIndex)}`;
}
// Check for async
const isAsync = node.children.some(c => c.type === 'async');
// Try to extract docstring
let docstring: string | undefined;
if (bodyNode && bodyNode.firstChild?.type === 'expression_statement') {
const exprStmt = bodyNode.firstChild;
const strNode = exprStmt.firstChild;
if (strNode?.type === 'string') {
docstring = this.extractFirstLineOfDocstring(
this.getText(source, strNode.startIndex, strNode.endIndex)
);
}
}
return {
name,
kind: 'function',
signature: signature || undefined,
docstring,
lineNumber: node.startPosition.row + 1,
isAsync,
};
}
private extractClass(node: Parser.SyntaxNode, source: string): ExportInfo {
const nameNode = node.childForFieldName('name');
const bodyNode = node.childForFieldName('body');
const name = nameNode ? this.getText(source, nameNode.startIndex, nameNode.endIndex) : 'unknown';
// Try to extract docstring
let docstring: string | undefined;
if (bodyNode && bodyNode.firstChild?.type === 'expression_statement') {
const exprStmt = bodyNode.firstChild;
const strNode = exprStmt.firstChild;
if (strNode?.type === 'string') {
docstring = this.extractFirstLineOfDocstring(
this.getText(source, strNode.startIndex, strNode.endIndex)
);
}
}
// Build a basic signature showing inheritance
let signature: string | undefined;
const superclassNode = node.childForFieldName('superclasses');
if (superclassNode) {
signature = this.getText(source, superclassNode.startIndex, superclassNode.endIndex);
}
return {
name,
kind: 'class',
signature,
docstring,
lineNumber: node.startPosition.row + 1,
};
}
private extractImport(node: Parser.SyntaxNode, source: string): string[] {
const imports: string[] = [];
for (const child of node.children) {
if (child.type === 'dotted_name') {
imports.push(this.getText(source, child.startIndex, child.endIndex));
} else if (child.type === 'aliased_import') {
const nameNode = child.childForFieldName('name');
if (nameNode) {
imports.push(this.getText(source, nameNode.startIndex, nameNode.endIndex));
}
}
}
return imports;
}
private extractFromImport(node: Parser.SyntaxNode, source: string): string[] {
const moduleNode = node.childForFieldName('module_name');
if (moduleNode) {
return [this.getText(source, moduleNode.startIndex, moduleNode.endIndex)];
}
return [];
}
}

View file

@ -0,0 +1,188 @@
import Parser from 'tree-sitter';
import TypeScriptLang from 'tree-sitter-typescript';
import { BaseParser } from './base.js';
import type { ModuleInfo, ExportInfo } from '../types.js';
const { typescript: TypeScript } = TypeScriptLang;
export class TypeScriptParser extends BaseParser {
readonly language = 'typescript';
private parser: Parser;
constructor() {
super();
this.parser = new Parser();
this.parser.setLanguage(TypeScript);
}
parse(source: string, filePath: string): ModuleInfo {
const tree = this.parser.parse(source);
const rootNode = tree.rootNode;
const exports: ExportInfo[] = [];
const imports: string[] = [];
this.walkNode(rootNode, source, exports, imports, false);
return {
path: filePath,
exports,
imports: [...new Set(imports)],
complexity: this.calculateComplexity(exports.length),
};
}
private walkNode(
node: Parser.SyntaxNode,
source: string,
exports: ExportInfo[],
imports: string[],
isExported: boolean
): void {
switch (node.type) {
case 'function_declaration':
exports.push(this.extractFunction(node, source, isExported));
break;
case 'class_declaration':
exports.push(this.extractClass(node, source, isExported));
break;
case 'lexical_declaration':
case 'variable_declaration':
exports.push(...this.extractVariables(node, source, isExported));
break;
case 'type_alias_declaration':
case 'interface_declaration':
exports.push(this.extractTypeDefinition(node, source, isExported));
break;
case 'export_statement':
// Recurse with isExported = true
for (const child of node.children) {
this.walkNode(child, source, exports, imports, true);
}
break;
case 'import_statement':
imports.push(...this.extractImport(node, source));
break;
case 'program':
// Recurse into top-level statements
for (const child of node.children) {
this.walkNode(child, source, exports, imports, false);
}
break;
}
}
private extractFunction(node: Parser.SyntaxNode, source: string, isExported: boolean): ExportInfo {
const nameNode = node.childForFieldName('name');
const paramsNode = node.childForFieldName('parameters');
const returnTypeNode = node.childForFieldName('return_type');
const name = nameNode ? this.getText(source, nameNode.startIndex, nameNode.endIndex) : 'unknown';
// Build signature
let signature = '';
if (paramsNode) {
signature = this.getText(source, paramsNode.startIndex, paramsNode.endIndex);
}
if (returnTypeNode) {
signature += `: ${this.getText(source, returnTypeNode.startIndex, returnTypeNode.endIndex)}`;
}
// Check for async
const isAsync = node.children.some(c => c.type === 'async');
return {
name,
kind: 'function',
signature: signature || undefined,
lineNumber: node.startPosition.row + 1,
isAsync,
isExported,
};
}
private extractClass(node: Parser.SyntaxNode, source: string, isExported: boolean): ExportInfo {
const nameNode = node.childForFieldName('name');
const name = nameNode ? this.getText(source, nameNode.startIndex, nameNode.endIndex) : 'unknown';
// Get heritage clause for extends/implements
let signature: string | undefined;
const heritageNode = node.children.find(c => c.type === 'class_heritage');
if (heritageNode) {
signature = this.getText(source, heritageNode.startIndex, heritageNode.endIndex);
}
return {
name,
kind: 'class',
signature,
lineNumber: node.startPosition.row + 1,
isExported,
};
}
private extractVariables(node: Parser.SyntaxNode, source: string, isExported: boolean): ExportInfo[] {
const exports: ExportInfo[] = [];
for (const child of node.children) {
if (child.type === 'variable_declarator') {
const nameNode = child.childForFieldName('name');
const valueNode = child.childForFieldName('value');
if (nameNode) {
const name = this.getText(source, nameNode.startIndex, nameNode.endIndex);
// Check if it's a function expression or arrow function
const isFunction = valueNode && (
valueNode.type === 'arrow_function' ||
valueNode.type === 'function_expression' ||
valueNode.type === 'function'
);
exports.push({
name,
kind: isFunction ? 'function' : 'constant',
lineNumber: child.startPosition.row + 1,
isExported,
isAsync: valueNode?.children.some(c => c.type === 'async'),
});
}
}
}
return exports;
}
private extractTypeDefinition(node: Parser.SyntaxNode, source: string, isExported: boolean): ExportInfo {
const nameNode = node.childForFieldName('name');
const name = nameNode ? this.getText(source, nameNode.startIndex, nameNode.endIndex) : 'unknown';
return {
name,
kind: 'type',
lineNumber: node.startPosition.row + 1,
isExported,
};
}
private extractImport(node: Parser.SyntaxNode, source: string): string[] {
const imports: string[] = [];
for (const child of node.children) {
if (child.type === 'string') {
// Remove quotes from the import path
const importPath = this.getText(source, child.startIndex, child.endIndex)
.replace(/^["']|["']$/g, '');
imports.push(importPath);
}
}
return imports;
}
}

213
src/introspector/scanner.ts Normal file
View file

@ -0,0 +1,213 @@
import { glob } from 'glob';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { FileInfo } from './types.js';
const IGNORE_PATTERNS = [
'node_modules/**',
'.git/**',
'__pycache__/**',
'*.pyc',
'dist/**',
'build/**',
'.venv/**',
'venv/**',
'.env/**',
'env/**',
'coverage/**',
'.next/**',
'.nuxt/**',
];
export async function scanDirectory(root: string): Promise<FileInfo[]> {
const patterns = ['**/*.py', '**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'];
const files: string[] = [];
for (const pattern of patterns) {
const matches = await glob(pattern, {
cwd: root,
ignore: IGNORE_PATTERNS,
nodir: true,
});
files.push(...matches);
}
const uniqueFiles = [...new Set(files)];
const fileInfos = await Promise.all(
uniqueFiles.map(async (relativePath) => {
const fullPath = path.join(root, relativePath);
try {
const stats = await fs.stat(fullPath);
return {
path: relativePath,
lang: detectLanguage(relativePath),
role: detectRole(relativePath),
size: stats.size,
lastModified: stats.mtime.toISOString(),
} satisfies FileInfo;
} catch {
return null;
}
})
);
return fileInfos.filter((f): f is FileInfo => f !== null);
}
function detectLanguage(filePath: string): FileInfo['lang'] {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.py':
return 'python';
case '.ts':
case '.tsx':
return 'typescript';
case '.js':
case '.jsx':
return 'typescript'; // Treat JS as TS for parsing
default:
return 'other';
}
}
function detectRole(filePath: string): FileInfo['role'] {
const lowerPath = filePath.toLowerCase();
const fileName = lowerPath.split('/').pop() || '';
// Test files - be more specific to avoid false positives
if (
lowerPath.includes('__tests__') ||
lowerPath.includes('/tests/') ||
lowerPath.includes('/test/') ||
fileName.endsWith('_test.py') ||
fileName.endsWith('.test.ts') ||
fileName.endsWith('.test.tsx') ||
fileName.endsWith('.test.js') ||
fileName.endsWith('.spec.ts') ||
fileName.endsWith('.spec.tsx') ||
fileName.endsWith('.spec.js') ||
fileName.startsWith('test_')
) {
return 'test';
}
// Config files
if (
lowerPath.includes('config') ||
lowerPath.includes('settings') ||
lowerPath.includes('.env') ||
lowerPath.endsWith('conftest.py') ||
lowerPath.endsWith('setup.py') ||
lowerPath.endsWith('pyproject.toml')
) {
return 'config';
}
// Documentation
if (
lowerPath.includes('docs') ||
lowerPath.includes('doc') ||
lowerPath.includes('readme')
) {
return 'docs';
}
return 'source';
}
export async function detectConfig(root: string): Promise<{
python?: {
entryPoints: string[];
testFramework: 'pytest' | 'unittest' | 'none';
hasTyping: boolean;
pyprojectToml: boolean;
setupPy: boolean;
};
typescript?: {
entryPoints: string[];
testFramework: 'vitest' | 'jest' | 'none';
hasTypes: boolean;
packageJson: boolean;
tsconfig: boolean;
};
}> {
const config: ReturnType<typeof detectConfig> extends Promise<infer T> ? T : never = {};
// Check for Python project
const hasPyprojectToml = await fileExists(path.join(root, 'pyproject.toml'));
const hasSetupPy = await fileExists(path.join(root, 'setup.py'));
const hasRequirementsTxt = await fileExists(path.join(root, 'requirements.txt'));
if (hasPyprojectToml || hasSetupPy || hasRequirementsTxt) {
let testFramework: 'pytest' | 'unittest' | 'none' = 'none';
// Check for pytest
if (hasPyprojectToml) {
try {
const content = await fs.readFile(path.join(root, 'pyproject.toml'), 'utf-8');
if (content.includes('pytest')) {
testFramework = 'pytest';
}
} catch {}
}
if (testFramework === 'none' && hasRequirementsTxt) {
try {
const content = await fs.readFile(path.join(root, 'requirements.txt'), 'utf-8');
if (content.includes('pytest')) {
testFramework = 'pytest';
}
} catch {}
}
config.python = {
entryPoints: [],
testFramework,
hasTyping: false,
pyprojectToml: hasPyprojectToml,
setupPy: hasSetupPy,
};
}
// Check for TypeScript/JavaScript project
const hasPackageJson = await fileExists(path.join(root, 'package.json'));
const hasTsconfig = await fileExists(path.join(root, 'tsconfig.json'));
if (hasPackageJson || hasTsconfig) {
let testFramework: 'vitest' | 'jest' | 'none' = 'none';
if (hasPackageJson) {
try {
const content = await fs.readFile(path.join(root, 'package.json'), 'utf-8');
const pkg = JSON.parse(content);
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
if ('vitest' in allDeps) {
testFramework = 'vitest';
} else if ('jest' in allDeps) {
testFramework = 'jest';
}
} catch {}
}
config.typescript = {
entryPoints: [],
testFramework,
hasTypes: hasTsconfig,
packageJson: hasPackageJson,
tsconfig: hasTsconfig,
};
}
return config;
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}

View file

@ -0,0 +1,134 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { scanDirectory, detectConfig } from './scanner.js';
import { PythonParser } from './parsers/python.js';
import { TypeScriptParser } from './parsers/typescript.js';
import { getGitInfo, getChangedFiles } from './git.js';
import { buildFileTree } from './tree.js';
import type { RepoSummary, ModuleInfo, FileInfo, Language } from './types.js';
export interface AnalyzeOptions {
root: string;
incremental?: boolean;
lastCommit?: string;
onlyFiles?: string[];
onProgress?: (message: string) => void;
}
export async function analyze(options: AnalyzeOptions): Promise<RepoSummary> {
const { root, incremental, lastCommit, onlyFiles, onProgress } = options;
onProgress?.('Scanning directory...');
let files = await scanDirectory(root);
// Filter for incremental analysis
if (onlyFiles && onlyFiles.length > 0) {
files = files.filter(f => onlyFiles.includes(f.path));
onProgress?.(`Filtered to ${files.length} changed files`);
}
onProgress?.(`Found ${files.length} source files`);
// Initialize parsers
const pythonParser = new PythonParser();
const tsParser = new TypeScriptParser();
const modules: ModuleInfo[] = [];
const sourceFiles = files.filter(f => f.role === 'source' && f.lang !== 'other');
onProgress?.(`Parsing ${sourceFiles.length} modules...`);
for (const file of sourceFiles) {
const fullPath = path.join(root, file.path);
try {
const source = await fs.readFile(fullPath, 'utf-8');
let moduleInfo: ModuleInfo;
if (file.lang === 'python') {
moduleInfo = pythonParser.parse(source, file.path);
} else if (file.lang === 'typescript') {
moduleInfo = tsParser.parse(source, file.path);
} else {
continue;
}
modules.push(moduleInfo);
} catch (error) {
// Skip files that can't be parsed
onProgress?.(`Warning: Could not parse ${file.path}`);
}
}
onProgress?.('Detecting project configuration...');
const config = await detectConfig(root);
onProgress?.('Getting git info...');
const git = await getGitInfo(root, lastCommit);
onProgress?.('Building file tree...');
const tree = buildFileTree(files, path.basename(root));
// Detect languages used
const languages = detectLanguages(files);
onProgress?.('Analysis complete');
return {
languages,
root,
analyzedAt: new Date().toISOString(),
files,
modules,
config,
git,
tree,
};
}
export async function analyzeIncremental(
root: string,
lastCommit: string,
onProgress?: (message: string) => void
): Promise<RepoSummary> {
onProgress?.('Getting changed files since last commit...');
const changedFiles = await getChangedFiles(root, lastCommit);
if (changedFiles.length === 0) {
onProgress?.('No files changed');
// Return minimal summary
return {
languages: [],
root,
analyzedAt: new Date().toISOString(),
files: [],
modules: [],
config: {},
git: await getGitInfo(root, lastCommit),
};
}
onProgress?.(`Found ${changedFiles.length} changed files`);
return analyze({
root,
incremental: true,
lastCommit,
onlyFiles: changedFiles,
onProgress,
});
}
function detectLanguages(files: FileInfo[]): Language[] {
const languages = new Set<Language>();
for (const file of files) {
if (file.lang === 'python') {
languages.add('python');
} else if (file.lang === 'typescript') {
languages.add('typescript');
}
}
return [...languages];
}

157
src/introspector/tree.ts Normal file
View file

@ -0,0 +1,157 @@
import type { FileInfo, FileTreeNode } from './types.js';
export function buildFileTree(files: FileInfo[], rootName: string = '.'): FileTreeNode {
const root: FileTreeNode = {
name: rootName,
path: '',
type: 'directory',
children: [],
};
// Build a map for quick lookup
const nodeMap = new Map<string, FileTreeNode>();
nodeMap.set('', root);
// Sort files to ensure parents are created before children
const sortedFiles = [...files].sort((a, b) => a.path.localeCompare(b.path));
for (const file of sortedFiles) {
const parts = file.path.split('/');
let currentPath = '';
// Create all parent directories
for (let i = 0; i < parts.length - 1; i++) {
const parentPath = currentPath;
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
if (!nodeMap.has(currentPath)) {
const dirNode: FileTreeNode = {
name: parts[i],
path: currentPath,
type: 'directory',
children: [],
};
nodeMap.set(currentPath, dirNode);
// Add to parent
const parent = nodeMap.get(parentPath);
if (parent && parent.children) {
parent.children.push(dirNode);
}
}
}
// Create the file node
const fileName = parts[parts.length - 1];
const fileNode: FileTreeNode = {
name: fileName,
path: file.path,
type: 'file',
lang: file.lang,
role: file.role,
};
// Add to parent directory
const parentPath = parts.slice(0, -1).join('/');
const parent = nodeMap.get(parentPath);
if (parent && parent.children) {
parent.children.push(fileNode);
}
}
// Sort children alphabetically (directories first)
sortTreeRecursive(root);
return root;
}
function sortTreeRecursive(node: FileTreeNode): void {
if (node.children) {
node.children.sort((a, b) => {
// Directories first
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
for (const child of node.children) {
sortTreeRecursive(child);
}
}
}
export function treeToString(node: FileTreeNode, prefix: string = '', isLast: boolean = true): string {
const lines: string[] = [];
const connector = isLast ? '└── ' : '├── ';
const extension = isLast ? ' ' : '│ ';
if (node.path === '') {
// Root node
lines.push(node.name);
} else {
const icon = node.type === 'directory' ? '📁' : getFileIcon(node.lang, node.role);
lines.push(`${prefix}${connector}${icon} ${node.name}`);
}
if (node.children) {
const children = node.children;
for (let i = 0; i < children.length; i++) {
const child = children[i];
const childIsLast = i === children.length - 1;
const newPrefix = node.path === '' ? '' : prefix + extension;
lines.push(treeToString(child, newPrefix, childIsLast));
}
}
return lines.join('\n');
}
function getFileIcon(lang?: string, role?: string): string {
if (role === 'test') return '🧪';
if (role === 'config') return '⚙️';
if (role === 'docs') return '📄';
switch (lang) {
case 'python': return '🐍';
case 'typescript': return '📘';
default: return '📄';
}
}
export function getTreeStats(node: FileTreeNode): {
directories: number;
files: number;
byLang: Record<string, number>;
byRole: Record<string, number>;
} {
const stats = {
directories: 0,
files: 0,
byLang: {} as Record<string, number>,
byRole: {} as Record<string, number>,
};
function traverse(n: FileTreeNode): void {
if (n.type === 'directory') {
stats.directories++;
if (n.children) {
for (const child of n.children) {
traverse(child);
}
}
} else {
stats.files++;
if (n.lang) {
stats.byLang[n.lang] = (stats.byLang[n.lang] || 0) + 1;
}
if (n.role) {
stats.byRole[n.role] = (stats.byRole[n.role] || 0) + 1;
}
}
}
traverse(node);
return stats;
}

88
src/introspector/types.ts Normal file
View file

@ -0,0 +1,88 @@
export interface RepoSummary {
languages: ('python' | 'typescript')[];
root: string;
analyzedAt: string;
files: FileInfo[];
modules: ModuleInfo[];
config: ConfigInfo;
git?: GitInfo;
tree?: FileTreeNode;
}
export interface FileInfo {
path: string;
lang: 'python' | 'typescript' | 'other';
role: 'source' | 'test' | 'config' | 'docs';
size: number;
lastModified: string;
}
export interface ModuleInfo {
path: string;
exports: ExportInfo[];
imports: string[];
complexity: 'low' | 'medium' | 'high';
}
export interface ExportInfo {
name: string;
kind: 'function' | 'class' | 'constant' | 'type';
signature?: string;
docstring?: string;
lineNumber: number;
isAsync?: boolean;
isExported?: boolean;
}
export interface ConfigInfo {
python?: {
entryPoints: string[];
testFramework: 'pytest' | 'unittest' | 'none';
hasTyping: boolean;
pyprojectToml: boolean;
setupPy: boolean;
};
typescript?: {
entryPoints: string[];
testFramework: 'vitest' | 'jest' | 'none';
hasTypes: boolean;
packageJson: boolean;
tsconfig: boolean;
};
}
export interface GitInfo {
lastAnalyzedCommit: string;
currentCommit: string;
changedSince: string[];
branch: string;
recentCommits?: CommitInfo[];
fileHistory?: FileHistoryInfo[];
}
export interface CommitInfo {
hash: string;
shortHash: string;
author: string;
date: string;
message: string;
filesChanged: number;
}
export interface FileHistoryInfo {
path: string;
commitCount: number;
lastModified: string;
contributors: string[];
}
export interface FileTreeNode {
name: string;
path: string;
type: 'file' | 'directory';
children?: FileTreeNode[];
lang?: 'python' | 'typescript' | 'other';
role?: 'source' | 'test' | 'config' | 'docs';
}
export type Language = 'python' | 'typescript';