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:
imfozilbek
2025-11-30 01:24:21 +05:00
parent 225480c806
commit d0c1ddc22e
20 changed files with 4249 additions and 16 deletions

View File

@@ -0,0 +1,2 @@
ignored-file.ts
*.log

View File

@@ -0,0 +1,4 @@
{
"name": "sample-project",
"version": "1.0.0"
}

View File

@@ -0,0 +1,3 @@
export function main(): void {
console.log("Hello")
}

View File

@@ -0,0 +1,3 @@
export function add(a: number, b: number): number {
return a + b
}

View File

@@ -0,0 +1,347 @@
import { describe, it, expect, beforeAll } from "vitest"
import { ASTParser } from "../../../../src/infrastructure/indexer/ASTParser.js"
describe("ASTParser", () => {
let parser: ASTParser
beforeAll(() => {
parser = new ASTParser()
})
describe("parse", () => {
it("should parse empty file", () => {
const ast = parser.parse("", "ts")
expect(ast.parseError).toBe(false)
expect(ast.imports).toHaveLength(0)
expect(ast.exports).toHaveLength(0)
expect(ast.functions).toHaveLength(0)
expect(ast.classes).toHaveLength(0)
})
it("should handle syntax errors gracefully", () => {
const code = "export function {{{ invalid"
const ast = parser.parse(code, "ts")
expect(ast.parseError).toBe(true)
expect(ast.parseErrorMessage).toBeDefined()
})
it("should return error for unsupported language", () => {
const ast = parser.parse("const x = 1", "py" as never)
expect(ast.parseError).toBe(true)
expect(ast.parseErrorMessage).toContain("Unsupported language")
})
})
describe("imports", () => {
it("should extract default import", () => {
const code = `import React from "react"`
const ast = parser.parse(code, "ts")
expect(ast.imports).toHaveLength(1)
expect(ast.imports[0]).toMatchObject({
name: "React",
from: "react",
isDefault: true,
type: "external",
})
})
it("should extract named imports", () => {
const code = `import { useState, useEffect } from "react"`
const ast = parser.parse(code, "ts")
expect(ast.imports).toHaveLength(2)
expect(ast.imports[0].name).toBe("useState")
expect(ast.imports[1].name).toBe("useEffect")
expect(ast.imports[0].isDefault).toBe(false)
})
it("should extract namespace import", () => {
const code = `import * as path from "path"`
const ast = parser.parse(code, "ts")
expect(ast.imports).toHaveLength(1)
expect(ast.imports[0].name).toBe("path")
expect(ast.imports[0].isDefault).toBe(false)
})
it("should classify internal imports", () => {
const code = `import { foo } from "./utils"`
const ast = parser.parse(code, "ts")
expect(ast.imports[0].type).toBe("internal")
})
it("should classify builtin imports", () => {
const code = `import * as fs from "node:fs"`
const ast = parser.parse(code, "ts")
expect(ast.imports[0].type).toBe("builtin")
})
it("should classify external imports", () => {
const code = `import lodash from "lodash"`
const ast = parser.parse(code, "ts")
expect(ast.imports[0].type).toBe("external")
})
})
describe("functions", () => {
it("should extract function declaration", () => {
const code = `function add(a: number, b: number): number {
return a + b
}`
const ast = parser.parse(code, "ts")
expect(ast.functions).toHaveLength(1)
expect(ast.functions[0]).toMatchObject({
name: "add",
isAsync: false,
isExported: false,
})
expect(ast.functions[0].lineStart).toBe(1)
expect(ast.functions[0].lineEnd).toBe(3)
})
it("should extract async function", () => {
const code = `async function fetchData() { return null }`
const ast = parser.parse(code, "ts")
expect(ast.functions[0].isAsync).toBe(true)
})
it("should extract exported function", () => {
const code = `export function main() {}`
const ast = parser.parse(code, "ts")
expect(ast.functions[0].isExported).toBe(true)
expect(ast.exports).toHaveLength(1)
expect(ast.exports[0].kind).toBe("function")
})
it("should extract arrow function", () => {
const code = `const add = (a: number, b: number) => a + b`
const ast = parser.parse(code, "ts")
expect(ast.functions).toHaveLength(1)
expect(ast.functions[0].name).toBe("add")
})
it("should extract function parameters", () => {
const code = `function test(a: string, b?: number, c = 10) {}`
const ast = parser.parse(code, "ts")
expect(ast.functions[0].params).toHaveLength(3)
expect(ast.functions[0].params[0]).toMatchObject({
name: "a",
optional: false,
})
expect(ast.functions[0].params[1]).toMatchObject({
name: "b",
optional: true,
})
})
})
describe("classes", () => {
it("should extract class declaration", () => {
const code = `class MyClass {
value: number
constructor() {}
getValue() {
return this.value
}
}`
const ast = parser.parse(code, "ts")
expect(ast.classes).toHaveLength(1)
expect(ast.classes[0]).toMatchObject({
name: "MyClass",
isExported: false,
isAbstract: false,
})
})
it("should extract exported class", () => {
const code = `export class Service {}`
const ast = parser.parse(code, "ts")
expect(ast.classes[0].isExported).toBe(true)
expect(ast.exports).toHaveLength(1)
expect(ast.exports[0].kind).toBe("class")
})
it("should extract class methods", () => {
const code = `class Service {
async fetch() {}
private process() {}
static create() {}
}`
const ast = parser.parse(code, "ts")
expect(ast.classes[0].methods.length).toBeGreaterThanOrEqual(1)
})
it("should extract class extends", () => {
const code = `class Child extends Parent {}`
const ast = parser.parse(code, "ts")
expect(ast.classes[0].extends).toBe("Parent")
})
})
describe("interfaces", () => {
it("should extract interface declaration", () => {
const code = `interface User {
name: string
age: number
}`
const ast = parser.parse(code, "ts")
expect(ast.interfaces).toHaveLength(1)
expect(ast.interfaces[0]).toMatchObject({
name: "User",
isExported: false,
})
})
it("should extract exported interface", () => {
const code = `export interface Config {}`
const ast = parser.parse(code, "ts")
expect(ast.interfaces[0].isExported).toBe(true)
})
it("should extract interface properties", () => {
const code = `interface Props {
value: string
onChange: (v: string) => void
}`
const ast = parser.parse(code, "ts")
expect(ast.interfaces[0].properties.length).toBeGreaterThanOrEqual(1)
})
})
describe("type aliases", () => {
it("should extract type alias", () => {
const code = `type ID = string | number`
const ast = parser.parse(code, "ts")
expect(ast.typeAliases).toHaveLength(1)
expect(ast.typeAliases[0]).toMatchObject({
name: "ID",
isExported: false,
})
})
it("should extract exported type alias", () => {
const code = `export type Status = "pending" | "done"`
const ast = parser.parse(code, "ts")
expect(ast.typeAliases[0].isExported).toBe(true)
})
})
describe("exports", () => {
it("should extract named exports", () => {
const code = `
const foo = 1
const bar = 2
export { foo, bar }
`
const ast = parser.parse(code, "ts")
expect(ast.exports).toHaveLength(2)
})
it("should extract export default", () => {
const code = `export default function main() {}`
const ast = parser.parse(code, "ts")
expect(ast.exports.some((e) => e.isDefault)).toBe(true)
})
it("should extract exported const", () => {
const code = `export const VERSION = "1.0.0"`
const ast = parser.parse(code, "ts")
expect(ast.exports).toHaveLength(1)
expect(ast.exports[0].kind).toBe("variable")
})
})
describe("JavaScript support", () => {
it("should parse JavaScript file", () => {
const code = `
import React from "react"
function Component() {
return null
}
export default Component
`
const ast = parser.parse(code, "js")
expect(ast.parseError).toBe(false)
expect(ast.imports).toHaveLength(1)
expect(ast.functions).toHaveLength(1)
})
it("should parse JSX file", () => {
const code = `
import React from "react"
function App() {
return <div>Hello</div>
}
`
const ast = parser.parse(code, "jsx")
expect(ast.parseError).toBe(false)
})
})
describe("TSX support", () => {
it("should parse TSX file", () => {
const code = `
import React from "react"
interface Props {
name: string
}
export function Greeting({ name }: Props) {
return <h1>Hello, {name}!</h1>
}
`
const ast = parser.parse(code, "tsx")
expect(ast.parseError).toBe(false)
expect(ast.interfaces).toHaveLength(1)
expect(ast.functions).toHaveLength(1)
})
})
describe("complex file", () => {
it("should parse complex TypeScript file", () => {
const code = `
import * as fs from "node:fs"
import { join } from "node:path"
import type { Config } from "./types"
export interface Options {
root: string
verbose?: boolean
}
export type Result = { success: boolean }
export class Scanner {
private options: Options
constructor(options: Options) {
this.options = options
}
async scan(): Promise<string[]> {
return []
}
}
export function createScanner(options: Options): Scanner {
return new Scanner(options)
}
export const VERSION = "1.0.0"
`
const ast = parser.parse(code, "ts")
expect(ast.parseError).toBe(false)
expect(ast.imports.length).toBeGreaterThanOrEqual(2)
expect(ast.interfaces).toHaveLength(1)
expect(ast.typeAliases).toHaveLength(1)
expect(ast.classes).toHaveLength(1)
expect(ast.functions.length).toBeGreaterThanOrEqual(1)
expect(ast.exports.length).toBeGreaterThanOrEqual(4)
})
})
})

View File

@@ -0,0 +1,238 @@
import * as fs from "node:fs/promises"
import * as path from "node:path"
import { describe, it, expect, beforeAll, afterAll } from "vitest"
import {
FileScanner,
type ScanProgress,
} from "../../../../src/infrastructure/indexer/FileScanner.js"
import type { ScanResult } from "../../../../src/domain/services/IIndexer.js"
const FIXTURES_DIR = path.join(__dirname, "../../../fixtures/sample-project")
describe("FileScanner", () => {
describe("constructor", () => {
it("should create instance with default options", () => {
const scanner = new FileScanner()
expect(scanner).toBeInstanceOf(FileScanner)
})
it("should accept custom extensions", () => {
const scanner = new FileScanner({ extensions: [".ts", ".js"] })
expect(scanner.isSupportedExtension("file.ts")).toBe(true)
expect(scanner.isSupportedExtension("file.js")).toBe(true)
expect(scanner.isSupportedExtension("file.tsx")).toBe(false)
})
it("should accept additional ignore patterns", () => {
const scanner = new FileScanner({ additionalIgnore: ["*.test.ts"] })
expect(scanner).toBeInstanceOf(FileScanner)
})
it("should accept progress callback", () => {
const onProgress = (progress: ScanProgress): void => {
// callback
}
const scanner = new FileScanner({ onProgress })
expect(scanner).toBeInstanceOf(FileScanner)
})
})
describe("isSupportedExtension", () => {
it("should return true for supported extensions", () => {
const scanner = new FileScanner()
expect(scanner.isSupportedExtension("file.ts")).toBe(true)
expect(scanner.isSupportedExtension("file.tsx")).toBe(true)
expect(scanner.isSupportedExtension("file.js")).toBe(true)
expect(scanner.isSupportedExtension("file.jsx")).toBe(true)
expect(scanner.isSupportedExtension("file.json")).toBe(true)
expect(scanner.isSupportedExtension("file.yaml")).toBe(true)
expect(scanner.isSupportedExtension("file.yml")).toBe(true)
})
it("should return false for unsupported extensions", () => {
const scanner = new FileScanner()
expect(scanner.isSupportedExtension("file.md")).toBe(false)
expect(scanner.isSupportedExtension("file.txt")).toBe(false)
expect(scanner.isSupportedExtension("file.png")).toBe(false)
})
it("should be case-insensitive", () => {
const scanner = new FileScanner()
expect(scanner.isSupportedExtension("file.TS")).toBe(true)
expect(scanner.isSupportedExtension("file.TSX")).toBe(true)
})
})
describe("scan", () => {
it("should scan directory and yield file results", async () => {
const scanner = new FileScanner()
const results: ScanResult[] = []
for await (const result of scanner.scan(FIXTURES_DIR)) {
results.push(result)
}
expect(results.length).toBeGreaterThan(0)
expect(results.every((r) => r.type === "file")).toBe(true)
})
it("should return relative paths", async () => {
const scanner = new FileScanner()
const results = await scanner.scanAll(FIXTURES_DIR)
for (const result of results) {
expect(path.isAbsolute(result.path)).toBe(false)
}
})
it("should include file stats", async () => {
const scanner = new FileScanner()
const results = await scanner.scanAll(FIXTURES_DIR)
for (const result of results) {
expect(typeof result.size).toBe("number")
expect(result.size).toBeGreaterThanOrEqual(0)
expect(typeof result.lastModified).toBe("number")
expect(result.lastModified).toBeGreaterThan(0)
}
})
it("should ignore node_modules by default", async () => {
const scanner = new FileScanner()
const results = await scanner.scanAll(FIXTURES_DIR)
const nodeModulesFiles = results.filter((r) => r.path.includes("node_modules"))
expect(nodeModulesFiles).toHaveLength(0)
})
it("should respect .gitignore", async () => {
const scanner = new FileScanner()
const results = await scanner.scanAll(FIXTURES_DIR)
const ignoredFile = results.find((r) => r.path.includes("ignored-file"))
expect(ignoredFile).toBeUndefined()
})
it("should only include supported extensions", async () => {
const scanner = new FileScanner({ extensions: [".ts"] })
const results = await scanner.scanAll(FIXTURES_DIR)
for (const result of results) {
expect(result.path.endsWith(".ts")).toBe(true)
}
})
it("should call progress callback", async () => {
const progressCalls: ScanProgress[] = []
const scanner = new FileScanner({
onProgress: (progress) => {
progressCalls.push({ ...progress })
},
})
await scanner.scanAll(FIXTURES_DIR)
expect(progressCalls.length).toBeGreaterThan(0)
for (const progress of progressCalls) {
expect(progress.current).toBeGreaterThan(0)
expect(progress.total).toBeGreaterThan(0)
expect(typeof progress.currentFile).toBe("string")
}
})
})
describe("scanAll", () => {
it("should return array of all results", async () => {
const scanner = new FileScanner()
const results = await scanner.scanAll(FIXTURES_DIR)
expect(Array.isArray(results)).toBe(true)
expect(results.length).toBeGreaterThan(0)
})
})
describe("isTextFile", () => {
let textFilePath: string
let binaryFilePath: string
beforeAll(async () => {
textFilePath = path.join(FIXTURES_DIR, "src", "index.ts")
binaryFilePath = path.join(FIXTURES_DIR, "binary-test.bin")
await fs.writeFile(binaryFilePath, Buffer.from([0x00, 0x01, 0x02]))
})
afterAll(async () => {
try {
await fs.unlink(binaryFilePath)
} catch {
// ignore
}
})
it("should return true for text files", async () => {
const isText = await FileScanner.isTextFile(textFilePath)
expect(isText).toBe(true)
})
it("should return false for binary files", async () => {
const isText = await FileScanner.isTextFile(binaryFilePath)
expect(isText).toBe(false)
})
it("should return false for non-existent files", async () => {
const isText = await FileScanner.isTextFile("/non/existent/file.ts")
expect(isText).toBe(false)
})
})
describe("readFileContent", () => {
it("should read text file content", async () => {
const filePath = path.join(FIXTURES_DIR, "src", "index.ts")
const content = await FileScanner.readFileContent(filePath)
expect(content).not.toBeNull()
expect(content).toContain("export function main")
})
it("should return null for binary files", async () => {
const binaryFilePath = path.join(FIXTURES_DIR, "binary-test2.bin")
await fs.writeFile(binaryFilePath, Buffer.from([0x00, 0x01, 0x02]))
try {
const content = await FileScanner.readFileContent(binaryFilePath)
expect(content).toBeNull()
} finally {
await fs.unlink(binaryFilePath)
}
})
it("should return null for non-existent files", async () => {
const content = await FileScanner.readFileContent("/non/existent/file.ts")
expect(content).toBeNull()
})
})
describe("empty directory handling", () => {
let emptyDir: string
beforeAll(async () => {
emptyDir = path.join(FIXTURES_DIR, "empty-dir")
await fs.mkdir(emptyDir, { recursive: true })
})
afterAll(async () => {
try {
await fs.rmdir(emptyDir)
} catch {
// ignore
}
})
it("should handle empty directories gracefully", async () => {
const scanner = new FileScanner()
const results = await scanner.scanAll(emptyDir)
expect(results).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,608 @@
import { describe, it, expect, beforeAll } from "vitest"
import { IndexBuilder } from "../../../../src/infrastructure/indexer/IndexBuilder.js"
import { ASTParser } from "../../../../src/infrastructure/indexer/ASTParser.js"
import type { FileAST } from "../../../../src/domain/value-objects/FileAST.js"
import { createEmptyFileAST } from "../../../../src/domain/value-objects/FileAST.js"
describe("IndexBuilder", () => {
let builder: IndexBuilder
let parser: ASTParser
const projectRoot = "/project"
beforeAll(() => {
builder = new IndexBuilder(projectRoot)
parser = new ASTParser()
})
describe("buildSymbolIndex", () => {
it("should index function declarations", () => {
const code = `
export function greet(name: string): string {
return \`Hello, \${name}!\`
}
function privateHelper(): void {}
`
const ast = parser.parse(code, "ts")
const asts = new Map<string, FileAST>([["/project/src/utils.ts", ast]])
const index = builder.buildSymbolIndex(asts)
expect(index.has("greet")).toBe(true)
expect(index.has("privateHelper")).toBe(true)
expect(index.get("greet")).toEqual([
{ path: "/project/src/utils.ts", line: 2, type: "function" },
])
})
it("should index class declarations and methods", () => {
const code = `
export class UserService {
async findById(id: string): Promise<User> {
return this.db.find(id)
}
private validate(data: unknown): void {}
}
`
const ast = parser.parse(code, "ts")
const asts = new Map<string, FileAST>([["/project/src/UserService.ts", ast]])
const index = builder.buildSymbolIndex(asts)
expect(index.has("UserService")).toBe(true)
expect(index.get("UserService")).toEqual([
{ path: "/project/src/UserService.ts", line: 2, type: "class" },
])
expect(index.has("UserService.findById")).toBe(true)
expect(index.has("UserService.validate")).toBe(true)
})
it("should index interface declarations", () => {
const code = `
export interface User {
id: string
name: string
}
interface InternalConfig {
debug: boolean
}
`
const ast = parser.parse(code, "ts")
const asts = new Map<string, FileAST>([["/project/src/types.ts", ast]])
const index = builder.buildSymbolIndex(asts)
expect(index.has("User")).toBe(true)
expect(index.has("InternalConfig")).toBe(true)
expect(index.get("User")).toEqual([
{ path: "/project/src/types.ts", line: 2, type: "interface" },
])
})
it("should index type alias declarations", () => {
const code = `
export type UserId = string
type Handler = (event: Event) => void
`
const ast = parser.parse(code, "ts")
const asts = new Map<string, FileAST>([["/project/src/types.ts", ast]])
const index = builder.buildSymbolIndex(asts)
expect(index.has("UserId")).toBe(true)
expect(index.has("Handler")).toBe(true)
expect(index.get("UserId")).toEqual([
{ path: "/project/src/types.ts", line: 2, type: "type" },
])
})
it("should index exported variables", () => {
const code = `
export const API_URL = "https://api.example.com"
export const DEFAULT_TIMEOUT = 5000
`
const ast = parser.parse(code, "ts")
const asts = new Map<string, FileAST>([["/project/src/config.ts", ast]])
const index = builder.buildSymbolIndex(asts)
expect(index.has("API_URL")).toBe(true)
expect(index.has("DEFAULT_TIMEOUT")).toBe(true)
})
it("should handle multiple files", () => {
const userCode = `export class User { name: string }`
const orderCode = `export class Order { id: string }`
const asts = new Map<string, FileAST>([
["/project/src/User.ts", parser.parse(userCode, "ts")],
["/project/src/Order.ts", parser.parse(orderCode, "ts")],
])
const index = builder.buildSymbolIndex(asts)
expect(index.has("User")).toBe(true)
expect(index.has("Order")).toBe(true)
expect(index.get("User")?.[0].path).toBe("/project/src/User.ts")
expect(index.get("Order")?.[0].path).toBe("/project/src/Order.ts")
})
it("should handle duplicate symbol names across files", () => {
const file1 = `export function helper(): void {}`
const file2 = `export function helper(): void {}`
const asts = new Map<string, FileAST>([
["/project/src/a/utils.ts", parser.parse(file1, "ts")],
["/project/src/b/utils.ts", parser.parse(file2, "ts")],
])
const index = builder.buildSymbolIndex(asts)
expect(index.has("helper")).toBe(true)
expect(index.get("helper")).toHaveLength(2)
})
it("should return empty index for empty ASTs", () => {
const asts = new Map<string, FileAST>()
const index = builder.buildSymbolIndex(asts)
expect(index.size).toBe(0)
})
it("should not index empty names", () => {
const ast = createEmptyFileAST()
ast.functions.push({
name: "",
lineStart: 1,
lineEnd: 3,
params: [],
isAsync: false,
isExported: false,
})
const asts = new Map<string, FileAST>([["/project/src/test.ts", ast]])
const index = builder.buildSymbolIndex(asts)
expect(index.has("")).toBe(false)
})
})
describe("buildDepsGraph", () => {
it("should build import relationships", () => {
const indexCode = `
import { helper } from "./utils"
export function main() { return helper() }
`
const utilsCode = `export function helper() { return 42 }`
const asts = new Map<string, FileAST>([
["/project/src/index.ts", parser.parse(indexCode, "ts")],
["/project/src/utils.ts", parser.parse(utilsCode, "ts")],
])
const graph = builder.buildDepsGraph(asts)
expect(graph.imports.get("/project/src/index.ts")).toContain("/project/src/utils.ts")
expect(graph.imports.get("/project/src/utils.ts")).toEqual([])
})
it("should build reverse import relationships", () => {
const indexCode = `import { helper } from "./utils"`
const utilsCode = `export function helper() {}`
const asts = new Map<string, FileAST>([
["/project/src/index.ts", parser.parse(indexCode, "ts")],
["/project/src/utils.ts", parser.parse(utilsCode, "ts")],
])
const graph = builder.buildDepsGraph(asts)
expect(graph.importedBy.get("/project/src/utils.ts")).toContain("/project/src/index.ts")
expect(graph.importedBy.get("/project/src/index.ts")).toEqual([])
})
it("should handle multiple imports from same file", () => {
const code = `
import { a } from "./utils"
import { b } from "./utils"
`
const utilsCode = `export const a = 1; export const b = 2;`
const asts = new Map<string, FileAST>([
["/project/src/index.ts", parser.parse(code, "ts")],
["/project/src/utils.ts", parser.parse(utilsCode, "ts")],
])
const graph = builder.buildDepsGraph(asts)
const imports = graph.imports.get("/project/src/index.ts") ?? []
expect(imports.filter((i) => i === "/project/src/utils.ts")).toHaveLength(1)
})
it("should ignore external imports", () => {
const code = `
import React from "react"
import { helper } from "./utils"
`
const utilsCode = `export function helper() {}`
const asts = new Map<string, FileAST>([
["/project/src/index.ts", parser.parse(code, "ts")],
["/project/src/utils.ts", parser.parse(utilsCode, "ts")],
])
const graph = builder.buildDepsGraph(asts)
const imports = graph.imports.get("/project/src/index.ts") ?? []
expect(imports).not.toContain("react")
expect(imports).toContain("/project/src/utils.ts")
})
it("should ignore builtin imports", () => {
const code = `
import * as fs from "node:fs"
import { helper } from "./utils"
`
const utilsCode = `export function helper() {}`
const asts = new Map<string, FileAST>([
["/project/src/index.ts", parser.parse(code, "ts")],
["/project/src/utils.ts", parser.parse(utilsCode, "ts")],
])
const graph = builder.buildDepsGraph(asts)
const imports = graph.imports.get("/project/src/index.ts") ?? []
expect(imports).not.toContain("node:fs")
})
it("should handle index.ts imports", () => {
const code = `import { util } from "./utils"`
const indexCode = `export function util() {}`
const asts = new Map<string, FileAST>([
["/project/src/main.ts", parser.parse(code, "ts")],
["/project/src/utils/index.ts", parser.parse(indexCode, "ts")],
])
const graph = builder.buildDepsGraph(asts)
expect(graph.imports.get("/project/src/main.ts")).toContain(
"/project/src/utils/index.ts",
)
})
it("should handle .js extension imports", () => {
const code = `import { helper } from "./utils.js"`
const utilsCode = `export function helper() {}`
const asts = new Map<string, FileAST>([
["/project/src/index.ts", parser.parse(code, "ts")],
["/project/src/utils.ts", parser.parse(utilsCode, "ts")],
])
const graph = builder.buildDepsGraph(asts)
expect(graph.imports.get("/project/src/index.ts")).toContain("/project/src/utils.ts")
})
it("should sort dependencies", () => {
const code = `
import { c } from "./c"
import { a } from "./a"
import { b } from "./b"
`
const asts = new Map<string, FileAST>([
["/project/src/index.ts", parser.parse(code, "ts")],
["/project/src/a.ts", parser.parse("export const a = 1", "ts")],
["/project/src/b.ts", parser.parse("export const b = 2", "ts")],
["/project/src/c.ts", parser.parse("export const c = 3", "ts")],
])
const graph = builder.buildDepsGraph(asts)
expect(graph.imports.get("/project/src/index.ts")).toEqual([
"/project/src/a.ts",
"/project/src/b.ts",
"/project/src/c.ts",
])
})
it("should return empty graph for empty ASTs", () => {
const asts = new Map<string, FileAST>()
const graph = builder.buildDepsGraph(asts)
expect(graph.imports.size).toBe(0)
expect(graph.importedBy.size).toBe(0)
})
})
describe("findSymbol", () => {
it("should find existing symbol", () => {
const code = `export function greet(): void {}`
const asts = new Map<string, FileAST>([
["/project/src/utils.ts", parser.parse(code, "ts")],
])
const index = builder.buildSymbolIndex(asts)
const locations = builder.findSymbol(index, "greet")
expect(locations).toHaveLength(1)
expect(locations[0].path).toBe("/project/src/utils.ts")
})
it("should return empty array for non-existent symbol", () => {
const asts = new Map<string, FileAST>()
const index = builder.buildSymbolIndex(asts)
const locations = builder.findSymbol(index, "nonexistent")
expect(locations).toEqual([])
})
})
describe("searchSymbols", () => {
it("should find symbols matching pattern", () => {
const code = `
export function getUserById(): void {}
export function getUserByEmail(): void {}
export function createOrder(): void {}
`
const asts = new Map<string, FileAST>([
["/project/src/api.ts", parser.parse(code, "ts")],
])
const index = builder.buildSymbolIndex(asts)
const results = builder.searchSymbols(index, "getUser")
expect(results.size).toBe(2)
expect(results.has("getUserById")).toBe(true)
expect(results.has("getUserByEmail")).toBe(true)
})
it("should be case insensitive", () => {
const code = `export function MyFunction(): void {}`
const asts = new Map<string, FileAST>([
["/project/src/test.ts", parser.parse(code, "ts")],
])
const index = builder.buildSymbolIndex(asts)
const results = builder.searchSymbols(index, "myfunction")
expect(results.has("MyFunction")).toBe(true)
})
it("should return empty map for no matches", () => {
const code = `export function test(): void {}`
const asts = new Map<string, FileAST>([
["/project/src/test.ts", parser.parse(code, "ts")],
])
const index = builder.buildSymbolIndex(asts)
const results = builder.searchSymbols(index, "xyz123")
expect(results.size).toBe(0)
})
})
describe("getDependencies", () => {
it("should return file dependencies", () => {
const indexCode = `import { a } from "./a"`
const aCode = `export const a = 1`
const asts = new Map<string, FileAST>([
["/project/src/index.ts", parser.parse(indexCode, "ts")],
["/project/src/a.ts", parser.parse(aCode, "ts")],
])
const graph = builder.buildDepsGraph(asts)
const deps = builder.getDependencies(graph, "/project/src/index.ts")
expect(deps).toContain("/project/src/a.ts")
})
it("should return empty array for file not in graph", () => {
const asts = new Map<string, FileAST>()
const graph = builder.buildDepsGraph(asts)
const deps = builder.getDependencies(graph, "/nonexistent.ts")
expect(deps).toEqual([])
})
})
describe("getDependents", () => {
it("should return file dependents", () => {
const indexCode = `import { a } from "./a"`
const aCode = `export const a = 1`
const asts = new Map<string, FileAST>([
["/project/src/index.ts", parser.parse(indexCode, "ts")],
["/project/src/a.ts", parser.parse(aCode, "ts")],
])
const graph = builder.buildDepsGraph(asts)
const dependents = builder.getDependents(graph, "/project/src/a.ts")
expect(dependents).toContain("/project/src/index.ts")
})
it("should return empty array for file not in graph", () => {
const asts = new Map<string, FileAST>()
const graph = builder.buildDepsGraph(asts)
const dependents = builder.getDependents(graph, "/nonexistent.ts")
expect(dependents).toEqual([])
})
})
describe("findCircularDependencies", () => {
it("should detect simple circular dependency", () => {
const aCode = `import { b } from "./b"; export const a = 1;`
const bCode = `import { a } from "./a"; export const b = 2;`
const asts = new Map<string, FileAST>([
["/project/src/a.ts", parser.parse(aCode, "ts")],
["/project/src/b.ts", parser.parse(bCode, "ts")],
])
const graph = builder.buildDepsGraph(asts)
const cycles = builder.findCircularDependencies(graph)
expect(cycles.length).toBe(1)
expect(cycles[0]).toContain("/project/src/a.ts")
expect(cycles[0]).toContain("/project/src/b.ts")
})
it("should detect three-way circular dependency", () => {
const aCode = `import { b } from "./b"; export const a = 1;`
const bCode = `import { c } from "./c"; export const b = 2;`
const cCode = `import { a } from "./a"; export const c = 3;`
const asts = new Map<string, FileAST>([
["/project/src/a.ts", parser.parse(aCode, "ts")],
["/project/src/b.ts", parser.parse(bCode, "ts")],
["/project/src/c.ts", parser.parse(cCode, "ts")],
])
const graph = builder.buildDepsGraph(asts)
const cycles = builder.findCircularDependencies(graph)
expect(cycles.length).toBe(1)
expect(cycles[0]).toHaveLength(4)
})
it("should return empty array when no cycles", () => {
const aCode = `export const a = 1`
const bCode = `import { a } from "./a"; export const b = a + 1;`
const cCode = `import { b } from "./b"; export const c = b + 1;`
const asts = new Map<string, FileAST>([
["/project/src/a.ts", parser.parse(aCode, "ts")],
["/project/src/b.ts", parser.parse(bCode, "ts")],
["/project/src/c.ts", parser.parse(cCode, "ts")],
])
const graph = builder.buildDepsGraph(asts)
const cycles = builder.findCircularDependencies(graph)
expect(cycles).toEqual([])
})
it("should handle self-reference", () => {
const aCode = `import { helper } from "./a"; export const a = 1; export function helper() {}`
const asts = new Map<string, FileAST>([
["/project/src/a.ts", parser.parse(aCode, "ts")],
])
const graph = builder.buildDepsGraph(asts)
const cycles = builder.findCircularDependencies(graph)
expect(cycles.length).toBe(1)
})
})
describe("getStats", () => {
it("should return comprehensive statistics", () => {
const code1 = `
export function func1(): void {}
export class Class1 {}
export interface Interface1 {}
export type Type1 = string
export const VAR1 = 1
`
const code2 = `
import { func1 } from "./file1"
export function func2(): void {}
`
const asts = new Map<string, FileAST>([
["/project/src/file1.ts", parser.parse(code1, "ts")],
["/project/src/file2.ts", parser.parse(code2, "ts")],
])
const symbolIndex = builder.buildSymbolIndex(asts)
const depsGraph = builder.buildDepsGraph(asts)
const stats = builder.getStats(symbolIndex, depsGraph)
expect(stats.totalSymbols).toBeGreaterThan(0)
expect(stats.symbolsByType.function).toBeGreaterThan(0)
expect(stats.symbolsByType.class).toBe(1)
expect(stats.symbolsByType.interface).toBe(1)
expect(stats.symbolsByType.type).toBe(1)
expect(stats.totalFiles).toBe(2)
expect(stats.totalDependencies).toBe(1)
})
it("should identify hubs", () => {
const hubCode = `export const shared = 1`
const consumerCodes = Array.from({ length: 6 }, () => `import { shared } from "./hub"`)
const asts = new Map<string, FileAST>([
["/project/src/hub.ts", parser.parse(hubCode, "ts")],
])
consumerCodes.forEach((code, i) => {
asts.set(`/project/src/consumer${i}.ts`, parser.parse(code, "ts"))
})
const symbolIndex = builder.buildSymbolIndex(asts)
const depsGraph = builder.buildDepsGraph(asts)
const stats = builder.getStats(symbolIndex, depsGraph)
expect(stats.hubs).toContain("/project/src/hub.ts")
})
it("should identify orphans", () => {
const orphanCode = `const internal = 1`
const asts = new Map<string, FileAST>([
["/project/src/orphan.ts", parser.parse(orphanCode, "ts")],
])
const symbolIndex = builder.buildSymbolIndex(asts)
const depsGraph = builder.buildDepsGraph(asts)
const stats = builder.getStats(symbolIndex, depsGraph)
expect(stats.orphans).toContain("/project/src/orphan.ts")
})
})
describe("integration with ASTParser", () => {
it("should work with complex TypeScript code", () => {
const code = `
import { BaseService } from "./base"
import type { User, UserDTO } from "./types"
export class UserService extends BaseService {
private readonly cache = new Map<string, User>()
async findById(id: string): Promise<User | null> {
if (this.cache.has(id)) {
return this.cache.get(id)!
}
return this.repository.find(id)
}
toDTO(user: User): UserDTO {
return { id: user.id, name: user.name }
}
}
export type ServiceResult<T> = { success: true; data: T } | { success: false; error: string }
`
const baseCode = `export class BaseService { protected repository: any }`
const typesCode = `export interface User { id: string; name: string }; export interface UserDTO { id: string; name: string }`
const asts = new Map<string, FileAST>([
["/project/src/UserService.ts", parser.parse(code, "ts")],
["/project/src/base.ts", parser.parse(baseCode, "ts")],
["/project/src/types.ts", parser.parse(typesCode, "ts")],
])
const symbolIndex = builder.buildSymbolIndex(asts)
const depsGraph = builder.buildDepsGraph(asts)
expect(symbolIndex.has("UserService")).toBe(true)
expect(symbolIndex.has("UserService.findById")).toBe(true)
expect(symbolIndex.has("UserService.toDTO")).toBe(true)
expect(symbolIndex.has("ServiceResult")).toBe(true)
expect(symbolIndex.has("BaseService")).toBe(true)
expect(symbolIndex.has("User")).toBe(true)
expect(depsGraph.imports.get("/project/src/UserService.ts")).toContain(
"/project/src/base.ts",
)
expect(depsGraph.imports.get("/project/src/UserService.ts")).toContain(
"/project/src/types.ts",
)
})
})
})

View File

@@ -0,0 +1,702 @@
import { describe, it, expect, beforeAll } from "vitest"
import { MetaAnalyzer } from "../../../../src/infrastructure/indexer/MetaAnalyzer.js"
import { ASTParser } from "../../../../src/infrastructure/indexer/ASTParser.js"
import type { FileAST } from "../../../../src/domain/value-objects/FileAST.js"
import { createEmptyFileAST } from "../../../../src/domain/value-objects/FileAST.js"
describe("MetaAnalyzer", () => {
let analyzer: MetaAnalyzer
let parser: ASTParser
const projectRoot = "/project"
beforeAll(() => {
analyzer = new MetaAnalyzer(projectRoot)
parser = new ASTParser()
})
describe("countLinesOfCode", () => {
it("should count non-empty lines", () => {
const content = `const a = 1
const b = 2
const c = 3`
const loc = analyzer.countLinesOfCode(content)
expect(loc).toBe(3)
})
it("should exclude empty lines", () => {
const content = `const a = 1
const b = 2
const c = 3`
const loc = analyzer.countLinesOfCode(content)
expect(loc).toBe(3)
})
it("should exclude single-line comments", () => {
const content = `// This is a comment
const a = 1
// Another comment
const b = 2`
const loc = analyzer.countLinesOfCode(content)
expect(loc).toBe(2)
})
it("should exclude block comments", () => {
const content = `/*
* Multi-line comment
*/
const a = 1
/* inline block */ const b = 2`
const loc = analyzer.countLinesOfCode(content)
expect(loc).toBe(2)
})
it("should handle multi-line block comments", () => {
const content = `const a = 1
/*
comment line 1
comment line 2
*/
const b = 2`
const loc = analyzer.countLinesOfCode(content)
expect(loc).toBe(2)
})
it("should return 0 for empty content", () => {
const loc = analyzer.countLinesOfCode("")
expect(loc).toBe(0)
})
it("should return 0 for only comments", () => {
const content = `// comment 1
// comment 2
/* block comment */`
const loc = analyzer.countLinesOfCode(content)
expect(loc).toBe(0)
})
})
describe("calculateMaxNesting", () => {
it("should return 0 for empty AST", () => {
const ast = createEmptyFileAST()
const nesting = analyzer.calculateMaxNesting(ast)
expect(nesting).toBe(0)
})
it("should estimate nesting for short functions", () => {
const ast = createEmptyFileAST()
ast.functions.push({
name: "test",
lineStart: 1,
lineEnd: 3,
params: [],
isAsync: false,
isExported: false,
})
const nesting = analyzer.calculateMaxNesting(ast)
expect(nesting).toBe(1)
})
it("should estimate higher nesting for longer functions", () => {
const ast = createEmptyFileAST()
ast.functions.push({
name: "test",
lineStart: 1,
lineEnd: 40,
params: [],
isAsync: false,
isExported: false,
})
const nesting = analyzer.calculateMaxNesting(ast)
expect(nesting).toBe(4)
})
it("should return max nesting across multiple functions", () => {
const ast = createEmptyFileAST()
ast.functions.push(
{
name: "short",
lineStart: 1,
lineEnd: 3,
params: [],
isAsync: false,
isExported: false,
},
{
name: "long",
lineStart: 5,
lineEnd: 60,
params: [],
isAsync: false,
isExported: false,
},
)
const nesting = analyzer.calculateMaxNesting(ast)
expect(nesting).toBe(5)
})
it("should account for class methods", () => {
const ast = createEmptyFileAST()
ast.classes.push({
name: "MyClass",
lineStart: 1,
lineEnd: 50,
methods: [
{
name: "method1",
lineStart: 2,
lineEnd: 25,
params: [],
isAsync: false,
visibility: "public",
isStatic: false,
},
],
properties: [],
implements: [],
isExported: false,
isAbstract: false,
})
const nesting = analyzer.calculateMaxNesting(ast)
expect(nesting).toBeGreaterThan(1)
})
})
describe("calculateCyclomaticComplexity", () => {
it("should return 1 for empty AST", () => {
const ast = createEmptyFileAST()
const complexity = analyzer.calculateCyclomaticComplexity(ast)
expect(complexity).toBe(1)
})
it("should increase complexity for functions", () => {
const ast = createEmptyFileAST()
ast.functions.push({
name: "test",
lineStart: 1,
lineEnd: 20,
params: [],
isAsync: false,
isExported: false,
})
const complexity = analyzer.calculateCyclomaticComplexity(ast)
expect(complexity).toBeGreaterThan(1)
})
it("should increase complexity for class methods", () => {
const ast = createEmptyFileAST()
ast.classes.push({
name: "MyClass",
lineStart: 1,
lineEnd: 50,
methods: [
{
name: "method1",
lineStart: 2,
lineEnd: 20,
params: [],
isAsync: false,
visibility: "public",
isStatic: false,
},
{
name: "method2",
lineStart: 22,
lineEnd: 45,
params: [],
isAsync: false,
visibility: "public",
isStatic: false,
},
],
properties: [],
implements: [],
isExported: false,
isAbstract: false,
})
const complexity = analyzer.calculateCyclomaticComplexity(ast)
expect(complexity).toBeGreaterThan(2)
})
})
describe("calculateComplexityScore", () => {
it("should return 0 for minimal values", () => {
const score = analyzer.calculateComplexityScore(0, 0, 0)
expect(score).toBe(0)
})
it("should return 100 for maximum values", () => {
const score = analyzer.calculateComplexityScore(1000, 10, 50)
expect(score).toBe(100)
})
it("should return intermediate values", () => {
const score = analyzer.calculateComplexityScore(100, 3, 10)
expect(score).toBeGreaterThan(0)
expect(score).toBeLessThan(100)
})
})
describe("resolveDependencies", () => {
it("should resolve relative imports", () => {
const ast = createEmptyFileAST()
ast.imports.push({
name: "foo",
from: "./utils",
line: 1,
type: "internal",
isDefault: false,
})
const deps = analyzer.resolveDependencies("/project/src/index.ts", ast)
expect(deps).toHaveLength(1)
expect(deps[0]).toBe("/project/src/utils.ts")
})
it("should resolve parent directory imports", () => {
const ast = createEmptyFileAST()
ast.imports.push({
name: "config",
from: "../config",
line: 1,
type: "internal",
isDefault: false,
})
const deps = analyzer.resolveDependencies("/project/src/utils/helper.ts", ast)
expect(deps).toHaveLength(1)
expect(deps[0]).toBe("/project/src/config.ts")
})
it("should ignore external imports", () => {
const ast = createEmptyFileAST()
ast.imports.push({
name: "React",
from: "react",
line: 1,
type: "external",
isDefault: true,
})
const deps = analyzer.resolveDependencies("/project/src/index.ts", ast)
expect(deps).toHaveLength(0)
})
it("should ignore builtin imports", () => {
const ast = createEmptyFileAST()
ast.imports.push({
name: "fs",
from: "node:fs",
line: 1,
type: "builtin",
isDefault: false,
})
const deps = analyzer.resolveDependencies("/project/src/index.ts", ast)
expect(deps).toHaveLength(0)
})
it("should handle .js extension to .ts conversion", () => {
const ast = createEmptyFileAST()
ast.imports.push({
name: "util",
from: "./util.js",
line: 1,
type: "internal",
isDefault: false,
})
const deps = analyzer.resolveDependencies("/project/src/index.ts", ast)
expect(deps).toHaveLength(1)
expect(deps[0]).toBe("/project/src/util.ts")
})
it("should deduplicate dependencies", () => {
const ast = createEmptyFileAST()
ast.imports.push(
{
name: "foo",
from: "./utils",
line: 1,
type: "internal",
isDefault: false,
},
{
name: "bar",
from: "./utils",
line: 2,
type: "internal",
isDefault: false,
},
)
const deps = analyzer.resolveDependencies("/project/src/index.ts", ast)
expect(deps).toHaveLength(1)
})
it("should sort dependencies", () => {
const ast = createEmptyFileAST()
ast.imports.push(
{
name: "c",
from: "./c",
line: 1,
type: "internal",
isDefault: false,
},
{
name: "a",
from: "./a",
line: 2,
type: "internal",
isDefault: false,
},
{
name: "b",
from: "./b",
line: 3,
type: "internal",
isDefault: false,
},
)
const deps = analyzer.resolveDependencies("/project/src/index.ts", ast)
expect(deps).toEqual(["/project/src/a.ts", "/project/src/b.ts", "/project/src/c.ts"])
})
})
describe("findDependents", () => {
it("should find files that import the given file", () => {
const allASTs = new Map<string, FileAST>()
const indexAST = createEmptyFileAST()
allASTs.set("/project/src/index.ts", indexAST)
const utilsAST = createEmptyFileAST()
utilsAST.imports.push({
name: "helper",
from: "./helper",
line: 1,
type: "internal",
isDefault: false,
})
allASTs.set("/project/src/utils.ts", utilsAST)
const dependents = analyzer.findDependents("/project/src/helper.ts", allASTs)
expect(dependents).toHaveLength(1)
expect(dependents[0]).toBe("/project/src/utils.ts")
})
it("should return empty array when no dependents", () => {
const allASTs = new Map<string, FileAST>()
allASTs.set("/project/src/index.ts", createEmptyFileAST())
allASTs.set("/project/src/utils.ts", createEmptyFileAST())
const dependents = analyzer.findDependents("/project/src/helper.ts", allASTs)
expect(dependents).toHaveLength(0)
})
it("should not include self as dependent", () => {
const allASTs = new Map<string, FileAST>()
const selfAST = createEmptyFileAST()
selfAST.imports.push({
name: "foo",
from: "./helper",
line: 1,
type: "internal",
isDefault: false,
})
allASTs.set("/project/src/helper.ts", selfAST)
const dependents = analyzer.findDependents("/project/src/helper.ts", allASTs)
expect(dependents).toHaveLength(0)
})
it("should handle index.ts imports", () => {
const allASTs = new Map<string, FileAST>()
const consumerAST = createEmptyFileAST()
consumerAST.imports.push({
name: "util",
from: "./utils",
line: 1,
type: "internal",
isDefault: false,
})
allASTs.set("/project/src/consumer.ts", consumerAST)
const dependents = analyzer.findDependents("/project/src/utils/index.ts", allASTs)
expect(dependents).toHaveLength(1)
})
it("should sort dependents", () => {
const allASTs = new Map<string, FileAST>()
const fileC = createEmptyFileAST()
fileC.imports.push({
name: "x",
from: "./target",
line: 1,
type: "internal",
isDefault: false,
})
allASTs.set("/project/src/c.ts", fileC)
const fileA = createEmptyFileAST()
fileA.imports.push({
name: "x",
from: "./target",
line: 1,
type: "internal",
isDefault: false,
})
allASTs.set("/project/src/a.ts", fileA)
const fileB = createEmptyFileAST()
fileB.imports.push({
name: "x",
from: "./target",
line: 1,
type: "internal",
isDefault: false,
})
allASTs.set("/project/src/b.ts", fileB)
const dependents = analyzer.findDependents("/project/src/target.ts", allASTs)
expect(dependents).toEqual([
"/project/src/a.ts",
"/project/src/b.ts",
"/project/src/c.ts",
])
})
})
describe("classifyFileType", () => {
it("should classify test files by .test. pattern", () => {
expect(analyzer.classifyFileType("/project/src/utils.test.ts")).toBe("test")
})
it("should classify test files by .spec. pattern", () => {
expect(analyzer.classifyFileType("/project/src/utils.spec.ts")).toBe("test")
})
it("should classify test files by /tests/ directory", () => {
expect(analyzer.classifyFileType("/project/tests/utils.ts")).toBe("test")
})
it("should classify test files by /__tests__/ directory", () => {
expect(analyzer.classifyFileType("/project/src/__tests__/utils.ts")).toBe("test")
})
it("should classify .d.ts as types", () => {
expect(analyzer.classifyFileType("/project/src/types.d.ts")).toBe("types")
})
it("should classify /types/ directory as types", () => {
expect(analyzer.classifyFileType("/project/src/types/index.ts")).toBe("types")
})
it("should classify types.ts as types", () => {
expect(analyzer.classifyFileType("/project/src/types.ts")).toBe("types")
})
it("should classify config files", () => {
expect(analyzer.classifyFileType("/project/tsconfig.json")).toBe("config")
expect(analyzer.classifyFileType("/project/eslint.config.js")).toBe("config")
expect(analyzer.classifyFileType("/project/vitest.config.ts")).toBe("config")
expect(analyzer.classifyFileType("/project/jest.config.js")).toBe("config")
})
it("should classify regular source files", () => {
expect(analyzer.classifyFileType("/project/src/index.ts")).toBe("source")
expect(analyzer.classifyFileType("/project/src/utils.tsx")).toBe("source")
expect(analyzer.classifyFileType("/project/src/helper.js")).toBe("source")
})
it("should classify unknown file types", () => {
expect(analyzer.classifyFileType("/project/README.md")).toBe("unknown")
expect(analyzer.classifyFileType("/project/data.json")).toBe("unknown")
})
})
describe("isEntryPointFile", () => {
it("should identify index files as entry points", () => {
expect(analyzer.isEntryPointFile("/project/src/index.ts", 5)).toBe(true)
expect(analyzer.isEntryPointFile("/project/src/index.js", 5)).toBe(true)
})
it("should identify files with no dependents as entry points", () => {
expect(analyzer.isEntryPointFile("/project/src/utils.ts", 0)).toBe(true)
})
it("should identify main.ts as entry point", () => {
expect(analyzer.isEntryPointFile("/project/src/main.ts", 5)).toBe(true)
})
it("should identify app.ts as entry point", () => {
expect(analyzer.isEntryPointFile("/project/src/app.tsx", 5)).toBe(true)
})
it("should identify cli.ts as entry point", () => {
expect(analyzer.isEntryPointFile("/project/src/cli.ts", 5)).toBe(true)
})
it("should identify server.ts as entry point", () => {
expect(analyzer.isEntryPointFile("/project/src/server.ts", 5)).toBe(true)
})
it("should not identify regular files with dependents as entry points", () => {
expect(analyzer.isEntryPointFile("/project/src/utils.ts", 3)).toBe(false)
})
})
describe("analyze", () => {
it("should produce complete FileMeta", () => {
const content = `import { helper } from "./helper"
export function main() {
return helper()
}
`
const ast = parser.parse(content, "ts")
const allASTs = new Map<string, FileAST>()
allASTs.set("/project/src/index.ts", ast)
const meta = analyzer.analyze("/project/src/index.ts", ast, content, allASTs)
expect(meta.complexity).toBeDefined()
expect(meta.complexity.loc).toBeGreaterThan(0)
expect(meta.dependencies).toHaveLength(1)
expect(meta.fileType).toBe("source")
expect(meta.isEntryPoint).toBe(true)
})
it("should identify hub files", () => {
const content = `export const util = () => {}`
const ast = parser.parse(content, "ts")
const allASTs = new Map<string, FileAST>()
for (let i = 0; i < 6; i++) {
const consumerAST = createEmptyFileAST()
consumerAST.imports.push({
name: "util",
from: "./shared",
line: 1,
type: "internal",
isDefault: false,
})
allASTs.set(`/project/src/consumer${i}.ts`, consumerAST)
}
const meta = analyzer.analyze("/project/src/shared.ts", ast, content, allASTs)
expect(meta.isHub).toBe(true)
expect(meta.dependents).toHaveLength(6)
})
it("should not identify as hub with few dependents", () => {
const content = `export const util = () => {}`
const ast = parser.parse(content, "ts")
const allASTs = new Map<string, FileAST>()
for (let i = 0; i < 3; i++) {
const consumerAST = createEmptyFileAST()
consumerAST.imports.push({
name: "util",
from: "./shared",
line: 1,
type: "internal",
isDefault: false,
})
allASTs.set(`/project/src/consumer${i}.ts`, consumerAST)
}
const meta = analyzer.analyze("/project/src/shared.ts", ast, content, allASTs)
expect(meta.isHub).toBe(false)
})
})
describe("analyzeAll", () => {
it("should analyze multiple files", () => {
const files = new Map<string, { ast: FileAST; content: string }>()
const indexContent = `import { util } from "./util"
export function main() { return util() }`
const indexAST = parser.parse(indexContent, "ts")
files.set("/project/src/index.ts", { ast: indexAST, content: indexContent })
const utilContent = `export function util() { return 42 }`
const utilAST = parser.parse(utilContent, "ts")
files.set("/project/src/util.ts", { ast: utilAST, content: utilContent })
const results = analyzer.analyzeAll(files)
expect(results.size).toBe(2)
expect(results.get("/project/src/index.ts")).toBeDefined()
expect(results.get("/project/src/util.ts")).toBeDefined()
const indexMeta = results.get("/project/src/index.ts")!
expect(indexMeta.dependencies).toContain("/project/src/util.ts")
const utilMeta = results.get("/project/src/util.ts")!
expect(utilMeta.dependents).toContain("/project/src/index.ts")
})
it("should handle empty files map", () => {
const files = new Map<string, { ast: FileAST; content: string }>()
const results = analyzer.analyzeAll(files)
expect(results.size).toBe(0)
})
})
describe("calculateComplexity", () => {
it("should return complete complexity metrics", () => {
const content = `function complex() {
if (true) {
for (let i = 0; i < 10; i++) {
if (i % 2 === 0) {
console.log(i)
}
}
}
return 42
}`
const ast = parser.parse(content, "ts")
const metrics = analyzer.calculateComplexity(ast, content)
expect(metrics.loc).toBeGreaterThan(0)
expect(metrics.nesting).toBeGreaterThan(0)
expect(metrics.cyclomaticComplexity).toBeGreaterThan(0)
expect(metrics.score).toBeGreaterThanOrEqual(0)
expect(metrics.score).toBeLessThanOrEqual(100)
})
})
describe("integration with ASTParser", () => {
it("should work with real parsed AST", () => {
const content = `import { readFile } from "node:fs"
import { helper } from "./helper"
import React from "react"
export class MyComponent {
private data: string[] = []
async loadData(): Promise<void> {
const content = await readFile("file.txt", "utf-8")
this.data = content.split("\\n")
}
render() {
return this.data.map(line => <div>{line}</div>)
}
}
export function createComponent(): MyComponent {
return new MyComponent()
}
`
const ast = parser.parse(content, "tsx")
const allASTs = new Map<string, FileAST>()
allASTs.set("/project/src/Component.tsx", ast)
const meta = analyzer.analyze("/project/src/Component.tsx", ast, content, allASTs)
expect(meta.complexity.loc).toBeGreaterThan(10)
expect(meta.dependencies).toContain("/project/src/helper.ts")
expect(meta.fileType).toBe("source")
})
})
})

View File

@@ -0,0 +1,278 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
import { Watchdog, type FileChangeEvent } from "../../../../src/infrastructure/indexer/Watchdog.js"
import * as fs from "node:fs/promises"
import * as path from "node:path"
import * as os from "node:os"
describe("Watchdog", () => {
let watchdog: Watchdog
let tempDir: string
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "watchdog-test-"))
watchdog = new Watchdog({ debounceMs: 50 })
})
afterEach(async () => {
await watchdog.stop()
await fs.rm(tempDir, { recursive: true, force: true })
})
describe("constructor", () => {
it("should create with default options", () => {
const wd = new Watchdog()
expect(wd.isWatching()).toBe(false)
expect(wd.getRoot()).toBe("")
})
it("should accept custom options", () => {
const wd = new Watchdog({
debounceMs: 100,
extensions: [".ts"],
usePolling: true,
})
expect(wd.isWatching()).toBe(false)
})
})
describe("start/stop", () => {
it("should start watching", () => {
watchdog.start(tempDir)
expect(watchdog.isWatching()).toBe(true)
expect(watchdog.getRoot()).toBe(tempDir)
})
it("should stop watching", async () => {
watchdog.start(tempDir)
await watchdog.stop()
expect(watchdog.isWatching()).toBe(false)
})
it("should handle stop when not started", async () => {
await watchdog.stop()
expect(watchdog.isWatching()).toBe(false)
})
it("should restart when start called while running", async () => {
watchdog.start(tempDir)
const newTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "watchdog-test2-"))
watchdog.start(newTempDir)
expect(watchdog.isWatching()).toBe(true)
expect(watchdog.getRoot()).toBe(newTempDir)
await fs.rm(newTempDir, { recursive: true, force: true })
})
})
describe("onFileChange/offFileChange", () => {
it("should register callback", () => {
const callback = vi.fn()
watchdog.onFileChange(callback)
expect(callback).not.toHaveBeenCalled()
})
it("should remove callback", () => {
const callback = vi.fn()
watchdog.onFileChange(callback)
watchdog.offFileChange(callback)
})
it("should handle removing non-existent callback", () => {
const callback = vi.fn()
watchdog.offFileChange(callback)
})
})
describe("getPendingCount", () => {
it("should return 0 when no pending changes", () => {
expect(watchdog.getPendingCount()).toBe(0)
})
})
describe("getWatchedPaths", () => {
it("should return empty array when not watching", () => {
expect(watchdog.getWatchedPaths()).toEqual([])
})
})
describe("flushAll", () => {
it("should not throw when no pending changes", () => {
expect(() => watchdog.flushAll()).not.toThrow()
})
})
describe("file change detection", () => {
it("should detect new file creation", async () => {
const events: FileChangeEvent[] = []
watchdog.onFileChange((event) => events.push(event))
watchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 100))
const testFile = path.join(tempDir, "test.ts")
await fs.writeFile(testFile, "const x = 1")
await new Promise((resolve) => setTimeout(resolve, 200))
expect(events.length).toBeGreaterThanOrEqual(0)
})
it("should detect file modification", async () => {
const testFile = path.join(tempDir, "test.ts")
await fs.writeFile(testFile, "const x = 1")
const events: FileChangeEvent[] = []
watchdog.onFileChange((event) => events.push(event))
watchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 100))
await fs.writeFile(testFile, "const x = 2")
await new Promise((resolve) => setTimeout(resolve, 200))
expect(events.length).toBeGreaterThanOrEqual(0)
})
it("should detect file deletion", async () => {
const testFile = path.join(tempDir, "test.ts")
await fs.writeFile(testFile, "const x = 1")
const events: FileChangeEvent[] = []
watchdog.onFileChange((event) => events.push(event))
watchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 100))
await fs.unlink(testFile)
await new Promise((resolve) => setTimeout(resolve, 200))
expect(events.length).toBeGreaterThanOrEqual(0)
})
it("should ignore non-watched extensions", async () => {
const events: FileChangeEvent[] = []
watchdog.onFileChange((event) => events.push(event))
watchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 100))
const txtFile = path.join(tempDir, "test.txt")
await fs.writeFile(txtFile, "hello")
await new Promise((resolve) => setTimeout(resolve, 200))
const tsEvents = events.filter((e) => e.path.endsWith(".txt"))
expect(tsEvents.length).toBe(0)
})
it("should debounce rapid changes", async () => {
const testFile = path.join(tempDir, "test.ts")
await fs.writeFile(testFile, "const x = 1")
const events: FileChangeEvent[] = []
watchdog.onFileChange((event) => events.push(event))
watchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 100))
await fs.writeFile(testFile, "const x = 2")
await fs.writeFile(testFile, "const x = 3")
await fs.writeFile(testFile, "const x = 4")
await new Promise((resolve) => setTimeout(resolve, 200))
expect(events.length).toBeLessThanOrEqual(3)
})
})
describe("callback error handling", () => {
it("should continue after callback throws", async () => {
const events: FileChangeEvent[] = []
watchdog.onFileChange(() => {
throw new Error("Test error")
})
watchdog.onFileChange((event) => events.push(event))
watchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 100))
const testFile = path.join(tempDir, "test.ts")
await fs.writeFile(testFile, "const x = 1")
await new Promise((resolve) => setTimeout(resolve, 200))
})
})
describe("custom extensions", () => {
it("should watch only specified extensions", async () => {
const customWatchdog = new Watchdog({
debounceMs: 50,
extensions: [".ts"],
})
const events: FileChangeEvent[] = []
customWatchdog.onFileChange((event) => events.push(event))
customWatchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 100))
const tsFile = path.join(tempDir, "test.ts")
const jsFile = path.join(tempDir, "test.js")
await fs.writeFile(tsFile, "const x = 1")
await fs.writeFile(jsFile, "const y = 2")
await new Promise((resolve) => setTimeout(resolve, 200))
const jsEvents = events.filter((e) => e.path.endsWith(".js"))
expect(jsEvents.length).toBe(0)
await customWatchdog.stop()
})
})
describe("multiple callbacks", () => {
it("should notify all registered callbacks", async () => {
const events1: FileChangeEvent[] = []
const events2: FileChangeEvent[] = []
watchdog.onFileChange((event) => events1.push(event))
watchdog.onFileChange((event) => events2.push(event))
watchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 100))
const testFile = path.join(tempDir, "test.ts")
await fs.writeFile(testFile, "const x = 1")
await new Promise((resolve) => setTimeout(resolve, 200))
expect(events1.length).toBe(events2.length)
})
})
describe("event properties", () => {
it("should include correct event type and path", async () => {
const events: FileChangeEvent[] = []
watchdog.onFileChange((event) => events.push(event))
watchdog.start(tempDir)
await new Promise((resolve) => setTimeout(resolve, 100))
const testFile = path.join(tempDir, "test.ts")
await fs.writeFile(testFile, "const x = 1")
await new Promise((resolve) => setTimeout(resolve, 200))
if (events.length > 0) {
const event = events[0]
expect(event.type).toMatch(/^(add|change)$/)
expect(event.path).toContain("test.ts")
expect(typeof event.timestamp).toBe("number")
expect(event.timestamp).toBeLessThanOrEqual(Date.now())
}
})
})
})