mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 15:26:53 +05:00
feat(ipuaro): implement indexer module (v0.3.0)
Add complete indexer infrastructure: - FileScanner: recursive scanning with gitignore support - ASTParser: tree-sitter based TS/JS/TSX/JSX parsing - MetaAnalyzer: complexity metrics, dependency analysis - IndexBuilder: symbol index and dependency graph - Watchdog: file watching with chokidar and debouncing 321 tests, 96.38% coverage
This commit is contained in:
532
packages/ipuaro/src/infrastructure/indexer/ASTParser.ts
Normal file
532
packages/ipuaro/src/infrastructure/indexer/ASTParser.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
import { builtinModules } from "node:module"
|
||||
import Parser from "tree-sitter"
|
||||
import TypeScript from "tree-sitter-typescript"
|
||||
import JavaScript from "tree-sitter-javascript"
|
||||
import {
|
||||
createEmptyFileAST,
|
||||
type ExportInfo,
|
||||
type FileAST,
|
||||
type ImportInfo,
|
||||
type MethodInfo,
|
||||
type ParameterInfo,
|
||||
type PropertyInfo,
|
||||
} from "../../domain/value-objects/FileAST.js"
|
||||
import { FieldName, NodeType } from "./tree-sitter-types.js"
|
||||
|
||||
type Language = "ts" | "tsx" | "js" | "jsx"
|
||||
type SyntaxNode = Parser.SyntaxNode
|
||||
|
||||
/**
|
||||
* Parses source code into AST using tree-sitter.
|
||||
*/
|
||||
export class ASTParser {
|
||||
private readonly parsers = new Map<Language, Parser>()
|
||||
|
||||
constructor() {
|
||||
this.initializeParsers()
|
||||
}
|
||||
|
||||
private initializeParsers(): void {
|
||||
const tsParser = new Parser()
|
||||
tsParser.setLanguage(TypeScript.typescript)
|
||||
this.parsers.set("ts", tsParser)
|
||||
|
||||
const tsxParser = new Parser()
|
||||
tsxParser.setLanguage(TypeScript.tsx)
|
||||
this.parsers.set("tsx", tsxParser)
|
||||
|
||||
const jsParser = new Parser()
|
||||
jsParser.setLanguage(JavaScript)
|
||||
this.parsers.set("js", jsParser)
|
||||
this.parsers.set("jsx", jsParser)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse source code and extract AST information.
|
||||
*/
|
||||
parse(content: string, language: Language): FileAST {
|
||||
const parser = this.parsers.get(language)
|
||||
if (!parser) {
|
||||
return {
|
||||
...createEmptyFileAST(),
|
||||
parseError: true,
|
||||
parseErrorMessage: `Unsupported language: ${language}`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const tree = parser.parse(content)
|
||||
const root = tree.rootNode
|
||||
|
||||
if (root.hasError) {
|
||||
const ast = this.extractAST(root, language)
|
||||
ast.parseError = true
|
||||
ast.parseErrorMessage = "Syntax error in source code"
|
||||
return ast
|
||||
}
|
||||
|
||||
return this.extractAST(root, language)
|
||||
} catch (error) {
|
||||
return {
|
||||
...createEmptyFileAST(),
|
||||
parseError: true,
|
||||
parseErrorMessage: error instanceof Error ? error.message : "Unknown parse error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractAST(root: SyntaxNode, language: Language): FileAST {
|
||||
const ast = createEmptyFileAST()
|
||||
const isTypeScript = language === "ts" || language === "tsx"
|
||||
|
||||
for (const child of root.children) {
|
||||
this.visitNode(child, ast, isTypeScript)
|
||||
}
|
||||
|
||||
return ast
|
||||
}
|
||||
|
||||
private visitNode(node: SyntaxNode, ast: FileAST, isTypeScript: boolean): void {
|
||||
switch (node.type) {
|
||||
case NodeType.IMPORT_STATEMENT:
|
||||
this.extractImport(node, ast)
|
||||
break
|
||||
case NodeType.EXPORT_STATEMENT:
|
||||
this.extractExport(node, ast)
|
||||
break
|
||||
case NodeType.FUNCTION_DECLARATION:
|
||||
this.extractFunction(node, ast, false)
|
||||
break
|
||||
case NodeType.LEXICAL_DECLARATION:
|
||||
this.extractLexicalDeclaration(node, ast)
|
||||
break
|
||||
case NodeType.CLASS_DECLARATION:
|
||||
this.extractClass(node, ast, false)
|
||||
break
|
||||
case NodeType.INTERFACE_DECLARATION:
|
||||
if (isTypeScript) {
|
||||
this.extractInterface(node, ast, false)
|
||||
}
|
||||
break
|
||||
case NodeType.TYPE_ALIAS_DECLARATION:
|
||||
if (isTypeScript) {
|
||||
this.extractTypeAlias(node, ast, false)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private extractImport(node: SyntaxNode, ast: FileAST): void {
|
||||
const sourceNode = node.childForFieldName(FieldName.SOURCE)
|
||||
if (!sourceNode) {
|
||||
return
|
||||
}
|
||||
|
||||
const from = this.getStringValue(sourceNode)
|
||||
const line = node.startPosition.row + 1
|
||||
const importType = this.classifyImport(from)
|
||||
|
||||
const importClause = node.children.find((c) => c.type === NodeType.IMPORT_CLAUSE)
|
||||
if (!importClause) {
|
||||
ast.imports.push({
|
||||
name: "*",
|
||||
from,
|
||||
line,
|
||||
type: importType,
|
||||
isDefault: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for (const child of importClause.children) {
|
||||
if (child.type === NodeType.IDENTIFIER) {
|
||||
ast.imports.push({
|
||||
name: child.text,
|
||||
from,
|
||||
line,
|
||||
type: importType,
|
||||
isDefault: true,
|
||||
})
|
||||
} else if (child.type === NodeType.NAMESPACE_IMPORT) {
|
||||
const alias = child.children.find((c) => c.type === NodeType.IDENTIFIER)
|
||||
ast.imports.push({
|
||||
name: alias?.text ?? "*",
|
||||
from,
|
||||
line,
|
||||
type: importType,
|
||||
isDefault: false,
|
||||
})
|
||||
} else if (child.type === NodeType.NAMED_IMPORTS) {
|
||||
for (const specifier of child.children) {
|
||||
if (specifier.type === NodeType.IMPORT_SPECIFIER) {
|
||||
const nameNode = specifier.childForFieldName(FieldName.NAME)
|
||||
const aliasNode = specifier.childForFieldName(FieldName.ALIAS)
|
||||
ast.imports.push({
|
||||
name: aliasNode?.text ?? nameNode?.text ?? "",
|
||||
from,
|
||||
line,
|
||||
type: importType,
|
||||
isDefault: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractExport(node: SyntaxNode, ast: FileAST): void {
|
||||
const isDefault = node.children.some((c) => c.type === NodeType.DEFAULT)
|
||||
const declaration = node.childForFieldName(FieldName.DECLARATION)
|
||||
|
||||
if (declaration) {
|
||||
switch (declaration.type) {
|
||||
case NodeType.FUNCTION_DECLARATION:
|
||||
this.extractFunction(declaration, ast, true)
|
||||
this.addExportInfo(ast, declaration, "function", isDefault)
|
||||
break
|
||||
case NodeType.CLASS_DECLARATION:
|
||||
this.extractClass(declaration, ast, true)
|
||||
this.addExportInfo(ast, declaration, "class", isDefault)
|
||||
break
|
||||
case NodeType.INTERFACE_DECLARATION:
|
||||
this.extractInterface(declaration, ast, true)
|
||||
this.addExportInfo(ast, declaration, "interface", isDefault)
|
||||
break
|
||||
case NodeType.TYPE_ALIAS_DECLARATION:
|
||||
this.extractTypeAlias(declaration, ast, true)
|
||||
this.addExportInfo(ast, declaration, "type", isDefault)
|
||||
break
|
||||
case NodeType.LEXICAL_DECLARATION:
|
||||
this.extractLexicalDeclaration(declaration, ast, true)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const exportClause = node.children.find((c) => c.type === NodeType.EXPORT_CLAUSE)
|
||||
if (exportClause) {
|
||||
for (const specifier of exportClause.children) {
|
||||
if (specifier.type === NodeType.EXPORT_SPECIFIER) {
|
||||
const nameNode = specifier.childForFieldName(FieldName.NAME)
|
||||
if (nameNode) {
|
||||
ast.exports.push({
|
||||
name: nameNode.text,
|
||||
line: node.startPosition.row + 1,
|
||||
isDefault: false,
|
||||
kind: "variable",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractFunction(node: SyntaxNode, ast: FileAST, isExported: boolean): void {
|
||||
const nameNode = node.childForFieldName(FieldName.NAME)
|
||||
if (!nameNode) {
|
||||
return
|
||||
}
|
||||
|
||||
const params = this.extractParameters(node)
|
||||
const isAsync = node.children.some((c) => c.type === NodeType.ASYNC)
|
||||
const returnTypeNode = node.childForFieldName(FieldName.RETURN_TYPE)
|
||||
|
||||
ast.functions.push({
|
||||
name: nameNode.text,
|
||||
lineStart: node.startPosition.row + 1,
|
||||
lineEnd: node.endPosition.row + 1,
|
||||
params,
|
||||
isAsync,
|
||||
isExported,
|
||||
returnType: returnTypeNode?.text?.replace(/^:\s*/, ""),
|
||||
})
|
||||
}
|
||||
|
||||
private extractLexicalDeclaration(node: SyntaxNode, ast: FileAST, isExported = false): void {
|
||||
for (const child of node.children) {
|
||||
if (child.type === NodeType.VARIABLE_DECLARATOR) {
|
||||
const nameNode = child.childForFieldName(FieldName.NAME)
|
||||
const valueNode = child.childForFieldName(FieldName.VALUE)
|
||||
|
||||
if (
|
||||
valueNode?.type === NodeType.ARROW_FUNCTION ||
|
||||
valueNode?.type === NodeType.FUNCTION
|
||||
) {
|
||||
const params = this.extractParameters(valueNode)
|
||||
const isAsync = valueNode.children.some((c) => c.type === NodeType.ASYNC)
|
||||
|
||||
ast.functions.push({
|
||||
name: nameNode?.text ?? "",
|
||||
lineStart: node.startPosition.row + 1,
|
||||
lineEnd: node.endPosition.row + 1,
|
||||
params,
|
||||
isAsync,
|
||||
isExported,
|
||||
})
|
||||
|
||||
if (isExported) {
|
||||
ast.exports.push({
|
||||
name: nameNode?.text ?? "",
|
||||
line: node.startPosition.row + 1,
|
||||
isDefault: false,
|
||||
kind: "function",
|
||||
})
|
||||
}
|
||||
} else if (isExported && nameNode) {
|
||||
ast.exports.push({
|
||||
name: nameNode.text,
|
||||
line: node.startPosition.row + 1,
|
||||
isDefault: false,
|
||||
kind: "variable",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractClass(node: SyntaxNode, ast: FileAST, isExported: boolean): void {
|
||||
const nameNode = node.childForFieldName(FieldName.NAME)
|
||||
if (!nameNode) {
|
||||
return
|
||||
}
|
||||
|
||||
const body = node.childForFieldName(FieldName.BODY)
|
||||
const methods: MethodInfo[] = []
|
||||
const properties: PropertyInfo[] = []
|
||||
|
||||
if (body) {
|
||||
for (const member of body.children) {
|
||||
if (member.type === NodeType.METHOD_DEFINITION) {
|
||||
methods.push(this.extractMethod(member))
|
||||
} else if (
|
||||
member.type === NodeType.PUBLIC_FIELD_DEFINITION ||
|
||||
member.type === NodeType.FIELD_DEFINITION
|
||||
) {
|
||||
properties.push(this.extractProperty(member))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let extendsName: string | undefined
|
||||
const implementsList: string[] = []
|
||||
|
||||
for (const child of node.children) {
|
||||
if (child.type === NodeType.CLASS_HERITAGE) {
|
||||
for (const clause of child.children) {
|
||||
if (clause.type === NodeType.EXTENDS_CLAUSE) {
|
||||
const typeNode = clause.children.find(
|
||||
(c) =>
|
||||
c.type === NodeType.TYPE_IDENTIFIER ||
|
||||
c.type === NodeType.IDENTIFIER,
|
||||
)
|
||||
extendsName = typeNode?.text
|
||||
} else if (clause.type === NodeType.IMPLEMENTS_CLAUSE) {
|
||||
for (const impl of clause.children) {
|
||||
if (
|
||||
impl.type === NodeType.TYPE_IDENTIFIER ||
|
||||
impl.type === NodeType.IDENTIFIER
|
||||
) {
|
||||
implementsList.push(impl.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (child.type === NodeType.EXTENDS_CLAUSE) {
|
||||
const typeNode = child.children.find(
|
||||
(c) => c.type === NodeType.TYPE_IDENTIFIER || c.type === NodeType.IDENTIFIER,
|
||||
)
|
||||
extendsName = typeNode?.text
|
||||
}
|
||||
}
|
||||
|
||||
const isAbstract = node.children.some((c) => c.type === NodeType.ABSTRACT)
|
||||
|
||||
ast.classes.push({
|
||||
name: nameNode.text,
|
||||
lineStart: node.startPosition.row + 1,
|
||||
lineEnd: node.endPosition.row + 1,
|
||||
methods,
|
||||
properties,
|
||||
extends: extendsName,
|
||||
implements: implementsList,
|
||||
isExported,
|
||||
isAbstract,
|
||||
})
|
||||
}
|
||||
|
||||
private extractMethod(node: SyntaxNode): MethodInfo {
|
||||
const nameNode = node.childForFieldName(FieldName.NAME)
|
||||
const params = this.extractParameters(node)
|
||||
const isAsync = node.children.some((c) => c.type === NodeType.ASYNC)
|
||||
const isStatic = node.children.some((c) => c.type === NodeType.STATIC)
|
||||
|
||||
let visibility: "public" | "private" | "protected" = "public"
|
||||
for (const child of node.children) {
|
||||
if (child.type === NodeType.ACCESSIBILITY_MODIFIER) {
|
||||
visibility = child.text as "public" | "private" | "protected"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: nameNode?.text ?? "",
|
||||
lineStart: node.startPosition.row + 1,
|
||||
lineEnd: node.endPosition.row + 1,
|
||||
params,
|
||||
isAsync,
|
||||
visibility,
|
||||
isStatic,
|
||||
}
|
||||
}
|
||||
|
||||
private extractProperty(node: SyntaxNode): PropertyInfo {
|
||||
const nameNode = node.childForFieldName(FieldName.NAME)
|
||||
const typeNode = node.childForFieldName(FieldName.TYPE)
|
||||
const isStatic = node.children.some((c) => c.type === NodeType.STATIC)
|
||||
const isReadonly = node.children.some((c) => c.text === NodeType.READONLY)
|
||||
|
||||
let visibility: "public" | "private" | "protected" = "public"
|
||||
for (const child of node.children) {
|
||||
if (child.type === NodeType.ACCESSIBILITY_MODIFIER) {
|
||||
visibility = child.text as "public" | "private" | "protected"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: nameNode?.text ?? "",
|
||||
line: node.startPosition.row + 1,
|
||||
type: typeNode?.text,
|
||||
visibility,
|
||||
isStatic,
|
||||
isReadonly,
|
||||
}
|
||||
}
|
||||
|
||||
private extractInterface(node: SyntaxNode, ast: FileAST, isExported: boolean): void {
|
||||
const nameNode = node.childForFieldName(FieldName.NAME)
|
||||
if (!nameNode) {
|
||||
return
|
||||
}
|
||||
|
||||
const body = node.childForFieldName(FieldName.BODY)
|
||||
const properties: PropertyInfo[] = []
|
||||
|
||||
if (body) {
|
||||
for (const member of body.children) {
|
||||
if (member.type === NodeType.PROPERTY_SIGNATURE) {
|
||||
const propName = member.childForFieldName(FieldName.NAME)
|
||||
const propType = member.childForFieldName(FieldName.TYPE)
|
||||
properties.push({
|
||||
name: propName?.text ?? "",
|
||||
line: member.startPosition.row + 1,
|
||||
type: propType?.text,
|
||||
visibility: "public",
|
||||
isStatic: false,
|
||||
isReadonly: member.children.some((c) => c.text === NodeType.READONLY),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const extendsList: string[] = []
|
||||
const extendsClause = node.children.find((c) => c.type === NodeType.EXTENDS_TYPE_CLAUSE)
|
||||
if (extendsClause) {
|
||||
for (const child of extendsClause.children) {
|
||||
if (child.type === NodeType.TYPE_IDENTIFIER) {
|
||||
extendsList.push(child.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ast.interfaces.push({
|
||||
name: nameNode.text,
|
||||
lineStart: node.startPosition.row + 1,
|
||||
lineEnd: node.endPosition.row + 1,
|
||||
properties,
|
||||
extends: extendsList,
|
||||
isExported,
|
||||
})
|
||||
}
|
||||
|
||||
private extractTypeAlias(node: SyntaxNode, ast: FileAST, isExported: boolean): void {
|
||||
const nameNode = node.childForFieldName(FieldName.NAME)
|
||||
if (!nameNode) {
|
||||
return
|
||||
}
|
||||
|
||||
ast.typeAliases.push({
|
||||
name: nameNode.text,
|
||||
line: node.startPosition.row + 1,
|
||||
isExported,
|
||||
})
|
||||
}
|
||||
|
||||
private extractParameters(node: SyntaxNode): ParameterInfo[] {
|
||||
const params: ParameterInfo[] = []
|
||||
const paramsNode = node.childForFieldName(FieldName.PARAMETERS)
|
||||
|
||||
if (paramsNode) {
|
||||
for (const param of paramsNode.children) {
|
||||
if (
|
||||
param.type === NodeType.REQUIRED_PARAMETER ||
|
||||
param.type === NodeType.OPTIONAL_PARAMETER ||
|
||||
param.type === NodeType.IDENTIFIER
|
||||
) {
|
||||
const nameNode =
|
||||
param.type === NodeType.IDENTIFIER
|
||||
? param
|
||||
: param.childForFieldName(FieldName.PATTERN)
|
||||
const typeNode = param.childForFieldName(FieldName.TYPE)
|
||||
const defaultValue = param.childForFieldName(FieldName.VALUE)
|
||||
|
||||
params.push({
|
||||
name: nameNode?.text ?? "",
|
||||
type: typeNode?.text,
|
||||
optional: param.type === NodeType.OPTIONAL_PARAMETER,
|
||||
hasDefault: defaultValue !== null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
private addExportInfo(
|
||||
ast: FileAST,
|
||||
node: SyntaxNode,
|
||||
kind: ExportInfo["kind"],
|
||||
isDefault: boolean,
|
||||
): void {
|
||||
const nameNode = node.childForFieldName(FieldName.NAME)
|
||||
if (nameNode) {
|
||||
ast.exports.push({
|
||||
name: nameNode.text,
|
||||
line: node.startPosition.row + 1,
|
||||
isDefault,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private classifyImport(from: string): ImportInfo["type"] {
|
||||
if (from.startsWith(".") || from.startsWith("/")) {
|
||||
return "internal"
|
||||
}
|
||||
if (from.startsWith("node:") || builtinModules.includes(from)) {
|
||||
return "builtin"
|
||||
}
|
||||
return "external"
|
||||
}
|
||||
|
||||
private getStringValue(node: SyntaxNode): string {
|
||||
const text = node.text
|
||||
if (
|
||||
(text.startsWith('"') && text.endsWith('"')) ||
|
||||
(text.startsWith("'") && text.endsWith("'"))
|
||||
) {
|
||||
return text.slice(1, -1)
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
189
packages/ipuaro/src/infrastructure/indexer/FileScanner.ts
Normal file
189
packages/ipuaro/src/infrastructure/indexer/FileScanner.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import * as fs from "node:fs/promises"
|
||||
import type { Stats } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { globby } from "globby"
|
||||
import {
|
||||
BINARY_EXTENSIONS,
|
||||
DEFAULT_IGNORE_PATTERNS,
|
||||
SUPPORTED_EXTENSIONS,
|
||||
} from "../../domain/constants/index.js"
|
||||
import type { ScanResult } from "../../domain/services/IIndexer.js"
|
||||
|
||||
/**
|
||||
* Progress callback for file scanning.
|
||||
*/
|
||||
export interface ScanProgress {
|
||||
current: number
|
||||
total: number
|
||||
currentFile: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for FileScanner.
|
||||
*/
|
||||
export interface FileScannerOptions {
|
||||
/** Additional ignore patterns (besides .gitignore and defaults) */
|
||||
additionalIgnore?: string[]
|
||||
/** Only include files with these extensions. Defaults to SUPPORTED_EXTENSIONS. */
|
||||
extensions?: readonly string[]
|
||||
/** Callback for progress updates */
|
||||
onProgress?: (progress: ScanProgress) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans project directories recursively using globby.
|
||||
* Respects .gitignore, skips binary files and default ignore patterns.
|
||||
*/
|
||||
export class FileScanner {
|
||||
private readonly extensions: Set<string>
|
||||
private readonly additionalIgnore: string[]
|
||||
private readonly onProgress?: (progress: ScanProgress) => void
|
||||
|
||||
constructor(options: FileScannerOptions = {}) {
|
||||
this.extensions = new Set(options.extensions ?? SUPPORTED_EXTENSIONS)
|
||||
this.additionalIgnore = options.additionalIgnore ?? []
|
||||
this.onProgress = options.onProgress
|
||||
}
|
||||
|
||||
/**
|
||||
* Build glob patterns from extensions.
|
||||
*/
|
||||
private buildGlobPatterns(): string[] {
|
||||
const exts = [...this.extensions].map((ext) => ext.replace(".", ""))
|
||||
if (exts.length === 1) {
|
||||
return [`**/*.${exts[0]}`]
|
||||
}
|
||||
return [`**/*.{${exts.join(",")}}`]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ignore patterns.
|
||||
*/
|
||||
private buildIgnorePatterns(): string[] {
|
||||
const patterns = [
|
||||
...DEFAULT_IGNORE_PATTERNS,
|
||||
...this.additionalIgnore,
|
||||
...BINARY_EXTENSIONS.map((ext) => `**/*${ext}`),
|
||||
]
|
||||
return patterns
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan directory and yield file results.
|
||||
* @param root - Root directory to scan
|
||||
*/
|
||||
async *scan(root: string): AsyncGenerator<ScanResult> {
|
||||
const globPatterns = this.buildGlobPatterns()
|
||||
const ignorePatterns = this.buildIgnorePatterns()
|
||||
|
||||
const files = await globby(globPatterns, {
|
||||
cwd: root,
|
||||
gitignore: true,
|
||||
ignore: ignorePatterns,
|
||||
absolute: false,
|
||||
onlyFiles: true,
|
||||
followSymbolicLinks: false,
|
||||
})
|
||||
|
||||
const total = files.length
|
||||
let current = 0
|
||||
|
||||
for (const relativePath of files) {
|
||||
current++
|
||||
this.reportProgress(relativePath, current, total)
|
||||
|
||||
const fullPath = path.join(root, relativePath)
|
||||
const stats = await this.safeStats(fullPath)
|
||||
|
||||
if (stats) {
|
||||
yield {
|
||||
path: relativePath,
|
||||
type: "file",
|
||||
size: stats.size,
|
||||
lastModified: stats.mtimeMs,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan and return all results as array.
|
||||
*/
|
||||
async scanAll(root: string): Promise<ScanResult[]> {
|
||||
const results: ScanResult[] = []
|
||||
for await (const result of this.scan(root)) {
|
||||
results.push(result)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file has supported extension.
|
||||
*/
|
||||
isSupportedExtension(filePath: string): boolean {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
return this.extensions.has(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely get file stats without throwing.
|
||||
*/
|
||||
private async safeStats(filePath: string): Promise<Stats | null> {
|
||||
try {
|
||||
return await fs.stat(filePath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report progress if callback is set.
|
||||
*/
|
||||
private reportProgress(currentFile: string, current: number, total: number): void {
|
||||
if (this.onProgress) {
|
||||
this.onProgress({ current, total, currentFile })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file content is likely UTF-8 text.
|
||||
* Reads first 8KB and checks for null bytes.
|
||||
*/
|
||||
static async isTextFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const handle = await fs.open(filePath, "r")
|
||||
try {
|
||||
const buffer = Buffer.alloc(8192)
|
||||
const { bytesRead } = await handle.read(buffer, 0, 8192, 0)
|
||||
if (bytesRead === 0) {
|
||||
return true
|
||||
}
|
||||
for (let i = 0; i < bytesRead; i++) {
|
||||
if (buffer[i] === 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content as string.
|
||||
* Returns null if file is binary or unreadable.
|
||||
*/
|
||||
static async readFileContent(filePath: string): Promise<string | null> {
|
||||
if (!(await FileScanner.isTextFile(filePath))) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return await fs.readFile(filePath, "utf-8")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
406
packages/ipuaro/src/infrastructure/indexer/IndexBuilder.ts
Normal file
406
packages/ipuaro/src/infrastructure/indexer/IndexBuilder.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import * as path from "node:path"
|
||||
import type { FileAST } from "../../domain/value-objects/FileAST.js"
|
||||
import type { DepsGraph, SymbolIndex, SymbolLocation } from "../../domain/services/IStorage.js"
|
||||
|
||||
/**
|
||||
* Builds searchable indexes from parsed ASTs.
|
||||
*/
|
||||
export class IndexBuilder {
|
||||
private readonly projectRoot: string
|
||||
|
||||
constructor(projectRoot: string) {
|
||||
this.projectRoot = projectRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Build symbol index from all ASTs.
|
||||
* Maps symbol names to their locations for quick lookup.
|
||||
*/
|
||||
buildSymbolIndex(asts: Map<string, FileAST>): SymbolIndex {
|
||||
const index: SymbolIndex = new Map()
|
||||
|
||||
for (const [filePath, ast] of asts) {
|
||||
this.indexFunctions(filePath, ast, index)
|
||||
this.indexClasses(filePath, ast, index)
|
||||
this.indexInterfaces(filePath, ast, index)
|
||||
this.indexTypeAliases(filePath, ast, index)
|
||||
this.indexExportedVariables(filePath, ast, index)
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Index function declarations.
|
||||
*/
|
||||
private indexFunctions(filePath: string, ast: FileAST, index: SymbolIndex): void {
|
||||
for (const func of ast.functions) {
|
||||
this.addSymbol(index, func.name, {
|
||||
path: filePath,
|
||||
line: func.lineStart,
|
||||
type: "function",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index class declarations.
|
||||
*/
|
||||
private indexClasses(filePath: string, ast: FileAST, index: SymbolIndex): void {
|
||||
for (const cls of ast.classes) {
|
||||
this.addSymbol(index, cls.name, {
|
||||
path: filePath,
|
||||
line: cls.lineStart,
|
||||
type: "class",
|
||||
})
|
||||
|
||||
for (const method of cls.methods) {
|
||||
const qualifiedName = `${cls.name}.${method.name}`
|
||||
this.addSymbol(index, qualifiedName, {
|
||||
path: filePath,
|
||||
line: method.lineStart,
|
||||
type: "function",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index interface declarations.
|
||||
*/
|
||||
private indexInterfaces(filePath: string, ast: FileAST, index: SymbolIndex): void {
|
||||
for (const iface of ast.interfaces) {
|
||||
this.addSymbol(index, iface.name, {
|
||||
path: filePath,
|
||||
line: iface.lineStart,
|
||||
type: "interface",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index type alias declarations.
|
||||
*/
|
||||
private indexTypeAliases(filePath: string, ast: FileAST, index: SymbolIndex): void {
|
||||
for (const typeAlias of ast.typeAliases) {
|
||||
this.addSymbol(index, typeAlias.name, {
|
||||
path: filePath,
|
||||
line: typeAlias.line,
|
||||
type: "type",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index exported variables (not functions).
|
||||
*/
|
||||
private indexExportedVariables(filePath: string, ast: FileAST, index: SymbolIndex): void {
|
||||
const functionNames = new Set(ast.functions.map((f) => f.name))
|
||||
|
||||
for (const exp of ast.exports) {
|
||||
if (exp.kind === "variable" && !functionNames.has(exp.name)) {
|
||||
this.addSymbol(index, exp.name, {
|
||||
path: filePath,
|
||||
line: exp.line,
|
||||
type: "variable",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a symbol to the index.
|
||||
*/
|
||||
private addSymbol(index: SymbolIndex, name: string, location: SymbolLocation): void {
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
|
||||
const existing = index.get(name)
|
||||
if (existing) {
|
||||
const isDuplicate = existing.some(
|
||||
(loc) => loc.path === location.path && loc.line === location.line,
|
||||
)
|
||||
if (!isDuplicate) {
|
||||
existing.push(location)
|
||||
}
|
||||
} else {
|
||||
index.set(name, [location])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dependency graph from all ASTs.
|
||||
* Creates bidirectional mapping of imports.
|
||||
*/
|
||||
buildDepsGraph(asts: Map<string, FileAST>): DepsGraph {
|
||||
const imports = new Map<string, string[]>()
|
||||
const importedBy = new Map<string, string[]>()
|
||||
|
||||
for (const filePath of asts.keys()) {
|
||||
imports.set(filePath, [])
|
||||
importedBy.set(filePath, [])
|
||||
}
|
||||
|
||||
for (const [filePath, ast] of asts) {
|
||||
const fileImports = this.resolveFileImports(filePath, ast, asts)
|
||||
imports.set(filePath, fileImports)
|
||||
|
||||
for (const importedFile of fileImports) {
|
||||
const dependents = importedBy.get(importedFile) ?? []
|
||||
if (!dependents.includes(filePath)) {
|
||||
dependents.push(filePath)
|
||||
importedBy.set(importedFile, dependents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [filePath, deps] of imports) {
|
||||
imports.set(filePath, deps.sort())
|
||||
}
|
||||
for (const [filePath, deps] of importedBy) {
|
||||
importedBy.set(filePath, deps.sort())
|
||||
}
|
||||
|
||||
return { imports, importedBy }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve internal imports for a file.
|
||||
*/
|
||||
private resolveFileImports(
|
||||
filePath: string,
|
||||
ast: FileAST,
|
||||
allASTs: Map<string, FileAST>,
|
||||
): string[] {
|
||||
const fileDir = path.dirname(filePath)
|
||||
const resolvedImports: string[] = []
|
||||
|
||||
for (const imp of ast.imports) {
|
||||
if (imp.type !== "internal") {
|
||||
continue
|
||||
}
|
||||
|
||||
const resolved = this.resolveImportPath(fileDir, imp.from, allASTs)
|
||||
if (resolved && !resolvedImports.includes(resolved)) {
|
||||
resolvedImports.push(resolved)
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedImports
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve import path to actual file path.
|
||||
*/
|
||||
private resolveImportPath(
|
||||
fromDir: string,
|
||||
importPath: string,
|
||||
allASTs: Map<string, FileAST>,
|
||||
): string | null {
|
||||
const absolutePath = path.resolve(fromDir, importPath)
|
||||
|
||||
const candidates = this.getImportCandidates(absolutePath)
|
||||
for (const candidate of candidates) {
|
||||
if (allASTs.has(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate possible file paths for an import.
|
||||
*/
|
||||
private getImportCandidates(basePath: string): string[] {
|
||||
const candidates: string[] = []
|
||||
|
||||
if (/\.(ts|tsx|js|jsx)$/.test(basePath)) {
|
||||
candidates.push(basePath)
|
||||
|
||||
if (basePath.endsWith(".js")) {
|
||||
candidates.push(`${basePath.slice(0, -3)}.ts`)
|
||||
} else if (basePath.endsWith(".jsx")) {
|
||||
candidates.push(`${basePath.slice(0, -4)}.tsx`)
|
||||
}
|
||||
} else {
|
||||
candidates.push(`${basePath}.ts`)
|
||||
candidates.push(`${basePath}.tsx`)
|
||||
candidates.push(`${basePath}.js`)
|
||||
candidates.push(`${basePath}.jsx`)
|
||||
candidates.push(`${basePath}/index.ts`)
|
||||
candidates.push(`${basePath}/index.tsx`)
|
||||
candidates.push(`${basePath}/index.js`)
|
||||
candidates.push(`${basePath}/index.jsx`)
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all locations of a symbol by name.
|
||||
*/
|
||||
findSymbol(index: SymbolIndex, name: string): SymbolLocation[] {
|
||||
return index.get(name) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Find symbols matching a pattern.
|
||||
*/
|
||||
searchSymbols(index: SymbolIndex, pattern: string): Map<string, SymbolLocation[]> {
|
||||
const results = new Map<string, SymbolLocation[]>()
|
||||
const regex = new RegExp(pattern, "i")
|
||||
|
||||
for (const [name, locations] of index) {
|
||||
if (regex.test(name)) {
|
||||
results.set(name, locations)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all files that the given file depends on (imports).
|
||||
*/
|
||||
getDependencies(graph: DepsGraph, filePath: string): string[] {
|
||||
return graph.imports.get(filePath) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all files that depend on the given file (import it).
|
||||
*/
|
||||
getDependents(graph: DepsGraph, filePath: string): string[] {
|
||||
return graph.importedBy.get(filePath) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Find circular dependencies in the graph.
|
||||
*/
|
||||
findCircularDependencies(graph: DepsGraph): string[][] {
|
||||
const cycles: string[][] = []
|
||||
const visited = new Set<string>()
|
||||
const recursionStack = new Set<string>()
|
||||
|
||||
const dfs = (node: string, path: string[]): void => {
|
||||
visited.add(node)
|
||||
recursionStack.add(node)
|
||||
path.push(node)
|
||||
|
||||
const deps = graph.imports.get(node) ?? []
|
||||
for (const dep of deps) {
|
||||
if (!visited.has(dep)) {
|
||||
dfs(dep, [...path])
|
||||
} else if (recursionStack.has(dep)) {
|
||||
const cycleStart = path.indexOf(dep)
|
||||
if (cycleStart !== -1) {
|
||||
const cycle = [...path.slice(cycleStart), dep]
|
||||
const normalized = this.normalizeCycle(cycle)
|
||||
if (!this.cycleExists(cycles, normalized)) {
|
||||
cycles.push(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recursionStack.delete(node)
|
||||
}
|
||||
|
||||
for (const node of graph.imports.keys()) {
|
||||
if (!visited.has(node)) {
|
||||
dfs(node, [])
|
||||
}
|
||||
}
|
||||
|
||||
return cycles
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a cycle to start with the smallest path.
|
||||
*/
|
||||
private normalizeCycle(cycle: string[]): string[] {
|
||||
if (cycle.length <= 1) {
|
||||
return cycle
|
||||
}
|
||||
|
||||
const withoutLast = cycle.slice(0, -1)
|
||||
const minIndex = withoutLast.reduce(
|
||||
(minIdx, path, idx) => (path < withoutLast[minIdx] ? idx : minIdx),
|
||||
0,
|
||||
)
|
||||
|
||||
const rotated = [...withoutLast.slice(minIndex), ...withoutLast.slice(0, minIndex)]
|
||||
rotated.push(rotated[0])
|
||||
|
||||
return rotated
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cycle already exists in the list.
|
||||
*/
|
||||
private cycleExists(cycles: string[][], newCycle: string[]): boolean {
|
||||
const newKey = newCycle.join("→")
|
||||
return cycles.some((cycle) => cycle.join("→") === newKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about the indexes.
|
||||
*/
|
||||
getStats(
|
||||
symbolIndex: SymbolIndex,
|
||||
depsGraph: DepsGraph,
|
||||
): {
|
||||
totalSymbols: number
|
||||
symbolsByType: Record<SymbolLocation["type"], number>
|
||||
totalFiles: number
|
||||
totalDependencies: number
|
||||
averageDependencies: number
|
||||
hubs: string[]
|
||||
orphans: string[]
|
||||
} {
|
||||
const symbolsByType: Record<SymbolLocation["type"], number> = {
|
||||
function: 0,
|
||||
class: 0,
|
||||
interface: 0,
|
||||
type: 0,
|
||||
variable: 0,
|
||||
}
|
||||
|
||||
let totalSymbols = 0
|
||||
for (const locations of symbolIndex.values()) {
|
||||
totalSymbols += locations.length
|
||||
for (const loc of locations) {
|
||||
symbolsByType[loc.type]++
|
||||
}
|
||||
}
|
||||
|
||||
const totalFiles = depsGraph.imports.size
|
||||
let totalDependencies = 0
|
||||
const hubs: string[] = []
|
||||
const orphans: string[] = []
|
||||
|
||||
for (const [_filePath, deps] of depsGraph.imports) {
|
||||
totalDependencies += deps.length
|
||||
}
|
||||
|
||||
for (const [filePath, dependents] of depsGraph.importedBy) {
|
||||
if (dependents.length > 5) {
|
||||
hubs.push(filePath)
|
||||
}
|
||||
if (dependents.length === 0 && (depsGraph.imports.get(filePath)?.length ?? 0) === 0) {
|
||||
orphans.push(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalSymbols,
|
||||
symbolsByType,
|
||||
totalFiles,
|
||||
totalDependencies,
|
||||
averageDependencies: totalFiles > 0 ? totalDependencies / totalFiles : 0,
|
||||
hubs: hubs.sort(),
|
||||
orphans: orphans.sort(),
|
||||
}
|
||||
}
|
||||
}
|
||||
448
packages/ipuaro/src/infrastructure/indexer/MetaAnalyzer.ts
Normal file
448
packages/ipuaro/src/infrastructure/indexer/MetaAnalyzer.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
import * as path from "node:path"
|
||||
import {
|
||||
type ComplexityMetrics,
|
||||
createFileMeta,
|
||||
type FileMeta,
|
||||
isHubFile,
|
||||
} from "../../domain/value-objects/FileMeta.js"
|
||||
import type { ClassInfo, FileAST, FunctionInfo } from "../../domain/value-objects/FileAST.js"
|
||||
|
||||
/**
|
||||
* Analyzes file metadata including complexity, dependencies, and classification.
|
||||
*/
|
||||
export class MetaAnalyzer {
|
||||
private readonly projectRoot: string
|
||||
|
||||
constructor(projectRoot: string) {
|
||||
this.projectRoot = projectRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a file and compute its metadata.
|
||||
* @param filePath - Absolute path to the file
|
||||
* @param ast - Parsed AST for the file
|
||||
* @param content - Raw file content (for LOC calculation)
|
||||
* @param allASTs - Map of all file paths to their ASTs (for dependents)
|
||||
*/
|
||||
analyze(
|
||||
filePath: string,
|
||||
ast: FileAST,
|
||||
content: string,
|
||||
allASTs: Map<string, FileAST>,
|
||||
): FileMeta {
|
||||
const complexity = this.calculateComplexity(ast, content)
|
||||
const dependencies = this.resolveDependencies(filePath, ast)
|
||||
const dependents = this.findDependents(filePath, allASTs)
|
||||
const fileType = this.classifyFileType(filePath)
|
||||
const isEntryPoint = this.isEntryPointFile(filePath, dependents.length)
|
||||
|
||||
return createFileMeta({
|
||||
complexity,
|
||||
dependencies,
|
||||
dependents,
|
||||
isHub: isHubFile(dependents.length),
|
||||
isEntryPoint,
|
||||
fileType,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate complexity metrics for a file.
|
||||
*/
|
||||
calculateComplexity(ast: FileAST, content: string): ComplexityMetrics {
|
||||
const loc = this.countLinesOfCode(content)
|
||||
const nesting = this.calculateMaxNesting(ast)
|
||||
const cyclomaticComplexity = this.calculateCyclomaticComplexity(ast)
|
||||
const score = this.calculateComplexityScore(loc, nesting, cyclomaticComplexity)
|
||||
|
||||
return {
|
||||
loc,
|
||||
nesting,
|
||||
cyclomaticComplexity,
|
||||
score,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count lines of code (excluding empty lines and comments).
|
||||
*/
|
||||
countLinesOfCode(content: string): number {
|
||||
const lines = content.split("\n")
|
||||
let loc = 0
|
||||
let inBlockComment = false
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (inBlockComment) {
|
||||
if (trimmed.includes("*/")) {
|
||||
inBlockComment = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("/*")) {
|
||||
if (!trimmed.includes("*/")) {
|
||||
inBlockComment = true
|
||||
continue
|
||||
}
|
||||
const afterComment = trimmed.substring(trimmed.indexOf("*/") + 2).trim()
|
||||
if (afterComment === "" || afterComment.startsWith("//")) {
|
||||
continue
|
||||
}
|
||||
loc++
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmed === "" || trimmed.startsWith("//")) {
|
||||
continue
|
||||
}
|
||||
|
||||
loc++
|
||||
}
|
||||
|
||||
return loc
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate maximum nesting depth from AST.
|
||||
*/
|
||||
calculateMaxNesting(ast: FileAST): number {
|
||||
let maxNesting = 0
|
||||
|
||||
for (const func of ast.functions) {
|
||||
const depth = this.estimateFunctionNesting(func)
|
||||
maxNesting = Math.max(maxNesting, depth)
|
||||
}
|
||||
|
||||
for (const cls of ast.classes) {
|
||||
const depth = this.estimateClassNesting(cls)
|
||||
maxNesting = Math.max(maxNesting, depth)
|
||||
}
|
||||
|
||||
return maxNesting
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate nesting depth for a function based on line count.
|
||||
* More accurate nesting would require full AST traversal.
|
||||
*/
|
||||
private estimateFunctionNesting(func: FunctionInfo): number {
|
||||
const lines = func.lineEnd - func.lineStart + 1
|
||||
if (lines <= 5) {
|
||||
return 1
|
||||
}
|
||||
if (lines <= 15) {
|
||||
return 2
|
||||
}
|
||||
if (lines <= 30) {
|
||||
return 3
|
||||
}
|
||||
if (lines <= 50) {
|
||||
return 4
|
||||
}
|
||||
return 5
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate nesting depth for a class.
|
||||
*/
|
||||
private estimateClassNesting(cls: ClassInfo): number {
|
||||
let maxMethodNesting = 1
|
||||
|
||||
for (const method of cls.methods) {
|
||||
const lines = method.lineEnd - method.lineStart + 1
|
||||
let depth = 1
|
||||
if (lines > 5) {
|
||||
depth = 2
|
||||
}
|
||||
if (lines > 15) {
|
||||
depth = 3
|
||||
}
|
||||
if (lines > 30) {
|
||||
depth = 4
|
||||
}
|
||||
maxMethodNesting = Math.max(maxMethodNesting, depth)
|
||||
}
|
||||
|
||||
return maxMethodNesting + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cyclomatic complexity from AST.
|
||||
* Base complexity is 1, +1 for each decision point.
|
||||
*/
|
||||
calculateCyclomaticComplexity(ast: FileAST): number {
|
||||
let complexity = 1
|
||||
|
||||
for (const func of ast.functions) {
|
||||
complexity += this.estimateFunctionComplexity(func)
|
||||
}
|
||||
|
||||
for (const cls of ast.classes) {
|
||||
for (const method of cls.methods) {
|
||||
const lines = method.lineEnd - method.lineStart + 1
|
||||
complexity += Math.max(1, Math.floor(lines / 10))
|
||||
}
|
||||
}
|
||||
|
||||
return complexity
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate function complexity based on size.
|
||||
*/
|
||||
private estimateFunctionComplexity(func: FunctionInfo): number {
|
||||
const lines = func.lineEnd - func.lineStart + 1
|
||||
return Math.max(1, Math.floor(lines / 8))
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall complexity score (0-100).
|
||||
*/
|
||||
calculateComplexityScore(loc: number, nesting: number, cyclomatic: number): number {
|
||||
const locWeight = 0.3
|
||||
const nestingWeight = 0.35
|
||||
const cyclomaticWeight = 0.35
|
||||
|
||||
const locScore = Math.min(100, (loc / 500) * 100)
|
||||
const nestingScore = Math.min(100, (nesting / 6) * 100)
|
||||
const cyclomaticScore = Math.min(100, (cyclomatic / 30) * 100)
|
||||
|
||||
const score =
|
||||
locScore * locWeight + nestingScore * nestingWeight + cyclomaticScore * cyclomaticWeight
|
||||
|
||||
return Math.round(Math.min(100, score))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve internal imports to absolute file paths.
|
||||
*/
|
||||
resolveDependencies(filePath: string, ast: FileAST): string[] {
|
||||
const dependencies: string[] = []
|
||||
const fileDir = path.dirname(filePath)
|
||||
|
||||
for (const imp of ast.imports) {
|
||||
if (imp.type !== "internal") {
|
||||
continue
|
||||
}
|
||||
|
||||
const resolved = this.resolveImportPath(fileDir, imp.from)
|
||||
if (resolved && !dependencies.includes(resolved)) {
|
||||
dependencies.push(resolved)
|
||||
}
|
||||
}
|
||||
|
||||
return dependencies.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a relative import path to an absolute path.
|
||||
*/
|
||||
private resolveImportPath(fromDir: string, importPath: string): string | null {
|
||||
const absolutePath = path.resolve(fromDir, importPath)
|
||||
const normalized = this.normalizeImportPath(absolutePath)
|
||||
|
||||
if (normalized.startsWith(this.projectRoot)) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize import path by removing file extension if present
|
||||
* and handling index imports.
|
||||
*/
|
||||
private normalizeImportPath(importPath: string): string {
|
||||
let normalized = importPath
|
||||
|
||||
if (normalized.endsWith(".js")) {
|
||||
normalized = `${normalized.slice(0, -3)}.ts`
|
||||
} else if (normalized.endsWith(".jsx")) {
|
||||
normalized = `${normalized.slice(0, -4)}.tsx`
|
||||
} else if (!/\.(ts|tsx|js|jsx)$/.exec(normalized)) {
|
||||
normalized = `${normalized}.ts`
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all files that import the given file.
|
||||
*/
|
||||
findDependents(filePath: string, allASTs: Map<string, FileAST>): string[] {
|
||||
const dependents: string[] = []
|
||||
const normalizedPath = this.normalizePathForComparison(filePath)
|
||||
|
||||
for (const [otherPath, ast] of allASTs) {
|
||||
if (otherPath === filePath) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (this.fileImportsTarget(otherPath, ast, normalizedPath)) {
|
||||
dependents.push(otherPath)
|
||||
}
|
||||
}
|
||||
|
||||
return dependents.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file imports the target path.
|
||||
*/
|
||||
private fileImportsTarget(filePath: string, ast: FileAST, normalizedTarget: string): boolean {
|
||||
const fileDir = path.dirname(filePath)
|
||||
|
||||
for (const imp of ast.imports) {
|
||||
if (imp.type !== "internal") {
|
||||
continue
|
||||
}
|
||||
|
||||
const resolvedImport = this.resolveImportPath(fileDir, imp.from)
|
||||
if (!resolvedImport) {
|
||||
continue
|
||||
}
|
||||
|
||||
const normalizedImport = this.normalizePathForComparison(resolvedImport)
|
||||
if (this.pathsMatch(normalizedTarget, normalizedImport)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path for comparison (handle index.ts and extensions).
|
||||
*/
|
||||
private normalizePathForComparison(filePath: string): string {
|
||||
let normalized = filePath
|
||||
|
||||
if (normalized.endsWith(".js")) {
|
||||
normalized = normalized.slice(0, -3)
|
||||
} else if (normalized.endsWith(".ts")) {
|
||||
normalized = normalized.slice(0, -3)
|
||||
} else if (normalized.endsWith(".jsx")) {
|
||||
normalized = normalized.slice(0, -4)
|
||||
} else if (normalized.endsWith(".tsx")) {
|
||||
normalized = normalized.slice(0, -4)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two normalized paths match (including index.ts resolution).
|
||||
*/
|
||||
private pathsMatch(path1: string, path2: string): boolean {
|
||||
if (path1 === path2) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (path1.endsWith("/index") && path2 === path1.slice(0, -6)) {
|
||||
return true
|
||||
}
|
||||
if (path2.endsWith("/index") && path1 === path2.slice(0, -6)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify file type based on path and name.
|
||||
*/
|
||||
classifyFileType(filePath: string): FileMeta["fileType"] {
|
||||
const basename = path.basename(filePath)
|
||||
const lowercasePath = filePath.toLowerCase()
|
||||
|
||||
if (basename.includes(".test.") || basename.includes(".spec.")) {
|
||||
return "test"
|
||||
}
|
||||
|
||||
if (lowercasePath.includes("/tests/") || lowercasePath.includes("/__tests__/")) {
|
||||
return "test"
|
||||
}
|
||||
|
||||
if (basename.endsWith(".d.ts")) {
|
||||
return "types"
|
||||
}
|
||||
|
||||
if (lowercasePath.includes("/types/") || basename === "types.ts") {
|
||||
return "types"
|
||||
}
|
||||
|
||||
const configPatterns = [
|
||||
"config",
|
||||
"tsconfig",
|
||||
"eslint",
|
||||
"prettier",
|
||||
"vitest",
|
||||
"jest",
|
||||
"babel",
|
||||
"webpack",
|
||||
"vite",
|
||||
"rollup",
|
||||
]
|
||||
|
||||
for (const pattern of configPatterns) {
|
||||
if (basename.toLowerCase().includes(pattern)) {
|
||||
return "config"
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
filePath.endsWith(".ts") ||
|
||||
filePath.endsWith(".tsx") ||
|
||||
filePath.endsWith(".js") ||
|
||||
filePath.endsWith(".jsx")
|
||||
) {
|
||||
return "source"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if file is an entry point.
|
||||
*/
|
||||
isEntryPointFile(filePath: string, dependentCount: number): boolean {
|
||||
const basename = path.basename(filePath)
|
||||
|
||||
if (basename.startsWith("index.")) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (dependentCount === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const entryPatterns = ["main.", "app.", "cli.", "server.", "index."]
|
||||
for (const pattern of entryPatterns) {
|
||||
if (basename.toLowerCase().startsWith(pattern)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch analyze multiple files.
|
||||
*/
|
||||
analyzeAll(files: Map<string, { ast: FileAST; content: string }>): Map<string, FileMeta> {
|
||||
const allASTs = new Map<string, FileAST>()
|
||||
for (const [filePath, { ast }] of files) {
|
||||
allASTs.set(filePath, ast)
|
||||
}
|
||||
|
||||
const results = new Map<string, FileMeta>()
|
||||
for (const [filePath, { ast, content }] of files) {
|
||||
const meta = this.analyze(filePath, ast, content, allASTs)
|
||||
results.set(filePath, meta)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
285
packages/ipuaro/src/infrastructure/indexer/Watchdog.ts
Normal file
285
packages/ipuaro/src/infrastructure/indexer/Watchdog.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import * as chokidar from "chokidar"
|
||||
import * as path from "node:path"
|
||||
import { DEFAULT_IGNORE_PATTERNS, SUPPORTED_EXTENSIONS } from "../../domain/constants/index.js"
|
||||
|
||||
export type FileChangeType = "add" | "change" | "unlink"
|
||||
|
||||
export interface FileChangeEvent {
|
||||
type: FileChangeType
|
||||
path: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type FileChangeCallback = (event: FileChangeEvent) => void
|
||||
|
||||
export interface WatchdogOptions {
|
||||
/** Debounce delay in milliseconds (default: 500) */
|
||||
debounceMs?: number
|
||||
/** Patterns to ignore (default: DEFAULT_IGNORE_PATTERNS) */
|
||||
ignorePatterns?: readonly string[]
|
||||
/** File extensions to watch (default: SUPPORTED_EXTENSIONS) */
|
||||
extensions?: readonly string[]
|
||||
/** Use polling instead of native events (useful for network drives) */
|
||||
usePolling?: boolean
|
||||
/** Polling interval in milliseconds (default: 1000) */
|
||||
pollInterval?: number
|
||||
}
|
||||
|
||||
interface ResolvedWatchdogOptions {
|
||||
debounceMs: number
|
||||
ignorePatterns: readonly string[]
|
||||
extensions: readonly string[]
|
||||
usePolling: boolean
|
||||
pollInterval: number
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: ResolvedWatchdogOptions = {
|
||||
debounceMs: 500,
|
||||
ignorePatterns: DEFAULT_IGNORE_PATTERNS,
|
||||
extensions: SUPPORTED_EXTENSIONS,
|
||||
usePolling: false,
|
||||
pollInterval: 1000,
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches for file changes in a directory using chokidar.
|
||||
*/
|
||||
export class Watchdog {
|
||||
private watcher: chokidar.FSWatcher | null = null
|
||||
private readonly callbacks: FileChangeCallback[] = []
|
||||
private readonly pendingChanges = new Map<string, FileChangeEvent>()
|
||||
private readonly debounceTimers = new Map<string, NodeJS.Timeout>()
|
||||
private readonly options: ResolvedWatchdogOptions
|
||||
private root = ""
|
||||
private isRunning = false
|
||||
|
||||
constructor(options: WatchdogOptions = {}) {
|
||||
this.options = { ...DEFAULT_OPTIONS, ...options }
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching a directory for file changes.
|
||||
*/
|
||||
start(root: string): void {
|
||||
if (this.isRunning) {
|
||||
void this.stop()
|
||||
}
|
||||
|
||||
this.root = root
|
||||
this.isRunning = true
|
||||
|
||||
const globPatterns = this.buildGlobPatterns(root)
|
||||
const ignorePatterns = this.buildIgnorePatterns()
|
||||
|
||||
this.watcher = chokidar.watch(globPatterns, {
|
||||
ignored: ignorePatterns,
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
usePolling: this.options.usePolling,
|
||||
interval: this.options.pollInterval,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 100,
|
||||
pollInterval: 100,
|
||||
},
|
||||
})
|
||||
|
||||
this.watcher.on("add", (filePath) => {
|
||||
this.handleChange("add", filePath)
|
||||
})
|
||||
this.watcher.on("change", (filePath) => {
|
||||
this.handleChange("change", filePath)
|
||||
})
|
||||
this.watcher.on("unlink", (filePath) => {
|
||||
this.handleChange("unlink", filePath)
|
||||
})
|
||||
this.watcher.on("error", (error) => {
|
||||
this.handleError(error)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching for file changes.
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const timer of this.debounceTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.debounceTimers.clear()
|
||||
this.pendingChanges.clear()
|
||||
|
||||
if (this.watcher) {
|
||||
await this.watcher.close()
|
||||
this.watcher = null
|
||||
}
|
||||
|
||||
this.isRunning = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for file change events.
|
||||
*/
|
||||
onFileChange(callback: FileChangeCallback): void {
|
||||
this.callbacks.push(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a callback.
|
||||
*/
|
||||
offFileChange(callback: FileChangeCallback): void {
|
||||
const index = this.callbacks.indexOf(callback)
|
||||
if (index !== -1) {
|
||||
this.callbacks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the watchdog is currently running.
|
||||
*/
|
||||
isWatching(): boolean {
|
||||
return this.isRunning
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the root directory being watched.
|
||||
*/
|
||||
getRoot(): string {
|
||||
return this.root
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of pending changes waiting to be processed.
|
||||
*/
|
||||
getPendingCount(): number {
|
||||
return this.pendingChanges.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a file change event with debouncing.
|
||||
*/
|
||||
private handleChange(type: FileChangeType, filePath: string): void {
|
||||
if (!this.isValidFile(filePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedPath = path.resolve(filePath)
|
||||
|
||||
const event: FileChangeEvent = {
|
||||
type,
|
||||
path: normalizedPath,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
this.pendingChanges.set(normalizedPath, event)
|
||||
|
||||
const existingTimer = this.debounceTimers.get(normalizedPath)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.flushChange(normalizedPath)
|
||||
}, this.options.debounceMs)
|
||||
|
||||
this.debounceTimers.set(normalizedPath, timer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush a pending change and notify callbacks.
|
||||
*/
|
||||
private flushChange(filePath: string): void {
|
||||
const event = this.pendingChanges.get(filePath)
|
||||
if (!event) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingChanges.delete(filePath)
|
||||
this.debounceTimers.delete(filePath)
|
||||
|
||||
for (const callback of this.callbacks) {
|
||||
try {
|
||||
callback(event)
|
||||
} catch {
|
||||
// Silently ignore callback errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle watcher errors.
|
||||
*/
|
||||
private handleError(error: Error): void {
|
||||
// Log error but don't crash
|
||||
console.error(`[Watchdog] Error: ${error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file should be watched based on extension.
|
||||
*/
|
||||
private isValidFile(filePath: string): boolean {
|
||||
const ext = path.extname(filePath)
|
||||
return this.options.extensions.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build glob patterns for watching.
|
||||
*/
|
||||
private buildGlobPatterns(root: string): string[] {
|
||||
return this.options.extensions.map((ext) => path.join(root, "**", `*${ext}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ignore patterns for chokidar.
|
||||
*/
|
||||
private buildIgnorePatterns(): (string | RegExp)[] {
|
||||
const patterns: (string | RegExp)[] = []
|
||||
|
||||
for (const pattern of this.options.ignorePatterns) {
|
||||
if (pattern.includes("*")) {
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, "\\.")
|
||||
.replace(/\*\*/g, ".*")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
patterns.push(new RegExp(regexPattern))
|
||||
} else {
|
||||
patterns.push(`**/${pattern}/**`)
|
||||
}
|
||||
}
|
||||
|
||||
return patterns
|
||||
}
|
||||
|
||||
/**
|
||||
* Force flush all pending changes immediately.
|
||||
*/
|
||||
flushAll(): void {
|
||||
for (const timer of this.debounceTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.debounceTimers.clear()
|
||||
|
||||
for (const filePath of this.pendingChanges.keys()) {
|
||||
this.flushChange(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get watched paths (for debugging).
|
||||
*/
|
||||
getWatchedPaths(): string[] {
|
||||
if (!this.watcher) {
|
||||
return []
|
||||
}
|
||||
const watched = this.watcher.getWatched()
|
||||
const paths: string[] = []
|
||||
for (const dir of Object.keys(watched)) {
|
||||
for (const file of watched[dir]) {
|
||||
paths.push(path.join(dir, file))
|
||||
}
|
||||
}
|
||||
return paths.sort()
|
||||
}
|
||||
}
|
||||
6
packages/ipuaro/src/infrastructure/indexer/index.ts
Normal file
6
packages/ipuaro/src/infrastructure/indexer/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./FileScanner.js"
|
||||
export * from "./ASTParser.js"
|
||||
export * from "./MetaAnalyzer.js"
|
||||
export * from "./IndexBuilder.js"
|
||||
export * from "./Watchdog.js"
|
||||
export * from "./tree-sitter-types.js"
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Tree-sitter node type constants for TypeScript/JavaScript parsing.
|
||||
* These are infrastructure-level constants, not exposed to domain/application layers.
|
||||
*
|
||||
* Source: tree-sitter-typescript/typescript/src/node-types.json
|
||||
*/
|
||||
|
||||
export const NodeType = {
|
||||
// Statements
|
||||
IMPORT_STATEMENT: "import_statement",
|
||||
EXPORT_STATEMENT: "export_statement",
|
||||
LEXICAL_DECLARATION: "lexical_declaration",
|
||||
|
||||
// Declarations
|
||||
FUNCTION_DECLARATION: "function_declaration",
|
||||
CLASS_DECLARATION: "class_declaration",
|
||||
INTERFACE_DECLARATION: "interface_declaration",
|
||||
TYPE_ALIAS_DECLARATION: "type_alias_declaration",
|
||||
|
||||
// Clauses
|
||||
IMPORT_CLAUSE: "import_clause",
|
||||
EXPORT_CLAUSE: "export_clause",
|
||||
EXTENDS_CLAUSE: "extends_clause",
|
||||
IMPLEMENTS_CLAUSE: "implements_clause",
|
||||
EXTENDS_TYPE_CLAUSE: "extends_type_clause",
|
||||
CLASS_HERITAGE: "class_heritage",
|
||||
|
||||
// Import specifiers
|
||||
NAMESPACE_IMPORT: "namespace_import",
|
||||
NAMED_IMPORTS: "named_imports",
|
||||
IMPORT_SPECIFIER: "import_specifier",
|
||||
EXPORT_SPECIFIER: "export_specifier",
|
||||
|
||||
// Class members
|
||||
METHOD_DEFINITION: "method_definition",
|
||||
PUBLIC_FIELD_DEFINITION: "public_field_definition",
|
||||
FIELD_DEFINITION: "field_definition",
|
||||
PROPERTY_SIGNATURE: "property_signature",
|
||||
|
||||
// Parameters
|
||||
REQUIRED_PARAMETER: "required_parameter",
|
||||
OPTIONAL_PARAMETER: "optional_parameter",
|
||||
|
||||
// Expressions & values
|
||||
ARROW_FUNCTION: "arrow_function",
|
||||
FUNCTION: "function",
|
||||
VARIABLE_DECLARATOR: "variable_declarator",
|
||||
|
||||
// Identifiers & types
|
||||
IDENTIFIER: "identifier",
|
||||
TYPE_IDENTIFIER: "type_identifier",
|
||||
|
||||
// Modifiers
|
||||
ASYNC: "async",
|
||||
STATIC: "static",
|
||||
ABSTRACT: "abstract",
|
||||
DEFAULT: "default",
|
||||
ACCESSIBILITY_MODIFIER: "accessibility_modifier",
|
||||
READONLY: "readonly",
|
||||
} as const
|
||||
|
||||
export type NodeTypeValue = (typeof NodeType)[keyof typeof NodeType]
|
||||
|
||||
export const FieldName = {
|
||||
SOURCE: "source",
|
||||
NAME: "name",
|
||||
ALIAS: "alias",
|
||||
DECLARATION: "declaration",
|
||||
PARAMETERS: "parameters",
|
||||
RETURN_TYPE: "return_type",
|
||||
BODY: "body",
|
||||
TYPE: "type",
|
||||
PATTERN: "pattern",
|
||||
VALUE: "value",
|
||||
} as const
|
||||
|
||||
export type FieldNameValue = (typeof FieldName)[keyof typeof FieldName]
|
||||
Reference in New Issue
Block a user