From c1219a4f3bd3c9e58c00561a8cfc92b391b37d61 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 09:11:18 +0700 Subject: [PATCH 01/31] feat(tree-sitter): add web-tree-sitter dep and grammar discovery Refs: dora-w6wj --- .gitignore | 3 ++ .pi/skills/toon/SKILL.md | 2 +- bun.lock | 3 ++ package.json | 1 + src/tree-sitter/grammar.ts | 70 ++++++++++++++++++++++++++++++++++++++ src/utils/config.ts | 5 +++ 6 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 src/tree-sitter/grammar.ts diff --git a/.gitignore b/.gitignore index 10741b8..cc5e3c4 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .dora/ stress-test/* + +.beans/* +.beans.yml diff --git a/.pi/skills/toon/SKILL.md b/.pi/skills/toon/SKILL.md index bb3deea..da0de0a 100644 --- a/.pi/skills/toon/SKILL.md +++ b/.pi/skills/toon/SKILL.md @@ -1,6 +1,6 @@ --- name: toon -description: **Token-Oriented Object Notation** is a compact, human-readable encoding of the JSON data model that minimizes tokens and makes structure easy for models to follow. It's intended for *LLM input* as a drop-in, lossless representation of your existing JSON. +description: Token-Oriented Object Notation is a compact, human-readable encoding of the JSON data model that minimizes tokens and makes structure easy for models to follow. It's intended for LLM input as a drop-in, lossless representation of your existing JSON. --- ![TOON logo with step‑by‑step guide](./.github/og.png) diff --git a/bun.lock b/bun.lock index 3e1a19a..7900dd7 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "debug": "^4.4.3", "ignore": "^5.3.0", "ts-pattern": "^5.9.0", + "web-tree-sitter": "^0.26.6", "zod": "^4.3.5", }, "devDependencies": { @@ -402,6 +403,8 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "web-tree-sitter": ["web-tree-sitter@0.26.6", "", {}, "sha512-fSPR7VBW/fZQdUSp/bXTDLT+i/9dwtbnqgEBMzowrM4U3DzeCwDbY3MKo0584uQxID4m/1xpLflrlT/rLIRPew=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], diff --git a/package.json b/package.json index 622466e..9ee88de 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "debug": "^4.4.3", "ignore": "^5.3.0", "ts-pattern": "^5.9.0", + "web-tree-sitter": "^0.26.6", "zod": "^4.3.5" }, "devDependencies": { diff --git a/src/tree-sitter/grammar.ts b/src/tree-sitter/grammar.ts new file mode 100644 index 0000000..74a4acd --- /dev/null +++ b/src/tree-sitter/grammar.ts @@ -0,0 +1,70 @@ +import { existsSync } from "fs"; +import { join } from "path"; +import { CtxError } from "../utils/errors.ts"; +import type { Config } from "../utils/config.ts"; + +async function findGlobalNodeModulesPath(): Promise { + const proc = Bun.spawn(["bun", "pm", "ls", "-g"], { + stdout: "pipe", + stderr: "pipe", + }); + + const output = await new Response(proc.stdout).text(); + await proc.exited; + + const lines = output.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("/") && trimmed.includes("node_modules")) { + const match = trimmed.match(/^(.+\/node_modules)/); + if (match && match[1]) { + return match[1]; + } + } + } + + return null; +} + +export async function findGrammarPath(params: { + lang: string; + config: Config; + projectRoot: string; +}): Promise { + const { lang, config, projectRoot } = params; + + const treeSitterConfig = config.treeSitter; + if (treeSitterConfig?.grammars?.[lang]) { + const explicitPath = treeSitterConfig.grammars[lang]; + if (existsSync(explicitPath)) { + return explicitPath; + } + } + + const wasmFileName = `tree-sitter-${lang}.wasm`; + const localPath = join( + projectRoot, + "node_modules", + `tree-sitter-${lang}`, + wasmFileName, + ); + if (existsSync(localPath)) { + return localPath; + } + + const globalNodeModules = await findGlobalNodeModulesPath(); + if (globalNodeModules) { + const globalPath = join( + globalNodeModules, + `tree-sitter-${lang}`, + wasmFileName, + ); + if (existsSync(globalPath)) { + return globalPath; + } + } + + throw new CtxError( + `tree-sitter-${lang} grammar not found. Install it: bun add tree-sitter-${lang}`, + ); +} diff --git a/src/utils/config.ts b/src/utils/config.ts index e935536..bde7995 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -42,6 +42,10 @@ export const LanguageSchema = z.enum([ export type Language = z.infer; +const TreeSitterSchema = z.object({ + grammars: z.record(z.string(), z.string()).optional(), +}); + const ConfigSchema = z.object({ root: z.string().min(1), scip: z.string().min(1), @@ -55,6 +59,7 @@ const ConfigSchema = z.object({ lastIndexed: z.string().nullable(), indexState: IndexStateSchema.optional(), ignore: z.array(z.string()).optional(), + treeSitter: TreeSitterSchema.optional(), }); // Export types inferred from schemas From f25e60cafec7393fd785311ab36eb7ff03321f84 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 09:12:34 +0700 Subject: [PATCH 02/31] feat(tree-sitter): add normalized types and Zod schemas - src/tree-sitter/types.ts: ParameterInfo, FunctionInfo, MethodInfo, ClassInfo, FileMetrics - src/schemas/treesitter.ts: Zod schemas + FnResult, SmellsResult, ClassResult - src/schemas/index.ts: export treesitter schemas Refs: dora-zp1e --- src/schemas/index.ts | 1 + src/schemas/treesitter.ts | 88 +++++++++++++++++++++++++++++++++++++++ src/tree-sitter/types.ts | 48 +++++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 src/schemas/treesitter.ts create mode 100644 src/tree-sitter/types.ts diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 29dd85e..7078ba5 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -6,3 +6,4 @@ export * from "./analysis.ts"; export * from "./metrics.ts"; export * from "./docs.ts"; export * from "./results.ts"; +export * from "./treesitter.ts"; diff --git a/src/schemas/treesitter.ts b/src/schemas/treesitter.ts new file mode 100644 index 0000000..bb8cfba --- /dev/null +++ b/src/schemas/treesitter.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; + +export const ParameterInfoSchema = z.object({ + name: z.string(), + type: z.string().nullable(), +}); + +export const FunctionInfoSchema = z.object({ + name: z.string(), + lines: z.tuple([z.number(), z.number()]), + loc: z.number(), + cyclomatic_complexity: z.number(), + parameters: z.array(ParameterInfoSchema), + return_type: z.string().nullable(), + is_async: z.boolean(), + is_exported: z.boolean(), + is_method: z.boolean(), + jsdoc: z.string().nullable(), + reference_count: z.number().optional(), +}); + +export const MethodInfoSchema = z.object({ + name: z.string(), + line: z.number(), + is_async: z.boolean(), + cyclomatic_complexity: z.number(), +}); + +export const ClassInfoSchema = z.object({ + name: z.string(), + lines: z.tuple([z.number(), z.number()]), + extends_name: z.string().nullable(), + implements: z.array(z.string()), + decorators: z.array(z.string()), + is_abstract: z.boolean(), + methods: z.array(MethodInfoSchema), + property_count: z.number(), + reference_count: z.number().optional(), +}); + +export const FileMetricsSchema = z.object({ + loc: z.number(), + sloc: z.number(), + comment_lines: z.number(), + blank_lines: z.number(), + function_count: z.number(), + class_count: z.number(), + avg_complexity: z.number(), + max_complexity: z.number(), +}); + +export const FnResultSchema = z.object({ + path: z.string(), + language: z.string(), + functions: z.array(FunctionInfoSchema), + file_stats: FileMetricsSchema, +}); + +export const SmellItemSchema = z.object({ + kind: z.string(), + function: z.string(), + line: z.number(), + value: z.number(), + threshold: z.number(), + message: z.string(), +}); + +export const SmellsResultSchema = z.object({ + path: z.string(), + clean: z.boolean(), + smells: z.array(SmellItemSchema), +}); + +export const ClassResultSchema = z.object({ + path: z.string(), + language: z.string(), + classes: z.array(ClassInfoSchema), +}); + +export type ParameterInfo = z.infer; +export type FunctionInfo = z.infer; +export type MethodInfo = z.infer; +export type ClassInfo = z.infer; +export type FileMetrics = z.infer; +export type FnResult = z.infer; +export type SmellItem = z.infer; +export type SmellsResult = z.infer; +export type ClassResult = z.infer; diff --git a/src/tree-sitter/types.ts b/src/tree-sitter/types.ts new file mode 100644 index 0000000..31741a5 --- /dev/null +++ b/src/tree-sitter/types.ts @@ -0,0 +1,48 @@ +export type ParameterInfo = { + name: string; + type: string | null; +}; + +export type FunctionInfo = { + name: string; + lines: [number, number]; + loc: number; + cyclomatic_complexity: number; + parameters: ParameterInfo[]; + return_type: string | null; + is_async: boolean; + is_exported: boolean; + is_method: boolean; + jsdoc: string | null; + reference_count?: number; +}; + +export type MethodInfo = { + name: string; + line: number; + is_async: boolean; + cyclomatic_complexity: number; +}; + +export type ClassInfo = { + name: string; + lines: [number, number]; + extends_name: string | null; + implements: string[]; + decorators: string[]; + is_abstract: boolean; + methods: MethodInfo[]; + property_count: number; + reference_count?: number; +}; + +export type FileMetrics = { + loc: number; + sloc: number; + comment_lines: number; + blank_lines: number; + function_count: number; + class_count: number; + avg_complexity: number; + max_complexity: number; +}; From eed8efe13235f3106975d27bf28b5a762ac4c09b Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 09:15:39 +0700 Subject: [PATCH 03/31] feat(tree-sitter): add TS/JS S-expression queries and language registry - typescript.ts: function + class queries with full capture extraction - javascript.ts: same for JS/JSX (no type annotations) - registry.ts: maps extensions to grammar WASM names and query modules Refs: dora-xe7i --- src/tree-sitter/languages/javascript.ts | 357 +++++++++++++++++++ src/tree-sitter/languages/registry.ts | 87 +++++ src/tree-sitter/languages/typescript.ts | 446 ++++++++++++++++++++++++ 3 files changed, 890 insertions(+) create mode 100644 src/tree-sitter/languages/javascript.ts create mode 100644 src/tree-sitter/languages/registry.ts create mode 100644 src/tree-sitter/languages/typescript.ts diff --git a/src/tree-sitter/languages/javascript.ts b/src/tree-sitter/languages/javascript.ts new file mode 100644 index 0000000..f1ffa15 --- /dev/null +++ b/src/tree-sitter/languages/javascript.ts @@ -0,0 +1,357 @@ +import type Parser from "web-tree-sitter"; +import type { ClassInfo, FunctionInfo, MethodInfo } from "../types.ts"; + +export const functionQueryString = ` +(function_declaration + name: (identifier) @fn.name + parameters: (formal_parameters) @fn.params + body: (statement_block) @fn.body) @fn.declaration + +(export_statement + declaration: (function_declaration + name: (identifier) @fn.name + parameters: (formal_parameters) @fn.params + body: (statement_block) @fn.body)) @fn.export + +(export_statement + declaration: (generator_function_declaration + name: (identifier) @fn.name + parameters: (formal_parameters) @fn.params + body: (statement_block) @fn.body)) @fn.export + +(generator_function_declaration + name: (identifier) @fn.name + parameters: (formal_parameters) @fn.params + body: (statement_block) @fn.body) @fn.declaration + +(lexical_declaration + (variable_declarator + name: (identifier) @fn.name + value: (arrow_function + parameters: (formal_parameters) @fn.params + body: (_) @fn.body))) @fn.arrow + +(export_statement + declaration: (lexical_declaration + (variable_declarator + name: (identifier) @fn.name + value: (arrow_function + parameters: (formal_parameters) @fn.params + body: (_) @fn.body)))) @fn.export_arrow + +(method_definition + name: (property_identifier) @fn.name + parameters: (formal_parameters) @fn.params + body: (statement_block) @fn.body) @fn.method +`; + +export const classQueryString = ` +(class_declaration + name: (identifier) @cls.name + (class_heritage + (extends_clause + value: (_) @cls.extends))? + body: (class_body) @cls.body) @cls.declaration + +(export_statement + declaration: (class_declaration + name: (identifier) @cls.name + (class_heritage + (extends_clause + value: (_) @cls.extends))? + body: (class_body) @cls.body)) @cls.export +`; + +const COMPLEXITY_NODE_TYPES = new Set([ + "if_statement", + "ternary_expression", + "for_statement", + "for_in_statement", + "while_statement", + "do_statement", + "catch_clause", +]); + +function countComplexity(bodyNode: Parser.Node): number { + let count = 1; + + function walk(node: Parser.Node): void { + if (COMPLEXITY_NODE_TYPES.has(node.type)) { + count++; + } else if (node.type === "switch_case") { + const firstChild = node.firstChild; + if (firstChild && firstChild.type !== "default") { + count++; + } + } else if (node.type === "binary_expression") { + const operatorNode = node.childForFieldName("operator"); + if ( + operatorNode && + (operatorNode.text === "&&" || operatorNode.text === "||") + ) { + count++; + } + } else if (node.type === "??") { + count++; + } + + for (const child of node.children) { + walk(child); + } + } + + walk(bodyNode); + return count; +} + +function extractParameters(paramsNode: Parser.Node): Array<{ name: string; type: string | null }> { + const params: Array<{ name: string; type: string | null }> = []; + + for (const child of paramsNode.namedChildren) { + if ( + child.type === "identifier" || + child.type === "shorthand_property_identifier_pattern" + ) { + params.push({ name: child.text, type: null }); + } else if (child.type === "rest_pattern" || child.type === "rest_element") { + const innerNode = child.namedChildren[0]; + if (innerNode) { + params.push({ name: `...${innerNode.text}`, type: null }); + } + } else if (child.type === "assignment_pattern") { + const leftNode = child.childForFieldName("left"); + if (leftNode) { + params.push({ name: leftNode.text, type: null }); + } + } else if (child.type === "object_pattern" || child.type === "array_pattern") { + params.push({ name: child.text, type: null }); + } + } + + return params; +} + +function findPrecedingJsdoc(node: Parser.Node): string | null { + const sibling = node.previousNamedSibling; + if (sibling && sibling.type === "comment") { + const text = sibling.text; + if (text.startsWith("/**")) { + return text; + } + } + return null; +} + +function isAsyncFunction(node: Parser.Node): boolean { + for (const child of node.children) { + if (child.type === "async") return true; + } + return false; +} + +export function parseFunctionCaptures( + captures: Parser.QueryCapture[], +): FunctionInfo[] { + const seen = new Set(); + const results: FunctionInfo[] = []; + + const declarationCaptures = captures.filter( + (c) => + c.name === "fn.declaration" || + c.name === "fn.export" || + c.name === "fn.arrow" || + c.name === "fn.export_arrow" || + c.name === "fn.method", + ); + + for (const capture of declarationCaptures) { + const declarationNode = capture.node; + + let fnNode = declarationNode; + if ( + capture.name === "fn.export" || + capture.name === "fn.export_arrow" + ) { + const inner = + fnNode.childForFieldName("declaration") || + fnNode.namedChildren.find( + (c) => + c.type === "function_declaration" || + c.type === "generator_function_declaration" || + c.type === "lexical_declaration", + ); + if (inner) fnNode = inner; + } + + if (fnNode.type === "lexical_declaration") { + const declarator = fnNode.namedChildren.find( + (c) => c.type === "variable_declarator", + ); + if (declarator) fnNode = declarator; + } + + if (seen.has(declarationNode.startIndex)) continue; + seen.add(declarationNode.startIndex); + + const nameCapture = captures.find( + (c) => + c.name === "fn.name" && + c.node.startIndex >= declarationNode.startIndex && + c.node.endIndex <= declarationNode.endIndex, + ); + const paramsCapture = captures.find( + (c) => + c.name === "fn.params" && + c.node.startIndex >= declarationNode.startIndex && + c.node.endIndex <= declarationNode.endIndex, + ); + const bodyCapture = captures.find( + (c) => + c.name === "fn.body" && + c.node.startIndex >= declarationNode.startIndex && + c.node.endIndex <= declarationNode.endIndex, + ); + + if (!nameCapture || !paramsCapture || !bodyCapture) continue; + + const name = nameCapture.node.text; + const startLine = declarationNode.startPosition.row + 1; + const endLine = declarationNode.endPosition.row + 1; + const loc = endLine - startLine + 1; + + const isMethod = capture.name === "fn.method"; + const isExported = + capture.name === "fn.export" || capture.name === "fn.export_arrow"; + const isAsync = isAsyncFunction( + fnNode.type === "variable_declarator" + ? fnNode.namedChildren.find((c) => c.type === "arrow_function") ?? fnNode + : fnNode, + ); + + const parameters = extractParameters(paramsCapture.node); + const jsdoc = findPrecedingJsdoc(declarationNode); + const complexity = countComplexity(bodyCapture.node); + + results.push({ + name, + lines: [startLine, endLine], + loc, + cyclomatic_complexity: complexity, + parameters, + return_type: null, + is_async: isAsync, + is_exported: isExported, + is_method: isMethod, + jsdoc, + }); + } + + return results; +} + +function extractDecorators(declarationNode: Parser.Node): string[] { + const decorators: string[] = []; + let sibling = declarationNode.previousNamedSibling; + while (sibling && sibling.type === "decorator") { + decorators.unshift(sibling.text); + sibling = sibling.previousNamedSibling; + } + return decorators; +} + +function extractMethods(bodyNode: Parser.Node): MethodInfo[] { + const methods: MethodInfo[] = []; + + for (const child of bodyNode.namedChildren) { + if (child.type === "method_definition") { + const nameNode = child.childForFieldName("name"); + const methodBodyNode = child.childForFieldName("body"); + if (!nameNode || !methodBodyNode) continue; + + const isAsync = isAsyncFunction(child); + const complexity = countComplexity(methodBodyNode); + + methods.push({ + name: nameNode.text, + line: child.startPosition.row + 1, + is_async: isAsync, + cyclomatic_complexity: complexity, + }); + } + } + + return methods; +} + +function countProperties(bodyNode: Parser.Node): number { + let count = 0; + for (const child of bodyNode.namedChildren) { + if ( + child.type === "field_definition" || + child.type === "public_field_definition" + ) { + count++; + } + } + return count; +} + +export function parseClassCaptures( + captures: Parser.QueryCapture[], +): ClassInfo[] { + const seen = new Set(); + const results: ClassInfo[] = []; + + const declarationCaptures = captures.filter( + (c) => c.name === "cls.declaration" || c.name === "cls.export", + ); + + for (const capture of declarationCaptures) { + const declarationNode = capture.node; + + if (seen.has(declarationNode.startIndex)) continue; + seen.add(declarationNode.startIndex); + + const nameCapture = captures.find( + (c) => + c.name === "cls.name" && + c.node.startIndex >= declarationNode.startIndex && + c.node.endIndex <= declarationNode.endIndex, + ); + const bodyCapture = captures.find( + (c) => + c.name === "cls.body" && + c.node.startIndex >= declarationNode.startIndex && + c.node.endIndex <= declarationNode.endIndex, + ); + const extendsCapture = captures.find( + (c) => + c.name === "cls.extends" && + c.node.startIndex >= declarationNode.startIndex && + c.node.endIndex <= declarationNode.endIndex, + ); + + if (!nameCapture || !bodyCapture) continue; + + const name = nameCapture.node.text; + const startLine = declarationNode.startPosition.row + 1; + const endLine = declarationNode.endPosition.row + 1; + const extendsName = extendsCapture ? extendsCapture.node.text : null; + const decorators = extractDecorators(declarationNode); + const methods = extractMethods(bodyCapture.node); + const propertyCount = countProperties(bodyCapture.node); + + results.push({ + name, + lines: [startLine, endLine], + extends_name: extendsName, + implements: [], + decorators, + is_abstract: false, + methods, + property_count: propertyCount, + }); + } + + return results; +} diff --git a/src/tree-sitter/languages/registry.ts b/src/tree-sitter/languages/registry.ts new file mode 100644 index 0000000..3b35ce5 --- /dev/null +++ b/src/tree-sitter/languages/registry.ts @@ -0,0 +1,87 @@ +import type Parser from "web-tree-sitter"; +import type { ClassInfo, FunctionInfo } from "../types.ts"; +import * as typescriptLang from "./typescript.ts"; +import * as javascriptLang from "./javascript.ts"; + +export type LanguageQueries = { + functionQuery: string; + classQuery: string; + parseResults: (params: { + functionCaptures: Parser.QueryCapture[]; + classCaptures: Parser.QueryCapture[]; + }) => { functions: FunctionInfo[]; classes: ClassInfo[] }; +}; + +export type LanguageEntry = { + grammarName: string; + extensions: string[]; + getQueries: () => LanguageQueries; +}; + +function buildTypescriptQueries(): LanguageQueries { + return { + functionQuery: typescriptLang.functionQueryString, + classQuery: typescriptLang.classQueryString, + parseResults: (params) => ({ + functions: typescriptLang.parseFunctionCaptures(params.functionCaptures), + classes: typescriptLang.parseClassCaptures(params.classCaptures), + }), + }; +} + +function buildJavascriptQueries(): LanguageQueries { + return { + functionQuery: javascriptLang.functionQueryString, + classQuery: javascriptLang.classQueryString, + parseResults: (params) => ({ + functions: javascriptLang.parseFunctionCaptures(params.functionCaptures), + classes: javascriptLang.parseClassCaptures(params.classCaptures), + }), + }; +} + +export const languageRegistry: Record = { + typescript: { + grammarName: "tree-sitter-typescript", + extensions: [".ts"], + getQueries: buildTypescriptQueries, + }, + tsx: { + grammarName: "tree-sitter-tsx", + extensions: [".tsx"], + getQueries: buildTypescriptQueries, + }, + javascript: { + grammarName: "tree-sitter-javascript", + extensions: [".js", ".mjs", ".cjs"], + getQueries: buildJavascriptQueries, + }, + jsx: { + grammarName: "tree-sitter-javascript", + extensions: [".jsx"], + getQueries: buildJavascriptQueries, + }, +}; + +const extensionToLanguage = new Map(); +for (const [lang, entry] of Object.entries(languageRegistry)) { + for (const ext of entry.extensions) { + extensionToLanguage.set(ext, lang); + } +} + +export function getLanguageForExtension(params: { + extension: string; +}): string | null { + return extensionToLanguage.get(params.extension) ?? null; +} + +export function getLanguageEntry(params: { + language: string; +}): LanguageEntry | null { + return languageRegistry[params.language] ?? null; +} + +export function getSupportedExtensions(): string[] { + return Array.from(extensionToLanguage.keys()); +} diff --git a/src/tree-sitter/languages/typescript.ts b/src/tree-sitter/languages/typescript.ts new file mode 100644 index 0000000..ec7ca4c --- /dev/null +++ b/src/tree-sitter/languages/typescript.ts @@ -0,0 +1,446 @@ +import type Parser from "web-tree-sitter"; +import type { ClassInfo, FunctionInfo, MethodInfo } from "../types.ts"; + +export const functionQueryString = ` +(function_declaration + name: (identifier) @fn.name + parameters: (formal_parameters) @fn.params + return_type: (type_annotation)? @fn.return_type + body: (statement_block) @fn.body) @fn.declaration + +(export_statement + declaration: (function_declaration + name: (identifier) @fn.name + parameters: (formal_parameters) @fn.params + return_type: (type_annotation)? @fn.return_type + body: (statement_block) @fn.body)) @fn.export + +(export_statement + declaration: (generator_function_declaration + name: (identifier) @fn.name + parameters: (formal_parameters) @fn.params + return_type: (type_annotation)? @fn.return_type + body: (statement_block) @fn.body)) @fn.export + +(generator_function_declaration + name: (identifier) @fn.name + parameters: (formal_parameters) @fn.params + return_type: (type_annotation)? @fn.return_type + body: (statement_block) @fn.body) @fn.declaration + +(lexical_declaration + (variable_declarator + name: (identifier) @fn.name + value: (arrow_function + parameters: (formal_parameters) @fn.params + return_type: (type_annotation)? @fn.return_type + body: (_) @fn.body))) @fn.arrow + +(export_statement + declaration: (lexical_declaration + (variable_declarator + name: (identifier) @fn.name + value: (arrow_function + parameters: (formal_parameters) @fn.params + return_type: (type_annotation)? @fn.return_type + body: (_) @fn.body)))) @fn.export_arrow + +(method_definition + name: (property_identifier) @fn.name + parameters: (formal_parameters) @fn.params + return_type: (type_annotation)? @fn.return_type + body: (statement_block) @fn.body) @fn.method +`; + +export const classQueryString = ` +(class_declaration + name: (type_identifier) @cls.name + (class_heritage + (extends_clause + value: (_) @cls.extends))? + body: (class_body) @cls.body) @cls.declaration + +(export_statement + declaration: (class_declaration + name: (type_identifier) @cls.name + (class_heritage + (extends_clause + value: (_) @cls.extends))? + body: (class_body) @cls.body)) @cls.export +`; + +const COMPLEXITY_NODE_TYPES = new Set([ + "if_statement", + "ternary_expression", + "for_statement", + "for_in_statement", + "while_statement", + "do_statement", + "catch_clause", +]); + +function countComplexity(bodyNode: Parser.Node): number { + let count = 1; + + function walk(node: Parser.Node): void { + if (COMPLEXITY_NODE_TYPES.has(node.type)) { + count++; + } else if (node.type === "switch_case") { + const firstChild = node.firstChild; + if (firstChild && firstChild.type !== "default") { + count++; + } + } else if (node.type === "binary_expression") { + const operatorNode = node.childForFieldName("operator"); + if ( + operatorNode && + (operatorNode.text === "&&" || operatorNode.text === "||") + ) { + count++; + } + } else if (node.type === "??") { + count++; + } + + for (const child of node.children) { + walk(child); + } + } + + walk(bodyNode); + return count; +} + +function extractParameters(paramsNode: Parser.Node): Array<{ name: string; type: string | null }> { + const params: Array<{ name: string; type: string | null }> = []; + + for (const child of paramsNode.namedChildren) { + if ( + child.type === "identifier" || + child.type === "shorthand_property_identifier_pattern" + ) { + params.push({ name: child.text, type: null }); + } else if ( + child.type === "required_parameter" || + child.type === "optional_parameter" + ) { + const nameNode = + child.childForFieldName("pattern") || + child.childForFieldName("name"); + const typeNode = child.childForFieldName("type"); + if (nameNode) { + let paramName = nameNode.text; + if (nameNode.type === "assignment_pattern") { + const leftNode = nameNode.childForFieldName("left"); + if (leftNode) paramName = leftNode.text; + } + params.push({ + name: paramName, + type: typeNode ? typeNode.text.replace(/^:\s*/, "") : null, + }); + } + } else if (child.type === "rest_pattern" || child.type === "rest_element") { + const innerNode = child.namedChildren[0]; + if (innerNode) { + params.push({ name: `...${innerNode.text}`, type: null }); + } + } else if (child.type === "assignment_pattern") { + const leftNode = child.childForFieldName("left"); + if (leftNode) { + params.push({ name: leftNode.text, type: null }); + } + } + } + + return params; +} + +function findPrecedingJsdoc(node: Parser.Node): string | null { + let sibling = node.previousNamedSibling; + if (sibling && sibling.type === "comment") { + const text = sibling.text; + if (text.startsWith("/**")) { + return text; + } + } + return null; +} + +function isAsyncFunction(node: Parser.Node): boolean { + for (const child of node.children) { + if (child.type === "async") return true; + } + return false; +} + +export function parseFunctionCaptures( + captures: Parser.QueryCapture[], +): FunctionInfo[] { + const seen = new Set(); + const results: FunctionInfo[] = []; + + const declarationCaptures = captures.filter( + (c) => + c.name === "fn.declaration" || + c.name === "fn.export" || + c.name === "fn.arrow" || + c.name === "fn.export_arrow" || + c.name === "fn.method", + ); + + for (const capture of declarationCaptures) { + const declarationNode = capture.node; + + let fnNode = declarationNode; + if ( + capture.name === "fn.export" || + capture.name === "fn.export_arrow" + ) { + const inner = + fnNode.childForFieldName("declaration") || + fnNode.namedChildren.find( + (c) => + c.type === "function_declaration" || + c.type === "generator_function_declaration" || + c.type === "lexical_declaration", + ); + if (inner) fnNode = inner; + } + + if (fnNode.type === "lexical_declaration") { + const declarator = fnNode.namedChildren.find( + (c) => c.type === "variable_declarator", + ); + if (declarator) fnNode = declarator; + } + + if (seen.has(declarationNode.startIndex)) continue; + seen.add(declarationNode.startIndex); + + const nameCapture = captures.find( + (c) => + c.name === "fn.name" && + c.node.startIndex >= declarationNode.startIndex && + c.node.endIndex <= declarationNode.endIndex, + ); + const paramsCapture = captures.find( + (c) => + c.name === "fn.params" && + c.node.startIndex >= declarationNode.startIndex && + c.node.endIndex <= declarationNode.endIndex, + ); + const bodyCapture = captures.find( + (c) => + c.name === "fn.body" && + c.node.startIndex >= declarationNode.startIndex && + c.node.endIndex <= declarationNode.endIndex, + ); + const returnCapture = captures.find( + (c) => + c.name === "fn.return_type" && + c.node.startIndex >= declarationNode.startIndex && + c.node.endIndex <= declarationNode.endIndex, + ); + + if (!nameCapture || !paramsCapture || !bodyCapture) continue; + + const name = nameCapture.node.text; + const startLine = declarationNode.startPosition.row + 1; + const endLine = declarationNode.endPosition.row + 1; + const loc = endLine - startLine + 1; + + const isMethod = capture.name === "fn.method"; + const isExported = + capture.name === "fn.export" || capture.name === "fn.export_arrow"; + const isAsync = isAsyncFunction(fnNode.type === "variable_declarator" + ? fnNode.namedChildren.find((c) => c.type === "arrow_function") ?? fnNode + : fnNode); + + const parameters = extractParameters(paramsCapture.node); + const returnType = returnCapture + ? returnCapture.node.text.replace(/^:\s*/, "") + : null; + const jsdoc = findPrecedingJsdoc(declarationNode); + const complexity = countComplexity(bodyCapture.node); + + results.push({ + name, + lines: [startLine, endLine], + loc, + cyclomatic_complexity: complexity, + parameters, + return_type: returnType, + is_async: isAsync, + is_exported: isExported, + is_method: isMethod, + jsdoc, + }); + } + + return results; +} + +function extractImplements(bodyNode: Parser.Node): string[] { + const parent = bodyNode.parent; + if (!parent) return []; + + const heritage = parent.namedChildren.find( + (c) => c.type === "class_heritage", + ); + if (!heritage) return []; + + const result: string[] = []; + for (const child of heritage.namedChildren) { + if (child.type === "implements_clause") { + for (const type of child.namedChildren) { + if (type.type !== "implements") { + result.push(type.text); + } + } + } + } + return result; +} + +function extractDecorators(declarationNode: Parser.Node): string[] { + const decorators: string[] = []; + let sibling = declarationNode.previousNamedSibling; + while (sibling && sibling.type === "decorator") { + decorators.unshift(sibling.text); + sibling = sibling.previousNamedSibling; + } + const parent = declarationNode.parent; + if (parent) { + for (const child of parent.namedChildren) { + if ( + child.type === "decorator" && + child.endIndex < declarationNode.startIndex + ) { + if (!decorators.includes(child.text)) { + decorators.push(child.text); + } + } + } + } + return decorators; +} + +function isAbstractClass(declarationNode: Parser.Node): boolean { + for (const child of declarationNode.children) { + if (child.type === "abstract") return true; + } + const parent = declarationNode.parent; + if (parent?.type === "export_statement") { + for (const child of parent.children) { + if (child.type === "abstract") return true; + } + } + return false; +} + +function extractMethods(bodyNode: Parser.Node): MethodInfo[] { + const methods: MethodInfo[] = []; + + for (const child of bodyNode.namedChildren) { + if (child.type === "method_definition") { + const nameNode = child.childForFieldName("name"); + const methodBodyNode = child.childForFieldName("body"); + if (!nameNode || !methodBodyNode) continue; + + const isAsync = isAsyncFunction(child); + const complexity = countComplexity(methodBodyNode); + + methods.push({ + name: nameNode.text, + line: child.startPosition.row + 1, + is_async: isAsync, + cyclomatic_complexity: complexity, + }); + } + } + + return methods; +} + +function countProperties(bodyNode: Parser.Node): number { + let count = 0; + for (const child of bodyNode.namedChildren) { + if ( + child.type === "public_field_definition" || + child.type === "field_definition" + ) { + count++; + } + } + return count; +} + +export function parseClassCaptures( + captures: Parser.QueryCapture[], +): ClassInfo[] { + const seen = new Set(); + const results: ClassInfo[] = []; + + const declarationCaptures = captures.filter( + (c) => c.name === "cls.declaration" || c.name === "cls.export", + ); + + for (const capture of declarationCaptures) { + const declarationNode = capture.node; + + if (seen.has(declarationNode.startIndex)) continue; + seen.add(declarationNode.startIndex); + + let classNode = declarationNode; + if (capture.name === "cls.export") { + const inner = classNode.namedChildren.find( + (c) => c.type === "class_declaration", + ); + if (inner) classNode = inner; + } + + const nameCapture = captures.find( + (c) => + c.name === "cls.name" && + c.node.startIndex >= declarationNode.startIndex && + c.node.endIndex <= declarationNode.endIndex, + ); + const bodyCapture = captures.find( + (c) => + c.name === "cls.body" && + c.node.startIndex >= declarationNode.startIndex && + c.node.endIndex <= declarationNode.endIndex, + ); + const extendsCapture = captures.find( + (c) => + c.name === "cls.extends" && + c.node.startIndex >= declarationNode.startIndex && + c.node.endIndex <= declarationNode.endIndex, + ); + + if (!nameCapture || !bodyCapture) continue; + + const name = nameCapture.node.text; + const startLine = declarationNode.startPosition.row + 1; + const endLine = declarationNode.endPosition.row + 1; + const extendsName = extendsCapture ? extendsCapture.node.text : null; + const implementsList = extractImplements(bodyCapture.node); + const decorators = extractDecorators(classNode); + const isAbstract = isAbstractClass(classNode); + const methods = extractMethods(bodyCapture.node); + const propertyCount = countProperties(bodyCapture.node); + + results.push({ + name, + lines: [startLine, endLine], + extends_name: extendsName, + implements: implementsList, + decorators, + is_abstract: isAbstract, + methods, + property_count: propertyCount, + }); + } + + return results; +} From 5c046bbdeefcd0e945da5ddc0718d0e7d63fc4a7 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 09:22:52 +0700 Subject: [PATCH 04/31] feat(tree-sitter): add core parser module Refs: dora-9mh7 --- src/tree-sitter/parser.ts | 315 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 src/tree-sitter/parser.ts diff --git a/src/tree-sitter/parser.ts b/src/tree-sitter/parser.ts new file mode 100644 index 0000000..df720c4 --- /dev/null +++ b/src/tree-sitter/parser.ts @@ -0,0 +1,315 @@ +import type Parser from "web-tree-sitter"; +import type { Config } from "../utils/config.ts"; +import { CtxError } from "../utils/errors.ts"; +import type { Database } from "bun:sqlite"; +import { getDb } from "../db/connection.ts"; +import { findGrammarPath } from "./grammar.ts"; +import { + getLanguageForExtension, + getLanguageEntry, +} from "./languages/registry.ts"; +import type { FunctionInfo, ClassInfo, FileMetrics } from "./types.ts"; + +type ParserModule = typeof import("web-tree-sitter"); + +const ParserPromise: Promise = import("web-tree-sitter"); + +const languageCache = new Map(); + +async function getParserModule(): Promise { + return await ParserPromise; +} + +async function getLanguage(params: { + grammarPath: string; +}): Promise { + const { grammarPath } = params; + + const cached = languageCache.get(grammarPath); + if (cached) { + return cached; + } + + const mod = await getParserModule(); + await mod.Parser.init(); + const language = await mod.Language.load(grammarPath); + languageCache.set(grammarPath, language); + return language; +} + +async function getDbConnection(params: { + config: Config; +}): Promise { + const { config } = params; + try { + return getDb(config); + } catch { + return null; + } +} + +async function correlateWithScip(params: { + db: Database | null; + filePath: string; + symbols: Array; +}): Promise { + const { db, filePath, symbols } = params; + if (!db || symbols.length === 0) return; + + const fileRow = db + .query("SELECT id FROM files WHERE path = ?") + .get(filePath) as { id: number } | null; + if (!fileRow) return; + + const fileId = fileRow.id; + + for (const symbol of symbols) { + const symbolRow = db + .query( + `SELECT reference_count FROM symbols + WHERE file_id = ? AND start_line = ? AND name = ? + LIMIT 1`, + ) + .get(fileId, symbol.lines[0], symbol.name) as + | { reference_count: number } + | null; + + if (symbolRow) { + symbol.reference_count = symbolRow.reference_count; + } + } +} + +function calculateFileMetrics(params: { + content: string; + functions: FunctionInfo[]; + classes: ClassInfo[]; +}): FileMetrics { + const { content, functions, classes } = params; + + const lines = content.split("\n"); + const totalLines = lines.length; + + let commentLines = 0; + let blankLines = 0; + let inBlockComment = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === "") { + blankLines++; + continue; + } + if (inBlockComment) { + commentLines++; + if (trimmed.endsWith("*/")) { + inBlockComment = false; + } + continue; + } + if (trimmed.startsWith("//")) { + commentLines++; + continue; + } + if (trimmed.startsWith("/*")) { + commentLines++; + if (!trimmed.endsWith("*/")) { + inBlockComment = true; + } + continue; + } + } + + const sloc = totalLines - commentLines - blankLines; + + const complexities = functions.map((f) => f.cyclomatic_complexity); + const avgComplexity = + complexities.length > 0 + ? complexities.reduce((a, b) => a + b, 0) / complexities.length + : 0; + const maxComplexity = complexities.length > 0 ? Math.max(...complexities) : 0; + + return { + loc: totalLines, + sloc, + comment_lines: commentLines, + blank_lines: blankLines, + function_count: functions.length, + class_count: classes.length, + avg_complexity: Math.round(avgComplexity * 100) / 100, + max_complexity: maxComplexity, + }; +} + +export async function parseFunctions(params: { + filePath: string; + config: Config; +}): Promise<{ functions: FunctionInfo[]; metrics: FileMetrics }> { + const { filePath, config } = params; + + const extension = filePath.includes(".") ? filePath.split(".").pop() || "" : ""; + const extWithDot = extension ? `.${extension}` : ""; + const languageKey = getLanguageForExtension({ extension: extWithDot }); + + if (!languageKey) { + throw new CtxError(`Unsupported file extension: ${extWithDot}`, undefined, { + filePath, + }); + } + + const langEntry = getLanguageEntry({ language: languageKey }); + if (!langEntry) { + throw new CtxError( + `Language entry not found for: ${languageKey}`, + undefined, + { filePath }, + ); + } + + let content: string; + try { + const file = Bun.file(filePath); + content = await file.text(); + } catch (error) { + throw new CtxError( + `Failed to read file: ${error instanceof Error ? error.message : String(error)}`, + undefined, + { filePath }, + ); + } + + const grammarPath = await findGrammarPath({ + lang: languageKey, + config, + projectRoot: config.root, + }); + + const language = await getLanguage({ grammarPath }); + const mod = await getParserModule(); + + const parser = new mod.Parser(); + parser.setLanguage(language); + + const tree = parser.parse(content); + if (!tree) { + throw new CtxError("Failed to parse file", undefined, { filePath }); + } + + const queries = langEntry.getQueries(); + + const functionQuery = new mod.Query(language, queries.functionQuery); + const functionCaptures = functionQuery.captures(tree.rootNode); + + const classQuery = new mod.Query(language, queries.classQuery); + const classCaptures = classQuery.captures(tree.rootNode); + + const parseResults = queries.parseResults({ + functionCaptures, + classCaptures, + }); + + const db = await getDbConnection({ config }); + + await correlateWithScip({ + db, + filePath, + symbols: parseResults.functions, + }); + + const metrics = calculateFileMetrics({ + content, + functions: parseResults.functions, + classes: parseResults.classes, + }); + + parser.delete(); + tree.delete(); + + return { + functions: parseResults.functions, + metrics, + }; +} + +export async function parseClasses(params: { + filePath: string; + config: Config; +}): Promise<{ classes: ClassInfo[] }> { + const { filePath, config } = params; + + const extension = filePath.includes(".") ? filePath.split(".").pop() || "" : ""; + const extWithDot = extension ? `.${extension}` : ""; + const languageKey = getLanguageForExtension({ extension: extWithDot }); + + if (!languageKey) { + throw new CtxError(`Unsupported file extension: ${extWithDot}`, undefined, { + filePath, + }); + } + + const langEntry = getLanguageEntry({ language: languageKey }); + if (!langEntry) { + throw new CtxError( + `Language entry not found for: ${languageKey}`, + undefined, + { filePath }, + ); + } + + let content: string; + try { + const file = Bun.file(filePath); + content = await file.text(); + } catch (error) { + throw new CtxError( + `Failed to read file: ${error instanceof Error ? error.message : String(error)}`, + undefined, + { filePath }, + ); + } + + const grammarPath = await findGrammarPath({ + lang: languageKey, + config, + projectRoot: config.root, + }); + + const language = await getLanguage({ grammarPath }); + const mod = await getParserModule(); + + const parser = new mod.Parser(); + parser.setLanguage(language); + + const tree = parser.parse(content); + if (!tree) { + throw new CtxError("Failed to parse file", undefined, { filePath }); + } + + const queries = langEntry.getQueries(); + + const functionQuery = new mod.Query(language, queries.functionQuery); + const functionCaptures = functionQuery.captures(tree.rootNode); + + const classQuery = new mod.Query(language, queries.classQuery); + const classCaptures = classQuery.captures(tree.rootNode); + + const parseResults = queries.parseResults({ + functionCaptures, + classCaptures, + }); + + const db = await getDbConnection({ config }); + + await correlateWithScip({ + db, + filePath, + symbols: parseResults.classes, + }); + + parser.delete(); + tree.delete(); + + return { + classes: parseResults.classes, + }; +} From 58659aecbd33e1ebb096fa9b1d0da3c7c30cba10 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 09:24:21 +0700 Subject: [PATCH 05/31] feat(tree-sitter): add dora fn command Refs: dora-rj0c --- src/commands/fn.ts | 63 +++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 13 ++++++++++ src/mcp/handlers.ts | 8 ++++++ src/mcp/metadata.ts | 31 ++++++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 src/commands/fn.ts diff --git a/src/commands/fn.ts b/src/commands/fn.ts new file mode 100644 index 0000000..fad7828 --- /dev/null +++ b/src/commands/fn.ts @@ -0,0 +1,63 @@ +import { parseFunctions } from "../tree-sitter/parser.ts"; +import type { FnResult, FunctionInfo } from "../schemas/treesitter.ts"; +import { resolveAndValidatePath, setupCommand } from "./shared.ts"; +import { getLanguageForExtension } from "../tree-sitter/languages/registry.ts"; + +export async function fn( + path: string, + options: { + sort?: string; + minComplexity?: number; + limit?: number; + }, +): Promise { + const ctx = await setupCommand(); + const relativePath = resolveAndValidatePath({ ctx, inputPath: path }); + + const absolutePath = `${ctx.config.root}/${relativePath}`; + + const extension = relativePath.includes(".") + ? relativePath.split(".").pop() || "" + : ""; + const extWithDot = extension ? `.${extension}` : ""; + const languageKey = getLanguageForExtension({ extension: extWithDot }) || "unknown"; + + const { functions, metrics } = await parseFunctions({ + filePath: absolutePath, + config: ctx.config, + }); + + let filteredFunctions = functions; + + const minComplexity = options.minComplexity; + if (minComplexity !== undefined && minComplexity > 0) { + filteredFunctions = filteredFunctions.filter( + (f) => f.cyclomatic_complexity >= minComplexity, + ); + } + + const sortBy = options.sort || "complexity"; + filteredFunctions.sort((a: FunctionInfo, b: FunctionInfo) => { + switch (sortBy) { + case "loc": + return b.loc - a.loc; + case "name": + return a.name.localeCompare(b.name); + case "complexity": + default: + return b.cyclomatic_complexity - a.cyclomatic_complexity; + } + }); + + const limit = options.limit; + if (limit !== undefined && limit > 0) { + filteredFunctions = filteredFunctions.slice(0, limit); + } + + return { + path: relativePath, + language: languageKey, + functions: filteredFunctions, + file_stats: metrics, + }; +} diff --git a/src/index.ts b/src/index.ts index 0b0be23..1528e2d 100755 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { docsSearch } from "./commands/docs/search.ts"; import { docsShow } from "./commands/docs/show.ts"; import { exports } from "./commands/exports.ts"; import { file } from "./commands/file.ts"; +import { fn } from "./commands/fn.ts"; import { graph } from "./commands/graph.ts"; import { imports } from "./commands/imports.ts"; import { index } from "./commands/index.ts"; @@ -124,6 +125,18 @@ program output({ data: result, isJson: program.opts().json }); })); +program + .command("fn") + .description("List all functions in a file with complexity metrics") + .argument("", "File path to analyze") + .option("--sort ", "Sort by: complexity, loc, or name (default: complexity)") + .option("--min-complexity ", "Filter functions below complexity threshold") + .option("--limit ", "Maximum number of results") + .action(wrapCommand(async (path: string, options) => { + const result = await fn(path, options); + output({ data: result, isJson: program.opts().json }); + })); + program .command("symbol") .description("Search for symbols by name") diff --git a/src/mcp/handlers.ts b/src/mcp/handlers.ts index 7260eb3..377f7a8 100644 --- a/src/mcp/handlers.ts +++ b/src/mcp/handlers.ts @@ -11,6 +11,7 @@ import { docsSearch } from "../commands/docs/search.ts"; import { docsShow } from "../commands/docs/show.ts"; import { exports } from "../commands/exports.ts"; import { file } from "../commands/file.ts"; +import { fn } from "../commands/fn.ts"; import { graph } from "../commands/graph.ts"; import { imports } from "../commands/imports.ts"; import { index } from "../commands/index.ts"; @@ -158,6 +159,13 @@ export async function handleToolCall( content: args.content, }); }) + .with("dora_fn", async () => { + return await fn(args.path, { + sort: args.sort, + minComplexity: args.minComplexity, + limit: args.limit, + }); + }) .otherwise(() => { throw new Error(`Unknown tool: ${name}`); }); diff --git a/src/mcp/metadata.ts b/src/mcp/metadata.ts index 138bd3d..76b4b92 100644 --- a/src/mcp/metadata.ts +++ b/src/mcp/metadata.ts @@ -456,4 +456,35 @@ export const toolsMetadata: ToolMetadata[] = [ }, ], }, + { + name: "dora_fn", + description: "List all functions in a file with complexity metrics", + arguments: [ + { + name: "path", + required: true, + description: "File path to analyze", + }, + ], + options: [ + { + name: "sort", + type: "string", + description: "Sort by: complexity, loc, or name (default: complexity)", + required: false, + }, + { + name: "minComplexity", + type: "number", + description: "Filter functions below complexity threshold", + required: false, + }, + { + name: "limit", + type: "number", + description: "Maximum number of results", + required: false, + }, + ], + }, ]; From 0a5ad52a8f974912247b7766f8e86d6652726c13 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 09:25:49 +0700 Subject: [PATCH 06/31] feat(tree-sitter): add dora class command Refs: dora-f9wi --- src/commands/class.ts | 54 +++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 12 ++++++++++ src/mcp/handlers.ts | 7 ++++++ src/mcp/metadata.ts | 25 ++++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 src/commands/class.ts diff --git a/src/commands/class.ts b/src/commands/class.ts new file mode 100644 index 0000000..5892aae --- /dev/null +++ b/src/commands/class.ts @@ -0,0 +1,54 @@ +import { parseClasses } from "../tree-sitter/parser.ts"; +import type { ClassResult, ClassInfo } from "../schemas/treesitter.ts"; +import { resolveAndValidatePath, setupCommand } from "./shared.ts"; +import { getLanguageForExtension } from "../tree-sitter/languages/registry.ts"; + +export async function classCommand( + path: string, + options: { + sort?: string; + limit?: number; + }, +): Promise { + const ctx = await setupCommand(); + const relativePath = resolveAndValidatePath({ ctx, inputPath: path }); + + const absolutePath = `${ctx.config.root}/${relativePath}`; + + const extension = relativePath.includes(".") + ? relativePath.split(".").pop() || "" + : ""; + const extWithDot = extension ? `.${extension}` : ""; + const languageKey = getLanguageForExtension({ extension: extWithDot }) || "unknown"; + + const { classes } = await parseClasses({ + filePath: absolutePath, + config: ctx.config, + }); + + let filteredClasses = classes; + + const sortBy = options.sort || "name"; + filteredClasses.sort((a: ClassInfo, b: ClassInfo) => { + switch (sortBy) { + case "methods": + return b.methods.length - a.methods.length; + case "complexity": + return (b.reference_count || 0) - (a.reference_count || 0); + case "name": + default: + return a.name.localeCompare(b.name); + } + }); + + const limit = options.limit; + if (limit !== undefined && limit > 0) { + filteredClasses = filteredClasses.slice(0, limit); + } + + return { + path: relativePath, + language: languageKey, + classes: filteredClasses, + }; +} diff --git a/src/index.ts b/src/index.ts index 1528e2d..35a355a 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun +import { classCommand } from "./commands/class.ts"; import { Command } from "commander"; import { adventure } from "./commands/adventure.ts"; import { changes } from "./commands/changes.ts"; @@ -137,6 +138,17 @@ program output({ data: result, isJson: program.opts().json }); })); +program + .command("class") + .description("List all classes in a file with hierarchy and method details") + .argument("", "File path to analyze") + .option("--sort ", "Sort by: name, methods, or complexity (default: name)") + .option("--limit ", "Maximum number of results") + .action(wrapCommand(async (path: string, options) => { + const result = await classCommand(path, options); + output({ data: result, isJson: program.opts().json }); + })); + program .command("symbol") .description("Search for symbols by name") diff --git a/src/mcp/handlers.ts b/src/mcp/handlers.ts index 377f7a8..413c9ac 100644 --- a/src/mcp/handlers.ts +++ b/src/mcp/handlers.ts @@ -1,6 +1,7 @@ import { match } from "ts-pattern"; import { adventure } from "../commands/adventure.ts"; import { changes } from "../commands/changes.ts"; +import { classCommand } from "../commands/class.ts"; import { complexity } from "../commands/complexity.ts"; import { cookbookList, cookbookShow } from "../commands/cookbook.ts"; import { coupling } from "../commands/coupling.ts"; @@ -166,6 +167,12 @@ export async function handleToolCall( limit: args.limit, }); }) + .with("dora_class", async () => { + return await classCommand(args.path, { + sort: args.sort, + limit: args.limit, + }); + }) .otherwise(() => { throw new Error(`Unknown tool: ${name}`); }); diff --git a/src/mcp/metadata.ts b/src/mcp/metadata.ts index 76b4b92..4fd5316 100644 --- a/src/mcp/metadata.ts +++ b/src/mcp/metadata.ts @@ -487,4 +487,29 @@ export const toolsMetadata: ToolMetadata[] = [ }, ], }, + { + name: "dora_class", + description: "List all classes in a file with hierarchy and method details", + arguments: [ + { + name: "path", + required: true, + description: "File path to analyze", + }, + ], + options: [ + { + name: "sort", + type: "string", + description: "Sort by: name, methods, or complexity (default: name)", + required: false, + }, + { + name: "limit", + type: "number", + description: "Maximum number of results", + required: false, + }, + ], + }, ]; From 93931052bfae9cc15fde2def61e3f80b0a717513 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 09:27:29 +0700 Subject: [PATCH 07/31] feat(tree-sitter): add dora smells command Refs: dora-q94q --- src/commands/smells.ts | 123 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 17 ++++++ src/mcp/handlers.ts | 8 +++ src/mcp/metadata.ts | 34 ++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 src/commands/smells.ts diff --git a/src/commands/smells.ts b/src/commands/smells.ts new file mode 100644 index 0000000..ef52185 --- /dev/null +++ b/src/commands/smells.ts @@ -0,0 +1,123 @@ +import { parseFunctions } from "../tree-sitter/parser.ts"; +import type { SmellsResult, SmellItem, FunctionInfo } from "../schemas/treesitter.ts"; +import { resolveAndValidatePath, setupCommand } from "./shared.ts"; + +async function scanTodoComments(params: { + filePath: string; +}): Promise> { + const { filePath } = params; + + const file = Bun.file(filePath); + const content = await file.text(); + const lines = content.split("\n"); + + const todoPattern = /TODO|FIXME|HACK/; + const results: Array<{ line: number; text: string }> = []; + + for (let i = 0; i < lines.length; i++) { + const lineContent = lines[i] ?? ""; + const commentMatch = lineContent.match(/\/\/(.+)|\/\*(.+?)\*\//); + if (commentMatch) { + const commentText = (commentMatch[1] ?? commentMatch[2]) || ""; + if (todoPattern.test(commentText)) { + results.push({ line: i + 1, text: commentText.trim() }); + } + } + } + + return results; +} + +function detectFunctionSmells(params: { + functions: FunctionInfo[]; + complexityThreshold: number; + locThreshold: number; + paramsThreshold: number; +}): SmellItem[] { + const { functions, complexityThreshold, locThreshold, paramsThreshold } = params; + const smells: SmellItem[] = []; + + for (const fn of functions) { + if (fn.cyclomatic_complexity > complexityThreshold) { + smells.push({ + kind: "high_complexity", + function: fn.name, + line: fn.lines[0], + value: fn.cyclomatic_complexity, + threshold: complexityThreshold, + message: `Cyclomatic complexity ${fn.cyclomatic_complexity} exceeds threshold ${complexityThreshold}`, + }); + } + + if (fn.loc > locThreshold) { + smells.push({ + kind: "long_function", + function: fn.name, + line: fn.lines[0], + value: fn.loc, + threshold: locThreshold, + message: `Function length ${fn.loc} lines exceeds threshold ${locThreshold}`, + }); + } + + if (fn.parameters.length > paramsThreshold) { + smells.push({ + kind: "too_many_params", + function: fn.name, + line: fn.lines[0], + value: fn.parameters.length, + threshold: paramsThreshold, + message: `Parameter count ${fn.parameters.length} exceeds threshold ${paramsThreshold}`, + }); + } + } + + return smells; +} + +export async function smells( + path: string, + options: { + complexityThreshold?: number; + locThreshold?: number; + paramsThreshold?: number; + }, +): Promise { + const ctx = await setupCommand(); + const relativePath = resolveAndValidatePath({ ctx, inputPath: path }); + const absolutePath = `${ctx.config.root}/${relativePath}`; + + const complexityThreshold = options.complexityThreshold ?? 10; + const locThreshold = options.locThreshold ?? 100; + const paramsThreshold = options.paramsThreshold ?? 5; + + const { functions } = await parseFunctions({ + filePath: absolutePath, + config: ctx.config, + }); + + const functionSmells = detectFunctionSmells({ + functions, + complexityThreshold, + locThreshold, + paramsThreshold, + }); + + const todoComments = await scanTodoComments({ filePath: absolutePath }); + const todoSmells: SmellItem[] = todoComments.map((todo) => ({ + kind: "todo_comment", + function: "", + line: todo.line, + value: 1, + threshold: 0, + message: `TODO/FIXME/HACK comment: ${todo.text}`, + })); + + const allSmells = [...functionSmells, ...todoSmells]; + + return { + path: relativePath, + clean: allSmells.length === 0, + smells: allSmells, + }; +} diff --git a/src/index.ts b/src/index.ts index 35a355a..fc4cbfa 100755 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ import { status } from "./commands/status.ts"; import { symbol } from "./commands/symbol.ts"; import { treasure } from "./commands/treasure.ts"; import { wrapCommand } from "./utils/errors.ts"; +import { smells } from "./commands/smells.ts"; import { output } from "./utils/output.ts"; import packageJson from "../package.json"; @@ -138,6 +139,22 @@ program output({ data: result, isJson: program.opts().json }); })); +program + .command("smells") + .description("Detect code smells in a file") + .argument("", "File path to analyze") + .option("--complexity-threshold ", "Cyclomatic complexity threshold (default: 10)", "10") + .option("--loc-threshold ", "Lines of code threshold (default: 100)", "100") + .option("--params-threshold ", "Parameter count threshold (default: 5)", "5") + .action(wrapCommand(async (path: string, options) => { + const result = await smells(path, { + complexityThreshold: parseInt(options.complexityThreshold, 10), + locThreshold: parseInt(options.locThreshold, 10), + paramsThreshold: parseInt(options.paramsThreshold, 10), + }); + output({ data: result, isJson: program.opts().json }); + })); + program .command("class") .description("List all classes in a file with hierarchy and method details") diff --git a/src/mcp/handlers.ts b/src/mcp/handlers.ts index 413c9ac..13147c2 100644 --- a/src/mcp/handlers.ts +++ b/src/mcp/handlers.ts @@ -27,6 +27,7 @@ import { refs } from "../commands/refs.ts"; import { schema } from "../commands/schema.ts"; import { status } from "../commands/status.ts"; import { symbol } from "../commands/symbol.ts"; +import { smells } from "../commands/smells.ts"; import { treasure } from "../commands/treasure.ts"; export async function handleToolCall( @@ -173,6 +174,13 @@ export async function handleToolCall( limit: args.limit, }); }) + .with("dora_smells", async () => { + return await smells(args.path, { + complexityThreshold: args.complexityThreshold, + locThreshold: args.locThreshold, + paramsThreshold: args.paramsThreshold, + }); + }) .otherwise(() => { throw new Error(`Unknown tool: ${name}`); }); diff --git a/src/mcp/metadata.ts b/src/mcp/metadata.ts index 4fd5316..2dcd751 100644 --- a/src/mcp/metadata.ts +++ b/src/mcp/metadata.ts @@ -512,4 +512,38 @@ export const toolsMetadata: ToolMetadata[] = [ }, ], }, + { + name: "dora_smells", + description: "Detect code smells in a file", + arguments: [ + { + name: "path", + required: true, + description: "File path to analyze", + }, + ], + options: [ + { + name: "complexityThreshold", + type: "number", + description: "Cyclomatic complexity threshold (default: 10)", + required: false, + defaultValue: 10, + }, + { + name: "locThreshold", + type: "number", + description: "Lines of code threshold (default: 100)", + required: false, + defaultValue: 100, + }, + { + name: "paramsThreshold", + type: "number", + description: "Parameter count threshold (default: 5)", + required: false, + defaultValue: 5, + }, + ], + }, ]; From ff43446f34dfc6fb486cdd44d90766d00bdaae2e Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 09:28:18 +0700 Subject: [PATCH 08/31] feat(tree-sitter): enhance dora file with metrics Refs: dora-ypef --- src/commands/file.ts | 28 +++++++++++++++++++++++++--- src/schemas/file.ts | 7 +++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/commands/file.ts b/src/commands/file.ts index 546a3bb..4cd7dc4 100644 --- a/src/commands/file.ts +++ b/src/commands/file.ts @@ -4,6 +4,9 @@ import { getFileSymbols, } from "../db/queries.ts"; import type { FileResult } from "../types.ts"; +import { parseFunctions } from "../tree-sitter/parser.ts"; +import { CtxError } from "../utils/errors.ts"; +import { debugDb } from "../utils/logger.ts"; import { resolveAndValidatePath, setupCommand } from "./shared.ts"; export async function file(path: string): Promise { @@ -15,9 +18,9 @@ export async function file(path: string): Promise { const depended_by = getFileDependents(ctx.db, relativePath); const fileIdQuery = "SELECT id FROM files WHERE path = ?"; - const fileRow = ctx.db.query(fileIdQuery).get(relativePath) as { - id: number; - } | null; + const fileRow = ctx.db.query(fileIdQuery).get(relativePath) as + | { id: number } + | null; let documented_in: string[] | undefined; @@ -39,11 +42,30 @@ export async function file(path: string): Promise { } } + const absolutePath = `${ctx.config.root}/${relativePath}`; + let metrics: FileResult["metrics"]; + let functions: FileResult["functions"]; + + try { + const parseResult = await parseFunctions({ + filePath: absolutePath, + config: ctx.config, + }); + metrics = parseResult.metrics; + functions = parseResult.functions; + } catch (error) { + if (error instanceof CtxError) { + debugDb(`Tree-sitter parse failed for ${relativePath}: ${error.message}`); + } + } + const result: FileResult = { path: relativePath, symbols, depends_on, depended_by, + ...(metrics && { metrics }), + ...(functions && { functions }), ...(documented_in && { documented_in }), }; diff --git a/src/schemas/file.ts b/src/schemas/file.ts index dc47e69..ab3162e 100644 --- a/src/schemas/file.ts +++ b/src/schemas/file.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import { + FileMetricsSchema, + FunctionInfoSchema, +} from "./treesitter.ts"; export const FileSymbolSchema = z.object({ name: z.string(), @@ -21,6 +25,9 @@ export const FileResultSchema = z.object({ symbols: z.array(FileSymbolSchema), depends_on: z.array(FileDependencySchema), depended_by: z.array(FileDependentSchema), + metrics: FileMetricsSchema.optional(), + functions: z.array(FunctionInfoSchema).optional(), + documented_in: z.array(z.string()).optional(), }); export const LeavesResultSchema = z.object({ From dcf0e2cf447040c329495ddadd0f956e5ded7111 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 09:29:24 +0700 Subject: [PATCH 09/31] feat(tree-sitter): enhance dora symbol with function signatures Refs: dora-0439 --- src/commands/symbol.ts | 75 ++++++++++++++++++++++++++++++++++++++++-- src/schemas/symbol.ts | 4 +++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/commands/symbol.ts b/src/commands/symbol.ts index 5dba67e..c080cf9 100644 --- a/src/commands/symbol.ts +++ b/src/commands/symbol.ts @@ -1,5 +1,6 @@ import { searchSymbols } from "../db/queries.ts"; -import type { SymbolSearchResult } from "../types.ts"; +import type { SymbolResult, SymbolSearchResult } from "../types.ts"; +import { parseFunctions } from "../tree-sitter/parser.ts"; import { DEFAULTS, parseIntFlag, @@ -7,6 +8,11 @@ import { setupCommand, } from "./shared.ts"; +type FileGroupItem = { + index: number; + result: SymbolResult; +}; + export async function symbol( query: string, flags: Record = {}, @@ -21,7 +27,70 @@ export async function symbol( const results = searchSymbols(ctx.db, query, { kind, limit }); - const enhancedResults = results.map((result) => { + const functionKinds = new Set(["function", "method"]); + const fileGroups = new Map(); + + for (let i = 0; i < results.length; i++) { + const result = results[i]!; + if (functionKinds.has(result.kind)) { + const existing = fileGroups.get(result.path); + if (existing) { + existing.push({ index: i, result }); + } else { + fileGroups.set(result.path, [{ index: i, result }]); + } + } + } + + const enhancedResults: SymbolResult[] = [...results]; + + for (const [filePath, items] of fileGroups) { + try { + const { functions } = await parseFunctions({ + filePath: `${ctx.config.root}/${filePath}`, + config: ctx.config, + }); + + const functionMap = new Map< + string, + { + cyclomatic_complexity: number; + parameters: Array<{ name: string; type: string | null }>; + return_type: string | null; + } + >(); + + for (const fn of functions) { + const key = `${fn.name}:${fn.lines[0]}`; + functionMap.set(key, { + cyclomatic_complexity: fn.cyclomatic_complexity, + parameters: fn.parameters, + return_type: fn.return_type, + }); + } + + for (const item of items) { + const startLine = item.result.lines?.[0]; + if (startLine === undefined) continue; + + const key = `${item.result.name}:${startLine}`; + const fnInfo = functionMap.get(key); + + if (fnInfo) { + enhancedResults[item.index] = { + ...item.result, + cyclomatic_complexity: fnInfo.cyclomatic_complexity, + parameters: fnInfo.parameters, + return_type: fnInfo.return_type, + }; + } + } + } catch { + // Gracefully skip if parsing fails or grammar unavailable + } + } + + const withDocs = enhancedResults.map((result) => { const symbolIdQuery = ` SELECT s.id FROM symbols s @@ -64,7 +133,7 @@ export async function symbol( const finalResult: SymbolSearchResult = { query, - results: enhancedResults, + results: withDocs, }; return finalResult; diff --git a/src/schemas/symbol.ts b/src/schemas/symbol.ts index 227d4fd..812dccd 100644 --- a/src/schemas/symbol.ts +++ b/src/schemas/symbol.ts @@ -1,10 +1,14 @@ import { z } from "zod"; +import { ParameterInfoSchema } from "./treesitter.ts"; export const SymbolResultSchema = z.object({ name: z.string(), kind: z.string(), path: z.string(), lines: z.tuple([z.number(), z.number()]).optional(), + cyclomatic_complexity: z.number().optional(), + parameters: z.array(ParameterInfoSchema).optional(), + return_type: z.string().nullable().optional(), }); export const SymbolSearchResultSchema = z.object({ From 81af061291aa305e9122118b7015c991a508d0ce Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 09:30:10 +0700 Subject: [PATCH 10/31] feat(tree-sitter): add grammar availability to dora status Refs: dora-y30c --- src/commands/status.ts | 22 ++++++++++++++++++++++ src/schemas/status.ts | 7 +++++++ 2 files changed, 29 insertions(+) diff --git a/src/commands/status.ts b/src/commands/status.ts index ab0f6ce..fbc6a9e 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -5,6 +5,7 @@ import { getFileCount, getSymbolCount, } from "../db/queries.ts"; +import { findGrammarPath } from "../tree-sitter/grammar.ts"; import type { StatusResult } from "../types.ts"; import { isIndexed, loadConfig } from "../utils/config.ts"; @@ -39,5 +40,26 @@ export async function status(): Promise { } } + // Check tree-sitter grammar availability (try TypeScript) + const grammarLanguage = config.language || "typescript"; + try { + const grammarPath = await findGrammarPath({ + lang: grammarLanguage, + config, + projectRoot: config.root, + }); + result.tree_sitter = { + available: true, + language: grammarLanguage, + grammar_path: grammarPath, + }; + } catch (error) { + result.tree_sitter = { + available: false, + language: grammarLanguage, + grammar_path: null, + }; + } + return result; } diff --git a/src/schemas/status.ts b/src/schemas/status.ts index 3bea32b..a5a0a89 100644 --- a/src/schemas/status.ts +++ b/src/schemas/status.ts @@ -21,6 +21,13 @@ export const StatusResultSchema = z.object({ }), ) .optional(), + tree_sitter: z + .object({ + available: z.boolean(), + language: z.string(), + grammar_path: z.string().nullable(), + }) + .optional(), }); export const IndexResultSchema = z.object({ From f5d0872a4fb31d33075557fd4a699acc6d2cea8c Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 09:30:59 +0700 Subject: [PATCH 11/31] docs: add tree-sitter cookbook recipe Refs: dora-us7d --- .dora/cookbooks/tree-sitter.md | 173 +++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 .dora/cookbooks/tree-sitter.md diff --git a/.dora/cookbooks/tree-sitter.md b/.dora/cookbooks/tree-sitter.md new file mode 100644 index 0000000..6a8eee0 --- /dev/null +++ b/.dora/cookbooks/tree-sitter.md @@ -0,0 +1,173 @@ +# Tree-Sitter Commands Cookbook + +Tree-sitter commands provide intra-file analysis that complements SCIP's cross-file capabilities. Use these commands to understand code complexity, detect smells, and navigate class hierarchies before making changes. + +## When to Use Each Command + +| Command | Use Case | Data Source | +|---------|----------|-------------| +| `dora fn` | Analyze function complexity, identify hotspots | Tree-sitter | +| `dora class` | Navigate class hierarchies, understand inheritance | Tree-sitter | +| `dora smells` | Pre-refactor checklist, detect problematic code | Tree-sitter | +| `dora file` | Cross-file dependencies + intra-file metrics | SCIP + Tree-sitter | +| `dora symbol` | Find symbols across the codebase | SCIP | + +**Rule of thumb:** Use tree-sitter commands for single-file deep analysis; use SCIP commands for cross-file relationships. + +## Typical AI Agent Workflow + +Before editing a file, always run `dora fn` to understand complexity hotspots: + +```bash +# 1. Get file overview with dependencies and function details +dora fn src/utils/validation.ts + +# 2. Check for code smells before refactoring +dora smells src/utils/validation.ts + +# 3. If file contains classes, understand the hierarchy +dora class src/services/UserService.ts + +# 4. View dependencies to assess impact +dora rdeps src/utils/validation.ts --depth 2 +``` + +## Interpreting Cyclomatic Complexity Scores + +Cyclomatic complexity measures the number of linearly independent paths through a function. + +| Score | Interpretation | Action | +|-------|----------------|--------| +| 1-5 | Simple, low risk | Safe to modify | +| 6-10 | Moderate complexity | Review before changes | +| 11-20 | High complexity | Refactor candidate | +| 21+ | Very high complexity | Prioritize refactoring | + +**Example:** +```bash +dora fn src/handlers/OrderProcessor.ts --sort complexity --limit 5 +``` + +**Output interpretation:** +- Functions with complexity > 10 warrant extra caution +- Multiple high-complexity functions in one file signal a maintenance burden +- Combine with `reference_count` to prioritize: high complexity + high references = critical to fix + +## Pre-Refactor Checklist with `dora smells` + +Run this checklist before any significant refactoring: + +```bash +dora smells src/components/CheckoutForm.ts +``` + +**Checks performed:** +1. High cyclomatic complexity (threshold: 10) +2. Long functions (threshold: 100 lines) +3. Excessive parameters (threshold: 5) +4. TODO/FIXME/HACK comments + +**Interpretation:** +- Clean output: Safe to proceed with changes +- Any smells: Address them before or during refactoring +- High complexity + long function: Break into smaller functions +- Too many params: Consider parameter object pattern + +**Custom thresholds:** +```bash +dora smells src/api/handlers.ts --complexity-threshold 15 --loc-threshold 80 +``` + +## Navigating Inheritance with `dora class` + +Use `dora class` to understand class hierarchies before modifying class-based code: + +```bash +# List all classes in a file with method counts +dora class src/services/BaseService.ts + +# Find classes with the most methods +dora class src/models/ --sort methods +``` + +**Output includes:** +- Class name and location (start/end lines) +- Parent class (`extends_name`) +- Implemented interfaces (`implements`) +- Method signatures with async flags +- Property counts +- Reference counts from SCIP correlation + +**Workflow for inheritance changes:** +1. Run `dora class` on the target file +2. Note the `extends_name` and `implements` arrays +3. Check parent classes with `dora symbol ` +4. Use `dora rdeps` to find all files that extend the class + +## Grammar Installation + +Tree-sitter requires language-specific grammars (WASM files). + +### Supported Languages + +| Language | Package | Extensions | +|----------|---------|------------| +| TypeScript | `tree-sitter-typescript` | .ts | +| TSX | `tree-sitter-tsx` | .tsx | +| JavaScript | `tree-sitter-javascript` | .js, .mjs, .cjs | +| JSX | `tree-sitter-javascript` | .jsx | + +### Installation + +Install grammars as project dependencies: + +```bash +bun add tree-sitter-typescript tree-sitter-tsx tree-sitter-javascript +``` + +Or install globally: + +```bash +bun add -g tree-sitter-typescript tree-sitter-tsx tree-sitter-javascript +``` + +### Custom Grammar Paths + +Configure explicit paths in `.dora/config.json`: + +```json +{ + "treeSitter": { + "grammars": { + "typescript": "/custom/path/tree-sitter-typescript.wasm" + } + } +} +``` + +## SCIP vs Tree-Sitter: Complementary Data + +| Aspect | SCIP | Tree-Sitter | +|--------|------|-------------| +| **Scope** | Cross-file relationships | Single-file structure | +| **Data** | Symbol references, dependencies | AST-based complexity, class hierarchies | +| **Speed** | Database queries (fast) | Parse on demand (slower) | +| **Use for** | Impact analysis, finding definitions | Refactoring prep, complexity analysis | + +**Combined workflow example:** + +```bash +# 1. Find where a function is used (SCIP) +dora refs validateEmail + +# 2. Analyze its complexity before changing (Tree-sitter) +dora fn src/utils/validation.ts | jq '.functions[] | select(.name == "validateEmail")' + +# 3. Check for smells in the file +dora smells src/utils/validation.ts + +# 4. Assess impact on dependents +dora rdeps src/utils/validation.ts --depth 2 +``` + +**Correlation:** `dora fn` and `dora class` automatically correlate with SCIP data when available, adding `reference_count` to each function/class showing how many times it is referenced across the codebase. From cf6fc22847461fa028604835a4a90dc768256c21 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 09:44:55 +0700 Subject: [PATCH 12/31] fix(tree-sitter): apply code convention fixes - Fix fn/class/smells calls to use single object param in index.ts and handlers.ts - Fix import order in index.ts - Remove duplicate src/tree-sitter/types.ts, use schemas/treesitter.ts as source of truth - Deduplicate parseFunctions/parseClasses into shared parseFile helper - Make getDbConnection synchronous (getDb is sync) - Rename inBlockComment to isInBlockComment per boolean naming convention --- src/commands/class.ts | 37 +++-- src/commands/fn.ts | 26 ++-- src/commands/smells.ts | 92 ++++++++----- src/commands/status.ts | 18 +-- src/commands/symbol.ts | 34 +++-- src/index.ts | 19 +-- src/mcp/handlers.ts | 31 +++-- src/schemas/symbol.ts | 1 + src/tree-sitter/languages/javascript.ts | 2 +- src/tree-sitter/languages/registry.ts | 2 +- src/tree-sitter/languages/typescript.ts | 2 +- src/tree-sitter/parser.ts | 171 +++++++----------------- src/tree-sitter/types.ts | 48 ------- 13 files changed, 208 insertions(+), 275 deletions(-) delete mode 100644 src/tree-sitter/types.ts diff --git a/src/commands/class.ts b/src/commands/class.ts index 5892aae..d63aa89 100644 --- a/src/commands/class.ts +++ b/src/commands/class.ts @@ -1,18 +1,30 @@ +import { getLanguageForExtension } from "../tree-sitter/languages/registry.ts"; import { parseClasses } from "../tree-sitter/parser.ts"; -import type { ClassResult, ClassInfo } from "../schemas/treesitter.ts"; +import type { ClassInfo, ClassResult } from "../schemas/treesitter.ts"; import { resolveAndValidatePath, setupCommand } from "./shared.ts"; -import { getLanguageForExtension } from "../tree-sitter/languages/registry.ts"; -export async function classCommand( - path: string, - options: { - sort?: string; - limit?: number; - }, -): Promise { +type ClassCommandOptions = { + sort?: string; + limit?: number; +}; + +type ClassCommandParams = { + path: string; + options?: ClassCommandOptions; +}; + +function getClassComplexity(params: { item: ClassInfo }): number { + const { item } = params; + return item.methods.reduce( + (total, method) => total + method.cyclomatic_complexity, + 0, + ); +} + +export async function classCommand(params: ClassCommandParams): Promise { + const { path, options = {} } = params; const ctx = await setupCommand(); const relativePath = resolveAndValidatePath({ ctx, inputPath: path }); - const absolutePath = `${ctx.config.root}/${relativePath}`; const extension = relativePath.includes(".") @@ -34,7 +46,10 @@ export async function classCommand( case "methods": return b.methods.length - a.methods.length; case "complexity": - return (b.reference_count || 0) - (a.reference_count || 0); + return ( + getClassComplexity({ item: b }) - + getClassComplexity({ item: a }) + ); case "name": default: return a.name.localeCompare(b.name); diff --git a/src/commands/fn.ts b/src/commands/fn.ts index fad7828..d369876 100644 --- a/src/commands/fn.ts +++ b/src/commands/fn.ts @@ -1,19 +1,23 @@ +import { getLanguageForExtension } from "../tree-sitter/languages/registry.ts"; import { parseFunctions } from "../tree-sitter/parser.ts"; import type { FnResult, FunctionInfo } from "../schemas/treesitter.ts"; import { resolveAndValidatePath, setupCommand } from "./shared.ts"; -import { getLanguageForExtension } from "../tree-sitter/languages/registry.ts"; -export async function fn( - path: string, - options: { - sort?: string; - minComplexity?: number; - limit?: number; - }, -): Promise { +type FnOptions = { + sort?: string; + minComplexity?: number; + limit?: number; +}; + +type FnParams = { + path: string; + options?: FnOptions; +}; + +export async function fn(params: FnParams): Promise { + const { path, options = {} } = params; const ctx = await setupCommand(); const relativePath = resolveAndValidatePath({ ctx, inputPath: path }); - const absolutePath = `${ctx.config.root}/${relativePath}`; const extension = relativePath.includes(".") @@ -32,7 +36,7 @@ export async function fn( const minComplexity = options.minComplexity; if (minComplexity !== undefined && minComplexity > 0) { filteredFunctions = filteredFunctions.filter( - (f) => f.cyclomatic_complexity >= minComplexity, + (item) => item.cyclomatic_complexity >= minComplexity, ); } diff --git a/src/commands/smells.ts b/src/commands/smells.ts index ef52185..affc7ce 100644 --- a/src/commands/smells.ts +++ b/src/commands/smells.ts @@ -1,26 +1,55 @@ import { parseFunctions } from "../tree-sitter/parser.ts"; -import type { SmellsResult, SmellItem, FunctionInfo } from "../schemas/treesitter.ts"; +import type { + FunctionInfo, + SmellItem, + SmellsResult, +} from "../schemas/treesitter.ts"; +import { CtxError } from "../utils/errors.ts"; import { resolveAndValidatePath, setupCommand } from "./shared.ts"; +type TodoComment = { + line: number; + text: string; +}; + +type SmellsOptions = { + complexityThreshold?: number; + locThreshold?: number; + paramsThreshold?: number; +}; + +type SmellsParams = { + path: string; + options?: SmellsOptions; +}; + async function scanTodoComments(params: { filePath: string; -}): Promise> { +}): Promise { const { filePath } = params; - const file = Bun.file(filePath); - const content = await file.text(); - const lines = content.split("\n"); + let content: string; + try { + content = await Bun.file(filePath).text(); + } catch (error) { + throw new CtxError( + `Failed to read file: ${error instanceof Error ? error.message : String(error)}`, + undefined, + { filePath }, + ); + } + const lines = content.split("\n"); const todoPattern = /TODO|FIXME|HACK/; - const results: Array<{ line: number; text: string }> = []; + const results: TodoComment[] = []; - for (let i = 0; i < lines.length; i++) { - const lineContent = lines[i] ?? ""; + for (let index = 0; index < lines.length; index++) { + const lineContent = lines[index] ?? ""; const commentMatch = lineContent.match(/\/\/(.+)|\/\*(.+?)\*\//); if (commentMatch) { const commentText = (commentMatch[1] ?? commentMatch[2]) || ""; if (todoPattern.test(commentText)) { - results.push({ line: i + 1, text: commentText.trim() }); + results.push({ line: index + 1, text: commentText.trim() }); } } } @@ -37,37 +66,37 @@ function detectFunctionSmells(params: { const { functions, complexityThreshold, locThreshold, paramsThreshold } = params; const smells: SmellItem[] = []; - for (const fn of functions) { - if (fn.cyclomatic_complexity > complexityThreshold) { + for (const fnItem of functions) { + if (fnItem.cyclomatic_complexity > complexityThreshold) { smells.push({ kind: "high_complexity", - function: fn.name, - line: fn.lines[0], - value: fn.cyclomatic_complexity, + function: fnItem.name, + line: fnItem.lines[0], + value: fnItem.cyclomatic_complexity, threshold: complexityThreshold, - message: `Cyclomatic complexity ${fn.cyclomatic_complexity} exceeds threshold ${complexityThreshold}`, + message: `Cyclomatic complexity ${fnItem.cyclomatic_complexity} exceeds threshold ${complexityThreshold}`, }); } - if (fn.loc > locThreshold) { + if (fnItem.loc > locThreshold) { smells.push({ kind: "long_function", - function: fn.name, - line: fn.lines[0], - value: fn.loc, + function: fnItem.name, + line: fnItem.lines[0], + value: fnItem.loc, threshold: locThreshold, - message: `Function length ${fn.loc} lines exceeds threshold ${locThreshold}`, + message: `Function length ${fnItem.loc} lines exceeds threshold ${locThreshold}`, }); } - if (fn.parameters.length > paramsThreshold) { + if (fnItem.parameters.length > paramsThreshold) { smells.push({ kind: "too_many_params", - function: fn.name, - line: fn.lines[0], - value: fn.parameters.length, + function: fnItem.name, + line: fnItem.lines[0], + value: fnItem.parameters.length, threshold: paramsThreshold, - message: `Parameter count ${fn.parameters.length} exceeds threshold ${paramsThreshold}`, + message: `Parameter count ${fnItem.parameters.length} exceeds threshold ${paramsThreshold}`, }); } } @@ -75,14 +104,8 @@ function detectFunctionSmells(params: { return smells; } -export async function smells( - path: string, - options: { - complexityThreshold?: number; - locThreshold?: number; - paramsThreshold?: number; - }, -): Promise { +export async function smells(params: SmellsParams): Promise { + const { path, options = {} } = params; const ctx = await setupCommand(); const relativePath = resolveAndValidatePath({ ctx, inputPath: path }); const absolutePath = `${ctx.config.root}/${relativePath}`; @@ -114,10 +137,11 @@ export async function smells( })); const allSmells = [...functionSmells, ...todoSmells]; + const isClean = allSmells.length === 0; return { path: relativePath, - clean: allSmells.length === 0, + clean: isClean, smells: allSmells, }; } diff --git a/src/commands/status.ts b/src/commands/status.ts index fbc6a9e..cf7a470 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -11,36 +11,30 @@ import { isIndexed, loadConfig } from "../utils/config.ts"; export async function status(): Promise { const config = await loadConfig(); - - // Check if indexed - const indexed = await isIndexed(config); + const isIndexedRepo = await isIndexed(config); const result: StatusResult = { - initialized: true, // If we got here, it's initialized - indexed, + initialized: true, + indexed: isIndexedRepo, }; - // If indexed, get stats - if (indexed) { + if (isIndexedRepo) { try { const db = getDb(config); result.file_count = getFileCount(db); result.symbol_count = getSymbolCount(db); result.last_indexed = config.lastIndexed; - // Add document statistics const documentCount = getDocumentCount(db); if (documentCount > 0) { result.document_count = documentCount; result.documents_by_type = getDocumentCountsByType(db); } - } catch (error) { - // Database might be corrupt, but we can still report it exists + } catch { result.indexed = false; } } - // Check tree-sitter grammar availability (try TypeScript) const grammarLanguage = config.language || "typescript"; try { const grammarPath = await findGrammarPath({ @@ -53,7 +47,7 @@ export async function status(): Promise { language: grammarLanguage, grammar_path: grammarPath, }; - } catch (error) { + } catch { result.tree_sitter = { available: false, language: grammarLanguage, diff --git a/src/commands/symbol.ts b/src/commands/symbol.ts index c080cf9..5edc818 100644 --- a/src/commands/symbol.ts +++ b/src/commands/symbol.ts @@ -1,6 +1,8 @@ import { searchSymbols } from "../db/queries.ts"; -import type { SymbolResult, SymbolSearchResult } from "../types.ts"; import { parseFunctions } from "../tree-sitter/parser.ts"; +import type { SymbolResult, SymbolSearchResult } from "../types.ts"; +import { CtxError } from "../utils/errors.ts"; +import { debugDb } from "../utils/logger.ts"; import { DEFAULTS, parseIntFlag, @@ -30,14 +32,14 @@ export async function symbol( const functionKinds = new Set(["function", "method"]); const fileGroups = new Map(); - for (let i = 0; i < results.length; i++) { - const result = results[i]!; + for (let index = 0; index < results.length; index++) { + const result = results[index]!; if (functionKinds.has(result.kind)) { const existing = fileGroups.get(result.path); if (existing) { - existing.push({ index: i, result }); + existing.push({ index, result }); } else { - fileGroups.set(result.path, [{ index: i, result }]); + fileGroups.set(result.path, [{ index, result }]); } } } @@ -60,18 +62,20 @@ export async function symbol( } >(); - for (const fn of functions) { - const key = `${fn.name}:${fn.lines[0]}`; + for (const fnItem of functions) { + const key = `${fnItem.name}:${fnItem.lines[0]}`; functionMap.set(key, { - cyclomatic_complexity: fn.cyclomatic_complexity, - parameters: fn.parameters, - return_type: fn.return_type, + cyclomatic_complexity: fnItem.cyclomatic_complexity, + parameters: fnItem.parameters, + return_type: fnItem.return_type, }); } for (const item of items) { const startLine = item.result.lines?.[0]; - if (startLine === undefined) continue; + if (startLine === undefined) { + continue; + } const key = `${item.result.name}:${startLine}`; const fnInfo = functionMap.get(key); @@ -85,8 +89,10 @@ export async function symbol( }; } } - } catch { - // Gracefully skip if parsing fails or grammar unavailable + } catch (error) { + if (error instanceof CtxError) { + debugDb(`Tree-sitter parse failed for ${filePath}: ${error.message}`); + } } } @@ -124,7 +130,7 @@ export async function symbol( if (docs.length > 0) { return { ...result, - documented_in: docs.map((d) => d.path), + documented_in: docs.map((item) => item.path), }; } diff --git a/src/index.ts b/src/index.ts index fc4cbfa..f2a7be4 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ #!/usr/bin/env bun -import { classCommand } from "./commands/class.ts"; import { Command } from "commander"; import { adventure } from "./commands/adventure.ts"; import { changes } from "./commands/changes.ts"; +import { classCommand } from "./commands/class.ts"; import { complexity } from "./commands/complexity.ts"; import { cookbookList, cookbookShow } from "./commands/cookbook.ts"; import { coupling } from "./commands/coupling.ts"; @@ -27,11 +27,11 @@ import { query } from "./commands/query.ts"; import { rdeps } from "./commands/rdeps.ts"; import { refs } from "./commands/refs.ts"; import { schema } from "./commands/schema.ts"; +import { smells } from "./commands/smells.ts"; import { status } from "./commands/status.ts"; import { symbol } from "./commands/symbol.ts"; import { treasure } from "./commands/treasure.ts"; import { wrapCommand } from "./utils/errors.ts"; -import { smells } from "./commands/smells.ts"; import { output } from "./utils/output.ts"; import packageJson from "../package.json"; @@ -135,7 +135,7 @@ program .option("--min-complexity ", "Filter functions below complexity threshold") .option("--limit ", "Maximum number of results") .action(wrapCommand(async (path: string, options) => { - const result = await fn(path, options); + const result = await fn({ path, options }); output({ data: result, isJson: program.opts().json }); })); @@ -147,10 +147,13 @@ program .option("--loc-threshold ", "Lines of code threshold (default: 100)", "100") .option("--params-threshold ", "Parameter count threshold (default: 5)", "5") .action(wrapCommand(async (path: string, options) => { - const result = await smells(path, { - complexityThreshold: parseInt(options.complexityThreshold, 10), - locThreshold: parseInt(options.locThreshold, 10), - paramsThreshold: parseInt(options.paramsThreshold, 10), + const result = await smells({ + path, + options: { + complexityThreshold: parseInt(options.complexityThreshold, 10), + locThreshold: parseInt(options.locThreshold, 10), + paramsThreshold: parseInt(options.paramsThreshold, 10), + }, }); output({ data: result, isJson: program.opts().json }); })); @@ -162,7 +165,7 @@ program .option("--sort ", "Sort by: name, methods, or complexity (default: name)") .option("--limit ", "Maximum number of results") .action(wrapCommand(async (path: string, options) => { - const result = await classCommand(path, options); + const result = await classCommand({ path, options }); output({ data: result, isJson: program.opts().json }); })); diff --git a/src/mcp/handlers.ts b/src/mcp/handlers.ts index 13147c2..9c88a0e 100644 --- a/src/mcp/handlers.ts +++ b/src/mcp/handlers.ts @@ -162,23 +162,32 @@ export async function handleToolCall( }); }) .with("dora_fn", async () => { - return await fn(args.path, { - sort: args.sort, - minComplexity: args.minComplexity, - limit: args.limit, + return await fn({ + path: args.path, + options: { + sort: args.sort, + minComplexity: args.minComplexity, + limit: args.limit, + }, }); }) .with("dora_class", async () => { - return await classCommand(args.path, { - sort: args.sort, - limit: args.limit, + return await classCommand({ + path: args.path, + options: { + sort: args.sort, + limit: args.limit, + }, }); }) .with("dora_smells", async () => { - return await smells(args.path, { - complexityThreshold: args.complexityThreshold, - locThreshold: args.locThreshold, - paramsThreshold: args.paramsThreshold, + return await smells({ + path: args.path, + options: { + complexityThreshold: args.complexityThreshold, + locThreshold: args.locThreshold, + paramsThreshold: args.paramsThreshold, + }, }); }) .otherwise(() => { diff --git a/src/schemas/symbol.ts b/src/schemas/symbol.ts index 812dccd..9c8d4cf 100644 --- a/src/schemas/symbol.ts +++ b/src/schemas/symbol.ts @@ -9,6 +9,7 @@ export const SymbolResultSchema = z.object({ cyclomatic_complexity: z.number().optional(), parameters: z.array(ParameterInfoSchema).optional(), return_type: z.string().nullable().optional(), + documented_in: z.array(z.string()).optional(), }); export const SymbolSearchResultSchema = z.object({ diff --git a/src/tree-sitter/languages/javascript.ts b/src/tree-sitter/languages/javascript.ts index f1ffa15..9a08803 100644 --- a/src/tree-sitter/languages/javascript.ts +++ b/src/tree-sitter/languages/javascript.ts @@ -1,5 +1,5 @@ import type Parser from "web-tree-sitter"; -import type { ClassInfo, FunctionInfo, MethodInfo } from "../types.ts"; +import type { ClassInfo, FunctionInfo, MethodInfo } from "../../schemas/treesitter.ts"; export const functionQueryString = ` (function_declaration diff --git a/src/tree-sitter/languages/registry.ts b/src/tree-sitter/languages/registry.ts index 3b35ce5..7ff9940 100644 --- a/src/tree-sitter/languages/registry.ts +++ b/src/tree-sitter/languages/registry.ts @@ -1,5 +1,5 @@ import type Parser from "web-tree-sitter"; -import type { ClassInfo, FunctionInfo } from "../types.ts"; +import type { ClassInfo, FunctionInfo } from "../../schemas/treesitter.ts"; import * as typescriptLang from "./typescript.ts"; import * as javascriptLang from "./javascript.ts"; diff --git a/src/tree-sitter/languages/typescript.ts b/src/tree-sitter/languages/typescript.ts index ec7ca4c..88185b0 100644 --- a/src/tree-sitter/languages/typescript.ts +++ b/src/tree-sitter/languages/typescript.ts @@ -1,5 +1,5 @@ import type Parser from "web-tree-sitter"; -import type { ClassInfo, FunctionInfo, MethodInfo } from "../types.ts"; +import type { ClassInfo, FunctionInfo, MethodInfo } from "../../schemas/treesitter.ts"; export const functionQueryString = ` (function_declaration diff --git a/src/tree-sitter/parser.ts b/src/tree-sitter/parser.ts index df720c4..7386b85 100644 --- a/src/tree-sitter/parser.ts +++ b/src/tree-sitter/parser.ts @@ -8,18 +8,14 @@ import { getLanguageForExtension, getLanguageEntry, } from "./languages/registry.ts"; -import type { FunctionInfo, ClassInfo, FileMetrics } from "./types.ts"; +import type { FunctionInfo, ClassInfo, FileMetrics } from "../schemas/treesitter.ts"; type ParserModule = typeof import("web-tree-sitter"); -const ParserPromise: Promise = import("web-tree-sitter"); +const parserModulePromise: Promise = import("web-tree-sitter"); const languageCache = new Map(); -async function getParserModule(): Promise { - return await ParserPromise; -} - async function getLanguage(params: { grammarPath: string; }): Promise { @@ -30,19 +26,16 @@ async function getLanguage(params: { return cached; } - const mod = await getParserModule(); + const mod = await parserModulePromise; await mod.Parser.init(); const language = await mod.Language.load(grammarPath); languageCache.set(grammarPath, language); return language; } -async function getDbConnection(params: { - config: Config; -}): Promise { - const { config } = params; +function getDbConnection(params: { config: Config }): Database | null { try { - return getDb(config); + return getDb(params.config); } catch { return null; } @@ -92,7 +85,7 @@ function calculateFileMetrics(params: { let commentLines = 0; let blankLines = 0; - let inBlockComment = false; + let isInBlockComment = false; for (const line of lines) { const trimmed = line.trim(); @@ -100,10 +93,10 @@ function calculateFileMetrics(params: { blankLines++; continue; } - if (inBlockComment) { + if (isInBlockComment) { commentLines++; if (trimmed.endsWith("*/")) { - inBlockComment = false; + isInBlockComment = false; } continue; } @@ -114,7 +107,7 @@ function calculateFileMetrics(params: { if (trimmed.startsWith("/*")) { commentLines++; if (!trimmed.endsWith("*/")) { - inBlockComment = true; + isInBlockComment = true; } continue; } @@ -141,35 +134,33 @@ function calculateFileMetrics(params: { }; } -export async function parseFunctions(params: { +type ParsedFile = { + functions: FunctionInfo[]; + classes: ClassInfo[]; + metrics: FileMetrics; +}; + +async function parseFile(params: { filePath: string; config: Config; -}): Promise<{ functions: FunctionInfo[]; metrics: FileMetrics }> { +}): Promise { const { filePath, config } = params; - const extension = filePath.includes(".") ? filePath.split(".").pop() || "" : ""; - const extWithDot = extension ? `.${extension}` : ""; - const languageKey = getLanguageForExtension({ extension: extWithDot }); + const extension = filePath.includes(".") ? `.${filePath.split(".").pop() || ""}` : ""; + const languageKey = getLanguageForExtension({ extension }); if (!languageKey) { - throw new CtxError(`Unsupported file extension: ${extWithDot}`, undefined, { - filePath, - }); + throw new CtxError(`Unsupported file extension: ${extension}`, undefined, { filePath }); } const langEntry = getLanguageEntry({ language: languageKey }); if (!langEntry) { - throw new CtxError( - `Language entry not found for: ${languageKey}`, - undefined, - { filePath }, - ); + throw new CtxError(`Language entry not found for: ${languageKey}`, undefined, { filePath }); } let content: string; try { - const file = Bun.file(filePath); - content = await file.text(); + content = await Bun.file(filePath).text(); } catch (error) { throw new CtxError( `Failed to read file: ${error instanceof Error ? error.message : String(error)}`, @@ -185,7 +176,7 @@ export async function parseFunctions(params: { }); const language = await getLanguage({ grammarPath }); - const mod = await getParserModule(); + const mod = await parserModulePromise; const parser = new mod.Parser(); parser.setLanguage(language); @@ -196,25 +187,13 @@ export async function parseFunctions(params: { } const queries = langEntry.getQueries(); + const functionCaptures = new mod.Query(language, queries.functionQuery).captures(tree.rootNode); + const classCaptures = new mod.Query(language, queries.classQuery).captures(tree.rootNode); - const functionQuery = new mod.Query(language, queries.functionQuery); - const functionCaptures = functionQuery.captures(tree.rootNode); - - const classQuery = new mod.Query(language, queries.classQuery); - const classCaptures = classQuery.captures(tree.rootNode); - - const parseResults = queries.parseResults({ - functionCaptures, - classCaptures, - }); - - const db = await getDbConnection({ config }); + const parseResults = queries.parseResults({ functionCaptures, classCaptures }); - await correlateWithScip({ - db, - filePath, - symbols: parseResults.functions, - }); + parser.delete(); + tree.delete(); const metrics = calculateFileMetrics({ content, @@ -222,94 +201,40 @@ export async function parseFunctions(params: { classes: parseResults.classes, }); - parser.delete(); - tree.delete(); - return { functions: parseResults.functions, + classes: parseResults.classes, metrics, }; } -export async function parseClasses(params: { +export async function parseFunctions(params: { filePath: string; config: Config; -}): Promise<{ classes: ClassInfo[] }> { +}): Promise<{ functions: FunctionInfo[]; metrics: FileMetrics }> { const { filePath, config } = params; + const parsed = await parseFile({ filePath, config }); - const extension = filePath.includes(".") ? filePath.split(".").pop() || "" : ""; - const extWithDot = extension ? `.${extension}` : ""; - const languageKey = getLanguageForExtension({ extension: extWithDot }); - - if (!languageKey) { - throw new CtxError(`Unsupported file extension: ${extWithDot}`, undefined, { - filePath, - }); - } - - const langEntry = getLanguageEntry({ language: languageKey }); - if (!langEntry) { - throw new CtxError( - `Language entry not found for: ${languageKey}`, - undefined, - { filePath }, - ); - } - - let content: string; - try { - const file = Bun.file(filePath); - content = await file.text(); - } catch (error) { - throw new CtxError( - `Failed to read file: ${error instanceof Error ? error.message : String(error)}`, - undefined, - { filePath }, - ); - } - - const grammarPath = await findGrammarPath({ - lang: languageKey, - config, - projectRoot: config.root, - }); - - const language = await getLanguage({ grammarPath }); - const mod = await getParserModule(); - - const parser = new mod.Parser(); - parser.setLanguage(language); + const db = getDbConnection({ config }); + await correlateWithScip({ db, filePath, symbols: parsed.functions }); - const tree = parser.parse(content); - if (!tree) { - throw new CtxError("Failed to parse file", undefined, { filePath }); - } - - const queries = langEntry.getQueries(); - - const functionQuery = new mod.Query(language, queries.functionQuery); - const functionCaptures = functionQuery.captures(tree.rootNode); - - const classQuery = new mod.Query(language, queries.classQuery); - const classCaptures = classQuery.captures(tree.rootNode); - - const parseResults = queries.parseResults({ - functionCaptures, - classCaptures, - }); - - const db = await getDbConnection({ config }); + return { + functions: parsed.functions, + metrics: parsed.metrics, + }; +} - await correlateWithScip({ - db, - filePath, - symbols: parseResults.classes, - }); +export async function parseClasses(params: { + filePath: string; + config: Config; +}): Promise<{ classes: ClassInfo[] }> { + const { filePath, config } = params; + const parsed = await parseFile({ filePath, config }); - parser.delete(); - tree.delete(); + const db = getDbConnection({ config }); + await correlateWithScip({ db, filePath, symbols: parsed.classes }); return { - classes: parseResults.classes, + classes: parsed.classes, }; } diff --git a/src/tree-sitter/types.ts b/src/tree-sitter/types.ts deleted file mode 100644 index 31741a5..0000000 --- a/src/tree-sitter/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -export type ParameterInfo = { - name: string; - type: string | null; -}; - -export type FunctionInfo = { - name: string; - lines: [number, number]; - loc: number; - cyclomatic_complexity: number; - parameters: ParameterInfo[]; - return_type: string | null; - is_async: boolean; - is_exported: boolean; - is_method: boolean; - jsdoc: string | null; - reference_count?: number; -}; - -export type MethodInfo = { - name: string; - line: number; - is_async: boolean; - cyclomatic_complexity: number; -}; - -export type ClassInfo = { - name: string; - lines: [number, number]; - extends_name: string | null; - implements: string[]; - decorators: string[]; - is_abstract: boolean; - methods: MethodInfo[]; - property_count: number; - reference_count?: number; -}; - -export type FileMetrics = { - loc: number; - sloc: number; - comment_lines: number; - blank_lines: number; - function_count: number; - class_count: number; - avg_complexity: number; - max_complexity: number; -}; From 42b8ec5efd760a3355ff6bfc9407274c5a28bcf6 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 09:53:39 +0700 Subject: [PATCH 13/31] fix(tree-sitter): runtime wasm resolution, grammar discovery, error handling, cookbook registration --- src/commands/file.ts | 5 +- src/commands/symbol.ts | 55 +++++++------------ .../templates/cookbook}/tree-sitter.md | 0 src/tree-sitter/grammar.ts | 13 ++--- src/tree-sitter/parser.ts | 3 +- src/utils/templates.ts | 7 +++ 6 files changed, 34 insertions(+), 49 deletions(-) rename {.dora/cookbooks => src/templates/cookbook}/tree-sitter.md (100%) diff --git a/src/commands/file.ts b/src/commands/file.ts index 4cd7dc4..89e1357 100644 --- a/src/commands/file.ts +++ b/src/commands/file.ts @@ -5,7 +5,6 @@ import { } from "../db/queries.ts"; import type { FileResult } from "../types.ts"; import { parseFunctions } from "../tree-sitter/parser.ts"; -import { CtxError } from "../utils/errors.ts"; import { debugDb } from "../utils/logger.ts"; import { resolveAndValidatePath, setupCommand } from "./shared.ts"; @@ -54,9 +53,7 @@ export async function file(path: string): Promise { metrics = parseResult.metrics; functions = parseResult.functions; } catch (error) { - if (error instanceof CtxError) { - debugDb(`Tree-sitter parse failed for ${relativePath}: ${error.message}`); - } + debugDb(`Tree-sitter parse failed for ${relativePath}: ${error instanceof Error ? error.message : String(error)}`); } const result: FileResult = { diff --git a/src/commands/symbol.ts b/src/commands/symbol.ts index 5edc818..8436088 100644 --- a/src/commands/symbol.ts +++ b/src/commands/symbol.ts @@ -1,7 +1,6 @@ import { searchSymbols } from "../db/queries.ts"; import { parseFunctions } from "../tree-sitter/parser.ts"; import type { SymbolResult, SymbolSearchResult } from "../types.ts"; -import { CtxError } from "../utils/errors.ts"; import { debugDb } from "../utils/logger.ts"; import { DEFAULTS, @@ -53,46 +52,32 @@ export async function symbol( config: ctx.config, }); - const functionMap = new Map< - string, - { - cyclomatic_complexity: number; - parameters: Array<{ name: string; type: string | null }>; - return_type: string | null; - } - >(); - + const functionsByName = new Map(); for (const fnItem of functions) { - const key = `${fnItem.name}:${fnItem.lines[0]}`; - functionMap.set(key, { - cyclomatic_complexity: fnItem.cyclomatic_complexity, - parameters: fnItem.parameters, - return_type: fnItem.return_type, - }); + const existing = functionsByName.get(fnItem.name) ?? []; + existing.push(fnItem); + functionsByName.set(fnItem.name, existing); } for (const item of items) { - const startLine = item.result.lines?.[0]; - if (startLine === undefined) { - continue; - } - - const key = `${item.result.name}:${startLine}`; - const fnInfo = functionMap.get(key); - - if (fnInfo) { - enhancedResults[item.index] = { - ...item.result, - cyclomatic_complexity: fnInfo.cyclomatic_complexity, - parameters: fnInfo.parameters, - return_type: fnInfo.return_type, - }; - } + const scipLine = item.result.lines?.[0]; + const cleanName = item.result.name.replace(/\(\)[^(]*$/, ""); + const candidates = functionsByName.get(cleanName); + if (!candidates || scipLine === undefined) continue; + + const best = candidates.reduce((a, b) => + Math.abs(a.lines[0] - scipLine) <= Math.abs(b.lines[0] - scipLine) ? a : b, + ); + + enhancedResults[item.index] = { + ...item.result, + cyclomatic_complexity: best.cyclomatic_complexity, + parameters: best.parameters, + return_type: best.return_type, + }; } } catch (error) { - if (error instanceof CtxError) { - debugDb(`Tree-sitter parse failed for ${filePath}: ${error.message}`); - } + debugDb(`Tree-sitter parse failed for ${filePath}: ${error instanceof Error ? error.message : String(error)}`); } } diff --git a/.dora/cookbooks/tree-sitter.md b/src/templates/cookbook/tree-sitter.md similarity index 100% rename from .dora/cookbooks/tree-sitter.md rename to src/templates/cookbook/tree-sitter.md diff --git a/src/tree-sitter/grammar.ts b/src/tree-sitter/grammar.ts index 74a4acd..fc251e4 100644 --- a/src/tree-sitter/grammar.ts +++ b/src/tree-sitter/grammar.ts @@ -12,15 +12,10 @@ async function findGlobalNodeModulesPath(): Promise { const output = await new Response(proc.stdout).text(); await proc.exited; - const lines = output.split("\n"); - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.startsWith("/") && trimmed.includes("node_modules")) { - const match = trimmed.match(/^(.+\/node_modules)/); - if (match && match[1]) { - return match[1]; - } - } + const firstLine = output.split("\n")[0]?.trim() ?? ""; + const match = firstLine.match(/^(\/.+?)\s+node_modules/); + if (match && match[1]) { + return join(match[1], "node_modules"); } return null; diff --git a/src/tree-sitter/parser.ts b/src/tree-sitter/parser.ts index 7386b85..1109e73 100644 --- a/src/tree-sitter/parser.ts +++ b/src/tree-sitter/parser.ts @@ -27,7 +27,8 @@ async function getLanguage(params: { } const mod = await parserModulePromise; - await mod.Parser.init(); + const wasmPath = import.meta.resolve("web-tree-sitter/web-tree-sitter.wasm").replace("file://", ""); + await mod.Parser.init({ locateFile: () => wasmPath }); const language = await mod.Language.load(grammarPath); languageCache.set(grammarPath, language); return language; diff --git a/src/utils/templates.ts b/src/utils/templates.ts index 80c0261..36207cf 100644 --- a/src/utils/templates.ts +++ b/src/utils/templates.ts @@ -23,6 +23,9 @@ import cookbookExportsMd from "../templates/cookbook/exports.md" with { import cookbookAgentSetupMd from "../templates/cookbook/agent-setup.md" with { type: "text", }; +import cookbookTreeSitterMd from "../templates/cookbook/tree-sitter.md" with { + type: "text", +}; /** * Copy a single file if it doesn't exist at target @@ -81,6 +84,10 @@ export async function copyTemplates(targetDoraDir: string): Promise { content: cookbookAgentSetupMd, target: join(targetDoraDir, "cookbook", "agent-setup.md"), }, + { + content: cookbookTreeSitterMd, + target: join(targetDoraDir, "cookbook", "tree-sitter.md"), + }, ]; // Create subdirectories From 2d533703b3b0ed7a19d9ef684402a4cb1a9b0f52 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 10:06:28 +0700 Subject: [PATCH 14/31] test(tree-sitter): add tests for function/class captures and file metrics - parseFunctionCaptures: all 5 capture types, dedup, async, params, return type, jsdoc, line numbers - parseClassCaptures: extends, implements, abstract, decorators, methods, properties - calculateFileMetrics: blank/comment/sloc counting, block comment state machine, complexity aggregation - export calculateFileMetrics from parser.ts - extend biome:format script to include ./test --- package.json | 2 +- src/tree-sitter/parser.ts | 48 +- test/tree-sitter/class-captures.test.ts | 979 ++++++++++++++++++ test/tree-sitter/file-metrics.test.ts | 261 +++++ test/tree-sitter/function-captures.test.ts | 1036 ++++++++++++++++++++ 5 files changed, 2312 insertions(+), 14 deletions(-) create mode 100644 test/tree-sitter/class-captures.test.ts create mode 100644 test/tree-sitter/file-metrics.test.ts create mode 100644 test/tree-sitter/function-captures.test.ts diff --git a/package.json b/package.json index 9ee88de..34bcfa7 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "build": "bun build src/index.ts --compile --outfile dist/dora", "build:npm": "bun build src/index.ts --outfile dist/index.js --target bun", "generate-proto": "protoc --plugin=./node_modules/.bin/protoc-gen-es --es_out=src/converter --es_opt=target=ts --proto_path=src/proto src/proto/scip.proto", - "biome:format": "biome format --write ./src", + "biome:format": "biome format --write ./src ./test", "build:linux": "bun build src/index.ts --compile --target=bun-linux-x64 --outfile dist/dora-linux-x64", "build:macos": "bun build src/index.ts --compile --target=bun-darwin-x64 --outfile dist/dora-darwin-x64", "build:macos-arm": "bun build src/index.ts --compile --target=bun-darwin-arm64 --outfile dist/dora-darwin-arm64", diff --git a/src/tree-sitter/parser.ts b/src/tree-sitter/parser.ts index 1109e73..3196215 100644 --- a/src/tree-sitter/parser.ts +++ b/src/tree-sitter/parser.ts @@ -8,7 +8,11 @@ import { getLanguageForExtension, getLanguageEntry, } from "./languages/registry.ts"; -import type { FunctionInfo, ClassInfo, FileMetrics } from "../schemas/treesitter.ts"; +import type { + FunctionInfo, + ClassInfo, + FileMetrics, +} from "../schemas/treesitter.ts"; type ParserModule = typeof import("web-tree-sitter"); @@ -27,7 +31,9 @@ async function getLanguage(params: { } const mod = await parserModulePromise; - const wasmPath = import.meta.resolve("web-tree-sitter/web-tree-sitter.wasm").replace("file://", ""); + const wasmPath = import.meta + .resolve("web-tree-sitter/web-tree-sitter.wasm") + .replace("file://", ""); await mod.Parser.init({ locateFile: () => wasmPath }); const language = await mod.Language.load(grammarPath); languageCache.set(grammarPath, language); @@ -64,9 +70,9 @@ async function correlateWithScip(params: { WHERE file_id = ? AND start_line = ? AND name = ? LIMIT 1`, ) - .get(fileId, symbol.lines[0], symbol.name) as - | { reference_count: number } - | null; + .get(fileId, symbol.lines[0], symbol.name) as { + reference_count: number; + } | null; if (symbolRow) { symbol.reference_count = symbolRow.reference_count; @@ -74,7 +80,7 @@ async function correlateWithScip(params: { } } -function calculateFileMetrics(params: { +export function calculateFileMetrics(params: { content: string; functions: FunctionInfo[]; classes: ClassInfo[]; @@ -147,16 +153,24 @@ async function parseFile(params: { }): Promise { const { filePath, config } = params; - const extension = filePath.includes(".") ? `.${filePath.split(".").pop() || ""}` : ""; + const extension = filePath.includes(".") + ? `.${filePath.split(".").pop() || ""}` + : ""; const languageKey = getLanguageForExtension({ extension }); if (!languageKey) { - throw new CtxError(`Unsupported file extension: ${extension}`, undefined, { filePath }); + throw new CtxError(`Unsupported file extension: ${extension}`, undefined, { + filePath, + }); } const langEntry = getLanguageEntry({ language: languageKey }); if (!langEntry) { - throw new CtxError(`Language entry not found for: ${languageKey}`, undefined, { filePath }); + throw new CtxError( + `Language entry not found for: ${languageKey}`, + undefined, + { filePath }, + ); } let content: string; @@ -188,10 +202,18 @@ async function parseFile(params: { } const queries = langEntry.getQueries(); - const functionCaptures = new mod.Query(language, queries.functionQuery).captures(tree.rootNode); - const classCaptures = new mod.Query(language, queries.classQuery).captures(tree.rootNode); - - const parseResults = queries.parseResults({ functionCaptures, classCaptures }); + const functionCaptures = new mod.Query( + language, + queries.functionQuery, + ).captures(tree.rootNode); + const classCaptures = new mod.Query(language, queries.classQuery).captures( + tree.rootNode, + ); + + const parseResults = queries.parseResults({ + functionCaptures, + classCaptures, + }); parser.delete(); tree.delete(); diff --git a/test/tree-sitter/class-captures.test.ts b/test/tree-sitter/class-captures.test.ts new file mode 100644 index 0000000..4e4b215 --- /dev/null +++ b/test/tree-sitter/class-captures.test.ts @@ -0,0 +1,979 @@ +import { describe, expect, test } from "bun:test"; +import { parseClassCaptures } from "../../src/tree-sitter/languages/typescript.ts"; +import type Parser from "web-tree-sitter"; + +type NodeOverrides = Partial< + Omit< + Parser.Node, + "parent" | "previousNamedSibling" | "children" | "namedChildren" + > & { + parent?: Parser.Node | null; + previousNamedSibling?: Parser.Node | null; + children?: Parser.Node[]; + namedChildren?: Parser.Node[]; + } +>; + +function makeNode(overrides: NodeOverrides = {}): Parser.Node { + const defaults = { + type: "class_declaration", + text: "", + startIndex: 0, + endIndex: 100, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 10 }, + children: [] as Parser.Node[], + namedChildren: [] as Parser.Node[], + previousNamedSibling: null as Parser.Node | null, + parent: null as Parser.Node | null, + }; + + const merged = { ...defaults, ...overrides }; + + return { + type: merged.type, + text: merged.text, + startIndex: merged.startIndex, + endIndex: merged.endIndex, + startPosition: merged.startPosition, + endPosition: merged.endPosition, + children: merged.children, + namedChildren: merged.namedChildren, + previousNamedSibling: merged.previousNamedSibling, + parent: merged.parent, + childForFieldName: (_name: string): Parser.Node | null => { + return null; + }, + } as unknown as Parser.Node; +} + +function makeCapture(name: string, node: Parser.Node): Parser.QueryCapture { + return { name, node }; +} + +describe("parseClassCaptures", () => { + test("basic class declaration", () => { + const classNode = makeNode({ + type: "class_declaration", + text: "class Foo {}", + startIndex: 0, + endIndex: 12, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 12 }, + }); + + const nameNode = makeNode({ + type: "type_identifier", + text: "Foo", + startIndex: 6, + endIndex: 9, + startPosition: { row: 0, column: 6 }, + endPosition: { row: 0, column: 9 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + text: "{}", + startIndex: 10, + endIndex: 12, + startPosition: { row: 0, column: 10 }, + endPosition: { row: 0, column: 12 }, + namedChildren: [], + children: [], + parent: classNode, + }); + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe("Foo"); + expect(result[0].lines).toEqual([1, 1]); + expect(result[0].is_abstract).toBe(false); + expect(result[0].decorators).toEqual([]); + expect(result[0].implements).toEqual([]); + expect(result[0].extends_name).toBeNull(); + expect(result[0].methods).toEqual([]); + expect(result[0].property_count).toBe(0); + }); + + test("exported class unwraps correctly", () => { + const exportNode = makeNode({ + type: "export_statement", + text: "export class Bar {}", + startIndex: 0, + endIndex: 19, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 19 }, + }); + + const classNode = makeNode({ + type: "class_declaration", + text: "class Bar {}", + startIndex: 7, + endIndex: 19, + startPosition: { row: 0, column: 7 }, + endPosition: { row: 0, column: 19 }, + parent: exportNode, + }); + + exportNode.namedChildren = [classNode]; + + const nameNode = makeNode({ + type: "type_identifier", + text: "Bar", + startIndex: 13, + endIndex: 16, + startPosition: { row: 0, column: 13 }, + endPosition: { row: 0, column: 16 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + text: "{}", + startIndex: 17, + endIndex: 19, + startPosition: { row: 0, column: 17 }, + endPosition: { row: 0, column: 19 }, + namedChildren: [], + children: [], + parent: classNode, + }); + + const captures = [ + makeCapture("cls.export", exportNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe("Bar"); + expect(result[0].lines).toEqual([1, 1]); + expect(result[0].is_abstract).toBe(false); + }); + + test("deduplication of same startIndex", () => { + const classNode = makeNode({ + type: "class_declaration", + startIndex: 50, + endIndex: 100, + startPosition: { row: 1, column: 0 }, + endPosition: { row: 5, column: 1 }, + }); + + const nameNode = makeNode({ + type: "type_identifier", + text: "Baz", + startIndex: 56, + endIndex: 59, + startPosition: { row: 1, column: 6 }, + endPosition: { row: 1, column: 9 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + startIndex: 60, + endIndex: 100, + startPosition: { row: 1, column: 10 }, + endPosition: { row: 5, column: 1 }, + namedChildren: [], + children: [], + parent: classNode, + }); + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.declaration", classNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe("Baz"); + }); + + test("extends clause", () => { + const classNode = makeNode({ + type: "class_declaration", + startIndex: 0, + endIndex: 30, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 30 }, + }); + + const nameNode = makeNode({ + type: "type_identifier", + text: "Child", + startIndex: 6, + endIndex: 11, + startPosition: { row: 0, column: 6 }, + endPosition: { row: 0, column: 11 }, + }); + + const extendsNode = makeNode({ + type: "type_identifier", + text: "Parent", + startIndex: 20, + endIndex: 26, + startPosition: { row: 0, column: 20 }, + endPosition: { row: 0, column: 26 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + startIndex: 27, + endIndex: 30, + startPosition: { row: 0, column: 27 }, + endPosition: { row: 0, column: 30 }, + namedChildren: [], + children: [], + parent: classNode, + }); + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + makeCapture("cls.extends", extendsNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].extends_name).toBe("Parent"); + }); + + test("no extends clause", () => { + const classNode = makeNode({ + type: "class_declaration", + startIndex: 0, + endIndex: 15, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 15 }, + }); + + const nameNode = makeNode({ + type: "type_identifier", + text: "Orphan", + startIndex: 6, + endIndex: 12, + startPosition: { row: 0, column: 6 }, + endPosition: { row: 0, column: 12 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + startIndex: 13, + endIndex: 15, + startPosition: { row: 0, column: 13 }, + endPosition: { row: 0, column: 15 }, + namedChildren: [], + children: [], + parent: classNode, + }); + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].extends_name).toBeNull(); + }); + + test("implements clause via heritage", () => { + const classNode = makeNode({ + type: "class_declaration", + startIndex: 0, + endIndex: 50, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 2, column: 1 }, + }); + + const nameNode = makeNode({ + type: "type_identifier", + text: "ImplClass", + startIndex: 6, + endIndex: 15, + startPosition: { row: 0, column: 6 }, + endPosition: { row: 0, column: 15 }, + }); + + const implementsClause = makeNode({ + type: "implements_clause", + text: "implements IFoo, IBar", + namedChildren: [ + makeNode({ + type: "type_identifier", + text: "IFoo", + startIndex: 30, + endIndex: 34, + startPosition: { row: 0, column: 30 }, + endPosition: { row: 0, column: 34 }, + }), + makeNode({ + type: "type_identifier", + text: "IBar", + startIndex: 36, + endIndex: 40, + startPosition: { row: 0, column: 36 }, + endPosition: { row: 0, column: 40 }, + }), + ], + }); + + const heritageNode = makeNode({ + type: "class_heritage", + text: "implements IFoo, IBar", + namedChildren: [implementsClause], + }); + + const bodyNode = makeNode({ + type: "class_body", + startIndex: 45, + endIndex: 50, + startPosition: { row: 1, column: 0 }, + endPosition: { row: 2, column: 1 }, + namedChildren: [], + children: [], + parent: classNode, + }); + + classNode.namedChildren = [heritageNode, bodyNode]; + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].implements).toEqual(["IFoo", "IBar"]); + }); + + test("no heritage means empty implements", () => { + const classNode = makeNode({ + type: "class_declaration", + startIndex: 0, + endIndex: 20, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 20 }, + }); + + const nameNode = makeNode({ + type: "type_identifier", + text: "Simple", + startIndex: 6, + endIndex: 12, + startPosition: { row: 0, column: 6 }, + endPosition: { row: 0, column: 12 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + startIndex: 13, + endIndex: 20, + startPosition: { row: 0, column: 13 }, + endPosition: { row: 0, column: 20 }, + namedChildren: [], + children: [], + parent: classNode, + }); + + classNode.namedChildren = [bodyNode]; + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].implements).toEqual([]); + }); + + test("abstract keyword in declaration children", () => { + const abstractKeyword = makeNode({ + type: "abstract", + text: "abstract", + }); + + const classNode = makeNode({ + type: "class_declaration", + startIndex: 0, + endIndex: 25, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 25 }, + children: [abstractKeyword], + }); + + const nameNode = makeNode({ + type: "type_identifier", + text: "AbsBase", + startIndex: 15, + endIndex: 22, + startPosition: { row: 0, column: 15 }, + endPosition: { row: 0, column: 22 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + startIndex: 23, + endIndex: 25, + startPosition: { row: 0, column: 23 }, + endPosition: { row: 0, column: 25 }, + namedChildren: [], + children: [], + parent: classNode, + }); + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].is_abstract).toBe(true); + }); + + test("abstract keyword in export statement parent", () => { + const abstractKeyword = makeNode({ + type: "abstract", + text: "abstract", + }); + + const exportNode = makeNode({ + type: "export_statement", + startIndex: 0, + endIndex: 30, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 30 }, + children: [abstractKeyword], + }); + + const classNode = makeNode({ + type: "class_declaration", + startIndex: 15, + endIndex: 30, + startPosition: { row: 0, column: 15 }, + endPosition: { row: 0, column: 30 }, + parent: exportNode, + }); + + exportNode.namedChildren = [classNode]; + + const nameNode = makeNode({ + type: "type_identifier", + text: "ExpAbs", + startIndex: 21, + endIndex: 27, + startPosition: { row: 0, column: 21 }, + endPosition: { row: 0, column: 27 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + startIndex: 28, + endIndex: 30, + startPosition: { row: 0, column: 28 }, + endPosition: { row: 0, column: 30 }, + namedChildren: [], + children: [], + parent: classNode, + }); + + const captures = [ + makeCapture("cls.export", exportNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].is_abstract).toBe(true); + }); + + test("non-abstract class", () => { + const classNode = makeNode({ + type: "class_declaration", + startIndex: 0, + endIndex: 20, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 20 }, + children: [], + }); + + const nameNode = makeNode({ + type: "type_identifier", + text: "Concrete", + startIndex: 6, + endIndex: 14, + startPosition: { row: 0, column: 6 }, + endPosition: { row: 0, column: 14 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + startIndex: 15, + endIndex: 20, + startPosition: { row: 0, column: 15 }, + endPosition: { row: 0, column: 20 }, + namedChildren: [], + children: [], + parent: classNode, + }); + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].is_abstract).toBe(false); + }); + + test("decorators via previousNamedSibling chain", () => { + const decorator2 = makeNode({ + type: "decorator", + text: "@Component", + startIndex: 0, + endIndex: 10, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 10 }, + }); + + const decorator1 = makeNode({ + type: "decorator", + text: "@Injectable", + startIndex: 11, + endIndex: 22, + startPosition: { row: 1, column: 0 }, + endPosition: { row: 1, column: 11 }, + previousNamedSibling: decorator2, + }); + + const classNode = makeNode({ + type: "class_declaration", + startIndex: 23, + endIndex: 50, + startPosition: { row: 2, column: 0 }, + endPosition: { row: 2, column: 27 }, + previousNamedSibling: decorator1, + }); + + const nameNode = makeNode({ + type: "type_identifier", + text: "Decorated", + startIndex: 29, + endIndex: 38, + startPosition: { row: 2, column: 6 }, + endPosition: { row: 2, column: 15 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + startIndex: 39, + endIndex: 50, + startPosition: { row: 2, column: 16 }, + endPosition: { row: 4, column: 1 }, + namedChildren: [], + children: [], + parent: classNode, + }); + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].decorators).toEqual(["@Component", "@Injectable"]); + }); + + test("no decorators", () => { + const classNode = makeNode({ + type: "class_declaration", + startIndex: 0, + endIndex: 20, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 20 }, + previousNamedSibling: null, + }); + + const nameNode = makeNode({ + type: "type_identifier", + text: "Plain", + startIndex: 6, + endIndex: 11, + startPosition: { row: 0, column: 6 }, + endPosition: { row: 0, column: 11 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + startIndex: 12, + endIndex: 20, + startPosition: { row: 0, column: 12 }, + endPosition: { row: 0, column: 20 }, + namedChildren: [], + children: [], + parent: classNode, + }); + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].decorators).toEqual([]); + }); + + test("method extraction with async and complexity", () => { + const asyncKeyword = makeNode({ + type: "async", + text: "async", + }); + + const ifStatement = makeNode({ + type: "if_statement", + text: "if (x) {}", + children: [], + namedChildren: [], + }); + + const methodBody = makeNode({ + type: "statement_block", + text: "{ if (x) {} }", + children: [ifStatement], + namedChildren: [ifStatement], + }); + + const methodName = makeNode({ + type: "property_identifier", + text: "asyncMethod", + }); + + const methodNode = makeNode({ + type: "method_definition", + text: "async asyncMethod() { if (x) {} }", + startIndex: 10, + endIndex: 50, + startPosition: { row: 1, column: 2 }, + endPosition: { row: 3, column: 3 }, + children: [asyncKeyword], + namedChildren: [], + }); + + methodNode.childForFieldName = (name: string): Parser.Node | null => { + if (name === "name") return methodName; + if (name === "body") return methodBody; + return null; + }; + + const normalMethodName = makeNode({ + type: "property_identifier", + text: "normalMethod", + }); + + const normalMethodBody = makeNode({ + type: "statement_block", + text: "{}", + children: [], + namedChildren: [], + }); + + const normalMethodNode = makeNode({ + type: "method_definition", + text: "normalMethod() {}", + startIndex: 60, + endIndex: 85, + startPosition: { row: 4, column: 2 }, + endPosition: { row: 4, column: 27 }, + children: [], + namedChildren: [], + }); + + normalMethodNode.childForFieldName = (name: string): Parser.Node | null => { + if (name === "name") return normalMethodName; + if (name === "body") return normalMethodBody; + return null; + }; + + const classNode = makeNode({ + type: "class_declaration", + startIndex: 0, + endIndex: 100, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 5, column: 1 }, + }); + + const nameNode = makeNode({ + type: "type_identifier", + text: "WithMethods", + startIndex: 6, + endIndex: 17, + startPosition: { row: 0, column: 6 }, + endPosition: { row: 0, column: 17 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + startIndex: 18, + endIndex: 100, + startPosition: { row: 0, column: 18 }, + endPosition: { row: 5, column: 1 }, + namedChildren: [methodNode, normalMethodNode], + children: [], + parent: classNode, + }); + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].methods).toHaveLength(2); + expect(result[0].methods[0].name).toBe("asyncMethod"); + expect(result[0].methods[0].is_async).toBe(true); + expect(result[0].methods[0].cyclomatic_complexity).toBe(2); + expect(result[0].methods[0].line).toBe(2); + expect(result[0].methods[1].name).toBe("normalMethod"); + expect(result[0].methods[1].is_async).toBe(false); + expect(result[0].methods[1].cyclomatic_complexity).toBe(1); + }); + + test("property counting with public_field_definition", () => { + const field1 = makeNode({ + type: "public_field_definition", + text: "foo: string", + }); + + const field2 = makeNode({ + type: "public_field_definition", + text: "bar: number", + }); + + const field3 = makeNode({ + type: "public_field_definition", + text: "baz: boolean", + }); + + const classNode = makeNode({ + type: "class_declaration", + startIndex: 0, + endIndex: 80, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 4, column: 1 }, + }); + + const nameNode = makeNode({ + type: "type_identifier", + text: "WithProps", + startIndex: 6, + endIndex: 15, + startPosition: { row: 0, column: 6 }, + endPosition: { row: 0, column: 15 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + startIndex: 16, + endIndex: 80, + startPosition: { row: 0, column: 16 }, + endPosition: { row: 4, column: 1 }, + namedChildren: [field1, field2, field3], + children: [], + parent: classNode, + }); + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].property_count).toBe(3); + }); + + test("property counting with field_definition", () => { + const field1 = makeNode({ + type: "field_definition", + text: "x = 1", + }); + + const classNode = makeNode({ + type: "class_declaration", + startIndex: 0, + endIndex: 30, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 2, column: 1 }, + }); + + const nameNode = makeNode({ + type: "type_identifier", + text: "FieldClass", + startIndex: 6, + endIndex: 16, + startPosition: { row: 0, column: 6 }, + endPosition: { row: 0, column: 16 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + startIndex: 17, + endIndex: 30, + startPosition: { row: 0, column: 17 }, + endPosition: { row: 2, column: 1 }, + namedChildren: [field1], + children: [], + parent: classNode, + }); + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].property_count).toBe(1); + }); + + test("empty body has zero properties", () => { + const classNode = makeNode({ + type: "class_declaration", + startIndex: 0, + endIndex: 15, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 15 }, + }); + + const nameNode = makeNode({ + type: "type_identifier", + text: "Empty", + startIndex: 6, + endIndex: 11, + startPosition: { row: 0, column: 6 }, + endPosition: { row: 0, column: 11 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + startIndex: 12, + endIndex: 15, + startPosition: { row: 0, column: 12 }, + endPosition: { row: 0, column: 15 }, + namedChildren: [], + children: [], + parent: classNode, + }); + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.name", nameNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(1); + expect(result[0].property_count).toBe(0); + }); + + test("missing name capture skips class", () => { + const classNode = makeNode({ + type: "class_declaration", + startIndex: 0, + endIndex: 15, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 15 }, + }); + + const bodyNode = makeNode({ + type: "class_body", + startIndex: 10, + endIndex: 15, + startPosition: { row: 0, column: 10 }, + endPosition: { row: 0, column: 15 }, + namedChildren: [], + children: [], + parent: classNode, + }); + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(0); + }); + + test("missing body capture skips class", () => { + const classNode = makeNode({ + type: "class_declaration", + startIndex: 0, + endIndex: 15, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 15 }, + }); + + const nameNode = makeNode({ + type: "type_identifier", + text: "NoBody", + startIndex: 6, + endIndex: 12, + startPosition: { row: 0, column: 6 }, + endPosition: { row: 0, column: 12 }, + }); + + const captures = [ + makeCapture("cls.declaration", classNode), + makeCapture("cls.name", nameNode), + ] as unknown as Parser.QueryCapture[]; + + const result = parseClassCaptures(captures); + + expect(result).toHaveLength(0); + }); +}); diff --git a/test/tree-sitter/file-metrics.test.ts b/test/tree-sitter/file-metrics.test.ts new file mode 100644 index 0000000..eddd7f1 --- /dev/null +++ b/test/tree-sitter/file-metrics.test.ts @@ -0,0 +1,261 @@ +import { describe, test, expect } from "bun:test"; +import { calculateFileMetrics } from "../../src/tree-sitter/parser.ts"; +import type { FunctionInfo, ClassInfo } from "../../src/schemas/treesitter.ts"; + +const makeFn = (complexity: number): FunctionInfo => ({ + name: "f", + lines: [1, 1], + loc: 1, + cyclomatic_complexity: complexity, + parameters: [], + return_type: null, + is_async: false, + is_exported: false, + is_method: false, + jsdoc: null, +}); + +const makeCls = (): ClassInfo => ({ + name: "C", + lines: [1, 1], + extends_name: null, + implements: [], + decorators: [], + is_abstract: false, + methods: [], + property_count: 0, +}); + +function assertMetricsInvariant(metrics: { + loc: number; + sloc: number; + comment_lines: number; + blank_lines: number; +}) { + expect(metrics.sloc + metrics.comment_lines + metrics.blank_lines).toBe( + metrics.loc, + ); +} + +describe("calculateFileMetrics", () => { + describe("blank lines", () => { + test("counts 3 empty lines", () => { + const content = "code\n\n\n"; + const functions: FunctionInfo[] = []; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.blank_lines).toBe(3); + expect(metrics.loc).toBe(4); + expect(metrics.sloc).toBe(1); + assertMetricsInvariant(metrics); + }); + + test("counts lines with only whitespace as blank", () => { + const content = "code\n \n\t\n "; + const functions: FunctionInfo[] = []; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.blank_lines).toBe(3); + expect(metrics.sloc).toBe(1); + assertMetricsInvariant(metrics); + }); + }); + + describe("single-line comments", () => { + test("counts lines starting with // as comment lines", () => { + const content = "code\n// comment\nmore code"; + const functions: FunctionInfo[] = []; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.comment_lines).toBe(1); + expect(metrics.sloc).toBe(2); + assertMetricsInvariant(metrics); + }); + + test("does not count trailing // comment as comment line", () => { + const content = "const x = 1; // trailing comment"; + const functions: FunctionInfo[] = []; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.comment_lines).toBe(0); + expect(metrics.sloc).toBe(1); + assertMetricsInvariant(metrics); + }); + }); + + describe("block comments", () => { + test("counts inline /* */ as one comment line", () => { + const content = "/* inline */"; + const functions: FunctionInfo[] = []; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.comment_lines).toBe(1); + expect(metrics.sloc).toBe(0); + assertMetricsInvariant(metrics); + }); + + test("counts multi-line block comment across all lines", () => { + const content = "/* start\nmiddle\nend */"; + const functions: FunctionInfo[] = []; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.comment_lines).toBe(3); + expect(metrics.sloc).toBe(0); + assertMetricsInvariant(metrics); + }); + + test("exits block comment mode after closing */", () => { + const content = "/* comment */\ncode after"; + const functions: FunctionInfo[] = []; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.comment_lines).toBe(1); + expect(metrics.sloc).toBe(1); + assertMetricsInvariant(metrics); + }); + + test("handles nested scenario: open, close, then code", () => { + const content = "/* open\nstill comment\nclose */\ncode here"; + const functions: FunctionInfo[] = []; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.comment_lines).toBe(3); + expect(metrics.sloc).toBe(1); + assertMetricsInvariant(metrics); + }); + }); + + describe("isInBlockComment edge cases", () => { + test("line with both /* and */ does not set isInBlockComment", () => { + const content = "/* both */\ncode after"; + const functions: FunctionInfo[] = []; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.comment_lines).toBe(1); + expect(metrics.sloc).toBe(1); + assertMetricsInvariant(metrics); + }); + + test("line inside block comment that ends with */ exits block comment mode", () => { + const content = "/* open\ninside\nstill inside\n*/ out"; + const functions: FunctionInfo[] = []; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.comment_lines).toBe(4); + expect(metrics.sloc).toBe(0); + assertMetricsInvariant(metrics); + }); + }); + + describe("function and class counts with complexity", () => { + test("empty functions and classes returns zeros", () => { + const content = "code"; + const functions: FunctionInfo[] = []; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.function_count).toBe(0); + expect(metrics.class_count).toBe(0); + expect(metrics.avg_complexity).toBe(0); + expect(metrics.max_complexity).toBe(0); + assertMetricsInvariant(metrics); + }); + + test("3 functions with complexities [1, 3, 5]", () => { + const content = "code"; + const functions = [makeFn(1), makeFn(3), makeFn(5)]; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.function_count).toBe(3); + expect(metrics.avg_complexity).toBe(3); + expect(metrics.max_complexity).toBe(5); + assertMetricsInvariant(metrics); + }); + + test("avg_complexity rounded to 2 decimals: [1, 2, 3] = 2.00", () => { + const content = "code"; + const functions = [makeFn(1), makeFn(2), makeFn(3)]; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.avg_complexity).toBe(2.0); + assertMetricsInvariant(metrics); + }); + + test("avg_complexity rounded to 2 decimals: [1, 1, 2] = 1.33", () => { + const content = "code"; + const functions = [makeFn(1), makeFn(1), makeFn(2)]; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.avg_complexity).toBe(1.33); + assertMetricsInvariant(metrics); + }); + + test("2 classes counted correctly", () => { + const content = "code"; + const functions: FunctionInfo[] = []; + const classes = [makeCls(), makeCls()]; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.class_count).toBe(2); + assertMetricsInvariant(metrics); + }); + }); + + describe("sloc correctness", () => { + test("file with only code lines has sloc = loc", () => { + const content = "line1\nline2\nline3"; + const functions: FunctionInfo[] = []; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.loc).toBe(3); + expect(metrics.sloc).toBe(3); + expect(metrics.blank_lines).toBe(0); + expect(metrics.comment_lines).toBe(0); + assertMetricsInvariant(metrics); + }); + + test("file with mix of code, blanks, comments has correct sloc", () => { + const content = "code1\n\n// comment\ncode2"; + const functions: FunctionInfo[] = []; + const classes: ClassInfo[] = []; + + const metrics = calculateFileMetrics({ content, functions, classes }); + + expect(metrics.loc).toBe(4); + expect(metrics.blank_lines).toBe(1); + expect(metrics.comment_lines).toBe(1); + expect(metrics.sloc).toBe(2); + assertMetricsInvariant(metrics); + }); + }); +}); diff --git a/test/tree-sitter/function-captures.test.ts b/test/tree-sitter/function-captures.test.ts new file mode 100644 index 0000000..c2fc9e4 --- /dev/null +++ b/test/tree-sitter/function-captures.test.ts @@ -0,0 +1,1036 @@ +import { describe, test, expect } from "bun:test"; +import { parseFunctionCaptures } from "../../src/tree-sitter/languages/typescript.ts"; +import type Parser from "web-tree-sitter"; + +type NodeOverrides = { + type?: string; + text?: string; + startIndex?: number; + endIndex?: number; + startPosition?: { row: number; column: number }; + endPosition?: { row: number; column: number }; + children?: Parser.Node[]; + namedChildren?: Parser.Node[]; + firstChild?: Parser.Node | null; + previousNamedSibling?: Parser.Node | null; + parent?: Parser.Node | null; + childForFieldName?: (name: string) => Parser.Node | null; +}; + +function makeNode(overrides: NodeOverrides = {}): Parser.Node { + const children = overrides.children ?? []; + const namedChildren = overrides.namedChildren ?? []; + const firstChild = + overrides.firstChild ?? (children.length > 0 ? children[0] : null); + + return { + type: overrides.type ?? "identifier", + text: overrides.text ?? "", + startIndex: overrides.startIndex ?? 0, + endIndex: overrides.endIndex ?? 0, + startPosition: overrides.startPosition ?? { row: 0, column: 0 }, + endPosition: overrides.endPosition ?? { row: 0, column: 0 }, + children, + namedChildren, + firstChild, + previousNamedSibling: overrides.previousNamedSibling ?? null, + parent: overrides.parent ?? null, + childForFieldName: overrides.childForFieldName ?? (() => null), + } as unknown as Parser.Node; +} + +function makeCapture(name: string, node: Parser.Node): Parser.QueryCapture { + return { name, node } as unknown as Parser.QueryCapture; +} + +describe("parseFunctionCaptures capture type routing", () => { + test("fn.declaration sets is_exported=false, is_method=false", () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 50, + startPosition: { row: 4, column: 0 }, + endPosition: { row: 9, column: 1 }, + }); + const nameNode = makeNode({ + type: "identifier", + text: "foo", + startIndex: 9, + endIndex: 12, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 12, + endIndex: 14, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 15, + endIndex: 50, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].name).toBe("foo"); + expect(results[0].is_exported).toBe(false); + expect(results[0].is_method).toBe(false); + }); + + test("fn.export sets is_exported=true", () => { + const declarationNode = makeNode({ + type: "export_statement", + startIndex: 0, + endIndex: 60, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 5, column: 1 }, + childForFieldName: (name: string) => { + if (name === "declaration") { + return makeNode({ + type: "function_declaration", + startIndex: 7, + endIndex: 60, + namedChildren: [], + }); + } + return null; + }, + }); + const nameNode = makeNode({ + type: "identifier", + text: "exportedFn", + startIndex: 16, + endIndex: 26, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 26, + endIndex: 28, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 29, + endIndex: 60, + }); + + const captures = [ + makeCapture("fn.export", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].is_exported).toBe(true); + expect(results[0].is_method).toBe(false); + }); + + test("fn.arrow is detected as arrow function", () => { + const declarationNode = makeNode({ + type: "lexical_declaration", + startIndex: 0, + endIndex: 40, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 40 }, + namedChildren: [ + makeNode({ + type: "variable_declarator", + startIndex: 4, + endIndex: 40, + namedChildren: [ + makeNode({ + type: "identifier", + text: "arrowFn", + startIndex: 4, + endIndex: 11, + }), + makeNode({ + type: "arrow_function", + startIndex: 14, + endIndex: 40, + namedChildren: [], + }), + ], + }), + ], + }); + const nameNode = makeNode({ + type: "identifier", + text: "arrowFn", + startIndex: 4, + endIndex: 11, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 14, + endIndex: 16, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 20, + endIndex: 40, + }); + + const captures = [ + makeCapture("fn.arrow", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].name).toBe("arrowFn"); + }); + + test("fn.export_arrow sets is_exported=true", () => { + const declarationNode = makeNode({ + type: "export_statement", + startIndex: 0, + endIndex: 50, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 50 }, + childForFieldName: (name: string) => { + if (name === "declaration") { + return makeNode({ + type: "lexical_declaration", + startIndex: 7, + endIndex: 50, + namedChildren: [ + makeNode({ + type: "variable_declarator", + startIndex: 11, + endIndex: 50, + namedChildren: [], + }), + ], + }); + } + return null; + }, + }); + const nameNode = makeNode({ + type: "identifier", + text: "exportedArrow", + startIndex: 11, + endIndex: 24, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 27, + endIndex: 29, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 33, + endIndex: 50, + }); + + const captures = [ + makeCapture("fn.export_arrow", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].is_exported).toBe(true); + }); + + test("fn.method sets is_method=true, is_exported=false", () => { + const declarationNode = makeNode({ + type: "method_definition", + startIndex: 0, + endIndex: 50, + startPosition: { row: 1, column: 2 }, + endPosition: { row: 6, column: 3 }, + }); + const nameNode = makeNode({ + type: "property_identifier", + text: "myMethod", + startIndex: 0, + endIndex: 8, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 8, + endIndex: 10, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 11, + endIndex: 50, + }); + + const captures = [ + makeCapture("fn.method", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].is_method).toBe(true); + expect(results[0].is_exported).toBe(false); + }); + + test("unknown capture name like fn.other is ignored", () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 50, + }); + const nameNode = makeNode({ + type: "identifier", + text: "foo", + startIndex: 9, + endIndex: 12, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 12, + endIndex: 14, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 15, + endIndex: 50, + }); + + const captures = [ + makeCapture("fn.other", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(0); + }); +}); + +describe("parseFunctionCaptures deduplication", () => { + test("two captures with same startIndex only emit one result", () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 50, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 5, column: 1 }, + }); + const nameNode = makeNode({ + type: "identifier", + text: "foo", + startIndex: 9, + endIndex: 12, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 12, + endIndex: 14, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 15, + endIndex: 50, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].name).toBe("foo"); + }); +}); + +describe("parseFunctionCaptures async detection", () => { + test("node with async child has is_async=true", () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 60, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 5, column: 1 }, + children: [ + makeNode({ type: "async", text: "async", startIndex: 0, endIndex: 5 }), + ], + }); + const nameNode = makeNode({ + type: "identifier", + text: "asyncFn", + startIndex: 15, + endIndex: 22, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 22, + endIndex: 24, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 25, + endIndex: 60, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].is_async).toBe(true); + }); + + test("node without async child has is_async=false", () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 50, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 5, column: 1 }, + children: [ + makeNode({ + type: "function", + text: "function", + startIndex: 0, + endIndex: 8, + }), + ], + }); + const nameNode = makeNode({ + type: "identifier", + text: "syncFn", + startIndex: 9, + endIndex: 15, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 15, + endIndex: 17, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 18, + endIndex: 50, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].is_async).toBe(false); + }); +}); + +describe("parseFunctionCaptures parameter extraction", () => { + test("identifier child becomes { name, type: null }", () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 50, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 5, column: 1 }, + }); + const nameNode = makeNode({ + type: "identifier", + text: "foo", + startIndex: 9, + endIndex: 12, + }); + const paramIdNode = makeNode({ + type: "identifier", + text: "x", + startIndex: 13, + endIndex: 14, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 12, + endIndex: 15, + namedChildren: [paramIdNode], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 16, + endIndex: 50, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].parameters).toEqual([{ name: "x", type: null }]); + }); + + test("required_parameter with pattern and type fields", () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 60, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 5, column: 1 }, + }); + const nameNode = makeNode({ + type: "identifier", + text: "foo", + startIndex: 9, + endIndex: 12, + }); + const patternNode = makeNode({ + type: "identifier", + text: "user", + startIndex: 13, + endIndex: 17, + }); + const typeNode = makeNode({ + type: "type_annotation", + text: ": User", + startIndex: 17, + endIndex: 23, + }); + const paramNode = makeNode({ + type: "required_parameter", + startIndex: 13, + endIndex: 23, + childForFieldName: (name: string) => { + if (name === "pattern") return patternNode; + if (name === "type") return typeNode; + return null; + }, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 12, + endIndex: 24, + namedChildren: [paramNode], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 25, + endIndex: 60, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].parameters).toEqual([{ name: "user", type: "User" }]); + }); + + test('rest_pattern becomes { name: "...args", type: null }', () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 60, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 5, column: 1 }, + }); + const nameNode = makeNode({ + type: "identifier", + text: "foo", + startIndex: 9, + endIndex: 12, + }); + const restElementNode = makeNode({ + type: "identifier", + text: "args", + startIndex: 16, + endIndex: 20, + }); + const restNode = makeNode({ + type: "rest_pattern", + startIndex: 15, + endIndex: 20, + namedChildren: [restElementNode], + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 12, + endIndex: 21, + namedChildren: [restNode], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 22, + endIndex: 60, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].parameters).toEqual([{ name: "...args", type: null }]); + }); + + test("assignment_pattern uses name from left field", () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 70, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 5, column: 1 }, + }); + const nameNode = makeNode({ + type: "identifier", + text: "foo", + startIndex: 9, + endIndex: 12, + }); + const leftNode = makeNode({ + type: "identifier", + text: "options", + startIndex: 13, + endIndex: 20, + }); + const assignmentNode = makeNode({ + type: "assignment_pattern", + startIndex: 13, + endIndex: 30, + childForFieldName: (name: string) => { + if (name === "left") return leftNode; + return null; + }, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 12, + endIndex: 31, + namedChildren: [assignmentNode], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 32, + endIndex: 70, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].parameters).toEqual([{ name: "options", type: null }]); + }); +}); + +describe("parseFunctionCaptures return type", () => { + test('fn.return_type capture strips leading ": "', () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 60, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 5, column: 1 }, + }); + const nameNode = makeNode({ + type: "identifier", + text: "foo", + startIndex: 9, + endIndex: 12, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 12, + endIndex: 14, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 25, + endIndex: 60, + }); + const returnTypeNode = makeNode({ + type: "type_annotation", + text: ": Promise", + startIndex: 15, + endIndex: 32, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + makeCapture("fn.return_type", returnTypeNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].return_type).toBe("Promise"); + }); + + test("no fn.return_type capture results in return_type=null", () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 50, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 5, column: 1 }, + }); + const nameNode = makeNode({ + type: "identifier", + text: "foo", + startIndex: 9, + endIndex: 12, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 12, + endIndex: 14, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 15, + endIndex: 50, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].return_type).toBe(null); + }); +}); + +describe("parseFunctionCaptures jsdoc", () => { + test("previousNamedSibling comment starting with /** becomes jsdoc", () => { + const jsdocNode = makeNode({ + type: "comment", + text: "/** This is a JSDoc comment */", + startIndex: 0, + endIndex: 30, + }); + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 31, + endIndex: 80, + startPosition: { row: 2, column: 0 }, + endPosition: { row: 7, column: 1 }, + previousNamedSibling: jsdocNode, + }); + const nameNode = makeNode({ + type: "identifier", + text: "foo", + startIndex: 40, + endIndex: 43, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 43, + endIndex: 45, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 46, + endIndex: 80, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].jsdoc).toBe("/** This is a JSDoc comment */"); + }); + + test("previousNamedSibling // comment results in jsdoc=null", () => { + const commentNode = makeNode({ + type: "comment", + text: "// This is a regular comment", + startIndex: 0, + endIndex: 28, + }); + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 29, + endIndex: 70, + startPosition: { row: 1, column: 0 }, + endPosition: { row: 6, column: 1 }, + previousNamedSibling: commentNode, + }); + const nameNode = makeNode({ + type: "identifier", + text: "foo", + startIndex: 38, + endIndex: 41, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 41, + endIndex: 43, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 44, + endIndex: 70, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].jsdoc).toBe(null); + }); + + test("no previousNamedSibling results in jsdoc=null", () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 50, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 5, column: 1 }, + previousNamedSibling: null, + }); + const nameNode = makeNode({ + type: "identifier", + text: "foo", + startIndex: 9, + endIndex: 12, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 12, + endIndex: 14, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 15, + endIndex: 50, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].jsdoc).toBe(null); + }); +}); + +describe("parseFunctionCaptures line numbers", () => { + test("startPosition.row=4, endPosition.row=9 produces lines=[5, 10], loc=6", () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 50, + startPosition: { row: 4, column: 0 }, + endPosition: { row: 9, column: 1 }, + }); + const nameNode = makeNode({ + type: "identifier", + text: "foo", + startIndex: 9, + endIndex: 12, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 12, + endIndex: 14, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 15, + endIndex: 50, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(1); + expect(results[0].lines).toEqual([5, 10]); + expect(results[0].loc).toBe(6); + }); +}); + +describe("parseFunctionCaptures missing required captures", () => { + test("capture with no matching fn.name within range is skipped", () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 50, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 5, column: 1 }, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 12, + endIndex: 14, + namedChildren: [], + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 15, + endIndex: 50, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.params", paramsNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(0); + }); + + test("capture with no matching fn.params within range is skipped", () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 50, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 5, column: 1 }, + }); + const nameNode = makeNode({ + type: "identifier", + text: "foo", + startIndex: 9, + endIndex: 12, + }); + const bodyNode = makeNode({ + type: "statement_block", + startIndex: 15, + endIndex: 50, + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.body", bodyNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(0); + }); + + test("capture with no matching fn.body within range is skipped", () => { + const declarationNode = makeNode({ + type: "function_declaration", + startIndex: 0, + endIndex: 50, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 5, column: 1 }, + }); + const nameNode = makeNode({ + type: "identifier", + text: "foo", + startIndex: 9, + endIndex: 12, + }); + const paramsNode = makeNode({ + type: "formal_parameters", + startIndex: 12, + endIndex: 14, + namedChildren: [], + }); + + const captures = [ + makeCapture("fn.declaration", declarationNode), + makeCapture("fn.name", nameNode), + makeCapture("fn.params", paramsNode), + ] as unknown as Parser.QueryCapture[]; + + const results = parseFunctionCaptures(captures); + + expect(results).toHaveLength(0); + }); +}); From 6235fa945767d5b7a69ca39376349e2ee9903cab Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 10:07:01 +0700 Subject: [PATCH 15/31] chore: apply biome formatting across src and test --- src/commands/adventure.ts | 256 ++-- src/commands/changes.ts | 48 +- src/commands/class.ts | 10 +- src/commands/exports.ts | 56 +- src/commands/file.ts | 10 +- src/commands/fn.ts | 3 +- src/commands/graph.ts | 104 +- src/commands/init.ts | 4 +- src/commands/ls.ts | 6 +- src/commands/smells.ts | 3 +- src/commands/symbol.ts | 8 +- src/converter/convert.ts | 1739 ++++++++++++----------- src/index.ts | 247 ++-- src/mcp/metadata.ts | 9 +- src/schemas/file.ts | 5 +- src/tree-sitter/languages/javascript.ts | 23 +- src/tree-sitter/languages/typescript.ts | 27 +- test/commands/adventure.test.ts | 348 ++--- test/converter/batch-processing.test.ts | 406 +++--- test/converter/convert.test.ts | 830 +++++------ test/converter/scip-parser.test.ts | 822 +++++------ test/db/queries.test.ts | 358 ++--- test/utils/config.test.ts | 3 +- test/utils/fileScanner.test.ts | 196 +-- 24 files changed, 2799 insertions(+), 2722 deletions(-) diff --git a/src/commands/adventure.ts b/src/commands/adventure.ts index 24682e8..a4a7476 100644 --- a/src/commands/adventure.ts +++ b/src/commands/adventure.ts @@ -2,44 +2,40 @@ import type { Database } from "bun:sqlite"; import { getDependencies, getReverseDependencies } from "../db/queries.ts"; import type { PathResult } from "../types.ts"; import { CtxError } from "../utils/errors.ts"; -import { - DEFAULTS, - resolveAndValidatePath, - setupCommand, -} from "./shared.ts"; +import { DEFAULTS, resolveAndValidatePath, setupCommand } from "./shared.ts"; export async function adventure(from: string, to: string): Promise { - const ctx = await setupCommand(); - - const fromPath = resolveAndValidatePath({ ctx, inputPath: from }); - const toPath = resolveAndValidatePath({ ctx, inputPath: to }); - - // If same file, return direct path - if (fromPath === toPath) { - const result: PathResult = { - from: fromPath, - to: toPath, - path: [fromPath], - distance: 0, - }; - return result; - } - - // Use BFS to find shortest path - const foundPath = findShortestPath(ctx.db, fromPath, toPath); - - if (!foundPath) { - throw new CtxError(`No path found from ${fromPath} to ${toPath}`); - } - - const result: PathResult = { - from: fromPath, - to: toPath, - path: foundPath, - distance: foundPath.length - 1, - }; - - return result; + const ctx = await setupCommand(); + + const fromPath = resolveAndValidatePath({ ctx, inputPath: from }); + const toPath = resolveAndValidatePath({ ctx, inputPath: to }); + + // If same file, return direct path + if (fromPath === toPath) { + const result: PathResult = { + from: fromPath, + to: toPath, + path: [fromPath], + distance: 0, + }; + return result; + } + + // Use BFS to find shortest path + const foundPath = findShortestPath(ctx.db, fromPath, toPath); + + if (!foundPath) { + throw new CtxError(`No path found from ${fromPath} to ${toPath}`); + } + + const result: PathResult = { + from: fromPath, + to: toPath, + path: foundPath, + distance: foundPath.length - 1, + }; + + return result; } /** @@ -52,106 +48,106 @@ export async function adventure(from: string, to: string): Promise { * This is not a blocker for release as path finding is infrequently used. */ function findShortestPath( - db: Database, - from: string, - to: string + db: Database, + from: string, + to: string, ): string[] | null { - // Try increasing depths until we find a path or reach max depth - const maxDepth = DEFAULTS.MAX_PATH_DEPTH; - - for (let depth = 1; depth <= maxDepth; depth++) { - // Get dependencies from 'from' file - const forwardDeps = getDependencies(db, from, depth); - const forwardSet = new Set(forwardDeps.map((d) => d.path)); - - // Check if 'to' is in forward dependencies - if (forwardSet.has(to)) { - // Reconstruct path using BFS - return reconstructPath(db, from, to, depth, true); - } - - // Get reverse dependencies from 'to' file - const reverseDeps = getReverseDependencies(db, to, depth); - const reverseSet = new Set(reverseDeps.map((d) => d.path)); - - // Check if 'from' is in reverse dependencies - if (reverseSet.has(from)) { - // Path exists in reverse direction - return reconstructPath(db, from, to, depth, true); - } - - // Check for intersection between forward and reverse - for (const forwardFile of forwardSet) { - if (reverseSet.has(forwardFile)) { - // Found a connecting file - const pathToMiddle = reconstructPath( - db, - from, - forwardFile, - depth, - true - ); - const pathFromMiddle = reconstructPath( - db, - forwardFile, - to, - depth, - true - ); - - if (pathToMiddle && pathFromMiddle) { - // Combine paths (remove duplicate middle file) - return [...pathToMiddle, ...pathFromMiddle.slice(1)]; - } - } - } - } - - return null; + // Try increasing depths until we find a path or reach max depth + const maxDepth = DEFAULTS.MAX_PATH_DEPTH; + + for (let depth = 1; depth <= maxDepth; depth++) { + // Get dependencies from 'from' file + const forwardDeps = getDependencies(db, from, depth); + const forwardSet = new Set(forwardDeps.map((d) => d.path)); + + // Check if 'to' is in forward dependencies + if (forwardSet.has(to)) { + // Reconstruct path using BFS + return reconstructPath(db, from, to, depth, true); + } + + // Get reverse dependencies from 'to' file + const reverseDeps = getReverseDependencies(db, to, depth); + const reverseSet = new Set(reverseDeps.map((d) => d.path)); + + // Check if 'from' is in reverse dependencies + if (reverseSet.has(from)) { + // Path exists in reverse direction + return reconstructPath(db, from, to, depth, true); + } + + // Check for intersection between forward and reverse + for (const forwardFile of forwardSet) { + if (reverseSet.has(forwardFile)) { + // Found a connecting file + const pathToMiddle = reconstructPath( + db, + from, + forwardFile, + depth, + true, + ); + const pathFromMiddle = reconstructPath( + db, + forwardFile, + to, + depth, + true, + ); + + if (pathToMiddle && pathFromMiddle) { + // Combine paths (remove duplicate middle file) + return [...pathToMiddle, ...pathFromMiddle.slice(1)]; + } + } + } + } + + return null; } /** * Reconstruct path using BFS */ function reconstructPath( - db: Database, - from: string, - to: string, - maxDepth: number, - forward: boolean + db: Database, + from: string, + to: string, + maxDepth: number, + forward: boolean, ): string[] | null { - // Simple BFS implementation - const queue: Array<{ file: string; path: string[] }> = [ - { file: from, path: [from] }, - ]; - const visited = new Set([from]); - - while (queue.length > 0) { - const current = queue.shift()!; - - if (current.file === to) { - return current.path; - } - - if (current.path.length > maxDepth) { - continue; - } - - // Get neighbors - const neighbors = forward - ? getDependencies(db, current.file, 1) - : getReverseDependencies(db, current.file, 1); - - for (const neighbor of neighbors) { - if (!visited.has(neighbor.path)) { - visited.add(neighbor.path); - queue.push({ - file: neighbor.path, - path: [...current.path, neighbor.path], - }); - } - } - } - - return null; + // Simple BFS implementation + const queue: Array<{ file: string; path: string[] }> = [ + { file: from, path: [from] }, + ]; + const visited = new Set([from]); + + while (queue.length > 0) { + const current = queue.shift()!; + + if (current.file === to) { + return current.path; + } + + if (current.path.length > maxDepth) { + continue; + } + + // Get neighbors + const neighbors = forward + ? getDependencies(db, current.file, 1) + : getReverseDependencies(db, current.file, 1); + + for (const neighbor of neighbors) { + if (!visited.has(neighbor.path)) { + visited.add(neighbor.path); + queue.push({ + file: neighbor.path, + path: [...current.path, neighbor.path], + }); + } + } + } + + return null; } diff --git a/src/commands/changes.ts b/src/commands/changes.ts index 038acb3..28beef3 100644 --- a/src/commands/changes.ts +++ b/src/commands/changes.ts @@ -5,34 +5,34 @@ import { getChangedFiles, isGitRepo } from "../utils/git.ts"; import { DEFAULTS, setupCommand } from "./shared.ts"; export async function changes( - ref: string, - _flags: Record = {} + ref: string, + _flags: Record = {}, ): Promise { - if (!(await isGitRepo())) { - throw new CtxError("Not a git repository"); - } + if (!(await isGitRepo())) { + throw new CtxError("Not a git repository"); + } - const ctx = await setupCommand(); - const changedFiles = await getChangedFiles(ref); + const ctx = await setupCommand(); + const changedFiles = await getChangedFiles(ref); - // For each changed file, get its reverse dependencies (depth 1) - const impacted = new Set(); + // For each changed file, get its reverse dependencies (depth 1) + const impacted = new Set(); - for (const file of changedFiles) { - try { - const rdeps = getReverseDependencies(ctx.db, file, DEFAULTS.DEPTH); - rdeps.forEach((dep) => { - impacted.add(dep.path); - }); - } catch {} - } + for (const file of changedFiles) { + try { + const rdeps = getReverseDependencies(ctx.db, file, DEFAULTS.DEPTH); + rdeps.forEach((dep) => { + impacted.add(dep.path); + }); + } catch {} + } - const result: ChangesResult = { - ref, - changed: changedFiles, - impacted: Array.from(impacted), - total_impacted: impacted.size, - }; + const result: ChangesResult = { + ref, + changed: changedFiles, + impacted: Array.from(impacted), + total_impacted: impacted.size, + }; - return result; + return result; } diff --git a/src/commands/class.ts b/src/commands/class.ts index d63aa89..acd66b3 100644 --- a/src/commands/class.ts +++ b/src/commands/class.ts @@ -21,7 +21,9 @@ function getClassComplexity(params: { item: ClassInfo }): number { ); } -export async function classCommand(params: ClassCommandParams): Promise { +export async function classCommand( + params: ClassCommandParams, +): Promise { const { path, options = {} } = params; const ctx = await setupCommand(); const relativePath = resolveAndValidatePath({ ctx, inputPath: path }); @@ -31,7 +33,8 @@ export async function classCommand(params: ClassCommandParams): Promise = {} + target: string, + _flags: Record = {}, ): Promise { - const ctx = await setupCommand(); + const ctx = await setupCommand(); - // Try as file path first - const relativePath = resolvePath({ ctx, inputPath: target }); + // Try as file path first + const relativePath = resolvePath({ ctx, inputPath: target }); - if (fileExists(ctx.db, relativePath)) { - const exportedSymbols = getFileExports(ctx.db, relativePath); - if (exportedSymbols.length > 0) { - const result: ExportsResult = { - target: relativePath, - exports: exportedSymbols, - }; - return result; - } - } + if (fileExists(ctx.db, relativePath)) { + const exportedSymbols = getFileExports(ctx.db, relativePath); + if (exportedSymbols.length > 0) { + const result: ExportsResult = { + target: relativePath, + exports: exportedSymbols, + }; + return result; + } + } - // Try as package name - const packageExports = getPackageExports(ctx.db, target); - if (packageExports.length > 0) { - const result: ExportsResult = { - target, - exports: packageExports, - }; - return result; - } + // Try as package name + const packageExports = getPackageExports(ctx.db, target); + if (packageExports.length > 0) { + const result: ExportsResult = { + target, + exports: packageExports, + }; + return result; + } - throw new CtxError(`No exports found for '${target}'`); + throw new CtxError(`No exports found for '${target}'`); } diff --git a/src/commands/file.ts b/src/commands/file.ts index 89e1357..f338a3d 100644 --- a/src/commands/file.ts +++ b/src/commands/file.ts @@ -17,9 +17,9 @@ export async function file(path: string): Promise { const depended_by = getFileDependents(ctx.db, relativePath); const fileIdQuery = "SELECT id FROM files WHERE path = ?"; - const fileRow = ctx.db.query(fileIdQuery).get(relativePath) as - | { id: number } - | null; + const fileRow = ctx.db.query(fileIdQuery).get(relativePath) as { + id: number; + } | null; let documented_in: string[] | undefined; @@ -53,7 +53,9 @@ export async function file(path: string): Promise { metrics = parseResult.metrics; functions = parseResult.functions; } catch (error) { - debugDb(`Tree-sitter parse failed for ${relativePath}: ${error instanceof Error ? error.message : String(error)}`); + debugDb( + `Tree-sitter parse failed for ${relativePath}: ${error instanceof Error ? error.message : String(error)}`, + ); } const result: FileResult = { diff --git a/src/commands/fn.ts b/src/commands/fn.ts index d369876..e5f2abd 100644 --- a/src/commands/fn.ts +++ b/src/commands/fn.ts @@ -24,7 +24,8 @@ export async function fn(params: FnParams): Promise { ? relativePath.split(".").pop() || "" : ""; const extWithDot = extension ? `.${extension}` : ""; - const languageKey = getLanguageForExtension({ extension: extWithDot }) || "unknown"; + const languageKey = + getLanguageForExtension({ extension: extWithDot }) || "unknown"; const { functions, metrics } = await parseFunctions({ filePath: absolutePath, diff --git a/src/commands/graph.ts b/src/commands/graph.ts index 224f93c..79da4fa 100644 --- a/src/commands/graph.ts +++ b/src/commands/graph.ts @@ -2,70 +2,70 @@ import { getDependencies, getReverseDependencies } from "../db/queries.ts"; import type { GraphEdge, GraphResult } from "../types.ts"; import { CtxError } from "../utils/errors.ts"; import { - DEFAULTS, - parseIntFlag, - parseStringFlag, - resolveAndValidatePath, - setupCommand, + DEFAULTS, + parseIntFlag, + parseStringFlag, + resolveAndValidatePath, + setupCommand, } from "./shared.ts"; const VALID_DIRECTIONS = ["deps", "rdeps", "both"] as const; export async function graph( - path: string, - flags: Record = {} + path: string, + flags: Record = {}, ): Promise { - const ctx = await setupCommand(); - const depth = parseIntFlag({ - flags, - key: "depth", - defaultValue: DEFAULTS.DEPTH, - }); - const direction = parseStringFlag({ - flags, - key: "direction", - defaultValue: "both", - }); + const ctx = await setupCommand(); + const depth = parseIntFlag({ + flags, + key: "depth", + defaultValue: DEFAULTS.DEPTH, + }); + const direction = parseStringFlag({ + flags, + key: "direction", + defaultValue: "both", + }); - if ( - !VALID_DIRECTIONS.includes(direction as (typeof VALID_DIRECTIONS)[number]) - ) { - throw new CtxError( - `Invalid direction: ${direction}. Must be one of: deps, rdeps, both` - ); - } + if ( + !VALID_DIRECTIONS.includes(direction as (typeof VALID_DIRECTIONS)[number]) + ) { + throw new CtxError( + `Invalid direction: ${direction}. Must be one of: deps, rdeps, both`, + ); + } - const relativePath = resolveAndValidatePath({ ctx, inputPath: path }); + const relativePath = resolveAndValidatePath({ ctx, inputPath: path }); - // Build graph - const nodes = new Set(); - const edges: GraphEdge[] = []; + // Build graph + const nodes = new Set(); + const edges: GraphEdge[] = []; - nodes.add(relativePath); + nodes.add(relativePath); - if (direction === "deps" || direction === "both") { - const deps = getDependencies(ctx.db, relativePath, depth); - deps.forEach((dep) => { - nodes.add(dep.path); - edges.push({ from: relativePath, to: dep.path }); - }); - } + if (direction === "deps" || direction === "both") { + const deps = getDependencies(ctx.db, relativePath, depth); + deps.forEach((dep) => { + nodes.add(dep.path); + edges.push({ from: relativePath, to: dep.path }); + }); + } - if (direction === "rdeps" || direction === "both") { - const rdeps = getReverseDependencies(ctx.db, relativePath, depth); - rdeps.forEach((rdep) => { - nodes.add(rdep.path); - edges.push({ from: rdep.path, to: relativePath }); - }); - } + if (direction === "rdeps" || direction === "both") { + const rdeps = getReverseDependencies(ctx.db, relativePath, depth); + rdeps.forEach((rdep) => { + nodes.add(rdep.path); + edges.push({ from: rdep.path, to: relativePath }); + }); + } - const result: GraphResult = { - root: relativePath, - direction, - depth, - nodes: Array.from(nodes), - edges, - }; + const result: GraphResult = { + root: relativePath, + direction, + depth, + nodes: Array.from(nodes), + edges, + }; - return result; + return result; } diff --git a/src/commands/init.ts b/src/commands/init.ts index fcc06d6..e4f8e5a 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -11,7 +11,9 @@ import { CtxError } from "../utils/errors.ts"; import { findRepoRoot, getConfigPath, getDoraDir } from "../utils/paths.ts"; import { copyTemplates } from "../utils/templates.ts"; -export async function init(params?: { language?: string }): Promise { +export async function init(params?: { + language?: string; +}): Promise { if (params?.language) { const result = LanguageSchema.safeParse(params.language); if (!result.success) { diff --git a/src/commands/ls.ts b/src/commands/ls.ts index 314b3a7..e366693 100644 --- a/src/commands/ls.ts +++ b/src/commands/ls.ts @@ -1,9 +1,5 @@ import type { Database } from "bun:sqlite"; -import { - parseIntFlag, - parseStringFlag, - setupCommand, -} from "./shared.ts"; +import { parseIntFlag, parseStringFlag, setupCommand } from "./shared.ts"; interface LsOptions { limit?: number; diff --git a/src/commands/smells.ts b/src/commands/smells.ts index affc7ce..ef5864a 100644 --- a/src/commands/smells.ts +++ b/src/commands/smells.ts @@ -63,7 +63,8 @@ function detectFunctionSmells(params: { locThreshold: number; paramsThreshold: number; }): SmellItem[] { - const { functions, complexityThreshold, locThreshold, paramsThreshold } = params; + const { functions, complexityThreshold, locThreshold, paramsThreshold } = + params; const smells: SmellItem[] = []; for (const fnItem of functions) { diff --git a/src/commands/symbol.ts b/src/commands/symbol.ts index 8436088..346e757 100644 --- a/src/commands/symbol.ts +++ b/src/commands/symbol.ts @@ -66,7 +66,9 @@ export async function symbol( if (!candidates || scipLine === undefined) continue; const best = candidates.reduce((a, b) => - Math.abs(a.lines[0] - scipLine) <= Math.abs(b.lines[0] - scipLine) ? a : b, + Math.abs(a.lines[0] - scipLine) <= Math.abs(b.lines[0] - scipLine) + ? a + : b, ); enhancedResults[item.index] = { @@ -77,7 +79,9 @@ export async function symbol( }; } } catch (error) { - debugDb(`Tree-sitter parse failed for ${filePath}: ${error instanceof Error ? error.message : String(error)}`); + debugDb( + `Tree-sitter parse failed for ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ); } } diff --git a/src/converter/convert.ts b/src/converter/convert.ts index 92a73ec..2000753 100644 --- a/src/converter/convert.ts +++ b/src/converter/convert.ts @@ -4,20 +4,20 @@ import path from "path"; import { debugConverter } from "../utils/logger.ts"; import { processDocuments } from "./documents"; import { - extractKindFromDocumentation, - extractNameFromScip, - extractPackageFromScip, - symbolKindToString, + extractKindFromDocumentation, + extractNameFromScip, + extractPackageFromScip, + symbolKindToString, } from "./helpers"; import { - extractDefinitions, - extractReferences, - getFileDependencies, - type ParsedDocument, - type ParsedSymbol, - parseScipFile, - type ScipData, - type SymbolDefinition, + extractDefinitions, + extractReferences, + getFileDependencies, + type ParsedDocument, + type ParsedSymbol, + parseScipFile, + type ScipData, + type SymbolDefinition, } from "./scip-parser"; // Batch size for processing documents to avoid memory exhaustion @@ -171,35 +171,35 @@ CREATE INDEX IF NOT EXISTS idx_document_document_refs_referenced ON document_doc CREATE INDEX IF NOT EXISTS idx_document_document_refs_line ON document_document_refs(line);`; export interface ConversionOptions { - force?: boolean; - ignore?: string[]; + force?: boolean; + ignore?: string[]; } export interface ConversionStats { - mode: "full" | "incremental"; - total_files: number; - total_symbols: number; - changed_files: number; - deleted_files: number; - time_ms: number; - total_documents?: number; - processed_documents?: number; + mode: "full" | "incremental"; + total_files: number; + total_symbols: number; + changed_files: number; + deleted_files: number; + time_ms: number; + total_documents?: number; + processed_documents?: number; } interface ChangedFile { - path: string; - mtime: number; + path: string; + mtime: number; } /** * Helper function to chunk an array into smaller batches */ function chunkArray(array: T[], chunkSize: number): T[][] { - const chunks: T[][] = []; - for (let i = 0; i < array.length; i += chunkSize) { - chunks.push(array.slice(i, i + chunkSize)); - } - return chunks; + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += chunkSize) { + chunks.push(array.slice(i, i + chunkSize)); + } + return chunks; } /** @@ -216,557 +216,561 @@ function chunkArray(array: T[], chunkSize: number): T[][] { * @throws {Error} If SCIP file cannot be parsed or database cannot be created */ export async function convertToDatabase({ - scipPath, - databasePath, - repoRoot, - options = {}, + scipPath, + databasePath, + repoRoot, + options = {}, }: { - scipPath: string; - databasePath: string; - repoRoot: string; - options?: ConversionOptions; + scipPath: string; + databasePath: string; + repoRoot: string; + options?: ConversionOptions; }): Promise { - const startTime = Date.now(); - - // Parse SCIP protobuf file - debugConverter(`Parsing SCIP file at ${scipPath}...`); - let scipData: ScipData; - try { - scipData = await parseScipFile(scipPath); - debugConverter(`Parsed SCIP file: ${scipData.documents.length} documents`); - } catch (error) { - throw new Error(`Failed to parse SCIP file at ${scipPath}: ${error}`); - } - - // Open database - debugConverter(`Opening database at ${databasePath}...`); - let db: Database; - try { - db = new Database(databasePath, { create: true }); - debugConverter("Database opened successfully"); - } catch (error) { - throw new Error( - `Failed to open/create database at ${databasePath}: ${error}` - ); - } - - // Initialize schema - debugConverter("Initializing database schema..."); - initializeSchema(db); - debugConverter("Schema initialized"); - - // Optimize database for bulk writes - optimizeDatabaseForWrites(db); - - // Determine if this is a full or incremental build - const isFirstRun = !hasExistingData(db); - const isForceFull = options.force === true; - const mode = isFirstRun || isForceFull ? "full" : "incremental"; - debugConverter( - `Build mode: ${mode} (firstRun=${isFirstRun}, force=${isForceFull})` - ); - - // Create ignore matcher if patterns are provided - const ig = ignore(); - if (options.ignore && options.ignore.length > 0) { - ig.add(options.ignore); - debugConverter(`Filtering with ${options.ignore.length} ignore patterns`); - } - - // Build a quick document lookup (lightweight - just paths) - const documentsByPath = new Map( - scipData.documents.map((doc) => [doc.relativePath, doc]) - ); - - let changedFiles: ChangedFile[]; - let deletedFiles: string[]; - - if (mode === "full") { - // Full rebuild: get all files from SCIP data - debugConverter("Getting all files for full rebuild..."); - changedFiles = await getAllFiles({ documentsByPath, repoRoot, ig }); - deletedFiles = []; - debugConverter(`Full rebuild: processing ${changedFiles.length} files`); - - // Clear existing data - debugConverter("Clearing existing database data..."); - clearAllData(db); - debugConverter("Existing data cleared"); - } else { - // Incremental: detect changes via filesystem scan - debugConverter("Detecting changed and deleted files..."); - const changes = await detectChangedFiles({ - documentsByPath, - db, - repoRoot, - ig, - }); - changedFiles = changes.changed; - deletedFiles = changes.deleted; - debugConverter( - `Incremental build: ${changedFiles.length} changed, ${deletedFiles.length} deleted` - ); - } - - // Delete old data - if (deletedFiles.length > 0) { - debugConverter( - `Deleting ${deletedFiles.length} old files from database...` - ); - deleteOldData(db, deletedFiles, []); - debugConverter(`Deleted ${deletedFiles.length} files from database`); - } - - if (changedFiles.length > 0) { - // Delete old versions of changed files - debugConverter( - `Removing old versions of ${changedFiles.length} changed files...` - ); - deleteOldData(db, [], changedFiles); - - // Process files in batches to avoid memory exhaustion - await processBatches({ db, scipData, changedFiles, repoRoot }); - } - - // Restore database settings - restoreDatabaseSettings(db); - - // Update packages (skip if no files changed) - debugConverter("Updating packages table..."); - updatePackages({ - db, - skipIfNoChanges: changedFiles.length === 0 && deletedFiles.length === 0, - }); - debugConverter("Packages table updated"); - - // Update denormalized fields - debugConverter("Updating denormalized fields..."); - updateDenormalizedFields(db); - debugConverter("Denormalized fields updated"); - - // Process documentation files - debugConverter("Processing documentation files..."); - const docStats = await processDocuments({ - db, - repoRoot, - mode, - ignorePatterns: options.ignore || [], - }); - debugConverter( - `Documentation processing complete: ${docStats.processed} processed, ${docStats.skipped} skipped` - ); - - // Update metadata and get stats - debugConverter("Updating metadata..."); - const stats = updateMetadata({ - db, - mode, - changedFiles: changedFiles.length, - deletedFiles: deletedFiles.length, - }); - debugConverter( - `Metadata updated: ${stats.total_files} total files, ${stats.total_symbols} total symbols` - ); - - // Close database - debugConverter("Closing database..."); - db.close(); - - const timeMs = Date.now() - startTime; - - return { - ...stats, - time_ms: timeMs, - total_documents: docStats.total, - processed_documents: docStats.processed, - }; + const startTime = Date.now(); + + // Parse SCIP protobuf file + debugConverter(`Parsing SCIP file at ${scipPath}...`); + let scipData: ScipData; + try { + scipData = await parseScipFile(scipPath); + debugConverter(`Parsed SCIP file: ${scipData.documents.length} documents`); + } catch (error) { + throw new Error(`Failed to parse SCIP file at ${scipPath}: ${error}`); + } + + // Open database + debugConverter(`Opening database at ${databasePath}...`); + let db: Database; + try { + db = new Database(databasePath, { create: true }); + debugConverter("Database opened successfully"); + } catch (error) { + throw new Error( + `Failed to open/create database at ${databasePath}: ${error}`, + ); + } + + // Initialize schema + debugConverter("Initializing database schema..."); + initializeSchema(db); + debugConverter("Schema initialized"); + + // Optimize database for bulk writes + optimizeDatabaseForWrites(db); + + // Determine if this is a full or incremental build + const isFirstRun = !hasExistingData(db); + const isForceFull = options.force === true; + const mode = isFirstRun || isForceFull ? "full" : "incremental"; + debugConverter( + `Build mode: ${mode} (firstRun=${isFirstRun}, force=${isForceFull})`, + ); + + // Create ignore matcher if patterns are provided + const ig = ignore(); + if (options.ignore && options.ignore.length > 0) { + ig.add(options.ignore); + debugConverter(`Filtering with ${options.ignore.length} ignore patterns`); + } + + // Build a quick document lookup (lightweight - just paths) + const documentsByPath = new Map( + scipData.documents.map((doc) => [doc.relativePath, doc]), + ); + + let changedFiles: ChangedFile[]; + let deletedFiles: string[]; + + if (mode === "full") { + // Full rebuild: get all files from SCIP data + debugConverter("Getting all files for full rebuild..."); + changedFiles = await getAllFiles({ documentsByPath, repoRoot, ig }); + deletedFiles = []; + debugConverter(`Full rebuild: processing ${changedFiles.length} files`); + + // Clear existing data + debugConverter("Clearing existing database data..."); + clearAllData(db); + debugConverter("Existing data cleared"); + } else { + // Incremental: detect changes via filesystem scan + debugConverter("Detecting changed and deleted files..."); + const changes = await detectChangedFiles({ + documentsByPath, + db, + repoRoot, + ig, + }); + changedFiles = changes.changed; + deletedFiles = changes.deleted; + debugConverter( + `Incremental build: ${changedFiles.length} changed, ${deletedFiles.length} deleted`, + ); + } + + // Delete old data + if (deletedFiles.length > 0) { + debugConverter( + `Deleting ${deletedFiles.length} old files from database...`, + ); + deleteOldData(db, deletedFiles, []); + debugConverter(`Deleted ${deletedFiles.length} files from database`); + } + + if (changedFiles.length > 0) { + // Delete old versions of changed files + debugConverter( + `Removing old versions of ${changedFiles.length} changed files...`, + ); + deleteOldData(db, [], changedFiles); + + // Process files in batches to avoid memory exhaustion + await processBatches({ db, scipData, changedFiles, repoRoot }); + } + + // Restore database settings + restoreDatabaseSettings(db); + + // Update packages (skip if no files changed) + debugConverter("Updating packages table..."); + updatePackages({ + db, + skipIfNoChanges: changedFiles.length === 0 && deletedFiles.length === 0, + }); + debugConverter("Packages table updated"); + + // Update denormalized fields + debugConverter("Updating denormalized fields..."); + updateDenormalizedFields(db); + debugConverter("Denormalized fields updated"); + + // Process documentation files + debugConverter("Processing documentation files..."); + const docStats = await processDocuments({ + db, + repoRoot, + mode, + ignorePatterns: options.ignore || [], + }); + debugConverter( + `Documentation processing complete: ${docStats.processed} processed, ${docStats.skipped} skipped`, + ); + + // Update metadata and get stats + debugConverter("Updating metadata..."); + const stats = updateMetadata({ + db, + mode, + changedFiles: changedFiles.length, + deletedFiles: deletedFiles.length, + }); + debugConverter( + `Metadata updated: ${stats.total_files} total files, ${stats.total_symbols} total symbols`, + ); + + // Close database + debugConverter("Closing database..."); + db.close(); + + const timeMs = Date.now() - startTime; + + return { + ...stats, + time_ms: timeMs, + total_documents: docStats.total, + processed_documents: docStats.processed, + }; } /** * Process files in batches to avoid memory exhaustion */ async function processBatches({ - db, - scipData, - changedFiles, - repoRoot, + db, + scipData, + changedFiles, + repoRoot, }: { - db: Database; - scipData: ScipData; - changedFiles: ChangedFile[]; - repoRoot: string; + db: Database; + scipData: ScipData; + changedFiles: ChangedFile[]; + repoRoot: string; }): Promise { - const timestamp = Math.floor(Date.now() / 1000); - - const insertedFiles = new Set(); - - // Create a set of changed paths for quick lookup - const changedPathsSet = new Set(changedFiles.map((f) => f.path)); - - // Filter scipData documents to only include changed files - const docsToProcess = scipData.documents.filter((doc) => - changedPathsSet.has(doc.relativePath) - ); - - debugConverter( - `Processing ${docsToProcess.length} documents in batches of ${BATCH_SIZE}...` - ); - - // Build LIGHTWEIGHT global definition map (only symbol -> file path) - debugConverter("Building lightweight global definition map..."); - const globalDefinitionsBySymbol = new Map< - string, - { file: string; definition: SymbolDefinition } - >(); - const externalSymbols = scipData.externalSymbols; - - // Process documents in chunks to build definition map without keeping all in memory - for (const doc of scipData.documents) { - // Extract only the symbol IDs and file path (very lightweight) - for (const occ of doc.occurrences) { - if (occ.symbolRoles & 0x1) { - // Definition bit - // Store minimal info - we'll get full details from documentsByPath later - if (!globalDefinitionsBySymbol.has(occ.symbol)) { - globalDefinitionsBySymbol.set(occ.symbol, { - file: doc.relativePath, - definition: { symbol: occ.symbol, range: occ.range }, - }); - } - } - } - } - debugConverter( - `Global definition map built: ${globalDefinitionsBySymbol.size} definitions` - ); - - // Build LIGHTWEIGHT global symbols map (only external symbols + doc symbols, no duplication) - debugConverter("Building global symbols map..."); - const globalSymbolsById = new Map(); - - // Add external symbols first (these are small, usually < 10K) - for (const sym of externalSymbols) { - globalSymbolsById.set(sym.symbol, sym); - } - - // Add document symbols efficiently (no deep copies) - for (const doc of scipData.documents) { - for (const sym of doc.symbols) { - if (!globalSymbolsById.has(sym.symbol)) { - globalSymbolsById.set(sym.symbol, sym); - } - } - } - debugConverter(`Global symbols map built: ${globalSymbolsById.size} symbols`); - - // Chunk documents into batches - const batches = chunkArray(docsToProcess, BATCH_SIZE); - - // Clear scipData external symbols reference (we copied it) - scipData.externalSymbols = []; - debugConverter("Cleared scipData external symbols"); - - const totalBatches = batches.length; - let processedFiles = 0; - const totalFiles = docsToProcess.length; - const progressStartTime = Date.now(); - - for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) { - const batch = batches[batchIndex]!; - const batchNum = batchIndex + 1; - - // Calculate progress - const percent = Math.floor((processedFiles / totalFiles) * 100); - const elapsed = (Date.now() - progressStartTime) / 1000; - const rate = processedFiles / elapsed || 0; - const remaining = totalFiles - processedFiles; - const eta = rate > 0 ? Math.ceil(remaining / rate) : 0; - - debugConverter( - `\rIndexing: ${percent}% (${processedFiles}/${totalFiles} files, batch ${batchNum}/${totalBatches}, ETA: ${eta}s) ` - ); - - // Build lightweight document map for this batch - const documentsByPath = new Map( - batch.map((doc) => [doc.relativePath, doc]) - ); - - // Get ChangedFile objects for this batch - const batchChangedFiles = changedFiles.filter((f) => - batch.some((doc) => doc.relativePath === f.path) - ); - - // Convert files in this batch - await convertFiles( - documentsByPath, - globalSymbolsById, - db, - batchChangedFiles, - timestamp, - insertedFiles - ); - - // Update dependencies for this batch (uses global maps for cross-batch deps) - await updateDependencies( - documentsByPath, - globalSymbolsById, - globalDefinitionsBySymbol, - db, - batchChangedFiles - ); - - // Update symbol references for this batch - await updateSymbolReferences({ - documentsByPath, - db, - changedFiles: batchChangedFiles, - }); - - processedFiles += batch.length; - } - - process.stderr.write("\n"); - debugConverter( - `Batch processing complete: ${processedFiles} files processed` - ); + const timestamp = Math.floor(Date.now() / 1000); + + const insertedFiles = new Set(); + + // Create a set of changed paths for quick lookup + const changedPathsSet = new Set(changedFiles.map((f) => f.path)); + + // Filter scipData documents to only include changed files + const docsToProcess = scipData.documents.filter((doc) => + changedPathsSet.has(doc.relativePath), + ); + + debugConverter( + `Processing ${docsToProcess.length} documents in batches of ${BATCH_SIZE}...`, + ); + + // Build LIGHTWEIGHT global definition map (only symbol -> file path) + debugConverter("Building lightweight global definition map..."); + const globalDefinitionsBySymbol = new Map< + string, + { file: string; definition: SymbolDefinition } + >(); + const externalSymbols = scipData.externalSymbols; + + // Process documents in chunks to build definition map without keeping all in memory + for (const doc of scipData.documents) { + // Extract only the symbol IDs and file path (very lightweight) + for (const occ of doc.occurrences) { + if (occ.symbolRoles & 0x1) { + // Definition bit + // Store minimal info - we'll get full details from documentsByPath later + if (!globalDefinitionsBySymbol.has(occ.symbol)) { + globalDefinitionsBySymbol.set(occ.symbol, { + file: doc.relativePath, + definition: { symbol: occ.symbol, range: occ.range }, + }); + } + } + } + } + debugConverter( + `Global definition map built: ${globalDefinitionsBySymbol.size} definitions`, + ); + + // Build LIGHTWEIGHT global symbols map (only external symbols + doc symbols, no duplication) + debugConverter("Building global symbols map..."); + const globalSymbolsById = new Map(); + + // Add external symbols first (these are small, usually < 10K) + for (const sym of externalSymbols) { + globalSymbolsById.set(sym.symbol, sym); + } + + // Add document symbols efficiently (no deep copies) + for (const doc of scipData.documents) { + for (const sym of doc.symbols) { + if (!globalSymbolsById.has(sym.symbol)) { + globalSymbolsById.set(sym.symbol, sym); + } + } + } + debugConverter(`Global symbols map built: ${globalSymbolsById.size} symbols`); + + // Chunk documents into batches + const batches = chunkArray(docsToProcess, BATCH_SIZE); + + // Clear scipData external symbols reference (we copied it) + scipData.externalSymbols = []; + debugConverter("Cleared scipData external symbols"); + + const totalBatches = batches.length; + let processedFiles = 0; + const totalFiles = docsToProcess.length; + const progressStartTime = Date.now(); + + for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) { + const batch = batches[batchIndex]!; + const batchNum = batchIndex + 1; + + // Calculate progress + const percent = Math.floor((processedFiles / totalFiles) * 100); + const elapsed = (Date.now() - progressStartTime) / 1000; + const rate = processedFiles / elapsed || 0; + const remaining = totalFiles - processedFiles; + const eta = rate > 0 ? Math.ceil(remaining / rate) : 0; + + debugConverter( + `\rIndexing: ${percent}% (${processedFiles}/${totalFiles} files, batch ${batchNum}/${totalBatches}, ETA: ${eta}s) `, + ); + + // Build lightweight document map for this batch + const documentsByPath = new Map( + batch.map((doc) => [doc.relativePath, doc]), + ); + + // Get ChangedFile objects for this batch + const batchChangedFiles = changedFiles.filter((f) => + batch.some((doc) => doc.relativePath === f.path), + ); + + // Convert files in this batch + await convertFiles( + documentsByPath, + globalSymbolsById, + db, + batchChangedFiles, + timestamp, + insertedFiles, + ); + + // Update dependencies for this batch (uses global maps for cross-batch deps) + await updateDependencies( + documentsByPath, + globalSymbolsById, + globalDefinitionsBySymbol, + db, + batchChangedFiles, + ); + + // Update symbol references for this batch + await updateSymbolReferences({ + documentsByPath, + db, + changedFiles: batchChangedFiles, + }); + + processedFiles += batch.length; + } + + process.stderr.write("\n"); + debugConverter( + `Batch processing complete: ${processedFiles} files processed`, + ); } /** * Initialize database schema */ function initializeSchema(db: Database): void { - // Check if all tables exist (including new ones like documents) - // We always run the schema if any table is missing - try { - const filesCheck = db - .query( - "SELECT name FROM sqlite_master WHERE type='table' AND name='files'" - ) - .get(); - - const documentsCheck = db - .query( - "SELECT name FROM sqlite_master WHERE type='table' AND name='documents'" - ) - .get(); - - if (filesCheck && documentsCheck) { - // All tables exist, skip initialization - return; - } - } catch { - // Continue with initialization - } - - // Execute schema (multiple statements) - const statements = SCHEMA_SQL.split(";") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - - for (const stmt of statements) { - db.run(stmt); - } + // Check if all tables exist (including new ones like documents) + // We always run the schema if any table is missing + try { + const filesCheck = db + .query( + "SELECT name FROM sqlite_master WHERE type='table' AND name='files'", + ) + .get(); + + const documentsCheck = db + .query( + "SELECT name FROM sqlite_master WHERE type='table' AND name='documents'", + ) + .get(); + + if (filesCheck && documentsCheck) { + // All tables exist, skip initialization + return; + } + } catch { + // Continue with initialization + } + + // Execute schema (multiple statements) + const statements = SCHEMA_SQL.split(";") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + for (const stmt of statements) { + db.run(stmt); + } } /** * Optimize database for bulk writes */ function optimizeDatabaseForWrites(db: Database): void { - debugConverter("Optimizing database for bulk writes..."); + debugConverter("Optimizing database for bulk writes..."); - // Disable synchronous writes (much faster, but less crash-safe during indexing) - db.run("PRAGMA synchronous = OFF"); + // Disable synchronous writes (much faster, but less crash-safe during indexing) + db.run("PRAGMA synchronous = OFF"); - // Use memory for journal (faster than disk) - // This can fail if database is in WAL mode or file locks aren't fully released - try { - db.run("PRAGMA journal_mode = MEMORY"); - } catch (error) { - debugConverter(`Note: Could not set journal_mode to MEMORY, continuing with default: ${error}`); - } + // Use memory for journal (faster than disk) + // This can fail if database is in WAL mode or file locks aren't fully released + try { + db.run("PRAGMA journal_mode = MEMORY"); + } catch (error) { + debugConverter( + `Note: Could not set journal_mode to MEMORY, continuing with default: ${error}`, + ); + } - // Increase cache size (10MB) - db.run("PRAGMA cache_size = -10000"); + // Increase cache size (10MB) + db.run("PRAGMA cache_size = -10000"); - debugConverter("Database optimizations applied"); + debugConverter("Database optimizations applied"); } /** * Restore normal database settings after bulk writes */ function restoreDatabaseSettings(db: Database): void { - debugConverter("Restoring normal database settings..."); - - // Re-enable synchronous writes - db.run("PRAGMA synchronous = FULL"); - - // Switch back to WAL mode - // This can fail if database is being closed or file locks are active - try { - db.run("PRAGMA journal_mode = WAL"); - } catch (error) { - debugConverter(`Note: Could not set journal_mode to WAL, continuing: ${error}`); - } - - debugConverter("Database settings restored"); + debugConverter("Restoring normal database settings..."); + + // Re-enable synchronous writes + db.run("PRAGMA synchronous = FULL"); + + // Switch back to WAL mode + // This can fail if database is being closed or file locks are active + try { + db.run("PRAGMA journal_mode = WAL"); + } catch (error) { + debugConverter( + `Note: Could not set journal_mode to WAL, continuing: ${error}`, + ); + } + + debugConverter("Database settings restored"); } /** * Check if database has existing data */ function hasExistingData(db: Database): boolean { - try { - const result = db.query("SELECT COUNT(*) as count FROM files").get() as { - count: number; - }; - return result.count > 0; - } catch { - return false; - } + try { + const result = db.query("SELECT COUNT(*) as count FROM files").get() as { + count: number; + }; + return result.count > 0; + } catch { + return false; + } } /** * Clear all data from database (for full rebuild) */ function clearAllData(db: Database): void { - db.run("BEGIN TRANSACTION"); - db.run("DELETE FROM symbol_references"); - db.run("DELETE FROM dependencies"); - db.run("DELETE FROM symbols"); - db.run("DELETE FROM files"); - db.run("DELETE FROM packages"); - db.run("DELETE FROM metadata"); - db.run("COMMIT"); + db.run("BEGIN TRANSACTION"); + db.run("DELETE FROM symbol_references"); + db.run("DELETE FROM dependencies"); + db.run("DELETE FROM symbols"); + db.run("DELETE FROM files"); + db.run("DELETE FROM packages"); + db.run("DELETE FROM metadata"); + db.run("COMMIT"); } /** * Get all files from SCIP data (for full rebuild) */ async function getAllFiles({ - documentsByPath, - repoRoot, - ig, + documentsByPath, + repoRoot, + ig, }: { - documentsByPath: Map; - repoRoot: string; - ig: ReturnType; + documentsByPath: Map; + repoRoot: string; + ig: ReturnType; }): Promise { - const files: ChangedFile[] = []; - - for (const [relativePath, doc] of documentsByPath) { - if (ig.ignores(relativePath)) { - continue; - } - - const fullPath = path.join(repoRoot, relativePath); - try { - const stat = await Bun.file(fullPath).stat(); - const mtime = Math.floor(stat.mtime.getTime() / 1000); - files.push({ path: relativePath, mtime }); - } catch {} - } - - return files; + const files: ChangedFile[] = []; + + for (const [relativePath, doc] of documentsByPath) { + if (ig.ignores(relativePath)) { + continue; + } + + const fullPath = path.join(repoRoot, relativePath); + try { + const stat = await Bun.file(fullPath).stat(); + const mtime = Math.floor(stat.mtime.getTime() / 1000); + files.push({ path: relativePath, mtime }); + } catch {} + } + + return files; } /** * Detect changed and deleted files (for incremental rebuild) */ async function detectChangedFiles({ - documentsByPath, - db, - repoRoot, - ig, + documentsByPath, + db, + repoRoot, + ig, }: { - documentsByPath: Map; - db: Database; - repoRoot: string; - ig: ReturnType; + documentsByPath: Map; + db: Database; + repoRoot: string; + ig: ReturnType; }): Promise<{ changed: ChangedFile[]; deleted: string[] }> { - // Get existing files from database with mtime - const existingFiles = new Map( - ( - db.query("SELECT path, mtime FROM files").all() as Array<{ - path: string; - mtime: number; - }> - ).map((f) => [f.path, f.mtime]) - ); - - const changed: ChangedFile[] = []; - const deleted = new Set(existingFiles.keys()); - - for (const [relativePath, doc] of documentsByPath) { - if (ig.ignores(relativePath)) { - continue; - } - - deleted.delete(relativePath); - - // Get current mtime from filesystem - const fullPath = path.join(repoRoot, relativePath); - try { - const stat = await Bun.file(fullPath).stat(); - const currentMtime = Math.floor(stat.mtime.getTime() / 1000); - - const existingMtime = existingFiles.get(relativePath); - - // File is new or modified - if (!existingMtime || currentMtime > existingMtime) { - changed.push({ path: relativePath, mtime: currentMtime }); - } - } catch {} - } - - return { changed, deleted: Array.from(deleted) }; + // Get existing files from database with mtime + const existingFiles = new Map( + ( + db.query("SELECT path, mtime FROM files").all() as Array<{ + path: string; + mtime: number; + }> + ).map((f) => [f.path, f.mtime]), + ); + + const changed: ChangedFile[] = []; + const deleted = new Set(existingFiles.keys()); + + for (const [relativePath, doc] of documentsByPath) { + if (ig.ignores(relativePath)) { + continue; + } + + deleted.delete(relativePath); + + // Get current mtime from filesystem + const fullPath = path.join(repoRoot, relativePath); + try { + const stat = await Bun.file(fullPath).stat(); + const currentMtime = Math.floor(stat.mtime.getTime() / 1000); + + const existingMtime = existingFiles.get(relativePath); + + // File is new or modified + if (!existingMtime || currentMtime > existingMtime) { + changed.push({ path: relativePath, mtime: currentMtime }); + } + } catch {} + } + + return { changed, deleted: Array.from(deleted) }; } /** * Delete old data for deleted or changed files */ function deleteOldData( - db: Database, - deletedFiles: string[], - changedFiles: ChangedFile[] + db: Database, + deletedFiles: string[], + changedFiles: ChangedFile[], ): void { - const allFilesToRemove = [ - ...deletedFiles, - ...changedFiles.map((f) => f.path), - ]; + const allFilesToRemove = [ + ...deletedFiles, + ...changedFiles.map((f) => f.path), + ]; - if (allFilesToRemove.length === 0) return; + if (allFilesToRemove.length === 0) return; - db.run("BEGIN TRANSACTION"); + db.run("BEGIN TRANSACTION"); - const stmt = db.prepare("DELETE FROM files WHERE path = ?"); - for (const filePath of allFilesToRemove) { - stmt.run(filePath); - } + const stmt = db.prepare("DELETE FROM files WHERE path = ?"); + for (const filePath of allFilesToRemove) { + stmt.run(filePath); + } - db.run("COMMIT"); + db.run("COMMIT"); } /** * Convert changed files from SCIP data to database */ async function convertFiles( - documentsByPath: Map, - symbolsById: Map, - db: Database, - changedFiles: ChangedFile[], - timestamp: number, - insertedFiles: Set + documentsByPath: Map, + symbolsById: Map, + db: Database, + changedFiles: ChangedFile[], + timestamp: number, + insertedFiles: Set, ): Promise { - if (changedFiles.length === 0) return; + if (changedFiles.length === 0) return; - debugConverter("Starting database transaction for file conversion..."); - db.run("BEGIN TRANSACTION"); + debugConverter("Starting database transaction for file conversion..."); + db.run("BEGIN TRANSACTION"); - const fileStmt = db.prepare( - "INSERT INTO files (path, language, mtime, indexed_at) VALUES (?, ?, ?, ?)" - ); + const fileStmt = db.prepare( + "INSERT INTO files (path, language, mtime, indexed_at) VALUES (?, ?, ?, ?)", + ); - const symbolStmt = db.prepare(` + const symbolStmt = db.prepare(` INSERT INTO symbols ( file_id, name, scip_symbol, kind, start_line, end_line, start_char, end_char, @@ -774,342 +778,345 @@ async function convertFiles( ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); - let processedCount = 0; - const logInterval = Math.max(1, Math.floor(changedFiles.length / 10)); // Log every 10% - - for (const { path: filePath, mtime } of changedFiles) { - processedCount++; - - if ( - processedCount % logInterval === 0 || - processedCount === changedFiles.length - ) { - debugConverter( - `Converting files: ${processedCount}/${ - changedFiles.length - } (${Math.floor((processedCount / changedFiles.length) * 100)}%)` - ); - } - - if (insertedFiles.has(filePath)) { - continue; - } - - // Get document from parsed SCIP data - const doc = documentsByPath.get(filePath); - if (!doc) continue; - - // Insert file - fileStmt.run(filePath, doc.language, mtime, timestamp); - - insertedFiles.add(filePath); - - // Get file_id from database - const fileRecord = db - .query("SELECT id FROM files WHERE path = ?") - .get(filePath) as { id: number } | undefined; - - if (!fileRecord) continue; - - const fileId = fileRecord.id; - - // Extract symbol definitions from occurrences - const definitions = extractDefinitions(doc); - - // Insert symbols (batch) - for (const def of definitions) { - const symbolInfo = symbolsById.get(def.symbol); - - // Get symbol metadata - let kind = symbolKindToString(symbolInfo?.kind ?? 0); - - // Fallback: If kind is unknown, try to extract from documentation - if (kind === "unknown" && symbolInfo?.documentation) { - kind = extractKindFromDocumentation(symbolInfo.documentation); - } - - const pkg = extractPackageFromScip(def.symbol); - const name = symbolInfo?.displayName || extractNameFromScip(def.symbol); - const documentation = symbolInfo?.documentation?.join("\n"); - - // Detect if symbol is local (function parameters, closure variables, etc.) - const isLocal = def.symbol.includes("local") ? 1 : 0; - - symbolStmt.run( - fileId, - name, - def.symbol, - kind, - def.range[0], // start_line - def.range[2], // end_line - def.range[1], // start_char - def.range[3], // end_char - documentation || null, - pkg, - isLocal - ); - } - } - - debugConverter(`Committing transaction for ${changedFiles.length} files...`); - db.run("COMMIT"); - debugConverter("Transaction committed successfully"); + let processedCount = 0; + const logInterval = Math.max(1, Math.floor(changedFiles.length / 10)); // Log every 10% + + for (const { path: filePath, mtime } of changedFiles) { + processedCount++; + + if ( + processedCount % logInterval === 0 || + processedCount === changedFiles.length + ) { + debugConverter( + `Converting files: ${processedCount}/${ + changedFiles.length + } (${Math.floor((processedCount / changedFiles.length) * 100)}%)`, + ); + } + + if (insertedFiles.has(filePath)) { + continue; + } + + // Get document from parsed SCIP data + const doc = documentsByPath.get(filePath); + if (!doc) continue; + + // Insert file + fileStmt.run(filePath, doc.language, mtime, timestamp); + + insertedFiles.add(filePath); + + // Get file_id from database + const fileRecord = db + .query("SELECT id FROM files WHERE path = ?") + .get(filePath) as { id: number } | undefined; + + if (!fileRecord) continue; + + const fileId = fileRecord.id; + + // Extract symbol definitions from occurrences + const definitions = extractDefinitions(doc); + + // Insert symbols (batch) + for (const def of definitions) { + const symbolInfo = symbolsById.get(def.symbol); + + // Get symbol metadata + let kind = symbolKindToString(symbolInfo?.kind ?? 0); + + // Fallback: If kind is unknown, try to extract from documentation + if (kind === "unknown" && symbolInfo?.documentation) { + kind = extractKindFromDocumentation(symbolInfo.documentation); + } + + const pkg = extractPackageFromScip(def.symbol); + const name = symbolInfo?.displayName || extractNameFromScip(def.symbol); + const documentation = symbolInfo?.documentation?.join("\n"); + + // Detect if symbol is local (function parameters, closure variables, etc.) + const isLocal = def.symbol.includes("local") ? 1 : 0; + + symbolStmt.run( + fileId, + name, + def.symbol, + kind, + def.range[0], // start_line + def.range[2], // end_line + def.range[1], // start_char + def.range[3], // end_char + documentation || null, + pkg, + isLocal, + ); + } + } + + debugConverter(`Committing transaction for ${changedFiles.length} files...`); + db.run("COMMIT"); + debugConverter("Transaction committed successfully"); } /** * Update dependencies for changed files */ async function updateDependencies( - documentsByPath: Map, - symbolsById: Map, - definitionsBySymbol: Map, - db: Database, - changedFiles: ChangedFile[] + documentsByPath: Map, + symbolsById: Map, + definitionsBySymbol: Map< + string, + { file: string; definition: SymbolDefinition } + >, + db: Database, + changedFiles: ChangedFile[], ): Promise { - if (changedFiles.length === 0) return; - - const changedPaths = changedFiles.map((f) => f.path); - debugConverter( - `Finding affected files for ${changedPaths.length} changed files...` - ); - - // Get affected files (changed + their dependents) - const affectedFiles = new Set(changedPaths); - - // Find files that import changed files - if (changedPaths.length > 0) { - const placeholders = changedPaths.map(() => "?").join(","); - const dependents = db - .query( - ` + if (changedFiles.length === 0) return; + + const changedPaths = changedFiles.map((f) => f.path); + debugConverter( + `Finding affected files for ${changedPaths.length} changed files...`, + ); + + // Get affected files (changed + their dependents) + const affectedFiles = new Set(changedPaths); + + // Find files that import changed files + if (changedPaths.length > 0) { + const placeholders = changedPaths.map(() => "?").join(","); + const dependents = db + .query( + ` SELECT DISTINCT f.path FROM dependencies d JOIN files f ON f.id = d.from_file_id JOIN files f2 ON f2.id = d.to_file_id WHERE f2.path IN (${placeholders}) - ` - ) - .all(...changedPaths) as Array<{ path: string }>; - - for (const { path } of dependents) { - affectedFiles.add(path); - } - debugConverter( - `Found ${dependents.length} dependent files, total affected: ${affectedFiles.size}` - ); - } - - // Delete old dependencies for affected files - debugConverter("Starting transaction for dependencies update..."); - db.run("BEGIN TRANSACTION"); - - const deleteStmt = db.prepare(` + `, + ) + .all(...changedPaths) as Array<{ path: string }>; + + for (const { path } of dependents) { + affectedFiles.add(path); + } + debugConverter( + `Found ${dependents.length} dependent files, total affected: ${affectedFiles.size}`, + ); + } + + // Delete old dependencies for affected files + debugConverter("Starting transaction for dependencies update..."); + db.run("BEGIN TRANSACTION"); + + const deleteStmt = db.prepare(` DELETE FROM dependencies WHERE from_file_id IN (SELECT id FROM files WHERE path = ?) `); - for (const filePath of affectedFiles) { - deleteStmt.run(filePath); - } - debugConverter(`Deleted old dependencies for ${affectedFiles.size} files`); + for (const filePath of affectedFiles) { + deleteStmt.run(filePath); + } + debugConverter(`Deleted old dependencies for ${affectedFiles.size} files`); - // Recompute dependencies from SCIP data - debugConverter(`Recomputing dependencies for ${affectedFiles.size} files...`); - const insertStmt = db.prepare(` + // Recompute dependencies from SCIP data + debugConverter(`Recomputing dependencies for ${affectedFiles.size} files...`); + const insertStmt = db.prepare(` INSERT INTO dependencies (from_file_id, to_file_id, symbol_count, symbols) VALUES (?, ?, ?, ?) `); - let processedCount = 0; - const logInterval = Math.max(1, Math.floor(affectedFiles.size / 10)); - - for (const fromPath of affectedFiles) { - processedCount++; - - if ( - processedCount % logInterval === 0 || - processedCount === affectedFiles.size - ) { - debugConverter( - `Processing dependencies: ${processedCount}/${ - affectedFiles.size - } (${Math.floor((processedCount / affectedFiles.size) * 100)}%)` - ); - } - const doc = documentsByPath.get(fromPath); - if (!doc) continue; - - // Get file dependencies - const depsByFile = getFileDependencies({ - doc, - definitionsBySymbol, - }); - - const fromFileRecord = db - .query("SELECT id FROM files WHERE path = ?") - .get(fromPath) as { id: number } | undefined; - - if (!fromFileRecord) continue; - - const fromFileId = fromFileRecord.id; - - for (const [toPath, symbols] of depsByFile) { - const toFileRecord = db - .query("SELECT id FROM files WHERE path = ?") - .get(toPath) as { id: number } | undefined; - - if (!toFileRecord) continue; - - // Extract symbol names - const symbolNames = Array.from( - new Set( - Array.from(symbols) - .filter((scipSymbol) => !scipSymbol.includes("local")) // Filter out local symbols - .map((scipSymbol) => { - const symbolInfo = symbolsById.get(scipSymbol); - return symbolInfo?.displayName || extractNameFromScip(scipSymbol); - }) - .filter((name) => name && name !== "unknown") - ) - ); - - if (symbolNames.length === 0) continue; - - insertStmt.run( - fromFileId, - toFileRecord.id, - symbolNames.length, - JSON.stringify(symbolNames) - ); - } - } - - debugConverter("Committing dependencies transaction..."); - db.run("COMMIT"); - debugConverter("Dependencies transaction committed"); + let processedCount = 0; + const logInterval = Math.max(1, Math.floor(affectedFiles.size / 10)); + + for (const fromPath of affectedFiles) { + processedCount++; + + if ( + processedCount % logInterval === 0 || + processedCount === affectedFiles.size + ) { + debugConverter( + `Processing dependencies: ${processedCount}/${ + affectedFiles.size + } (${Math.floor((processedCount / affectedFiles.size) * 100)}%)`, + ); + } + const doc = documentsByPath.get(fromPath); + if (!doc) continue; + + // Get file dependencies + const depsByFile = getFileDependencies({ + doc, + definitionsBySymbol, + }); + + const fromFileRecord = db + .query("SELECT id FROM files WHERE path = ?") + .get(fromPath) as { id: number } | undefined; + + if (!fromFileRecord) continue; + + const fromFileId = fromFileRecord.id; + + for (const [toPath, symbols] of depsByFile) { + const toFileRecord = db + .query("SELECT id FROM files WHERE path = ?") + .get(toPath) as { id: number } | undefined; + + if (!toFileRecord) continue; + + // Extract symbol names + const symbolNames = Array.from( + new Set( + Array.from(symbols) + .filter((scipSymbol) => !scipSymbol.includes("local")) // Filter out local symbols + .map((scipSymbol) => { + const symbolInfo = symbolsById.get(scipSymbol); + return symbolInfo?.displayName || extractNameFromScip(scipSymbol); + }) + .filter((name) => name && name !== "unknown"), + ), + ); + + if (symbolNames.length === 0) continue; + + insertStmt.run( + fromFileId, + toFileRecord.id, + symbolNames.length, + JSON.stringify(symbolNames), + ); + } + } + + debugConverter("Committing dependencies transaction..."); + db.run("COMMIT"); + debugConverter("Dependencies transaction committed"); } /** * Update symbol references for changed files */ async function updateSymbolReferences({ - documentsByPath, - db, - changedFiles, + documentsByPath, + db, + changedFiles, }: { - documentsByPath: Map; - db: Database; - changedFiles: ChangedFile[]; + documentsByPath: Map; + db: Database; + changedFiles: ChangedFile[]; }): Promise { - if (changedFiles.length === 0) return; + if (changedFiles.length === 0) return; - const affectedFiles = changedFiles.map((f) => f.path); - debugConverter( - `Updating symbol references for ${affectedFiles.length} files...` - ); + const affectedFiles = changedFiles.map((f) => f.path); + debugConverter( + `Updating symbol references for ${affectedFiles.length} files...`, + ); - db.run("BEGIN TRANSACTION"); + db.run("BEGIN TRANSACTION"); - // Delete old references from changed files - const deleteStmt = db.prepare(` + // Delete old references from changed files + const deleteStmt = db.prepare(` DELETE FROM symbol_references WHERE file_id IN (SELECT id FROM files WHERE path = ?) `); - for (const filePath of affectedFiles) { - deleteStmt.run(filePath); - } - debugConverter(`Deleted old references for ${affectedFiles.length} files`); - - // Build symbol lookup map (scip_symbol -> id) for fast lookups - debugConverter("Building symbol ID lookup map..."); - const symbolIdMap = new Map(); - const allSymbols = db - .query("SELECT id, scip_symbol FROM symbols") - .all() as Array<{ - id: number; - scip_symbol: string; - }>; - for (const sym of allSymbols) { - symbolIdMap.set(sym.scip_symbol, sym.id); - } - debugConverter(`Symbol lookup map built: ${symbolIdMap.size} symbols`); - - // Build file ID lookup map for fast lookups - debugConverter("Building file ID lookup map..."); - const fileIdMap = new Map(); - for (const filePath of affectedFiles) { - const fileRecord = db - .query("SELECT id FROM files WHERE path = ?") - .get(filePath) as { id: number } | undefined; - if (fileRecord) { - fileIdMap.set(filePath, fileRecord.id); - } - } - debugConverter(`File lookup map built: ${fileIdMap.size} files`); - - // Insert new references from changed files - const insertStmt = db.prepare(` + for (const filePath of affectedFiles) { + deleteStmt.run(filePath); + } + debugConverter(`Deleted old references for ${affectedFiles.length} files`); + + // Build symbol lookup map (scip_symbol -> id) for fast lookups + debugConverter("Building symbol ID lookup map..."); + const symbolIdMap = new Map(); + const allSymbols = db + .query("SELECT id, scip_symbol FROM symbols") + .all() as Array<{ + id: number; + scip_symbol: string; + }>; + for (const sym of allSymbols) { + symbolIdMap.set(sym.scip_symbol, sym.id); + } + debugConverter(`Symbol lookup map built: ${symbolIdMap.size} symbols`); + + // Build file ID lookup map for fast lookups + debugConverter("Building file ID lookup map..."); + const fileIdMap = new Map(); + for (const filePath of affectedFiles) { + const fileRecord = db + .query("SELECT id FROM files WHERE path = ?") + .get(filePath) as { id: number } | undefined; + if (fileRecord) { + fileIdMap.set(filePath, fileRecord.id); + } + } + debugConverter(`File lookup map built: ${fileIdMap.size} files`); + + // Insert new references from changed files + const insertStmt = db.prepare(` INSERT INTO symbol_references (symbol_id, file_id, line) VALUES (?, ?, ?) `); - let processedCount = 0; - let totalReferences = 0; - const logInterval = Math.max(1, Math.floor(affectedFiles.length / 10)); - - for (const fromPath of affectedFiles) { - processedCount++; - - if ( - processedCount % logInterval === 0 || - processedCount === affectedFiles.length - ) { - debugConverter( - `Processing references: ${processedCount}/${ - affectedFiles.length - } (${Math.floor( - (processedCount / affectedFiles.length) * 100 - )}%) - ${totalReferences} refs inserted` - ); - } - - const doc = documentsByPath.get(fromPath); - if (!doc) continue; - - const fromFileId = fileIdMap.get(fromPath); - if (!fromFileId) continue; - - // Extract references from occurrences - const references = extractReferences(doc); - - // For each reference, look up symbol ID from map - for (const ref of references) { - // Skip local symbols - if (ref.symbol.includes("local")) continue; - - const symbolId = symbolIdMap.get(ref.symbol); - if (!symbolId) continue; - - insertStmt.run(symbolId, fromFileId, ref.line); - totalReferences++; - } - } - - debugConverter(`Total references inserted: ${totalReferences}`); - - debugConverter("Committing symbol references transaction..."); - db.run("COMMIT"); - debugConverter("Symbol references transaction committed"); + let processedCount = 0; + let totalReferences = 0; + const logInterval = Math.max(1, Math.floor(affectedFiles.length / 10)); + + for (const fromPath of affectedFiles) { + processedCount++; + + if ( + processedCount % logInterval === 0 || + processedCount === affectedFiles.length + ) { + debugConverter( + `Processing references: ${processedCount}/${ + affectedFiles.length + } (${Math.floor( + (processedCount / affectedFiles.length) * 100, + )}%) - ${totalReferences} refs inserted`, + ); + } + + const doc = documentsByPath.get(fromPath); + if (!doc) continue; + + const fromFileId = fileIdMap.get(fromPath); + if (!fromFileId) continue; + + // Extract references from occurrences + const references = extractReferences(doc); + + // For each reference, look up symbol ID from map + for (const ref of references) { + // Skip local symbols + if (ref.symbol.includes("local")) continue; + + const symbolId = symbolIdMap.get(ref.symbol); + if (!symbolId) continue; + + insertStmt.run(symbolId, fromFileId, ref.line); + totalReferences++; + } + } + + debugConverter(`Total references inserted: ${totalReferences}`); + + debugConverter("Committing symbol references transaction..."); + db.run("COMMIT"); + debugConverter("Symbol references transaction committed"); } /** * Update denormalized fields for performance */ function updateDenormalizedFields(db: Database): void { - // Update file symbol counts - debugConverter("Computing file symbol counts..."); - db.run(` + // Update file symbol counts + debugConverter("Computing file symbol counts..."); + db.run(` UPDATE files SET symbol_count = ( SELECT COUNT(*) @@ -1117,11 +1124,11 @@ function updateDenormalizedFields(db: Database): void { WHERE s.file_id = files.id ) `); - debugConverter("File symbol counts updated"); + debugConverter("File symbol counts updated"); - // Update symbol reference counts - debugConverter("Computing symbol reference counts..."); - db.run(` + // Update symbol reference counts + debugConverter("Computing symbol reference counts..."); + db.run(` UPDATE symbols SET reference_count = ( SELECT COUNT(*) @@ -1129,11 +1136,11 @@ function updateDenormalizedFields(db: Database): void { WHERE sr.symbol_id = symbols.id ) `); - debugConverter("Symbol reference counts updated"); + debugConverter("Symbol reference counts updated"); - // Update file dependency counts (outgoing dependencies) - debugConverter("Computing file dependency counts..."); - db.run(` + // Update file dependency counts (outgoing dependencies) + debugConverter("Computing file dependency counts..."); + db.run(` UPDATE files SET dependency_count = ( SELECT COUNT(DISTINCT to_file_id) @@ -1141,11 +1148,11 @@ function updateDenormalizedFields(db: Database): void { WHERE d.from_file_id = files.id ) `); - debugConverter("File dependency counts updated"); + debugConverter("File dependency counts updated"); - // Update file dependent counts (incoming dependencies / fan-in) - debugConverter("Computing file dependent counts..."); - db.run(` + // Update file dependent counts (incoming dependencies / fan-in) + debugConverter("Computing file dependent counts..."); + db.run(` UPDATE files SET dependent_count = ( SELECT COUNT(DISTINCT from_file_id) @@ -1153,43 +1160,43 @@ function updateDenormalizedFields(db: Database): void { WHERE d.to_file_id = files.id ) `); - debugConverter("File dependent counts updated"); + debugConverter("File dependent counts updated"); } /** * Update packages table */ function updatePackages({ - db, - skipIfNoChanges = false, + db, + skipIfNoChanges = false, }: { - db: Database; - skipIfNoChanges?: boolean; + db: Database; + skipIfNoChanges?: boolean; }): void { - if (skipIfNoChanges) { - // Check if packages table needs update - const packageCount = ( - db.query("SELECT COUNT(*) as c FROM packages").get() as { - c: number; - } - ).c; - const symbolPackageCount = ( - db - .query( - "SELECT COUNT(DISTINCT package) as c FROM symbols WHERE package IS NOT NULL" - ) - .get() as { c: number } - ).c; - - // Skip if counts match (no new packages) - if (packageCount === symbolPackageCount) { - return; - } - } - - db.run("DELETE FROM packages"); - - db.run(` + if (skipIfNoChanges) { + // Check if packages table needs update + const packageCount = ( + db.query("SELECT COUNT(*) as c FROM packages").get() as { + c: number; + } + ).c; + const symbolPackageCount = ( + db + .query( + "SELECT COUNT(DISTINCT package) as c FROM symbols WHERE package IS NOT NULL", + ) + .get() as { c: number } + ).c; + + // Skip if counts match (no new packages) + if (packageCount === symbolPackageCount) { + return; + } + } + + db.run("DELETE FROM packages"); + + db.run(` INSERT INTO packages (name, manager, symbol_count) SELECT package, @@ -1205,42 +1212,42 @@ function updatePackages({ * Update metadata table */ function updateMetadata({ - db, - mode, - changedFiles, - deletedFiles, + db, + mode, + changedFiles, + deletedFiles, }: { - db: Database; - mode: string; - changedFiles: number; - deletedFiles: number; + db: Database; + mode: string; + changedFiles: number; + deletedFiles: number; }): ConversionStats { - const totalFiles = ( - db.query("SELECT COUNT(*) as c FROM files").get() as { c: number } - ).c; - const totalSymbols = ( - db.query("SELECT COUNT(*) as c FROM symbols").get() as { c: number } - ).c; - - const metadata = { - last_indexed: new Date().toISOString(), - total_files: totalFiles.toString(), - total_symbols: totalSymbols.toString(), - }; - - for (const [key, value] of Object.entries(metadata)) { - db.run("INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)", [ - key, - value, - ]); - } - - return { - mode: mode as "full" | "incremental", - total_files: totalFiles, - total_symbols: totalSymbols, - changed_files: changedFiles, - deleted_files: deletedFiles, - time_ms: 0, // Will be set by caller - }; + const totalFiles = ( + db.query("SELECT COUNT(*) as c FROM files").get() as { c: number } + ).c; + const totalSymbols = ( + db.query("SELECT COUNT(*) as c FROM symbols").get() as { c: number } + ).c; + + const metadata = { + last_indexed: new Date().toISOString(), + total_files: totalFiles.toString(), + total_symbols: totalSymbols.toString(), + }; + + for (const [key, value] of Object.entries(metadata)) { + db.run("INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)", [ + key, + value, + ]); + } + + return { + mode: mode as "full" | "incremental", + total_files: totalFiles, + total_symbols: totalSymbols, + changed_files: changedFiles, + deleted_files: deletedFiles, + time_ms: 0, // Will be set by caller + }; } diff --git a/src/index.ts b/src/index.ts index f2a7be4..3aaa68f 100755 --- a/src/index.ts +++ b/src/index.ts @@ -91,18 +91,22 @@ program program .command("status") .description("Show index status and statistics") - .action(wrapCommand(async () => { - const result = await status(); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async () => { + const result = await status(); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("map") .description("Show high-level codebase map") - .action(wrapCommand(async () => { - const result = await map(); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async () => { + const result = await map(); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("ls") @@ -113,61 +117,92 @@ program "--sort ", "Sort by: path, symbols, deps, or rdeps (default: path)", ) - .action(wrapCommand(async (directory, options) => { - const result = await ls(directory, options); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (directory, options) => { + const result = await ls(directory, options); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("file") .description("Analyze a specific file with symbols and dependencies") .argument("", "File path to analyze") - .action(wrapCommand(async (path: string) => { - const result = await file(path); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (path: string) => { + const result = await file(path); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("fn") .description("List all functions in a file with complexity metrics") .argument("", "File path to analyze") - .option("--sort ", "Sort by: complexity, loc, or name (default: complexity)") - .option("--min-complexity ", "Filter functions below complexity threshold") + .option( + "--sort ", + "Sort by: complexity, loc, or name (default: complexity)", + ) + .option( + "--min-complexity ", + "Filter functions below complexity threshold", + ) .option("--limit ", "Maximum number of results") - .action(wrapCommand(async (path: string, options) => { - const result = await fn({ path, options }); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (path: string, options) => { + const result = await fn({ path, options }); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("smells") .description("Detect code smells in a file") .argument("", "File path to analyze") - .option("--complexity-threshold ", "Cyclomatic complexity threshold (default: 10)", "10") - .option("--loc-threshold ", "Lines of code threshold (default: 100)", "100") - .option("--params-threshold ", "Parameter count threshold (default: 5)", "5") - .action(wrapCommand(async (path: string, options) => { - const result = await smells({ - path, - options: { - complexityThreshold: parseInt(options.complexityThreshold, 10), - locThreshold: parseInt(options.locThreshold, 10), - paramsThreshold: parseInt(options.paramsThreshold, 10), - }, - }); - output({ data: result, isJson: program.opts().json }); - })); + .option( + "--complexity-threshold ", + "Cyclomatic complexity threshold (default: 10)", + "10", + ) + .option( + "--loc-threshold ", + "Lines of code threshold (default: 100)", + "100", + ) + .option( + "--params-threshold ", + "Parameter count threshold (default: 5)", + "5", + ) + .action( + wrapCommand(async (path: string, options) => { + const result = await smells({ + path, + options: { + complexityThreshold: parseInt(options.complexityThreshold, 10), + locThreshold: parseInt(options.locThreshold, 10), + paramsThreshold: parseInt(options.paramsThreshold, 10), + }, + }); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("class") .description("List all classes in a file with hierarchy and method details") .argument("", "File path to analyze") - .option("--sort ", "Sort by: name, methods, or complexity (default: name)") + .option( + "--sort ", + "Sort by: name, methods, or complexity (default: name)", + ) .option("--limit ", "Maximum number of results") - .action(wrapCommand(async (path: string, options) => { - const result = await classCommand({ path, options }); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (path: string, options) => { + const result = await classCommand({ path, options }); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("symbol") @@ -178,10 +213,12 @@ program "--kind ", "Filter by symbol kind (type, class, function, interface)", ) - .action(wrapCommand(async (query, options) => { - const result = await symbol(query, options); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (query, options) => { + const result = await symbol(query, options); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("refs") @@ -189,30 +226,36 @@ program .argument("", "Symbol name to find references for") .option("--kind ", "Filter by symbol kind") .option("--limit ", "Maximum number of results") - .action(wrapCommand(async (symbol, options) => { - const result = await refs(symbol, options); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (symbol, options) => { + const result = await refs(symbol, options); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("deps") .description("Show file dependencies") .argument("", "File path to analyze") .option("--depth ", "Recursion depth (default: 1)") - .action(wrapCommand(async (path, options) => { - const result = await deps(path, options); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (path, options) => { + const result = await deps(path, options); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("rdeps") .description("Show reverse dependencies (what depends on this file)") .argument("", "File path to analyze") .option("--depth ", "Recursion depth (default: 1)") - .action(wrapCommand(async (path, options) => { - const result = await rdeps(path, options); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (path, options) => { + const result = await rdeps(path, options); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("adventure") @@ -233,10 +276,12 @@ program "--max-dependents ", "Maximum number of dependents (default: 0)", ) - .action(wrapCommand(async (options) => { - const result = await leaves(options); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (options) => { + const result = await leaves(options); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("exports") @@ -253,28 +298,34 @@ program .command("imports") .description("Show what a file imports (direct dependencies)") .argument("", "File path to analyze") - .action(wrapCommand(async (path, options) => { - const result = await imports(path, options); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (path, options) => { + const result = await imports(path, options); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("lost") .description("Find lost symbols (potentially unused)") .option("--limit ", "Maximum number of results (default: 50)") - .action(wrapCommand(async (options) => { - const result = await lost(options); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (options) => { + const result = await lost(options); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("treasure") .description("Find treasure (most referenced files and largest dependencies)") .option("--limit ", "Maximum number of results (default: 10)") - .action(wrapCommand(async (options) => { - const result = await treasure(options); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (options) => { + const result = await treasure(options); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("changes") @@ -307,19 +358,23 @@ program .command("cycles") .description("Find bidirectional dependencies (A imports B, B imports A)") .option("--limit ", "Maximum number of results (default: 50)") - .action(wrapCommand(async (options) => { - const result = await cycles(options); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (options) => { + const result = await cycles(options); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("coupling") .description("Find tightly coupled file pairs") .option("--threshold ", "Minimum total coupling score (default: 5)") - .action(wrapCommand(async (options) => { - const result = await coupling(options); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (options) => { + const result = await coupling(options); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("complexity") @@ -328,27 +383,33 @@ program "--sort ", "Sort by: complexity, symbols, or stability (default: complexity)", ) - .action(wrapCommand(async (options) => { - const result = await complexity(options); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (options) => { + const result = await complexity(options); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("schema") .description("Show database schema (tables, columns, indexes)") - .action(wrapCommand(async () => { - const result = await schema(); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async () => { + const result = await schema(); + output({ data: result, isJson: program.opts().json }); + }), + ); program .command("query") .description("Execute raw SQL query (read-only)") .argument("", "SQL query to execute") - .action(wrapCommand(async (sql) => { - const result = await query(sql); - output({ data: result, isJson: program.opts().json }); - })); + .action( + wrapCommand(async (sql) => { + const result = await query(sql); + output({ data: result, isJson: program.opts().json }); + }), + ); const cookbook = program .command("cookbook") diff --git a/src/mcp/metadata.ts b/src/mcp/metadata.ts index 2dcd751..94aff2e 100644 --- a/src/mcp/metadata.ts +++ b/src/mcp/metadata.ts @@ -124,8 +124,7 @@ export const toolsMetadata: ToolMetadata[] = [ { name: "kind", type: "string", - description: - "Filter by symbol kind (type, class, function, interface)", + description: "Filter by symbol kind (type, class, function, interface)", required: false, }, ], @@ -313,8 +312,7 @@ export const toolsMetadata: ToolMetadata[] = [ }, { name: "dora_cycles", - description: - "Find bidirectional dependencies (A imports B, B imports A)", + description: "Find bidirectional dependencies (A imports B, B imports A)", arguments: [], options: [ { @@ -391,8 +389,7 @@ export const toolsMetadata: ToolMetadata[] = [ { name: "recipe", required: false, - description: - "Recipe name (quickstart, methods, references, exports)", + description: "Recipe name (quickstart, methods, references, exports)", }, ], options: [ diff --git a/src/schemas/file.ts b/src/schemas/file.ts index ab3162e..c3103d2 100644 --- a/src/schemas/file.ts +++ b/src/schemas/file.ts @@ -1,8 +1,5 @@ import { z } from "zod"; -import { - FileMetricsSchema, - FunctionInfoSchema, -} from "./treesitter.ts"; +import { FileMetricsSchema, FunctionInfoSchema } from "./treesitter.ts"; export const FileSymbolSchema = z.object({ name: z.string(), diff --git a/src/tree-sitter/languages/javascript.ts b/src/tree-sitter/languages/javascript.ts index 9a08803..0b21e8f 100644 --- a/src/tree-sitter/languages/javascript.ts +++ b/src/tree-sitter/languages/javascript.ts @@ -1,5 +1,9 @@ import type Parser from "web-tree-sitter"; -import type { ClassInfo, FunctionInfo, MethodInfo } from "../../schemas/treesitter.ts"; +import type { + ClassInfo, + FunctionInfo, + MethodInfo, +} from "../../schemas/treesitter.ts"; export const functionQueryString = ` (function_declaration @@ -104,7 +108,9 @@ function countComplexity(bodyNode: Parser.Node): number { return count; } -function extractParameters(paramsNode: Parser.Node): Array<{ name: string; type: string | null }> { +function extractParameters( + paramsNode: Parser.Node, +): Array<{ name: string; type: string | null }> { const params: Array<{ name: string; type: string | null }> = []; for (const child of paramsNode.namedChildren) { @@ -123,7 +129,10 @@ function extractParameters(paramsNode: Parser.Node): Array<{ name: string; type: if (leftNode) { params.push({ name: leftNode.text, type: null }); } - } else if (child.type === "object_pattern" || child.type === "array_pattern") { + } else if ( + child.type === "object_pattern" || + child.type === "array_pattern" + ) { params.push({ name: child.text, type: null }); } } @@ -168,10 +177,7 @@ export function parseFunctionCaptures( const declarationNode = capture.node; let fnNode = declarationNode; - if ( - capture.name === "fn.export" || - capture.name === "fn.export_arrow" - ) { + if (capture.name === "fn.export" || capture.name === "fn.export_arrow") { const inner = fnNode.childForFieldName("declaration") || fnNode.namedChildren.find( @@ -224,7 +230,8 @@ export function parseFunctionCaptures( capture.name === "fn.export" || capture.name === "fn.export_arrow"; const isAsync = isAsyncFunction( fnNode.type === "variable_declarator" - ? fnNode.namedChildren.find((c) => c.type === "arrow_function") ?? fnNode + ? (fnNode.namedChildren.find((c) => c.type === "arrow_function") ?? + fnNode) : fnNode, ); diff --git a/src/tree-sitter/languages/typescript.ts b/src/tree-sitter/languages/typescript.ts index 88185b0..b5c2a33 100644 --- a/src/tree-sitter/languages/typescript.ts +++ b/src/tree-sitter/languages/typescript.ts @@ -1,5 +1,9 @@ import type Parser from "web-tree-sitter"; -import type { ClassInfo, FunctionInfo, MethodInfo } from "../../schemas/treesitter.ts"; +import type { + ClassInfo, + FunctionInfo, + MethodInfo, +} from "../../schemas/treesitter.ts"; export const functionQueryString = ` (function_declaration @@ -111,7 +115,9 @@ function countComplexity(bodyNode: Parser.Node): number { return count; } -function extractParameters(paramsNode: Parser.Node): Array<{ name: string; type: string | null }> { +function extractParameters( + paramsNode: Parser.Node, +): Array<{ name: string; type: string | null }> { const params: Array<{ name: string; type: string | null }> = []; for (const child of paramsNode.namedChildren) { @@ -125,8 +131,7 @@ function extractParameters(paramsNode: Parser.Node): Array<{ name: string; type: child.type === "optional_parameter" ) { const nameNode = - child.childForFieldName("pattern") || - child.childForFieldName("name"); + child.childForFieldName("pattern") || child.childForFieldName("name"); const typeNode = child.childForFieldName("type"); if (nameNode) { let paramName = nameNode.text; @@ -192,10 +197,7 @@ export function parseFunctionCaptures( const declarationNode = capture.node; let fnNode = declarationNode; - if ( - capture.name === "fn.export" || - capture.name === "fn.export_arrow" - ) { + if (capture.name === "fn.export" || capture.name === "fn.export_arrow") { const inner = fnNode.childForFieldName("declaration") || fnNode.namedChildren.find( @@ -252,9 +254,12 @@ export function parseFunctionCaptures( const isMethod = capture.name === "fn.method"; const isExported = capture.name === "fn.export" || capture.name === "fn.export_arrow"; - const isAsync = isAsyncFunction(fnNode.type === "variable_declarator" - ? fnNode.namedChildren.find((c) => c.type === "arrow_function") ?? fnNode - : fnNode); + const isAsync = isAsyncFunction( + fnNode.type === "variable_declarator" + ? (fnNode.namedChildren.find((c) => c.type === "arrow_function") ?? + fnNode) + : fnNode, + ); const parameters = extractParameters(paramsCapture.node); const returnType = returnCapture diff --git a/test/commands/adventure.test.ts b/test/commands/adventure.test.ts index a555ee9..8deb58e 100644 --- a/test/commands/adventure.test.ts +++ b/test/commands/adventure.test.ts @@ -3,19 +3,19 @@ import { Database } from "bun:sqlite"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { - getDependencies, - getReverseDependencies, + getDependencies, + getReverseDependencies, } from "../../src/db/queries.ts"; describe("Adventure Command - Pathfinding Algorithm", () => { - let db: Database; + let db: Database; - beforeAll(() => { - // Create in-memory test database with dependency graph - db = new Database(":memory:"); + beforeAll(() => { + // Create in-memory test database with dependency graph + db = new Database(":memory:"); - // Create schema - db.exec(` + // Create schema + db.exec(` CREATE TABLE files ( id INTEGER PRIMARY KEY, path TEXT UNIQUE NOT NULL, @@ -38,179 +38,179 @@ describe("Adventure Command - Pathfinding Algorithm", () => { ); `); - // Create test dependency graph: - // A -> B -> C -> D - // | | - // v v - // E F - // G (isolated) - - const files = [ - { id: 1, path: "a.ts" }, - { id: 2, path: "b.ts" }, - { id: 3, path: "c.ts" }, - { id: 4, path: "d.ts" }, - { id: 5, path: "e.ts" }, - { id: 6, path: "f.ts" }, - { id: 7, path: "g.ts" }, // isolated - ]; - - for (const file of files) { - db.run( - "INSERT INTO files (id, path, language, mtime, indexed_at) VALUES (?, ?, 'typescript', 1000, 1000)", - [file.id, file.path] - ); - } - - // Create dependencies - const deps = [ - { from: 1, to: 2 }, // A -> B - { from: 2, to: 3 }, // B -> C - { from: 3, to: 4 }, // C -> D - { from: 2, to: 5 }, // B -> E - { from: 3, to: 6 }, // C -> F - ]; - - for (const dep of deps) { - db.run( - "INSERT INTO dependencies (from_file_id, to_file_id, symbol_count) VALUES (?, ?, 1)", - [dep.from, dep.to] - ); - } - }); - - afterAll(() => { - db.close(); - }); - - describe("Direct dependencies", () => { - test("should find direct dependency at depth 1", () => { - const deps = getDependencies(db, "a.ts", 1); - - expect(deps).toHaveLength(1); - expect(deps[0]!.path).toBe("b.ts"); - expect(deps[0]!.depth).toBe(1); - }); - - test("should find multiple direct dependencies", () => { - const deps = getDependencies(db, "b.ts", 1); - - expect(deps).toHaveLength(2); - const paths = deps.map((d) => d.path).sort(); - expect(paths).toEqual(["c.ts", "e.ts"]); - }); - }); - - describe("Multi-hop dependencies", () => { - test("should find dependencies at depth 2", () => { - const deps = getDependencies(db, "a.ts", 2); - - expect(deps.length).toBeGreaterThanOrEqual(2); - const depMap = new Map(deps.map((d) => [d.path, d.depth])); - - // Direct: A -> B - expect(depMap.get("b.ts")).toBe(1); - - // 2-hop: A -> B -> C and A -> B -> E - expect(depMap.get("c.ts")).toBe(2); - expect(depMap.get("e.ts")).toBe(2); - }); - - test("should find dependencies at depth 3", () => { - const deps = getDependencies(db, "a.ts", 3); - - const depMap = new Map(deps.map((d) => [d.path, d.depth])); - - // Should have all reachable nodes - expect(depMap.get("b.ts")).toBe(1); - expect(depMap.get("c.ts")).toBe(2); - expect(depMap.get("e.ts")).toBe(2); - expect(depMap.get("d.ts")).toBe(3); - expect(depMap.get("f.ts")).toBe(3); - }); - }); - - describe("Reverse dependencies", () => { - test("should find direct reverse dependency", () => { - const rdeps = getReverseDependencies(db, "b.ts", 1); - - expect(rdeps).toHaveLength(1); - expect(rdeps[0]!.path).toBe("a.ts"); - expect(rdeps[0]!.depth).toBe(1); - }); - - test("should find multi-hop reverse dependencies", () => { - const rdeps = getReverseDependencies(db, "c.ts", 2); - - const depMap = new Map(rdeps.map((d) => [d.path, d.depth])); - - // Direct: B -> C - expect(depMap.get("b.ts")).toBe(1); - - // 2-hop: A -> B -> C - expect(depMap.get("a.ts")).toBe(2); - }); - }); - - describe("Isolated nodes", () => { - test("should return empty for isolated node dependencies", () => { - const deps = getDependencies(db, "g.ts", 5); - - expect(deps).toHaveLength(0); - }); - - test("should return empty for isolated node reverse deps", () => { - const rdeps = getReverseDependencies(db, "g.ts", 5); - - expect(rdeps).toHaveLength(0); - }); - }); - - describe("Leaf nodes", () => { - test("should return empty for leaf node (no outgoing deps)", () => { - const deps = getDependencies(db, "d.ts", 2); - - expect(deps).toHaveLength(0); - }); - - test("should find reverse deps for leaf node", () => { - const rdeps = getReverseDependencies(db, "d.ts", 3); + // Create test dependency graph: + // A -> B -> C -> D + // | | + // v v + // E F + // G (isolated) + + const files = [ + { id: 1, path: "a.ts" }, + { id: 2, path: "b.ts" }, + { id: 3, path: "c.ts" }, + { id: 4, path: "d.ts" }, + { id: 5, path: "e.ts" }, + { id: 6, path: "f.ts" }, + { id: 7, path: "g.ts" }, // isolated + ]; + + for (const file of files) { + db.run( + "INSERT INTO files (id, path, language, mtime, indexed_at) VALUES (?, ?, 'typescript', 1000, 1000)", + [file.id, file.path], + ); + } + + // Create dependencies + const deps = [ + { from: 1, to: 2 }, // A -> B + { from: 2, to: 3 }, // B -> C + { from: 3, to: 4 }, // C -> D + { from: 2, to: 5 }, // B -> E + { from: 3, to: 6 }, // C -> F + ]; + + for (const dep of deps) { + db.run( + "INSERT INTO dependencies (from_file_id, to_file_id, symbol_count) VALUES (?, ?, 1)", + [dep.from, dep.to], + ); + } + }); + + afterAll(() => { + db.close(); + }); + + describe("Direct dependencies", () => { + test("should find direct dependency at depth 1", () => { + const deps = getDependencies(db, "a.ts", 1); + + expect(deps).toHaveLength(1); + expect(deps[0]!.path).toBe("b.ts"); + expect(deps[0]!.depth).toBe(1); + }); + + test("should find multiple direct dependencies", () => { + const deps = getDependencies(db, "b.ts", 1); + + expect(deps).toHaveLength(2); + const paths = deps.map((d) => d.path).sort(); + expect(paths).toEqual(["c.ts", "e.ts"]); + }); + }); + + describe("Multi-hop dependencies", () => { + test("should find dependencies at depth 2", () => { + const deps = getDependencies(db, "a.ts", 2); + + expect(deps.length).toBeGreaterThanOrEqual(2); + const depMap = new Map(deps.map((d) => [d.path, d.depth])); + + // Direct: A -> B + expect(depMap.get("b.ts")).toBe(1); + + // 2-hop: A -> B -> C and A -> B -> E + expect(depMap.get("c.ts")).toBe(2); + expect(depMap.get("e.ts")).toBe(2); + }); + + test("should find dependencies at depth 3", () => { + const deps = getDependencies(db, "a.ts", 3); + + const depMap = new Map(deps.map((d) => [d.path, d.depth])); + + // Should have all reachable nodes + expect(depMap.get("b.ts")).toBe(1); + expect(depMap.get("c.ts")).toBe(2); + expect(depMap.get("e.ts")).toBe(2); + expect(depMap.get("d.ts")).toBe(3); + expect(depMap.get("f.ts")).toBe(3); + }); + }); + + describe("Reverse dependencies", () => { + test("should find direct reverse dependency", () => { + const rdeps = getReverseDependencies(db, "b.ts", 1); + + expect(rdeps).toHaveLength(1); + expect(rdeps[0]!.path).toBe("a.ts"); + expect(rdeps[0]!.depth).toBe(1); + }); + + test("should find multi-hop reverse dependencies", () => { + const rdeps = getReverseDependencies(db, "c.ts", 2); + + const depMap = new Map(rdeps.map((d) => [d.path, d.depth])); + + // Direct: B -> C + expect(depMap.get("b.ts")).toBe(1); + + // 2-hop: A -> B -> C + expect(depMap.get("a.ts")).toBe(2); + }); + }); + + describe("Isolated nodes", () => { + test("should return empty for isolated node dependencies", () => { + const deps = getDependencies(db, "g.ts", 5); + + expect(deps).toHaveLength(0); + }); + + test("should return empty for isolated node reverse deps", () => { + const rdeps = getReverseDependencies(db, "g.ts", 5); + + expect(rdeps).toHaveLength(0); + }); + }); + + describe("Leaf nodes", () => { + test("should return empty for leaf node (no outgoing deps)", () => { + const deps = getDependencies(db, "d.ts", 2); + + expect(deps).toHaveLength(0); + }); + + test("should find reverse deps for leaf node", () => { + const rdeps = getReverseDependencies(db, "d.ts", 3); - expect(rdeps.length).toBeGreaterThan(0); - const depMap = new Map(rdeps.map((d) => [d.path, d.depth])); + expect(rdeps.length).toBeGreaterThan(0); + const depMap = new Map(rdeps.map((d) => [d.path, d.depth])); - // D is reachable from C -> B -> A - expect(depMap.get("c.ts")).toBe(1); - expect(depMap.get("b.ts")).toBe(2); - expect(depMap.get("a.ts")).toBe(3); - }); - }); + // D is reachable from C -> B -> A + expect(depMap.get("c.ts")).toBe(1); + expect(depMap.get("b.ts")).toBe(2); + expect(depMap.get("a.ts")).toBe(3); + }); + }); - describe("Depth limiting", () => { - test("should respect depth limit", () => { - const deps = getDependencies(db, "a.ts", 1); + describe("Depth limiting", () => { + test("should respect depth limit", () => { + const deps = getDependencies(db, "a.ts", 1); - // Should only get direct dependency (B), not transitive ones - expect(deps).toHaveLength(1); - expect(deps[0]!.path).toBe("b.ts"); + // Should only get direct dependency (B), not transitive ones + expect(deps).toHaveLength(1); + expect(deps[0]!.path).toBe("b.ts"); - // Should not include C, D, E, F - const paths = deps.map((d) => d.path); - expect(paths).not.toContain("c.ts"); - expect(paths).not.toContain("d.ts"); - }); + // Should not include C, D, E, F + const paths = deps.map((d) => d.path); + expect(paths).not.toContain("c.ts"); + expect(paths).not.toContain("d.ts"); + }); - test("should find shortest path (min depth) when multiple paths exist", () => { - // Both B and C depend on different files, but B is closer - const deps = getDependencies(db, "a.ts", 2); + test("should find shortest path (min depth) when multiple paths exist", () => { + // Both B and C depend on different files, but B is closer + const deps = getDependencies(db, "a.ts", 2); - const depMap = new Map(deps.map((d) => [d.path, d.depth])); + const depMap = new Map(deps.map((d) => [d.path, d.depth])); - // B should be at depth 1 (shortest) - expect(depMap.get("b.ts")).toBe(1); + // B should be at depth 1 (shortest) + expect(depMap.get("b.ts")).toBe(1); - // C should be at depth 2 (A -> B -> C) - expect(depMap.get("c.ts")).toBe(2); - }); - }); + // C should be at depth 2 (A -> B -> C) + expect(depMap.get("c.ts")).toBe(2); + }); + }); }); diff --git a/test/converter/batch-processing.test.ts b/test/converter/batch-processing.test.ts index bcc61ff..38c7412 100644 --- a/test/converter/batch-processing.test.ts +++ b/test/converter/batch-processing.test.ts @@ -5,237 +5,237 @@ import { join } from "path"; import { convertToDatabase } from "../../src/converter/convert.ts"; describe("Batch Processing - Duplicate File Paths", () => { - const testDir = join(process.cwd(), ".test-batch-regression"); - const scipPath = join(process.cwd(), "test", "fixtures", "index.scip"); - const dbPath = join(testDir, "test.db"); - const repoRoot = join(process.cwd(), "test", "fixtures"); - - const skipTests = !existsSync(scipPath); - - beforeEach(() => { - if (existsSync(testDir)) { - rmSync(testDir, { recursive: true, force: true }); - } - mkdirSync(testDir, { recursive: true }); - }); - - afterEach(() => { - if (existsSync(testDir)) { - rmSync(testDir, { recursive: true, force: true }); - } - }); - - test("should not create duplicate files in database", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - await convertToDatabase({ scipPath, databasePath: dbPath, repoRoot }); - - const db = new Database(dbPath); - - const files = db.query("SELECT path FROM files").all() as Array<{ - path: string; - }>; - - const paths = files.map((f) => f.path); - const uniquePaths = new Set(paths); - - expect(paths.length).toBe(uniquePaths.size); - - for (const path of paths) { - const count = paths.filter((p) => p === path).length; - if (count > 1) { - throw new Error(`Duplicate file path found: ${path} (${count} times)`); - } - } - - db.close(); - }); - - test("should handle batch processing correctly", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const stats = await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot, - }); - - expect(stats.mode).toBe("full"); - expect(stats.total_files).toBeGreaterThan(0); - - const db = new Database(dbPath); - - const duplicateCheck = db - .query( - ` + const testDir = join(process.cwd(), ".test-batch-regression"); + const scipPath = join(process.cwd(), "test", "fixtures", "index.scip"); + const dbPath = join(testDir, "test.db"); + const repoRoot = join(process.cwd(), "test", "fixtures"); + + const skipTests = !existsSync(scipPath); + + beforeEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + test("should not create duplicate files in database", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + await convertToDatabase({ scipPath, databasePath: dbPath, repoRoot }); + + const db = new Database(dbPath); + + const files = db.query("SELECT path FROM files").all() as Array<{ + path: string; + }>; + + const paths = files.map((f) => f.path); + const uniquePaths = new Set(paths); + + expect(paths.length).toBe(uniquePaths.size); + + for (const path of paths) { + const count = paths.filter((p) => p === path).length; + if (count > 1) { + throw new Error(`Duplicate file path found: ${path} (${count} times)`); + } + } + + db.close(); + }); + + test("should handle batch processing correctly", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const stats = await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot, + }); + + expect(stats.mode).toBe("full"); + expect(stats.total_files).toBeGreaterThan(0); + + const db = new Database(dbPath); + + const duplicateCheck = db + .query( + ` SELECT path, COUNT(*) as count FROM files GROUP BY path HAVING COUNT(*) > 1 - ` - ) - .all() as Array<{ - path: string; - count: number; - }>; + `, + ) + .all() as Array<{ + path: string; + count: number; + }>; - expect(duplicateCheck.length).toBe(0); + expect(duplicateCheck.length).toBe(0); - db.close(); - }); + db.close(); + }); - test("should maintain UNIQUE constraint on files.path", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } + test("should maintain UNIQUE constraint on files.path", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } - await convertToDatabase({ scipPath, databasePath: dbPath, repoRoot }); + await convertToDatabase({ scipPath, databasePath: dbPath, repoRoot }); - const db = new Database(dbPath); + const db = new Database(dbPath); - const indexes = db - .query( - ` + const indexes = db + .query( + ` SELECT sql FROM sqlite_master WHERE type='table' AND name='files' - ` - ) - .all() as Array<{ - sql: string; - }>; - - expect(indexes.length).toBeGreaterThan(0); - expect(indexes[0]!.sql).toContain("UNIQUE"); - - db.close(); - }); - - test("should handle full rebuild without errors", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - let error: Error | null = null; - try { - await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot, - options: { force: true }, - }); - } catch (e) { - error = e as Error; - } - - expect(error).toBeNull(); - - const db = new Database(dbPath); - - const files = db.query("SELECT COUNT(*) as c FROM files").get() as { - c: number; - }; - expect(files.c).toBeGreaterThan(0); - - const duplicates = db - .query( - ` + `, + ) + .all() as Array<{ + sql: string; + }>; + + expect(indexes.length).toBeGreaterThan(0); + expect(indexes[0]!.sql).toContain("UNIQUE"); + + db.close(); + }); + + test("should handle full rebuild without errors", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + let error: Error | null = null; + try { + await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot, + options: { force: true }, + }); + } catch (e) { + error = e as Error; + } + + expect(error).toBeNull(); + + const db = new Database(dbPath); + + const files = db.query("SELECT COUNT(*) as c FROM files").get() as { + c: number; + }; + expect(files.c).toBeGreaterThan(0); + + const duplicates = db + .query( + ` SELECT path, COUNT(*) as count FROM files GROUP BY path HAVING COUNT(*) > 1 - ` - ) - .all() as Array<{ - path: string; - count: number; - }>; + `, + ) + .all() as Array<{ + path: string; + count: number; + }>; - expect(duplicates.length).toBe(0); + expect(duplicates.length).toBe(0); - db.close(); - }); + db.close(); + }); - test("should properly track inserted files across batches", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } + test("should properly track inserted files across batches", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } - await convertToDatabase({ scipPath, databasePath: dbPath, repoRoot }); + await convertToDatabase({ scipPath, databasePath: dbPath, repoRoot }); - const db = new Database(dbPath); + const db = new Database(dbPath); - const fileIdCheck = db - .query( - ` + const fileIdCheck = db + .query( + ` SELECT f.id, f.path, COUNT(s.id) as symbol_count FROM files f LEFT JOIN symbols s ON s.file_id = f.id GROUP BY f.id - ` - ) - .all() as Array<{ - id: number; - path: string; - symbol_count: number; - }>; - - for (const file of fileIdCheck) { - expect(file.id).toBeGreaterThan(0); - expect(typeof file.path).toBe("string"); - expect(file.symbol_count).toBeGreaterThanOrEqual(0); - } - - const orphanedSymbols = db - .query( - ` + `, + ) + .all() as Array<{ + id: number; + path: string; + symbol_count: number; + }>; + + for (const file of fileIdCheck) { + expect(file.id).toBeGreaterThan(0); + expect(typeof file.path).toBe("string"); + expect(file.symbol_count).toBeGreaterThanOrEqual(0); + } + + const orphanedSymbols = db + .query( + ` SELECT COUNT(*) as c FROM symbols s WHERE NOT EXISTS (SELECT 1 FROM files f WHERE f.id = s.file_id) - ` - ) - .get() as { c: number }; - - expect(orphanedSymbols.c).toBe(0); - - db.close(); - }); - - test("regression: UNIQUE constraint should not fail on first run", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - let uniqueConstraintError = false; - try { - await convertToDatabase({ scipPath, databasePath: dbPath, repoRoot }); - } catch (error) { - if ( - error instanceof Error && - error.message.includes("UNIQUE constraint failed") - ) { - uniqueConstraintError = true; - } - throw error; - } - - expect(uniqueConstraintError).toBe(false); - - const db = new Database(dbPath); - const fileCount = ( - db.query("SELECT COUNT(*) as c FROM files").get() as { c: number } - ).c; - expect(fileCount).toBeGreaterThan(0); - db.close(); - }); + `, + ) + .get() as { c: number }; + + expect(orphanedSymbols.c).toBe(0); + + db.close(); + }); + + test("regression: UNIQUE constraint should not fail on first run", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + let uniqueConstraintError = false; + try { + await convertToDatabase({ scipPath, databasePath: dbPath, repoRoot }); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("UNIQUE constraint failed") + ) { + uniqueConstraintError = true; + } + throw error; + } + + expect(uniqueConstraintError).toBe(false); + + const db = new Database(dbPath); + const fileCount = ( + db.query("SELECT COUNT(*) as c FROM files").get() as { c: number } + ).c; + expect(fileCount).toBeGreaterThan(0); + db.close(); + }); }); diff --git a/test/converter/convert.test.ts b/test/converter/convert.test.ts index 183bd7f..5508552 100644 --- a/test/converter/convert.test.ts +++ b/test/converter/convert.test.ts @@ -5,419 +5,419 @@ import { join } from "path"; import { convertToDatabase } from "../../src/converter/convert.ts"; describe("Database Converter", () => { - const testDir = join(process.cwd(), "test", "fixtures"); - const scipPath = join(testDir, "index.scip"); - const testDbDir = join(process.cwd(), ".test-db"); - const dbPath = join(testDbDir, "test.db"); - - const skipTests = !existsSync(scipPath); - - beforeEach(() => { - if (!existsSync(testDbDir)) { - mkdirSync(testDbDir, { recursive: true }); - } - }); - - afterEach(() => { - if (existsSync(testDbDir)) { - rmSync(testDbDir, { recursive: true, force: true }); - } - }); - - test("should convert SCIP to database successfully", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const stats = await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot: testDir, - }); - - expect(stats).toBeDefined(); - expect(stats.mode).toBeDefined(); - expect(stats.total_files).toBeGreaterThan(0); - expect(stats.total_symbols).toBeGreaterThan(0); - expect(stats.time_ms).toBeGreaterThan(0); - - expect(existsSync(dbPath)).toBe(true); - }); - - test("should create database with correct schema", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot: testDir, - }); - - const db = new Database(dbPath); - - const tables = db - .query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") - .all() as Array<{ name: string }>; - - const tableNames = tables.map((t) => t.name); - - expect(tableNames).toContain("files"); - expect(tableNames).toContain("symbols"); - expect(tableNames).toContain("dependencies"); - expect(tableNames).toContain("symbol_references"); - expect(tableNames).toContain("packages"); - expect(tableNames).toContain("metadata"); - expect(tableNames).toContain("documents"); - - db.close(); - }); - - test("should insert files without duplicates", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot: testDir, - }); - - const db = new Database(dbPath); - - const files = db - .query("SELECT path FROM files ORDER BY path") - .all() as Array<{ - path: string; - }>; - - const paths = files.map((f) => f.path); - const uniquePaths = new Set(paths); - - expect(paths.length).toBe(uniquePaths.size); - - db.close(); - }); - - test("should handle symbols correctly", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot: testDir, - }); - - const db = new Database(dbPath); - - const symbols = db - .query("SELECT id, file_id, name, kind FROM symbols LIMIT 10") - .all() as Array<{ - id: number; - file_id: number; - name: string; - kind: string; - }>; - - expect(symbols.length).toBeGreaterThan(0); - - for (const sym of symbols) { - expect(sym.id).toBeGreaterThan(0); - expect(sym.file_id).toBeGreaterThan(0); - expect(typeof sym.name).toBe("string"); - expect(typeof sym.kind).toBe("string"); - } - - db.close(); - }); - - test("should handle dependencies correctly", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot: testDir, - }); - - const db = new Database(dbPath); - - const deps = db - .query( - "SELECT from_file_id, to_file_id, symbol_count FROM dependencies LIMIT 10" - ) - .all() as Array<{ - from_file_id: number; - to_file_id: number; - symbol_count: number; - }>; - - for (const dep of deps) { - expect(dep.from_file_id).toBeGreaterThan(0); - expect(dep.to_file_id).toBeGreaterThan(0); - expect(dep.symbol_count).toBeGreaterThan(0); - expect(dep.from_file_id).not.toBe(dep.to_file_id); - } - - db.close(); - }); - - test("should update denormalized fields correctly", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot: testDir, - }); - - const db = new Database(dbPath); - - const files = db - .query( - "SELECT id, path, symbol_count, dependency_count, dependent_count FROM files" - ) - .all() as Array<{ - id: number; - path: string; - symbol_count: number; - dependency_count: number; - dependent_count: number; - }>; - - for (const file of files) { - const actualSymbolCount = ( - db - .query("SELECT COUNT(*) as c FROM symbols WHERE file_id = ?") - .get(file.id) as { c: number } - ).c; - expect(file.symbol_count).toBe(actualSymbolCount); - - const actualDependencyCount = ( - db - .query( - "SELECT COUNT(DISTINCT to_file_id) as c FROM dependencies WHERE from_file_id = ?" - ) - .get(file.id) as { c: number } - ).c; - expect(file.dependency_count).toBe(actualDependencyCount); - - const actualDependentCount = ( - db - .query( - "SELECT COUNT(DISTINCT from_file_id) as c FROM dependencies WHERE to_file_id = ?" - ) - .get(file.id) as { c: number } - ).c; - expect(file.dependent_count).toBe(actualDependentCount); - } - - db.close(); - }); - - test("should handle incremental builds", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const stats1 = await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot: testDir, - }); - expect(stats1.mode).toBe("full"); - - await Bun.sleep(100); - - const stats2 = await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot: testDir, - }); - expect(stats2.mode).toBe("incremental"); - expect(stats2.changed_files).toBe(0); - expect(stats2.deleted_files).toBe(0); - }); - - test("should force full rebuild with force option", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const stats1 = await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot: testDir, - }); - expect(stats1.mode).toBe("full"); - - await Bun.sleep(100); - - const stats2 = await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot: testDir, - options: { - force: true, - }, - }); - expect(stats2.mode).toBe("full"); - }); - - test("should handle symbol references correctly", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot: testDir, - }); - - const db = new Database(dbPath); - - const refs = db - .query("SELECT symbol_id, file_id, line FROM symbol_references LIMIT 10") - .all() as Array<{ - symbol_id: number; - file_id: number; - line: number; - }>; - - for (const ref of refs) { - expect(ref.symbol_id).toBeGreaterThan(0); - expect(ref.file_id).toBeGreaterThan(0); - expect(ref.line).toBeGreaterThanOrEqual(0); - - const symbol = db - .query("SELECT id FROM symbols WHERE id = ?") - .get(ref.symbol_id) as { id: number } | undefined; - expect(symbol).toBeDefined(); - - const file = db - .query("SELECT id FROM files WHERE id = ?") - .get(ref.file_id) as { id: number } | undefined; - expect(file).toBeDefined(); - } - - db.close(); - }); - - test("should filter local symbols correctly", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot: testDir, - }); - - const db = new Database(dbPath); - - const localSymbols = db - .query( - "SELECT name, scip_symbol FROM symbols WHERE is_local = 1 LIMIT 10" - ) - .all() as Array<{ - name: string; - scip_symbol: string; - }>; - - for (const sym of localSymbols) { - expect(sym.scip_symbol).toContain("local"); - } - - db.close(); - }); - - test("should handle packages correctly", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot: testDir, - }); - - const db = new Database(dbPath); - - const packages = db - .query("SELECT name, manager, symbol_count FROM packages") - .all() as Array<{ - name: string; - manager: string; - symbol_count: number; - }>; - - for (const pkg of packages) { - expect(typeof pkg.name).toBe("string"); - expect(pkg.name.length).toBeGreaterThan(0); - expect(pkg.manager).toBe("npm"); - expect(pkg.symbol_count).toBeGreaterThan(0); - } - - db.close(); - }); - - test("should store metadata correctly", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - await convertToDatabase({ - scipPath, - databasePath: dbPath, - repoRoot: testDir, - }); - - const db = new Database(dbPath); - - const metadata = db - .query("SELECT key, value FROM metadata") - .all() as Array<{ - key: string; - value: string; - }>; - - const metadataMap = new Map(metadata.map((m) => [m.key, m.value])); - - expect(metadataMap.has("last_indexed")).toBe(true); - expect(metadataMap.has("total_files")).toBe(true); - expect(metadataMap.has("total_symbols")).toBe(true); - - const totalFiles = Number.parseInt(metadataMap.get("total_files") || "0"); - const totalSymbols = Number.parseInt( - metadataMap.get("total_symbols") || "0" - ); - - expect(totalFiles).toBeGreaterThan(0); - expect(totalSymbols).toBeGreaterThan(0); - - db.close(); - }); + const testDir = join(process.cwd(), "test", "fixtures"); + const scipPath = join(testDir, "index.scip"); + const testDbDir = join(process.cwd(), ".test-db"); + const dbPath = join(testDbDir, "test.db"); + + const skipTests = !existsSync(scipPath); + + beforeEach(() => { + if (!existsSync(testDbDir)) { + mkdirSync(testDbDir, { recursive: true }); + } + }); + + afterEach(() => { + if (existsSync(testDbDir)) { + rmSync(testDbDir, { recursive: true, force: true }); + } + }); + + test("should convert SCIP to database successfully", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const stats = await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot: testDir, + }); + + expect(stats).toBeDefined(); + expect(stats.mode).toBeDefined(); + expect(stats.total_files).toBeGreaterThan(0); + expect(stats.total_symbols).toBeGreaterThan(0); + expect(stats.time_ms).toBeGreaterThan(0); + + expect(existsSync(dbPath)).toBe(true); + }); + + test("should create database with correct schema", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot: testDir, + }); + + const db = new Database(dbPath); + + const tables = db + .query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() as Array<{ name: string }>; + + const tableNames = tables.map((t) => t.name); + + expect(tableNames).toContain("files"); + expect(tableNames).toContain("symbols"); + expect(tableNames).toContain("dependencies"); + expect(tableNames).toContain("symbol_references"); + expect(tableNames).toContain("packages"); + expect(tableNames).toContain("metadata"); + expect(tableNames).toContain("documents"); + + db.close(); + }); + + test("should insert files without duplicates", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot: testDir, + }); + + const db = new Database(dbPath); + + const files = db + .query("SELECT path FROM files ORDER BY path") + .all() as Array<{ + path: string; + }>; + + const paths = files.map((f) => f.path); + const uniquePaths = new Set(paths); + + expect(paths.length).toBe(uniquePaths.size); + + db.close(); + }); + + test("should handle symbols correctly", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot: testDir, + }); + + const db = new Database(dbPath); + + const symbols = db + .query("SELECT id, file_id, name, kind FROM symbols LIMIT 10") + .all() as Array<{ + id: number; + file_id: number; + name: string; + kind: string; + }>; + + expect(symbols.length).toBeGreaterThan(0); + + for (const sym of symbols) { + expect(sym.id).toBeGreaterThan(0); + expect(sym.file_id).toBeGreaterThan(0); + expect(typeof sym.name).toBe("string"); + expect(typeof sym.kind).toBe("string"); + } + + db.close(); + }); + + test("should handle dependencies correctly", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot: testDir, + }); + + const db = new Database(dbPath); + + const deps = db + .query( + "SELECT from_file_id, to_file_id, symbol_count FROM dependencies LIMIT 10", + ) + .all() as Array<{ + from_file_id: number; + to_file_id: number; + symbol_count: number; + }>; + + for (const dep of deps) { + expect(dep.from_file_id).toBeGreaterThan(0); + expect(dep.to_file_id).toBeGreaterThan(0); + expect(dep.symbol_count).toBeGreaterThan(0); + expect(dep.from_file_id).not.toBe(dep.to_file_id); + } + + db.close(); + }); + + test("should update denormalized fields correctly", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot: testDir, + }); + + const db = new Database(dbPath); + + const files = db + .query( + "SELECT id, path, symbol_count, dependency_count, dependent_count FROM files", + ) + .all() as Array<{ + id: number; + path: string; + symbol_count: number; + dependency_count: number; + dependent_count: number; + }>; + + for (const file of files) { + const actualSymbolCount = ( + db + .query("SELECT COUNT(*) as c FROM symbols WHERE file_id = ?") + .get(file.id) as { c: number } + ).c; + expect(file.symbol_count).toBe(actualSymbolCount); + + const actualDependencyCount = ( + db + .query( + "SELECT COUNT(DISTINCT to_file_id) as c FROM dependencies WHERE from_file_id = ?", + ) + .get(file.id) as { c: number } + ).c; + expect(file.dependency_count).toBe(actualDependencyCount); + + const actualDependentCount = ( + db + .query( + "SELECT COUNT(DISTINCT from_file_id) as c FROM dependencies WHERE to_file_id = ?", + ) + .get(file.id) as { c: number } + ).c; + expect(file.dependent_count).toBe(actualDependentCount); + } + + db.close(); + }); + + test("should handle incremental builds", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const stats1 = await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot: testDir, + }); + expect(stats1.mode).toBe("full"); + + await Bun.sleep(100); + + const stats2 = await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot: testDir, + }); + expect(stats2.mode).toBe("incremental"); + expect(stats2.changed_files).toBe(0); + expect(stats2.deleted_files).toBe(0); + }); + + test("should force full rebuild with force option", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const stats1 = await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot: testDir, + }); + expect(stats1.mode).toBe("full"); + + await Bun.sleep(100); + + const stats2 = await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot: testDir, + options: { + force: true, + }, + }); + expect(stats2.mode).toBe("full"); + }); + + test("should handle symbol references correctly", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot: testDir, + }); + + const db = new Database(dbPath); + + const refs = db + .query("SELECT symbol_id, file_id, line FROM symbol_references LIMIT 10") + .all() as Array<{ + symbol_id: number; + file_id: number; + line: number; + }>; + + for (const ref of refs) { + expect(ref.symbol_id).toBeGreaterThan(0); + expect(ref.file_id).toBeGreaterThan(0); + expect(ref.line).toBeGreaterThanOrEqual(0); + + const symbol = db + .query("SELECT id FROM symbols WHERE id = ?") + .get(ref.symbol_id) as { id: number } | undefined; + expect(symbol).toBeDefined(); + + const file = db + .query("SELECT id FROM files WHERE id = ?") + .get(ref.file_id) as { id: number } | undefined; + expect(file).toBeDefined(); + } + + db.close(); + }); + + test("should filter local symbols correctly", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot: testDir, + }); + + const db = new Database(dbPath); + + const localSymbols = db + .query( + "SELECT name, scip_symbol FROM symbols WHERE is_local = 1 LIMIT 10", + ) + .all() as Array<{ + name: string; + scip_symbol: string; + }>; + + for (const sym of localSymbols) { + expect(sym.scip_symbol).toContain("local"); + } + + db.close(); + }); + + test("should handle packages correctly", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot: testDir, + }); + + const db = new Database(dbPath); + + const packages = db + .query("SELECT name, manager, symbol_count FROM packages") + .all() as Array<{ + name: string; + manager: string; + symbol_count: number; + }>; + + for (const pkg of packages) { + expect(typeof pkg.name).toBe("string"); + expect(pkg.name.length).toBeGreaterThan(0); + expect(pkg.manager).toBe("npm"); + expect(pkg.symbol_count).toBeGreaterThan(0); + } + + db.close(); + }); + + test("should store metadata correctly", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + await convertToDatabase({ + scipPath, + databasePath: dbPath, + repoRoot: testDir, + }); + + const db = new Database(dbPath); + + const metadata = db + .query("SELECT key, value FROM metadata") + .all() as Array<{ + key: string; + value: string; + }>; + + const metadataMap = new Map(metadata.map((m) => [m.key, m.value])); + + expect(metadataMap.has("last_indexed")).toBe(true); + expect(metadataMap.has("total_files")).toBe(true); + expect(metadataMap.has("total_symbols")).toBe(true); + + const totalFiles = Number.parseInt(metadataMap.get("total_files") || "0"); + const totalSymbols = Number.parseInt( + metadataMap.get("total_symbols") || "0", + ); + + expect(totalFiles).toBeGreaterThan(0); + expect(totalSymbols).toBeGreaterThan(0); + + db.close(); + }); }); diff --git a/test/converter/scip-parser.test.ts b/test/converter/scip-parser.test.ts index 3f4f0af..f699d6a 100644 --- a/test/converter/scip-parser.test.ts +++ b/test/converter/scip-parser.test.ts @@ -4,418 +4,418 @@ import { describe, expect, test } from "bun:test"; import { existsSync } from "fs"; import { join } from "path"; import { - buildLookupMaps, - extractDefinitions, - extractReferences, - findDefinitionFile, - getDocumentSymbols, - getFileDependencies, - type ParsedDocument, - parseScipFile, - type ScipData, + buildLookupMaps, + extractDefinitions, + extractReferences, + findDefinitionFile, + getDocumentSymbols, + getFileDependencies, + type ParsedDocument, + parseScipFile, + type ScipData, } from "../../src/converter/scip-parser.ts"; describe("SCIP Parser", () => { - const exampleScipPath = join(process.cwd(), "test", "fixtures", "index.scip"); - const skipTests = !existsSync(exampleScipPath); - - test("should parse SCIP file successfully", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const scipData = await parseScipFile(exampleScipPath); - - expect(scipData).toBeDefined(); - expect(scipData.documents).toBeDefined(); - expect(scipData.documents.length).toBeGreaterThan(0); - expect(scipData.externalSymbols).toBeDefined(); - }); - - test("should parse document with correct structure", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const scipData = await parseScipFile(exampleScipPath); - const sampleDoc = scipData.documents[0]; - - if (!sampleDoc) { - console.log("Skipping test: no documents in SCIP file"); - return; - } - - expect(sampleDoc).toHaveProperty("relativePath"); - expect(sampleDoc).toHaveProperty("language"); - expect(sampleDoc).toHaveProperty("occurrences"); - expect(sampleDoc).toHaveProperty("symbols"); - expect(typeof sampleDoc.relativePath).toBe("string"); - expect(typeof sampleDoc.language).toBe("string"); - expect(Array.isArray(sampleDoc.occurrences)).toBe(true); - expect(Array.isArray(sampleDoc.symbols)).toBe(true); - }); - - test("should parse occurrences with correct range format", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const scipData = await parseScipFile(exampleScipPath); - const sampleDoc = scipData.documents[0]; - - if (!sampleDoc || sampleDoc.occurrences.length === 0) { - console.log("Skipping test: no occurrences in SCIP file"); - return; - } - - const occ = sampleDoc.occurrences[0]!; - expect(occ).toHaveProperty("range"); - expect(occ).toHaveProperty("symbol"); - expect(occ).toHaveProperty("symbolRoles"); - - // Range should be [startLine, startChar, endLine, endChar] - expect(occ.range.length).toBe(4); - expect(typeof occ.range[0]).toBe("number"); // startLine - expect(typeof occ.range[1]).toBe("number"); // startChar - expect(typeof occ.range[2]).toBe("number"); // endLine - expect(typeof occ.range[3]).toBe("number"); // endChar - }); - - test("should extract definitions from document", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const scipData = await parseScipFile(exampleScipPath); - const sampleDoc = scipData.documents[0]; - - if (!sampleDoc) { - console.log("Skipping test: no documents in SCIP file"); - return; - } - - const definitions = extractDefinitions(sampleDoc); - expect(Array.isArray(definitions)).toBe(true); - - if (definitions.length > 0) { - const def = definitions[0]!; - expect(def).toHaveProperty("symbol"); - expect(def).toHaveProperty("range"); - expect(def.range.length).toBe(4); - expect(typeof def.symbol).toBe("string"); - } - }); - - test("should extract references from document", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const scipData = await parseScipFile(exampleScipPath); - const sampleDoc = scipData.documents[0]; - - if (!sampleDoc) { - console.log("Skipping test: no documents in SCIP file"); - return; - } - - const references = extractReferences(sampleDoc); - expect(Array.isArray(references)).toBe(true); - - if (references.length > 0) { - const ref = references[0]!; - expect(ref).toHaveProperty("symbol"); - expect(ref).toHaveProperty("range"); - expect(ref).toHaveProperty("line"); - expect(ref.range.length).toBe(4); - expect(typeof ref.symbol).toBe("string"); - expect(typeof ref.line).toBe("number"); - } - }); - - test("should not have overlap between definitions and references", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const scipData = await parseScipFile(exampleScipPath); - const sampleDoc = scipData.documents[0]; - - if (!sampleDoc) { - console.log("Skipping test: no documents in SCIP file"); - return; - } - - const definitions = extractDefinitions(sampleDoc); - const references = extractReferences(sampleDoc); - - // Build sets of occurrence indices - const defIndices = new Set( - sampleDoc.occurrences - .map((occ, idx) => (occ.symbolRoles & 0x1 ? idx : -1)) - .filter((idx) => idx !== -1) - ); - - const refIndices = new Set( - sampleDoc.occurrences - .map((occ, idx) => (!(occ.symbolRoles & 0x1) ? idx : -1)) - .filter((idx) => idx !== -1) - ); - - // Should have no overlap - for (const defIdx of defIndices) { - expect(refIndices.has(defIdx)).toBe(false); - } - }); - - test("should build lookup maps correctly", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const scipData = await parseScipFile(exampleScipPath); - const maps = buildLookupMaps(scipData); - - expect(maps).toHaveProperty("documentsByPath"); - expect(maps).toHaveProperty("symbolsById"); - expect(maps).toHaveProperty("definitionsBySymbol"); - - expect(maps.documentsByPath instanceof Map).toBe(true); - expect(maps.symbolsById instanceof Map).toBe(true); - expect(maps.definitionsBySymbol instanceof Map).toBe(true); - - // Documents map should have all documents - expect(maps.documentsByPath.size).toBe(scipData.documents.length); - - // Symbol map should have at least external symbols - expect(maps.symbolsById.size).toBeGreaterThanOrEqual( - scipData.externalSymbols.length - ); - }); - - test("should find definition file for a symbol", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const scipData = await parseScipFile(exampleScipPath); - const sampleDoc = scipData.documents[0]; - - if (!sampleDoc) { - console.log("Skipping test: no documents in SCIP file"); - return; - } - - const maps = buildLookupMaps(scipData); - const definitions = extractDefinitions(sampleDoc); - - if (definitions.length > 0) { - const def = definitions[0]!; - const defFile = findDefinitionFile({ - symbol: def.symbol, - documents: maps.documentsByPath, - }); - - // Should find the file (or null for external symbols) - expect(defFile === null || typeof defFile === "string").toBe(true); - } - }); - - test("should get document symbols with metadata", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const scipData = await parseScipFile(exampleScipPath); - const sampleDoc = scipData.documents[0]; - - if (!sampleDoc) { - console.log("Skipping test: no documents in SCIP file"); - return; - } - - const maps = buildLookupMaps(scipData); - const docSymbols = getDocumentSymbols({ - doc: sampleDoc, - symbolsById: maps.symbolsById, - }); - - expect(Array.isArray(docSymbols)).toBe(true); - - if (docSymbols.length > 0) { - const sym = docSymbols[0]!; - expect(sym).toHaveProperty("symbol"); - expect(sym).toHaveProperty("kind"); - expect(sym).toHaveProperty("range"); - expect(typeof sym.symbol).toBe("string"); - expect(typeof sym.kind).toBe("number"); - expect(sym.range.length).toBe(4); - } - }); - - test("should get file dependencies excluding self-references", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const scipData = await parseScipFile(exampleScipPath); - const sampleDoc = scipData.documents[0]; - - if (!sampleDoc) { - console.log("Skipping test: no documents in SCIP file"); - return; - } - - const maps = buildLookupMaps(scipData); - const deps = getFileDependencies({ - doc: sampleDoc, - definitionsBySymbol: maps.definitionsBySymbol, - }); - - expect(deps instanceof Map).toBe(true); - - // Verify no self-references - expect(deps.has(sampleDoc.relativePath)).toBe(false); - - // Dependencies should map file path -> set of symbols - for (const [depPath, symbols] of deps) { - expect(typeof depPath).toBe("string"); - expect(symbols instanceof Set).toBe(true); - expect(depPath).not.toBe(sampleDoc.relativePath); - - // All symbols should be non-empty strings - for (const sym of symbols) { - expect(typeof sym).toBe("string"); - expect(sym.length).toBeGreaterThan(0); - } - } - }); - - test("should handle SCIP metadata", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const scipData = await parseScipFile(exampleScipPath); - - if (scipData.metadata) { - expect(scipData.metadata).toHaveProperty("toolName"); - expect(scipData.metadata).toHaveProperty("toolVersion"); - - if (scipData.metadata.toolName) { - expect(typeof scipData.metadata.toolName).toBe("string"); - } - } - }); - - test("should parse external symbols", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const scipData = await parseScipFile(exampleScipPath); - - expect(Array.isArray(scipData.externalSymbols)).toBe(true); - - if (scipData.externalSymbols.length > 0) { - const extSym = scipData.externalSymbols[0]!; - expect(extSym).toHaveProperty("symbol"); - expect(extSym).toHaveProperty("kind"); - expect(typeof extSym.symbol).toBe("string"); - expect(typeof extSym.kind).toBe("number"); - } - }); - - test("should correctly identify local symbols", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const scipData = await parseScipFile(exampleScipPath); - const maps = buildLookupMaps(scipData); - - // Find a local symbol (contains 'local' in symbol identifier) - let foundLocalSymbol = false; - for (const doc of scipData.documents) { - const definitions = extractDefinitions(doc); - for (const def of definitions) { - if (def.symbol.includes("local")) { - foundLocalSymbol = true; - - // Local symbols should be defined in the same file - const defFile = findDefinitionFile({ - symbol: def.symbol, - documents: maps.documentsByPath, - }); - - // Should find in current document or return null - expect(defFile === null || defFile === doc.relativePath).toBe(true); - break; - } - } - if (foundLocalSymbol) break; - } - }); - - test("should handle documents with no symbols", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const scipData = await parseScipFile(exampleScipPath); - - // Find or create a document with no symbols - const emptyDoc: ParsedDocument = { - relativePath: "test/empty.ts", - language: "TypeScript", - occurrences: [], - symbols: [], - }; - - const definitions = extractDefinitions(emptyDoc); - const references = extractReferences(emptyDoc); - - expect(definitions.length).toBe(0); - expect(references.length).toBe(0); - - const maps = buildLookupMaps(scipData); - const docSymbols = getDocumentSymbols({ - doc: emptyDoc, - symbolsById: maps.symbolsById, - }); - expect(docSymbols.length).toBe(0); - - const deps = getFileDependencies({ - doc: emptyDoc, - definitionsBySymbol: maps.definitionsBySymbol, - }); - expect(deps.size).toBe(0); - }); - - test("should handle 3-element ranges (same line)", async () => { - if (skipTests) { - console.log("Skipping test: example SCIP file not found"); - return; - } - - const scipData = await parseScipFile(exampleScipPath); - - // This is tested implicitly in the parsing - if we got here without errors, - // the parser handled both 3 and 4 element ranges correctly - expect(scipData.documents.length).toBeGreaterThan(0); - }); + const exampleScipPath = join(process.cwd(), "test", "fixtures", "index.scip"); + const skipTests = !existsSync(exampleScipPath); + + test("should parse SCIP file successfully", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const scipData = await parseScipFile(exampleScipPath); + + expect(scipData).toBeDefined(); + expect(scipData.documents).toBeDefined(); + expect(scipData.documents.length).toBeGreaterThan(0); + expect(scipData.externalSymbols).toBeDefined(); + }); + + test("should parse document with correct structure", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const scipData = await parseScipFile(exampleScipPath); + const sampleDoc = scipData.documents[0]; + + if (!sampleDoc) { + console.log("Skipping test: no documents in SCIP file"); + return; + } + + expect(sampleDoc).toHaveProperty("relativePath"); + expect(sampleDoc).toHaveProperty("language"); + expect(sampleDoc).toHaveProperty("occurrences"); + expect(sampleDoc).toHaveProperty("symbols"); + expect(typeof sampleDoc.relativePath).toBe("string"); + expect(typeof sampleDoc.language).toBe("string"); + expect(Array.isArray(sampleDoc.occurrences)).toBe(true); + expect(Array.isArray(sampleDoc.symbols)).toBe(true); + }); + + test("should parse occurrences with correct range format", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const scipData = await parseScipFile(exampleScipPath); + const sampleDoc = scipData.documents[0]; + + if (!sampleDoc || sampleDoc.occurrences.length === 0) { + console.log("Skipping test: no occurrences in SCIP file"); + return; + } + + const occ = sampleDoc.occurrences[0]!; + expect(occ).toHaveProperty("range"); + expect(occ).toHaveProperty("symbol"); + expect(occ).toHaveProperty("symbolRoles"); + + // Range should be [startLine, startChar, endLine, endChar] + expect(occ.range.length).toBe(4); + expect(typeof occ.range[0]).toBe("number"); // startLine + expect(typeof occ.range[1]).toBe("number"); // startChar + expect(typeof occ.range[2]).toBe("number"); // endLine + expect(typeof occ.range[3]).toBe("number"); // endChar + }); + + test("should extract definitions from document", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const scipData = await parseScipFile(exampleScipPath); + const sampleDoc = scipData.documents[0]; + + if (!sampleDoc) { + console.log("Skipping test: no documents in SCIP file"); + return; + } + + const definitions = extractDefinitions(sampleDoc); + expect(Array.isArray(definitions)).toBe(true); + + if (definitions.length > 0) { + const def = definitions[0]!; + expect(def).toHaveProperty("symbol"); + expect(def).toHaveProperty("range"); + expect(def.range.length).toBe(4); + expect(typeof def.symbol).toBe("string"); + } + }); + + test("should extract references from document", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const scipData = await parseScipFile(exampleScipPath); + const sampleDoc = scipData.documents[0]; + + if (!sampleDoc) { + console.log("Skipping test: no documents in SCIP file"); + return; + } + + const references = extractReferences(sampleDoc); + expect(Array.isArray(references)).toBe(true); + + if (references.length > 0) { + const ref = references[0]!; + expect(ref).toHaveProperty("symbol"); + expect(ref).toHaveProperty("range"); + expect(ref).toHaveProperty("line"); + expect(ref.range.length).toBe(4); + expect(typeof ref.symbol).toBe("string"); + expect(typeof ref.line).toBe("number"); + } + }); + + test("should not have overlap between definitions and references", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const scipData = await parseScipFile(exampleScipPath); + const sampleDoc = scipData.documents[0]; + + if (!sampleDoc) { + console.log("Skipping test: no documents in SCIP file"); + return; + } + + const definitions = extractDefinitions(sampleDoc); + const references = extractReferences(sampleDoc); + + // Build sets of occurrence indices + const defIndices = new Set( + sampleDoc.occurrences + .map((occ, idx) => (occ.symbolRoles & 0x1 ? idx : -1)) + .filter((idx) => idx !== -1), + ); + + const refIndices = new Set( + sampleDoc.occurrences + .map((occ, idx) => (!(occ.symbolRoles & 0x1) ? idx : -1)) + .filter((idx) => idx !== -1), + ); + + // Should have no overlap + for (const defIdx of defIndices) { + expect(refIndices.has(defIdx)).toBe(false); + } + }); + + test("should build lookup maps correctly", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const scipData = await parseScipFile(exampleScipPath); + const maps = buildLookupMaps(scipData); + + expect(maps).toHaveProperty("documentsByPath"); + expect(maps).toHaveProperty("symbolsById"); + expect(maps).toHaveProperty("definitionsBySymbol"); + + expect(maps.documentsByPath instanceof Map).toBe(true); + expect(maps.symbolsById instanceof Map).toBe(true); + expect(maps.definitionsBySymbol instanceof Map).toBe(true); + + // Documents map should have all documents + expect(maps.documentsByPath.size).toBe(scipData.documents.length); + + // Symbol map should have at least external symbols + expect(maps.symbolsById.size).toBeGreaterThanOrEqual( + scipData.externalSymbols.length, + ); + }); + + test("should find definition file for a symbol", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const scipData = await parseScipFile(exampleScipPath); + const sampleDoc = scipData.documents[0]; + + if (!sampleDoc) { + console.log("Skipping test: no documents in SCIP file"); + return; + } + + const maps = buildLookupMaps(scipData); + const definitions = extractDefinitions(sampleDoc); + + if (definitions.length > 0) { + const def = definitions[0]!; + const defFile = findDefinitionFile({ + symbol: def.symbol, + documents: maps.documentsByPath, + }); + + // Should find the file (or null for external symbols) + expect(defFile === null || typeof defFile === "string").toBe(true); + } + }); + + test("should get document symbols with metadata", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const scipData = await parseScipFile(exampleScipPath); + const sampleDoc = scipData.documents[0]; + + if (!sampleDoc) { + console.log("Skipping test: no documents in SCIP file"); + return; + } + + const maps = buildLookupMaps(scipData); + const docSymbols = getDocumentSymbols({ + doc: sampleDoc, + symbolsById: maps.symbolsById, + }); + + expect(Array.isArray(docSymbols)).toBe(true); + + if (docSymbols.length > 0) { + const sym = docSymbols[0]!; + expect(sym).toHaveProperty("symbol"); + expect(sym).toHaveProperty("kind"); + expect(sym).toHaveProperty("range"); + expect(typeof sym.symbol).toBe("string"); + expect(typeof sym.kind).toBe("number"); + expect(sym.range.length).toBe(4); + } + }); + + test("should get file dependencies excluding self-references", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const scipData = await parseScipFile(exampleScipPath); + const sampleDoc = scipData.documents[0]; + + if (!sampleDoc) { + console.log("Skipping test: no documents in SCIP file"); + return; + } + + const maps = buildLookupMaps(scipData); + const deps = getFileDependencies({ + doc: sampleDoc, + definitionsBySymbol: maps.definitionsBySymbol, + }); + + expect(deps instanceof Map).toBe(true); + + // Verify no self-references + expect(deps.has(sampleDoc.relativePath)).toBe(false); + + // Dependencies should map file path -> set of symbols + for (const [depPath, symbols] of deps) { + expect(typeof depPath).toBe("string"); + expect(symbols instanceof Set).toBe(true); + expect(depPath).not.toBe(sampleDoc.relativePath); + + // All symbols should be non-empty strings + for (const sym of symbols) { + expect(typeof sym).toBe("string"); + expect(sym.length).toBeGreaterThan(0); + } + } + }); + + test("should handle SCIP metadata", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const scipData = await parseScipFile(exampleScipPath); + + if (scipData.metadata) { + expect(scipData.metadata).toHaveProperty("toolName"); + expect(scipData.metadata).toHaveProperty("toolVersion"); + + if (scipData.metadata.toolName) { + expect(typeof scipData.metadata.toolName).toBe("string"); + } + } + }); + + test("should parse external symbols", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const scipData = await parseScipFile(exampleScipPath); + + expect(Array.isArray(scipData.externalSymbols)).toBe(true); + + if (scipData.externalSymbols.length > 0) { + const extSym = scipData.externalSymbols[0]!; + expect(extSym).toHaveProperty("symbol"); + expect(extSym).toHaveProperty("kind"); + expect(typeof extSym.symbol).toBe("string"); + expect(typeof extSym.kind).toBe("number"); + } + }); + + test("should correctly identify local symbols", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const scipData = await parseScipFile(exampleScipPath); + const maps = buildLookupMaps(scipData); + + // Find a local symbol (contains 'local' in symbol identifier) + let foundLocalSymbol = false; + for (const doc of scipData.documents) { + const definitions = extractDefinitions(doc); + for (const def of definitions) { + if (def.symbol.includes("local")) { + foundLocalSymbol = true; + + // Local symbols should be defined in the same file + const defFile = findDefinitionFile({ + symbol: def.symbol, + documents: maps.documentsByPath, + }); + + // Should find in current document or return null + expect(defFile === null || defFile === doc.relativePath).toBe(true); + break; + } + } + if (foundLocalSymbol) break; + } + }); + + test("should handle documents with no symbols", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const scipData = await parseScipFile(exampleScipPath); + + // Find or create a document with no symbols + const emptyDoc: ParsedDocument = { + relativePath: "test/empty.ts", + language: "TypeScript", + occurrences: [], + symbols: [], + }; + + const definitions = extractDefinitions(emptyDoc); + const references = extractReferences(emptyDoc); + + expect(definitions.length).toBe(0); + expect(references.length).toBe(0); + + const maps = buildLookupMaps(scipData); + const docSymbols = getDocumentSymbols({ + doc: emptyDoc, + symbolsById: maps.symbolsById, + }); + expect(docSymbols.length).toBe(0); + + const deps = getFileDependencies({ + doc: emptyDoc, + definitionsBySymbol: maps.definitionsBySymbol, + }); + expect(deps.size).toBe(0); + }); + + test("should handle 3-element ranges (same line)", async () => { + if (skipTests) { + console.log("Skipping test: example SCIP file not found"); + return; + } + + const scipData = await parseScipFile(exampleScipPath); + + // This is tested implicitly in the parsing - if we got here without errors, + // the parser handled both 3 and 4 element ranges correctly + expect(scipData.documents.length).toBeGreaterThan(0); + }); }); diff --git a/test/db/queries.test.ts b/test/db/queries.test.ts index 2b807af..eb75d91 100644 --- a/test/db/queries.test.ts +++ b/test/db/queries.test.ts @@ -3,23 +3,23 @@ import { Database } from "bun:sqlite"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { - getDependencies, - getFileDependencies, - getFileDependents, - getFileSymbols, - getReverseDependencies, - searchSymbols, + getDependencies, + getFileDependencies, + getFileDependents, + getFileSymbols, + getReverseDependencies, + searchSymbols, } from "../../src/db/queries.ts"; describe("Database Queries", () => { - let db: Database; + let db: Database; - beforeAll(() => { - // Create in-memory test database - db = new Database(":memory:"); + beforeAll(() => { + // Create in-memory test database + db = new Database(":memory:"); - // Create schema - db.exec(` + // Create schema + db.exec(` CREATE TABLE files ( id INTEGER PRIMARY KEY, path TEXT UNIQUE NOT NULL, @@ -75,181 +75,181 @@ describe("Database Queries", () => { ); `); - // Insert test files - db.run( - "INSERT INTO files (id, path, language, mtime, indexed_at) VALUES (1, 'packages/app-utils/src/index.ts', 'typescript', 1000, 1000)" - ); - db.run( - "INSERT INTO files (id, path, language, mtime, indexed_at) VALUES (2, 'packages/app-utils/src/logger.ts', 'typescript', 1000, 1000)" - ); - db.run( - "INSERT INTO files (id, path, language, mtime, indexed_at) VALUES (3, 'apps/api-worker/src/index.ts', 'typescript', 1000, 1000)" - ); - db.run( - "INSERT INTO files (id, path, language, mtime, indexed_at) VALUES (4, 'packages/app-auth/src/index.ts', 'typescript', 1000, 1000)" - ); - - // Insert test symbols (all non-local symbols, so is_local = 0) - db.run(`INSERT INTO symbols (id, file_id, name, scip_symbol, kind, start_line, end_line, start_char, end_char, package, is_local) + // Insert test files + db.run( + "INSERT INTO files (id, path, language, mtime, indexed_at) VALUES (1, 'packages/app-utils/src/index.ts', 'typescript', 1000, 1000)", + ); + db.run( + "INSERT INTO files (id, path, language, mtime, indexed_at) VALUES (2, 'packages/app-utils/src/logger.ts', 'typescript', 1000, 1000)", + ); + db.run( + "INSERT INTO files (id, path, language, mtime, indexed_at) VALUES (3, 'apps/api-worker/src/index.ts', 'typescript', 1000, 1000)", + ); + db.run( + "INSERT INTO files (id, path, language, mtime, indexed_at) VALUES (4, 'packages/app-auth/src/index.ts', 'typescript', 1000, 1000)", + ); + + // Insert test symbols (all non-local symbols, so is_local = 0) + db.run(`INSERT INTO symbols (id, file_id, name, scip_symbol, kind, start_line, end_line, start_char, end_char, package, is_local) VALUES (1, 1, 'exportUtils', 'scip-typescript npm @pkg/app-utils 1.0.0 src/index.ts/exportUtils.', 'function', 5, 10, 0, 1, '@pkg/app-utils', 0)`); - db.run(`INSERT INTO symbols (id, file_id, name, scip_symbol, kind, start_line, end_line, start_char, end_char, package, is_local) + db.run(`INSERT INTO symbols (id, file_id, name, scip_symbol, kind, start_line, end_line, start_char, end_char, package, is_local) VALUES (2, 2, 'Logger', 'scip-typescript npm @pkg/app-utils 1.0.0 src/logger.ts/Logger#', 'interface', 3, 8, 0, 1, '@pkg/app-utils', 0)`); - db.run(`INSERT INTO symbols (id, file_id, name, scip_symbol, kind, start_line, end_line, start_char, end_char, package, is_local) + db.run(`INSERT INTO symbols (id, file_id, name, scip_symbol, kind, start_line, end_line, start_char, end_char, package, is_local) VALUES (3, 3, 'main', 'scip-typescript npm @pkg/api-worker 1.0.0 src/index.ts/main.', 'function', 1, 5, 0, 1, '@pkg/api-worker', 0)`); - db.run(`INSERT INTO symbols (id, file_id, name, scip_symbol, kind, start_line, end_line, start_char, end_char, package, is_local) + db.run(`INSERT INTO symbols (id, file_id, name, scip_symbol, kind, start_line, end_line, start_char, end_char, package, is_local) VALUES (4, 4, 'AuthSession', 'scip-typescript npm @pkg/app-auth 1.0.0 src/index.ts/AuthSession#', 'type', 2, 6, 0, 1, '@pkg/app-auth', 0)`); - // Insert dependencies - db.run(`INSERT INTO dependencies (from_file_id, to_file_id, symbol_count, symbols) + // Insert dependencies + db.run(`INSERT INTO dependencies (from_file_id, to_file_id, symbol_count, symbols) VALUES (1, 2, 1, '["Logger"]')`); - db.run(`INSERT INTO dependencies (from_file_id, to_file_id, symbol_count, symbols) + db.run(`INSERT INTO dependencies (from_file_id, to_file_id, symbol_count, symbols) VALUES (3, 1, 1, '["exportUtils"]')`); - db.run(`INSERT INTO dependencies (from_file_id, to_file_id, symbol_count, symbols) + db.run(`INSERT INTO dependencies (from_file_id, to_file_id, symbol_count, symbols) VALUES (3, 2, 1, '["Logger"]')`); - db.run(`INSERT INTO dependencies (from_file_id, to_file_id, symbol_count, symbols) + db.run(`INSERT INTO dependencies (from_file_id, to_file_id, symbol_count, symbols) VALUES (3, 4, 1, '["AuthSession"]')`); - // Insert symbol references - db.run( - "INSERT INTO symbol_references (symbol_id, file_id, line) VALUES (2, 3, 10)" - ); - db.run( - "INSERT INTO symbol_references (symbol_id, file_id, line) VALUES (1, 3, 15)" - ); - - // Insert packages - db.run( - "INSERT INTO packages (name, manager, symbol_count) VALUES ('@pkg/app-utils', 'npm', 2)" - ); - db.run( - "INSERT INTO packages (name, manager, symbol_count) VALUES ('@pkg/api-worker', 'npm', 1)" - ); - db.run( - "INSERT INTO packages (name, manager, symbol_count) VALUES ('@pkg/app-auth', 'npm', 1)" - ); - }); - - afterAll(() => { - db.close(); - }); - - // Note: Trivial queries (getFileCount, getSymbolCount, getPackages) are not tested - // They're simple SELECT COUNT(*) or SELECT name queries with no complex logic - - describe("File Queries", () => { - test("getFileSymbols should return symbols for a file", () => { - const symbols = getFileSymbols(db, "packages/app-utils/src/index.ts"); - - expect(Array.isArray(symbols)).toBe(true); - // File should have at least one symbol (module) - expect(symbols.length).toBeGreaterThanOrEqual(1); - - if (symbols.length > 0) { - expect(symbols[0]).toHaveProperty("name"); - expect(symbols[0]).toHaveProperty("kind"); - expect(symbols[0]).toHaveProperty("lines"); - } - }); - - test("getFileDependencies should return dependencies", () => { - const deps = getFileDependencies(db, "packages/app-utils/src/index.ts"); - - expect(Array.isArray(deps)).toBe(true); - - if (deps.length > 0) { - expect(deps[0]).toHaveProperty("path"); - } - }); - - test("getFileDependents should return dependents", () => { - const dependents = getFileDependents( - db, - "packages/app-utils/src/logger.ts" - ); - - expect(Array.isArray(dependents)).toBe(true); - // logger.ts is used by other files, so should have dependents - expect(dependents.length).toBeGreaterThan(0); - - expect(dependents[0]!).toHaveProperty("path"); - expect(dependents[0]!).toHaveProperty("refs"); - expect(typeof dependents[0]!.refs).toBe("number"); - }); - }); - - describe("Symbol Queries", () => { - test("searchSymbols should find symbols by name", () => { - const results = searchSymbols(db, "Logger", { limit: 10 }); - - expect(Array.isArray(results)).toBe(true); - - if (results.length > 0) { - expect(results[0]).toHaveProperty("name"); - expect(results[0]).toHaveProperty("kind"); - expect(results[0]).toHaveProperty("path"); - } - }); - - test("searchSymbols should respect limit option", () => { - const results = searchSymbols(db, "index", { limit: 5 }); - - expect(results.length).toBeLessThanOrEqual(5); - }); - - test("searchSymbols should return empty array for non-existent symbol", () => { - const results = searchSymbols(db, "NonExistentSymbolXYZ123", { - limit: 10, - }); - - expect(Array.isArray(results)).toBe(true); - expect(results.length).toBe(0); - }); - }); - - describe("Dependency Graph Queries", () => { - test("getDependencies should return dependencies at depth 1", () => { - const deps = getDependencies(db, "packages/app-utils/src/index.ts", 1); - - expect(Array.isArray(deps)).toBe(true); - // Should have logger.ts as dependency - expect(deps.length).toBeGreaterThan(0); - - if (deps.length > 0) { - expect(deps[0]!).toHaveProperty("path"); - expect(deps[0]!).toHaveProperty("depth"); - expect(deps[0]!.depth).toBe(1); - } - }); - - test("getDependencies should handle depth 2", () => { - const deps = getDependencies(db, "apps/api-worker/src/index.ts", 2); - - expect(Array.isArray(deps)).toBe(true); - // api-worker depends on multiple files at different depths - expect(deps.length).toBeGreaterThan(0); - }); - - test("getReverseDependencies should return dependents", () => { - const rdeps = getReverseDependencies( - db, - "packages/app-utils/src/logger.ts", - 1 - ); - - expect(Array.isArray(rdeps)).toBe(true); - expect(rdeps.length).toBeGreaterThan(0); - - expect(rdeps[0]!).toHaveProperty("path"); - expect(rdeps[0]!).toHaveProperty("depth"); - expect(rdeps[0]!.depth).toBe(1); - }); - - test("getDependencies should return empty array for file with no deps", () => { - const deps = getDependencies(db, "packages/app-utils/src/logger.ts", 1); - - expect(Array.isArray(deps)).toBe(true); - // logger.ts has no dependencies in our test data - expect(deps.length).toBe(0); - }); - }); + // Insert symbol references + db.run( + "INSERT INTO symbol_references (symbol_id, file_id, line) VALUES (2, 3, 10)", + ); + db.run( + "INSERT INTO symbol_references (symbol_id, file_id, line) VALUES (1, 3, 15)", + ); + + // Insert packages + db.run( + "INSERT INTO packages (name, manager, symbol_count) VALUES ('@pkg/app-utils', 'npm', 2)", + ); + db.run( + "INSERT INTO packages (name, manager, symbol_count) VALUES ('@pkg/api-worker', 'npm', 1)", + ); + db.run( + "INSERT INTO packages (name, manager, symbol_count) VALUES ('@pkg/app-auth', 'npm', 1)", + ); + }); + + afterAll(() => { + db.close(); + }); + + // Note: Trivial queries (getFileCount, getSymbolCount, getPackages) are not tested + // They're simple SELECT COUNT(*) or SELECT name queries with no complex logic + + describe("File Queries", () => { + test("getFileSymbols should return symbols for a file", () => { + const symbols = getFileSymbols(db, "packages/app-utils/src/index.ts"); + + expect(Array.isArray(symbols)).toBe(true); + // File should have at least one symbol (module) + expect(symbols.length).toBeGreaterThanOrEqual(1); + + if (symbols.length > 0) { + expect(symbols[0]).toHaveProperty("name"); + expect(symbols[0]).toHaveProperty("kind"); + expect(symbols[0]).toHaveProperty("lines"); + } + }); + + test("getFileDependencies should return dependencies", () => { + const deps = getFileDependencies(db, "packages/app-utils/src/index.ts"); + + expect(Array.isArray(deps)).toBe(true); + + if (deps.length > 0) { + expect(deps[0]).toHaveProperty("path"); + } + }); + + test("getFileDependents should return dependents", () => { + const dependents = getFileDependents( + db, + "packages/app-utils/src/logger.ts", + ); + + expect(Array.isArray(dependents)).toBe(true); + // logger.ts is used by other files, so should have dependents + expect(dependents.length).toBeGreaterThan(0); + + expect(dependents[0]!).toHaveProperty("path"); + expect(dependents[0]!).toHaveProperty("refs"); + expect(typeof dependents[0]!.refs).toBe("number"); + }); + }); + + describe("Symbol Queries", () => { + test("searchSymbols should find symbols by name", () => { + const results = searchSymbols(db, "Logger", { limit: 10 }); + + expect(Array.isArray(results)).toBe(true); + + if (results.length > 0) { + expect(results[0]).toHaveProperty("name"); + expect(results[0]).toHaveProperty("kind"); + expect(results[0]).toHaveProperty("path"); + } + }); + + test("searchSymbols should respect limit option", () => { + const results = searchSymbols(db, "index", { limit: 5 }); + + expect(results.length).toBeLessThanOrEqual(5); + }); + + test("searchSymbols should return empty array for non-existent symbol", () => { + const results = searchSymbols(db, "NonExistentSymbolXYZ123", { + limit: 10, + }); + + expect(Array.isArray(results)).toBe(true); + expect(results.length).toBe(0); + }); + }); + + describe("Dependency Graph Queries", () => { + test("getDependencies should return dependencies at depth 1", () => { + const deps = getDependencies(db, "packages/app-utils/src/index.ts", 1); + + expect(Array.isArray(deps)).toBe(true); + // Should have logger.ts as dependency + expect(deps.length).toBeGreaterThan(0); + + if (deps.length > 0) { + expect(deps[0]!).toHaveProperty("path"); + expect(deps[0]!).toHaveProperty("depth"); + expect(deps[0]!.depth).toBe(1); + } + }); + + test("getDependencies should handle depth 2", () => { + const deps = getDependencies(db, "apps/api-worker/src/index.ts", 2); + + expect(Array.isArray(deps)).toBe(true); + // api-worker depends on multiple files at different depths + expect(deps.length).toBeGreaterThan(0); + }); + + test("getReverseDependencies should return dependents", () => { + const rdeps = getReverseDependencies( + db, + "packages/app-utils/src/logger.ts", + 1, + ); + + expect(Array.isArray(rdeps)).toBe(true); + expect(rdeps.length).toBeGreaterThan(0); + + expect(rdeps[0]!).toHaveProperty("path"); + expect(rdeps[0]!).toHaveProperty("depth"); + expect(rdeps[0]!.depth).toBe(1); + }); + + test("getDependencies should return empty array for file with no deps", () => { + const deps = getDependencies(db, "packages/app-utils/src/logger.ts", 1); + + expect(Array.isArray(deps)).toBe(true); + // logger.ts has no dependencies in our test data + expect(deps.length).toBe(0); + }); + }); }); diff --git a/test/utils/config.test.ts b/test/utils/config.test.ts index ed775e8..702482b 100644 --- a/test/utils/config.test.ts +++ b/test/utils/config.test.ts @@ -260,8 +260,7 @@ describe("Config Management", () => { afterEach(async () => { try { await Bun.$`rm -rf ${tempDir}`; - } catch { - } + } catch {} }); test("should use explicit language when provided", async () => { diff --git a/test/utils/fileScanner.test.ts b/test/utils/fileScanner.test.ts index 8e0f7db..409395c 100644 --- a/test/utils/fileScanner.test.ts +++ b/test/utils/fileScanner.test.ts @@ -4,105 +4,105 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { mkdir, rm, writeFile } from "fs/promises"; import { join } from "path"; import { - filterChangedDocuments, - scanDocumentFiles, + filterChangedDocuments, + scanDocumentFiles, } from "../../src/utils/fileScanner.ts"; describe("File Scanner", () => { - const testDir = join(process.cwd(), "test", "fixtures", "test-repo"); - - beforeAll(async () => { - // Create test directory structure - await mkdir(testDir, { recursive: true }); - await mkdir(join(testDir, "docs"), { recursive: true }); - await mkdir(join(testDir, "node_modules"), { recursive: true }); - await mkdir(join(testDir, "src"), { recursive: true }); - - // Create test files - await writeFile(join(testDir, "README.md"), "# Test"); - await writeFile(join(testDir, "docs", "guide.md"), "# Guide"); - await writeFile(join(testDir, "node_modules", "foo.md"), "# Foo"); - await writeFile(join(testDir, "notes.txt"), "Project notes"); - - // Create .gitignore - await writeFile(join(testDir, ".gitignore"), "node_modules/\n*.log\n"); - }); - - afterAll(async () => { - // Clean up test directory - await rm(testDir, { recursive: true, force: true }); - }); - - test("should scan and find document files", async () => { - const docs = await scanDocumentFiles({ repoRoot: testDir }); - - expect(docs.length).toBeGreaterThan(0); - expect(docs.some((d) => d.path.endsWith("README.md"))).toBe(true); - expect(docs.some((d) => d.path.endsWith("guide.md"))).toBe(true); - expect(docs.some((d) => d.path.endsWith("notes.txt"))).toBe(true); - }); - - test("should respect .gitignore rules", async () => { - const docs = await scanDocumentFiles({ repoRoot: testDir }); - - // Should not include files in node_modules - expect(docs.some((d) => d.path.includes("node_modules"))).toBe(false); - }); - - test("should include file metadata", async () => { - const docs = await scanDocumentFiles({ repoRoot: testDir }); - const readme = docs.find((d) => d.path.endsWith("README.md")); - - expect(readme).toBeDefined(); - expect(readme?.mtime).toBeGreaterThan(0); - expect(readme?.type).toBe("md"); - }); - - test("should filter by custom extensions", async () => { - const docs = await scanDocumentFiles({ - repoRoot: testDir, - extensions: [".md"], - }); - - expect(docs.every((d) => d.type === "md")).toBe(true); - expect(docs.some((d) => d.type === "txt")).toBe(false); - }); - - test("should filter changed documents", () => { - const existingDocs = new Map([ - ["README.md", 1000], - ["docs/guide.md", 2000], - ]); - - const scannedDocs = [ - { path: "README.md", mtime: 1000, type: "md" }, // unchanged - { path: "docs/guide.md", mtime: 3000, type: "md" }, // modified - { path: "NEW.md", mtime: 4000, type: "md" }, // new - ]; - - const changed = filterChangedDocuments({ existingDocs, scannedDocs }); - - expect(changed.length).toBe(2); - expect(changed.some((d) => d.path === "docs/guide.md")).toBe(true); - expect(changed.some((d) => d.path === "NEW.md")).toBe(true); - expect(changed.some((d) => d.path === "README.md")).toBe(false); - }); - - test("should scan and find .txt files", async () => { - const docs = await scanDocumentFiles({ repoRoot: testDir }); - - // Should include .txt files - expect(docs.some((d) => d.path.endsWith("notes.txt"))).toBe(true); - - const txtFile = docs.find((d) => d.path.endsWith("notes.txt")); - expect(txtFile?.type).toBe("txt"); - }); - - test("should include .txt in default extensions", async () => { - const docs = await scanDocumentFiles({ repoRoot: testDir }); - - // Verify that .txt files are scanned by default - const txtDocs = docs.filter((d) => d.type === "txt"); - expect(txtDocs.length).toBeGreaterThan(0); - }); + const testDir = join(process.cwd(), "test", "fixtures", "test-repo"); + + beforeAll(async () => { + // Create test directory structure + await mkdir(testDir, { recursive: true }); + await mkdir(join(testDir, "docs"), { recursive: true }); + await mkdir(join(testDir, "node_modules"), { recursive: true }); + await mkdir(join(testDir, "src"), { recursive: true }); + + // Create test files + await writeFile(join(testDir, "README.md"), "# Test"); + await writeFile(join(testDir, "docs", "guide.md"), "# Guide"); + await writeFile(join(testDir, "node_modules", "foo.md"), "# Foo"); + await writeFile(join(testDir, "notes.txt"), "Project notes"); + + // Create .gitignore + await writeFile(join(testDir, ".gitignore"), "node_modules/\n*.log\n"); + }); + + afterAll(async () => { + // Clean up test directory + await rm(testDir, { recursive: true, force: true }); + }); + + test("should scan and find document files", async () => { + const docs = await scanDocumentFiles({ repoRoot: testDir }); + + expect(docs.length).toBeGreaterThan(0); + expect(docs.some((d) => d.path.endsWith("README.md"))).toBe(true); + expect(docs.some((d) => d.path.endsWith("guide.md"))).toBe(true); + expect(docs.some((d) => d.path.endsWith("notes.txt"))).toBe(true); + }); + + test("should respect .gitignore rules", async () => { + const docs = await scanDocumentFiles({ repoRoot: testDir }); + + // Should not include files in node_modules + expect(docs.some((d) => d.path.includes("node_modules"))).toBe(false); + }); + + test("should include file metadata", async () => { + const docs = await scanDocumentFiles({ repoRoot: testDir }); + const readme = docs.find((d) => d.path.endsWith("README.md")); + + expect(readme).toBeDefined(); + expect(readme?.mtime).toBeGreaterThan(0); + expect(readme?.type).toBe("md"); + }); + + test("should filter by custom extensions", async () => { + const docs = await scanDocumentFiles({ + repoRoot: testDir, + extensions: [".md"], + }); + + expect(docs.every((d) => d.type === "md")).toBe(true); + expect(docs.some((d) => d.type === "txt")).toBe(false); + }); + + test("should filter changed documents", () => { + const existingDocs = new Map([ + ["README.md", 1000], + ["docs/guide.md", 2000], + ]); + + const scannedDocs = [ + { path: "README.md", mtime: 1000, type: "md" }, // unchanged + { path: "docs/guide.md", mtime: 3000, type: "md" }, // modified + { path: "NEW.md", mtime: 4000, type: "md" }, // new + ]; + + const changed = filterChangedDocuments({ existingDocs, scannedDocs }); + + expect(changed.length).toBe(2); + expect(changed.some((d) => d.path === "docs/guide.md")).toBe(true); + expect(changed.some((d) => d.path === "NEW.md")).toBe(true); + expect(changed.some((d) => d.path === "README.md")).toBe(false); + }); + + test("should scan and find .txt files", async () => { + const docs = await scanDocumentFiles({ repoRoot: testDir }); + + // Should include .txt files + expect(docs.some((d) => d.path.endsWith("notes.txt"))).toBe(true); + + const txtFile = docs.find((d) => d.path.endsWith("notes.txt")); + expect(txtFile?.type).toBe("txt"); + }); + + test("should include .txt in default extensions", async () => { + const docs = await scanDocumentFiles({ repoRoot: testDir }); + + // Verify that .txt files are scanned by default + const txtDocs = docs.filter((d) => d.type === "txt"); + expect(txtDocs.length).toBeGreaterThan(0); + }); }); From b5e684692fde9d28ea03cc0e3527ac810bdea14f Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 10:10:10 +0700 Subject: [PATCH 16/31] fix(tests): resolve type-check errors in tree-sitter test files --- test/tree-sitter/class-captures.test.ts | 81 ++++++++++++---------- test/tree-sitter/function-captures.test.ts | 48 ++++++------- 2 files changed, 68 insertions(+), 61 deletions(-) diff --git a/test/tree-sitter/class-captures.test.ts b/test/tree-sitter/class-captures.test.ts index 4e4b215..000ae2d 100644 --- a/test/tree-sitter/class-captures.test.ts +++ b/test/tree-sitter/class-captures.test.ts @@ -48,7 +48,7 @@ function makeNode(overrides: NodeOverrides = {}): Parser.Node { } function makeCapture(name: string, node: Parser.Node): Parser.QueryCapture { - return { name, node }; + return { name, node, patternIndex: 0 } as unknown as Parser.QueryCapture; } describe("parseClassCaptures", () => { @@ -92,14 +92,14 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].name).toBe("Foo"); - expect(result[0].lines).toEqual([1, 1]); - expect(result[0].is_abstract).toBe(false); - expect(result[0].decorators).toEqual([]); - expect(result[0].implements).toEqual([]); - expect(result[0].extends_name).toBeNull(); - expect(result[0].methods).toEqual([]); - expect(result[0].property_count).toBe(0); + expect(result[0]!.name).toBe("Foo"); + expect(result[0]!.lines).toEqual([1, 1]); + expect(result[0]!.is_abstract).toBe(false); + expect(result[0]!.decorators).toEqual([]); + expect(result[0]!.implements).toEqual([]); + expect(result[0]!.extends_name).toBeNull(); + expect(result[0]!.methods).toEqual([]); + expect(result[0]!.property_count).toBe(0); }); test("exported class unwraps correctly", () => { @@ -122,7 +122,8 @@ describe("parseClassCaptures", () => { parent: exportNode, }); - exportNode.namedChildren = [classNode]; + (exportNode as unknown as { namedChildren: Parser.Node[] }).namedChildren = + [classNode]; const nameNode = makeNode({ type: "type_identifier", @@ -154,9 +155,9 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].name).toBe("Bar"); - expect(result[0].lines).toEqual([1, 1]); - expect(result[0].is_abstract).toBe(false); + expect(result[0]!.name).toBe("Bar"); + expect(result[0]!.lines).toEqual([1, 1]); + expect(result[0]!.is_abstract).toBe(false); }); test("deduplication of same startIndex", () => { @@ -198,7 +199,7 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].name).toBe("Baz"); + expect(result[0]!.name).toBe("Baz"); }); test("extends clause", () => { @@ -249,7 +250,7 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].extends_name).toBe("Parent"); + expect(result[0]!.extends_name).toBe("Parent"); }); test("no extends clause", () => { @@ -290,7 +291,7 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].extends_name).toBeNull(); + expect(result[0]!.extends_name).toBeNull(); }); test("implements clause via heritage", () => { @@ -351,7 +352,10 @@ describe("parseClassCaptures", () => { parent: classNode, }); - classNode.namedChildren = [heritageNode, bodyNode]; + (classNode as unknown as { namedChildren: Parser.Node[] }).namedChildren = [ + heritageNode, + bodyNode, + ]; const captures = [ makeCapture("cls.declaration", classNode), @@ -362,7 +366,7 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].implements).toEqual(["IFoo", "IBar"]); + expect(result[0]!.implements).toEqual(["IFoo", "IBar"]); }); test("no heritage means empty implements", () => { @@ -394,7 +398,9 @@ describe("parseClassCaptures", () => { parent: classNode, }); - classNode.namedChildren = [bodyNode]; + (classNode as unknown as { namedChildren: Parser.Node[] }).namedChildren = [ + bodyNode, + ]; const captures = [ makeCapture("cls.declaration", classNode), @@ -405,7 +411,7 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].implements).toEqual([]); + expect(result[0]!.implements).toEqual([]); }); test("abstract keyword in declaration children", () => { @@ -452,7 +458,7 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].is_abstract).toBe(true); + expect(result[0]!.is_abstract).toBe(true); }); test("abstract keyword in export statement parent", () => { @@ -479,7 +485,8 @@ describe("parseClassCaptures", () => { parent: exportNode, }); - exportNode.namedChildren = [classNode]; + (exportNode as unknown as { namedChildren: Parser.Node[] }).namedChildren = + [classNode]; const nameNode = makeNode({ type: "type_identifier", @@ -510,7 +517,7 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].is_abstract).toBe(true); + expect(result[0]!.is_abstract).toBe(true); }); test("non-abstract class", () => { @@ -552,7 +559,7 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].is_abstract).toBe(false); + expect(result[0]!.is_abstract).toBe(false); }); test("decorators via previousNamedSibling chain", () => { @@ -613,7 +620,7 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].decorators).toEqual(["@Component", "@Injectable"]); + expect(result[0]!.decorators).toEqual(["@Component", "@Injectable"]); }); test("no decorators", () => { @@ -655,7 +662,7 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].decorators).toEqual([]); + expect(result[0]!.decorators).toEqual([]); }); test("method extraction with async and complexity", () => { @@ -766,14 +773,14 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].methods).toHaveLength(2); - expect(result[0].methods[0].name).toBe("asyncMethod"); - expect(result[0].methods[0].is_async).toBe(true); - expect(result[0].methods[0].cyclomatic_complexity).toBe(2); - expect(result[0].methods[0].line).toBe(2); - expect(result[0].methods[1].name).toBe("normalMethod"); - expect(result[0].methods[1].is_async).toBe(false); - expect(result[0].methods[1].cyclomatic_complexity).toBe(1); + expect(result[0]!.methods).toHaveLength(2); + expect(result[0]!.methods[0]!.name).toBe("asyncMethod"); + expect(result[0]!.methods[0]!.is_async).toBe(true); + expect(result[0]!.methods[0]!.cyclomatic_complexity).toBe(2); + expect(result[0]!.methods[0]!.line).toBe(2); + expect(result[0]!.methods[1]!.name).toBe("normalMethod"); + expect(result[0]!.methods[1]!.is_async).toBe(false); + expect(result[0]!.methods[1]!.cyclomatic_complexity).toBe(1); }); test("property counting with public_field_definition", () => { @@ -829,7 +836,7 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].property_count).toBe(3); + expect(result[0]!.property_count).toBe(3); }); test("property counting with field_definition", () => { @@ -875,7 +882,7 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].property_count).toBe(1); + expect(result[0]!.property_count).toBe(1); }); test("empty body has zero properties", () => { @@ -916,7 +923,7 @@ describe("parseClassCaptures", () => { const result = parseClassCaptures(captures); expect(result).toHaveLength(1); - expect(result[0].property_count).toBe(0); + expect(result[0]!.property_count).toBe(0); }); test("missing name capture skips class", () => { diff --git a/test/tree-sitter/function-captures.test.ts b/test/tree-sitter/function-captures.test.ts index c2fc9e4..fe7f426 100644 --- a/test/tree-sitter/function-captures.test.ts +++ b/test/tree-sitter/function-captures.test.ts @@ -40,7 +40,7 @@ function makeNode(overrides: NodeOverrides = {}): Parser.Node { } function makeCapture(name: string, node: Parser.Node): Parser.QueryCapture { - return { name, node } as unknown as Parser.QueryCapture; + return { name, node, patternIndex: 0 } as unknown as Parser.QueryCapture; } describe("parseFunctionCaptures capture type routing", () => { @@ -80,9 +80,9 @@ describe("parseFunctionCaptures capture type routing", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].name).toBe("foo"); - expect(results[0].is_exported).toBe(false); - expect(results[0].is_method).toBe(false); + expect(results[0]!.name).toBe("foo"); + expect(results[0]!.is_exported).toBe(false); + expect(results[0]!.is_method).toBe(false); }); test("fn.export sets is_exported=true", () => { @@ -132,8 +132,8 @@ describe("parseFunctionCaptures capture type routing", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].is_exported).toBe(true); - expect(results[0].is_method).toBe(false); + expect(results[0]!.is_exported).toBe(true); + expect(results[0]!.is_method).toBe(false); }); test("fn.arrow is detected as arrow function", () => { @@ -193,7 +193,7 @@ describe("parseFunctionCaptures capture type routing", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].name).toBe("arrowFn"); + expect(results[0]!.name).toBe("arrowFn"); }); test("fn.export_arrow sets is_exported=true", () => { @@ -250,7 +250,7 @@ describe("parseFunctionCaptures capture type routing", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].is_exported).toBe(true); + expect(results[0]!.is_exported).toBe(true); }); test("fn.method sets is_method=true, is_exported=false", () => { @@ -289,8 +289,8 @@ describe("parseFunctionCaptures capture type routing", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].is_method).toBe(true); - expect(results[0].is_exported).toBe(false); + expect(results[0]!.is_method).toBe(true); + expect(results[0]!.is_exported).toBe(false); }); test("unknown capture name like fn.other is ignored", () => { @@ -368,7 +368,7 @@ describe("parseFunctionCaptures deduplication", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].name).toBe("foo"); + expect(results[0]!.name).toBe("foo"); }); }); @@ -412,7 +412,7 @@ describe("parseFunctionCaptures async detection", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].is_async).toBe(true); + expect(results[0]!.is_async).toBe(true); }); test("node without async child has is_async=false", () => { @@ -459,7 +459,7 @@ describe("parseFunctionCaptures async detection", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].is_async).toBe(false); + expect(results[0]!.is_async).toBe(false); }); }); @@ -506,7 +506,7 @@ describe("parseFunctionCaptures parameter extraction", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].parameters).toEqual([{ name: "x", type: null }]); + expect(results[0]!.parameters).toEqual([{ name: "x", type: null }]); }); test("required_parameter with pattern and type fields", () => { @@ -567,7 +567,7 @@ describe("parseFunctionCaptures parameter extraction", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].parameters).toEqual([{ name: "user", type: "User" }]); + expect(results[0]!.parameters).toEqual([{ name: "user", type: "User" }]); }); test('rest_pattern becomes { name: "...args", type: null }', () => { @@ -618,7 +618,7 @@ describe("parseFunctionCaptures parameter extraction", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].parameters).toEqual([{ name: "...args", type: null }]); + expect(results[0]!.parameters).toEqual([{ name: "...args", type: null }]); }); test("assignment_pattern uses name from left field", () => { @@ -672,7 +672,7 @@ describe("parseFunctionCaptures parameter extraction", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].parameters).toEqual([{ name: "options", type: null }]); + expect(results[0]!.parameters).toEqual([{ name: "options", type: null }]); }); }); @@ -720,7 +720,7 @@ describe("parseFunctionCaptures return type", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].return_type).toBe("Promise"); + expect(results[0]!.return_type).toBe("Promise"); }); test("no fn.return_type capture results in return_type=null", () => { @@ -759,7 +759,7 @@ describe("parseFunctionCaptures return type", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].return_type).toBe(null); + expect(results[0]!.return_type).toBe(null); }); }); @@ -807,7 +807,7 @@ describe("parseFunctionCaptures jsdoc", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].jsdoc).toBe("/** This is a JSDoc comment */"); + expect(results[0]!.jsdoc).toBe("/** This is a JSDoc comment */"); }); test("previousNamedSibling // comment results in jsdoc=null", () => { @@ -853,7 +853,7 @@ describe("parseFunctionCaptures jsdoc", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].jsdoc).toBe(null); + expect(results[0]!.jsdoc).toBe(null); }); test("no previousNamedSibling results in jsdoc=null", () => { @@ -893,7 +893,7 @@ describe("parseFunctionCaptures jsdoc", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].jsdoc).toBe(null); + expect(results[0]!.jsdoc).toBe(null); }); }); @@ -934,8 +934,8 @@ describe("parseFunctionCaptures line numbers", () => { const results = parseFunctionCaptures(captures); expect(results).toHaveLength(1); - expect(results[0].lines).toEqual([5, 10]); - expect(results[0].loc).toBe(6); + expect(results[0]!.lines).toEqual([5, 10]); + expect(results[0]!.loc).toBe(6); }); }); From 35dab3e8509c46d05804223e3c266805a8c4fef0 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 10:18:07 +0700 Subject: [PATCH 17/31] docs: rewrite README, CONTRIBUTING, and rename CLAUDE.md to AGENTS.md --- AGENTS.md | 80 +++ CLAUDE.md | 1447 ----------------------------------------------- CONTRIBUTING.md | 243 +++----- README.md | 563 +++--------------- 4 files changed, 231 insertions(+), 2102 deletions(-) create mode 100644 AGENTS.md delete mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2d65db9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# CLAUDE.md + +dora is a CLI that converts SCIP indexes into a queryable SQLite database. AI agents use it to answer questions about large codebases without reading files or tracing imports manually. + +## Stack + +- Runtime: Bun +- Database: SQLite via `bun:sqlite` +- Language: TypeScript +- Protobuf parsing: `@bufbuild/protobuf` +- AST parsing: `web-tree-sitter` (on-demand, per file) +- Output format: TOON by default (`dora status`), JSON via `--json` (`dora status --json`) + +## Source layout + +``` +src/ +├── commands/ # one file per CLI command +├── converter/ # SCIP protobuf parser + SQLite converter +├── db/ # schema and all SQL queries +├── mcp/ # MCP server, tool definitions, handlers +├── schemas/ # Zod schemas and inferred types +├── tree-sitter/ # grammar discovery, parser, language registry +└── utils/ # config, errors, output formatting +``` + +## Two indexing layers + +**SCIP** — runs the configured indexer (e.g. `scip-typescript`), produces a `.scip` protobuf, converts it to SQLite. Gives you symbols, references, and file-to-file dependencies derived from actual import resolution. + +**Tree-sitter** — parses source files on-demand using wasm grammars. Covers what SCIP doesn't: function signatures, cyclomatic complexity, class hierarchy, code smells. Grammar discovery checks local `node_modules`, then global bun packages, then explicit config paths. + +## Database design + +Denormalized counts (`symbol_count`, `dependency_count`, `dependent_count`, `reference_count`) are pre-computed at index time. Most queries are index lookups, not aggregations. + +Local symbols (function parameters, closure variables) are flagged `is_local = 1` and filtered out by default. Symbol kinds are extracted from SCIP documentation strings since `scip-typescript` doesn't populate the kind field. + +Schema: `src/converter/schema.sql`. All queries: `src/db/queries.ts`. + +## Config file: `.dora/config.json` + +```json +{ + "root": "/absolute/path/to/repo", + "scip": ".dora/index.scip", + "db": ".dora/dora.db", + "commands": { + "index": "scip-typescript index --output .dora/index.scip" + }, + "lastIndexed": "2025-01-15T10:30:00Z", + "ignore": ["test/**", "**/*.generated.ts"], + "treeSitter": { + "grammars": { + "typescript": "/explicit/path/to/tree-sitter-typescript.wasm" + } + } +} +``` + +## Code conventions + +- Single object parameter — never multiple positional params +- No inline comments, no section separators, no file headers +- No `any` — use `unknown` or proper types +- Boolean variables prefixed with `is` or `has` +- Use `type` not `interface` +- No emojis +- Output JSON to stdout, errors to stderr as `{"error": "message"}`, exit 1 on error + +## Adding a tree-sitter language + +1. Create `src/tree-sitter/languages/mylang.ts` — export `functionQueryString`, `classQueryString`, `parseFunctionCaptures`, `parseClassCaptures` +2. Register in `src/tree-sitter/languages/registry.ts` with grammar name and extensions +3. Add tests in `test/tree-sitter/` — see `function-captures.test.ts` as the reference. Tests mock `Parser.QueryCapture[]` objects directly; no wasm or disk I/O needed. + +## Hooks (`.claude/settings.json`) + +- **Stop**: runs `dora index` in the background after each turn +- **SessionStart**: checks index health, prompts to init if missing diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 4d77c2f..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,1447 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -# Build: dora - Code Context CLI for AI Agents - -## Overview - -Build a CLI tool called `dora` using Bun and SQLite. It helps AI agents understand large codebases by parsing SCIP protobuf indexes directly. - -## Tech Stack - -- Runtime: Bun -- Database: SQLite (via bun:sqlite) -- Language: TypeScript -- Protobuf: @bufbuild/protobuf -- Indexer: scip-typescript (external dependency) - -## Documentation Website - -The `docs/` directory contains the documentation website for dora, built with Astro and deployed to https://dora-cli.dev. - -- **Tech Stack:** Astro 5.x, Tailwind CSS 4.x, Cloudflare Workers -- **Pages:** Landing page (with platform/language detection), full documentation, command reference, architecture guide -- **Features:** Dynamic installation instructions for 9+ languages, AI agent integration examples, responsive design -- **Deployment:** Cloudflare Workers via wrangler (`bun run deploy` in docs directory) - -See `docs/CLAUDE.md` for detailed documentation-specific guidance on maintaining and updating the website. - -## MCP Server - -dora includes an MCP (Model Context Protocol) server that exposes all dora commands as tools for AI assistants like Claude Desktop. - -- **Command:** `dora mcp` -- **Implementation:** `src/mcp.ts` - Uses @modelcontextprotocol/sdk -- **Transport:** stdio (foreground process) -- **Tool handlers:** `src/mcp/handlers.ts` - Routes MCP tool calls to dora commands -- **Metadata:** `src/mcp/metadata.ts` - Tool definitions and schemas - -The MCP server runs in the foreground and communicates via stdin/stdout. It cannot be daemonized because MCP requires active stdio communication with the client. - -## Code Style Guidelines - -### Comments - -This codebase follows strict comment guidelines to maintain clean, self-documenting code: - -**Rules:** - -1. **NO inline comments** - Code should be self-explanatory through clear function and variable names -2. **NO section separator comments** - Like `// ===== Section Name =====` or `// Query commands` -3. **NO file header comments** - Like `// dora symbol command` or `// Type definitions for dora CLI` -4. **Use ONLY valid JSDoc** - And only when clearly warranted for complex public APIs -5. **NO emojis** - Keep code and comments professional and emoji-free - -**Valid JSDoc Format:** - -```typescript -/** - * Find shortest path between two files using BFS - */ -export function findPath(from: string, to: string): string[] { - // Implementation -} -``` - -**Examples of Comments to AVOID:** - -```typescript -// BAD - Obvious inline comment -const limit = options.limit || 20; // Default limit is 20 - -// BAD - Section separator -// ===== Status Queries ===== - -// BAD - File header -// dora symbol command - -// BAD - Explaining obvious code -// Get the symbol ID -const symbolId = getSymbolId(name); - -// BAD - Trivial explanation -// Loop through results -for (const result of results) { - // Process each result - processResult(result); -} -``` - -**Examples of Comments to KEEP:** - -```typescript -/** - * GOOD - Valid JSDoc for complex function - * Find tightly coupled file pairs (bidirectional dependencies) - */ -export function getCoupledFiles(threshold: number): CoupledFiles[] { - // Implementation -} - -/** - * GOOD - JSDoc with parameters and return type - * @param db - Database connection - * @param path - File path to analyze - * @returns Array of dependency nodes with depth information - */ -export function getDependencies(db: Database, path: string): DependencyNode[] { - // Implementation -} -``` - -**When Comments ARE Warranted:** - -- Complex algorithms that aren't immediately obvious -- Non-obvious edge cases or workarounds -- Security-related warnings or considerations -- Performance optimizations that aren't self-evident - -**Note:** Generated files (like `scip_pb.ts`) are exempt from these rules. - -### Function Parameters - -Functions with more than one parameter must use a single object parameter instead of multiple positional parameters. - -**Rules:** - -1. **Single parameter functions** - Can use a simple parameter type -2. **Multiple parameters** - Must use a single object parameter with named properties - -**Good:** - -```typescript -// Single parameter - OK -function getSymbol(id: number): Symbol { - // Implementation -} - -// Multiple parameters - use object -function createDocument(params: { - path: string; - type: string; - content: string; - mtime: number; -}): Document { - // Implementation -} - -// Better with type alias -type CreateDocumentParams = { - path: string; - type: string; - content: string; - mtime: number; -}; - -function createDocument(params: CreateDocumentParams): Document { - // Implementation -} -``` - -**Bad:** - -```typescript -// BAD - Multiple positional parameters -function createDocument( - path: string, - type: string, - content: string, - mtime: number, -): Document { - // Implementation -} - -// BAD - Two or more parameters -function batchInsert( - db: Database, - table: string, - columns: string[], - rows: Array>, -): void { - // Implementation -} -``` - -## Directory Structure - -Note: Example scip files in `example` folder -Required tools: - -``` -scip-typescript # For generating SCIP indexes -``` - -``` -dora/ -├── src/ -│ ├── index.ts # CLI entry point (Commander) -│ ├── commands/ -│ │ ├── init.ts # Initialize dora -│ │ ├── index.ts # Reindex command -│ │ ├── status.ts # Show index status -│ │ ├── overview.ts # High-level statistics -│ │ ├── file.ts # File analysis -│ │ ├── symbol.ts # Symbol search -│ │ ├── deps.ts # Dependencies -│ │ ├── rdeps.ts # Reverse dependencies -│ │ ├── path.ts # Find path between files -│ │ ├── changes.ts # Changed/impacted files -│ │ ├── exports.ts # Exported symbols -│ │ ├── imports.ts # File imports -│ │ ├── leaves.ts # Leaf nodes -│ │ ├── refs.ts # Symbol references -│ │ ├── unused.ts # Unused symbols -│ │ ├── hotspots.ts # Most referenced files -│ │ ├── graph.ts # Dependency graph -│ │ ├── ls.ts # List files in directory -│ │ └── shared.ts # Shared utilities -│ ├── converter/ -│ │ ├── convert.ts # SCIP → SQLite converter -│ │ ├── scip-parser.ts # SCIP protobuf parser -│ │ ├── scip_pb.ts # Generated protobuf types -│ │ ├── helpers.ts # Symbol kind mappings -│ │ └── schema.sql # Database schema -│ ├── proto/ -│ │ └── scip.proto # SCIP protobuf schema -│ ├── db/ -│ │ ├── queries.ts # All SQL queries -│ │ └── connection.ts # Database setup -│ └── utils/ -│ ├── config.ts # Config read/write -│ ├── output.ts # JSON formatting -│ ├── errors.ts # Error handling -│ └── changeDetection.ts # Incremental indexing -├── package.json -└── tsconfig.json -``` - -## Config File: .dora/config.json - -```json -{ - "root": "/absolute/path/to/repo", - "scip": ".dora/index.scip", - "db": ".dora/dora.db", - "commands": { - "index": "scip-typescript index --output .dora/index.scip" - }, - "lastIndexed": "2025-01-15T10:30:00Z", - "ignore": ["test/**", "**/*.generated.ts"] -} -``` - -**Optional fields:** - -- `ignore` - Array of glob patterns for files to exclude from indexing (e.g., `["test/**", "**/*.d.ts"]`) - -## Hooks - -This project uses two Claude Code hooks configured in `.claude/settings.json`: - -### Stop hook - -Automatically runs `dora index` in the background after each AI turn to keep the index up-to-date. Logs output to `/tmp/dora-index.log`. Non-blocking and never fails. - -Command: `(dora index > /tmp/dora-index.log 2>&1 &) || true` - -### SessionStart hook - -Checks if dora is initialized when a new session starts. If not initialized, displays a hint to run `dora init && dora index`. - -Command: `dora status 2>/dev/null || echo 'dora not initialized. Run: dora init && dora index'` - -## Database Schema - -The doraCLI uses an optimized SQLite database with denormalized fields for high performance: - -### files - -- `id` - Primary key -- `path` - Relative path from repo root (UNIQUE) -- `language` - Programming language -- `mtime` - File modification time -- `symbol_count` - Number of symbols in file (denormalized) -- `indexed_at` - When file was indexed -- `dependency_count` - Number of outgoing dependencies (denormalized) -- `dependent_count` - Number of incoming dependencies / fan-in (denormalized) - -### symbols - -- `id` - Primary key -- `file_id` - Foreign key to files table -- `name` - Symbol name (e.g., "Logger", "UserContext") -- `scip_symbol` - Full SCIP symbol identifier -- `kind` - Symbol kind extracted from documentation (class, function, interface, property, method, parameter, variable, type, enum, etc.) -- `start_line`, `end_line` - Line range -- `start_char`, `end_char` - Character range -- `documentation` - Symbol documentation/comments -- `package` - Package name if external symbol -- `is_local` - Boolean flag for local symbols (function parameters, closure variables) - filtered by default -- `reference_count` - Number of references to this symbol (denormalized) - -### dependencies - -- `from_file_id` - File that imports -- `to_file_id` - File being imported -- `symbol_count` - Number of symbols used -- `symbols` - JSON array of symbol names used - -### symbol_references - -- `id` - Primary key -- `symbol_id` - Foreign key to symbols table -- `file_id` - File where symbol is referenced -- `line` - Line number of reference - -### packages - -- `id` - Primary key -- `name` - Package name (e.g., "@org/package") -- `manager` - Package manager (npm, yarn, etc.) -- `version` - Package version -- `symbol_count` - Number of symbols from this package - -### metadata - -- `key` - Metadata key -- `value` - Metadata value - -## Commands - -### dora init - -- Detect repo root (find nearest package.json or tsconfig.json) -- Create .dora/ directory -- Add .dora to .gitignore if not present -- Write initial config.json -- Output: success message - -Note: Hooks are configured in .claude/settings.json (Stop and SessionStart) - -### dora index - -- Run: `scip-typescript index --output .dora/index.scip` (if configured) -- Parse SCIP protobuf file directly using @bufbuild/protobuf -- Convert to optimized custom SQLite database -- Support incremental builds (only reindex changed files) -- Update lastIndexed in config -- **Flags:** - - `--full` - Force full rebuild (ignore incremental detection) - - `--skip-scip` - Skip running SCIP indexer, use existing .scip file - - `--ignore ` - Ignore files matching glob pattern (can be repeated) -- Output: file count, symbol count, time taken, mode (full/incremental) - -### dora status - -- Check if .dora/dora.db exists -- Query file count, symbol count -- Show lastIndexed timestamp -- Output: JSON with health info - -### dora map - -Provides high-level statistics about the codebase: packages, file count, and symbol count. - -Queries: - -```sql --- Packages -SELECT name -FROM packages -ORDER BY name; - --- File count -SELECT COUNT(*) as count FROM files; - --- Symbol count -SELECT COUNT(*) as count FROM symbols; -``` - -Output: - -```json -{ - "packages": ["@zomunk/api-worker", ...], - "file_count": 412, - "symbol_count": 58917 -} -``` - -**Note:** `dora map` provides basic statistics only. For detailed code exploration: - -- Use `dora symbol ` to find specific symbols -- Use `dora file ` to explore specific files with dependencies -- Use `dora deps`/`dora rdeps` to understand relationships - -### dora ls [directory] - -List files in a directory from the index with metadata. - -**Arguments:** - -- `[directory]` - Optional directory path to list. Omit to list all files. Uses SQL LIKE pattern matching (`directory/%`). - -**Flags:** - -- `--limit ` - Maximum number of results (default: 100) -- `--sort ` - Sort by: `path`, `symbols`, `deps`, or `rdeps` (default: `path`) - -**Query:** - -```sql -SELECT - f.path, - f.symbol_count as symbols, - f.dependency_count as dependencies, - f.dependent_count as dependents -FROM files f -WHERE f.path LIKE ? -ORDER BY [selected_field] -LIMIT ? -``` - -**Output:** - -```json -{ - "directory": "src/commands", - "files": [ - { - "path": "src/commands/shared.ts", - "symbols": 35, - "dependencies": 7, - "dependents": 18 - } - ], - "total": 27 -} -``` - -**Use Cases:** - -- Browse files in a specific directory: `dora ls src/components` -- Find files with most symbols: `dora ls --sort symbols --limit 10` -- Find files with most dependencies: `dora ls --sort deps --limit 20` -- Find hub files (most dependents): `dora ls --sort rdeps --limit 10` - -### dora file - -Queries: - -```sql --- Symbols in file -SELECT - s.name, - s.kind, - s.start_line, - s.end_line -FROM symbols s -JOIN files f ON f.id = s.file_id -WHERE f.path = ? -ORDER BY s.start_line; - --- Dependencies (files this file imports) -SELECT - f.path as depends_on, - d.symbols as symbols_used -FROM dependencies d -JOIN files f ON f.id = d.to_file_id -WHERE d.from_file_id = (SELECT id FROM files WHERE path = ?) -ORDER BY f.path; - --- Dependents (files that import this file) -SELECT - f.path as dependent, - d.symbol_count as ref_count -FROM dependencies d -JOIN files f ON f.id = d.from_file_id -WHERE d.to_file_id = (SELECT id FROM files WHERE path = ?) -ORDER BY d.symbol_count DESC; -``` - -Output: - -```json -{ - "path": "apps/api-worker/src/context.ts", - "symbols": [ - { - "name": "PlatformContext", - "kind": "type", - "lines": [6, 21] - } - ], - "depends_on": [ - { "path": "packages/app-auth/src/index.ts", "symbols": ["AuthSession"] } - ], - "depended_by": [ - { "path": "apps/api-worker/src/router/billing/router.ts", "refs": 81 } - ] -} -``` - -**Note:** Does NOT include source code. Use Read tool to get file contents. - -### dora symbol - -Search for symbols by name with automatic filtering of local symbols. - -Query: - -```sql -SELECT - s.name, - s.kind, - f.path, - s.start_line, - s.end_line -FROM symbols s -JOIN files f ON f.id = s.file_id -WHERE s.name LIKE '%' || ? || '%' - AND s.is_local = 0 -- Filter out local symbols (parameters, closure vars) -LIMIT ?; -``` - -Flags: --kind, --limit (default: 20) - -Output: - -```json -{ - "query": "Logger", - "results": [ - { - "name": "Logger", - "kind": "interface", - "path": "packages/app-utils/src/logger.ts", - "lines": [7, 11] - } - ] -} -``` - -**Note:** Symbol kinds are automatically extracted from SCIP documentation strings since scip-typescript doesn't populate the kind field. - -### dora deps [--depth N] - -Query: - -```sql -WITH RECURSIVE dep_tree AS ( - -- Base case: start with the target file - SELECT id, path, 0 as depth - FROM files - WHERE path = ? - - UNION - - -- Recursive case: find files that this file depends on - SELECT DISTINCT f.id, f.path, dt.depth + 1 - FROM dep_tree dt - JOIN dependencies d ON d.from_file_id = dt.id - JOIN files f ON f.id = d.to_file_id - WHERE dt.depth < ? -) -SELECT path, MIN(depth) as depth -FROM dep_tree -WHERE depth > 0 -GROUP BY path -ORDER BY depth, path; -``` - -Default depth: 1 - -Output: - -```json -{ - "path": "apps/api-worker/src/context.ts", - "depth": 2, - "dependencies": [ - { "path": "packages/app-auth/src/index.ts", "depth": 1 }, - { "path": "packages/app-db/src/data/index.ts", "depth": 1 }, - { "path": "packages/app-db/src/billing/client.ts", "depth": 2 } - ] -} -``` - -### dora rdeps [--depth N] - -Query: - -```sql -WITH RECURSIVE rdep_tree AS ( - -- Base case: start with the target file - SELECT id, path, 0 as depth - FROM files - WHERE path = ? - - UNION - - -- Recursive case: find files that depend on this file - SELECT DISTINCT f.id, f.path, rt.depth + 1 - FROM rdep_tree rt - JOIN dependencies d ON d.to_file_id = rt.id - JOIN files f ON f.id = d.from_file_id - WHERE rt.depth < ? -) -SELECT path, MIN(depth) as depth -FROM rdep_tree -WHERE depth > 0 -GROUP BY path -ORDER BY depth, path; -``` - -Default depth: 1 - -Output: - -```json -{ - "path": "apps/api-worker/src/context.ts", - "depth": 2, - "dependents": [ - { "path": "apps/api-worker/src/router/billing/router.ts", "depth": 1 }, - { "path": "apps/api-worker/src/index.ts", "depth": 2 } - ] -} -``` - -### dora adventure - -Find shortest path between two files using BFS on the dependency graph. - -Query both deps and rdeps, find intersection. - -Output: - -```json -{ - "from": "apps/api-worker/src/router/billing/router.ts", - "to": "packages/app-utils/src/logger.ts", - "path": [ - "apps/api-worker/src/router/billing/router.ts", - "apps/api-worker/src/context.ts", - "packages/app-utils/src/logger.ts" - ], - "distance": 2 -} -``` - -## Documentation Commands - -### dora docs [--type TYPE] - -List all indexed documentation files. - -**Purpose:** Discover what documentation exists in the project. Useful for AI agents to understand what documentation is available before searching or exploring. - -**Flags:** - -- `--type ` - Filter by document type (md, txt) - -**Query:** - -```sql -SELECT path, type, symbol_count, file_count, document_count -FROM documents -ORDER BY path; -``` - -**Output:** - -```json -{ - "documents": [ - { - "path": "README.md", - "type": "markdown", - "symbol_refs": 12, - "file_refs": 4, - "document_refs": 2 - }, - { - "path": "docs/api.md", - "type": "markdown", - "symbol_refs": 8, - "file_refs": 3, - "document_refs": 0 - } - ], - "total": 2 -} -``` - -**Use Cases:** - -- Discover what documentation files exist in the project -- Filter documentation by type (markdown vs plain text) -- Quick overview of documentation coverage - -**Note:** To find documentation about specific code, use `dora symbol` or `dora file` which include a `documented_in` field showing which docs reference that code. - ---- - -### dora docs search - -Search through all indexed documentation files for specific text content. - -**Purpose:** Full-text search across all documentation. Useful for finding mentions of concepts, keywords, or specific phrases in your project's documentation. - -**Flags:** - -- `--limit ` - Maximum number of results to return (default: 20) - -**Query:** - -```sql -SELECT - d.path, - d.type, - d.symbol_count, - d.file_count -FROM documents d -WHERE d.content LIKE '%' || ? || '%' -ORDER BY d.path -LIMIT ?; -``` - -**Output:** - -```json -{ - "query": "authentication", - "limit": 20, - "results": [ - { - "path": "docs/api.md", - "type": "markdown", - "symbol_refs": 8, - "file_refs": 3 - }, - { - "path": "docs/setup.md", - "type": "markdown", - "symbol_refs": 2, - "file_refs": 1 - } - ], - "total": 2 -} -``` - -**Use Cases:** - -- Finding documentation about a specific topic -- Searching for configuration examples -- Locating documentation that needs updating -- Discovering related documentation across the project - ---- - -### dora docs show - -Display metadata and references for a specific documentation file. - -**Purpose:** Understand what a documentation file covers by showing which symbols and files it references, along with line numbers where references occur. - -**Flags:** - -- `--content` - Include the full document content in the output - -**Queries:** - -```sql --- Get document metadata -SELECT path, type, content -FROM documents -WHERE path = ?; - --- Get symbol references with line numbers -SELECT - s.name, - s.kind, - f.path, - s.start_line, - s.end_line, - dsr.line as ref_line -FROM document_symbol_refs dsr -JOIN symbols s ON s.id = dsr.symbol_id -JOIN files f ON f.id = s.file_id -WHERE dsr.document_id = ? AND s.name != '' -ORDER BY dsr.line; - --- Get file references with line numbers -SELECT - f.path, - dfr.line as ref_line -FROM document_file_refs dfr -JOIN files f ON f.id = dfr.file_id -WHERE dfr.document_id = ? -ORDER BY dfr.line; -``` - -**Output (without --content):** - -```json -{ - "path": "docs/authentication.md", - "type": "markdown", - "symbol_refs": [ - { - "name": "AuthService", - "kind": "class", - "path": "src/auth/service.ts", - "lines": [15, 42], - "ref_line": 23 - }, - { - "name": "validateToken", - "kind": "function", - "path": "src/auth/token.ts", - "lines": [8, 12], - "ref_line": 45 - } - ], - "file_refs": [ - { - "path": "src/auth/config.ts", - "ref_line": 12 - } - ] -} -``` - -**Output (with --content):** - -```json -{ - "path": "docs/authentication.md", - "type": "markdown", - "symbol_refs": [...], - "file_refs": [...], - "content": "# Authentication\n\nThis document describes..." -} -``` - -**Use Cases:** - -- Understanding what code a documentation file covers -- Finding exact line numbers where symbols/files are mentioned -- Verifying documentation accuracy against code -- Reviewing documentation coverage for specific features - ---- - -**What Files Are Indexed:** - -The documentation indexer automatically processes these file types: - -- `.md` - Markdown files -- `.txt` - Plain text documentation - -**Exclusions:** - -- Respects `.gitignore` patterns -- Auto-ignores: `node_modules/`, `.git/`, `.dora/`, `dist/`, `build/`, `coverage/`, `.next/`, `.nuxt/`, `out/`, `*.log` - -**Integration with Other Commands:** - -Documentation references are automatically included in: - -- `dora status` - Shows document count and breakdown by type -- `dora symbol ` - Shows which docs mention the symbol (via `documented_in` field) -- `dora file ` - Shows which docs reference the file (via `documented_in` field) - -## Architecture Analysis Commands - -### dora cycles [--limit N] - -Find bidirectional dependencies (files that import each other). - -**Purpose:** Identify 2-node circular dependencies where A imports B and B imports A. These are the most common and impactful architectural code smells. - -**Note:** This command only detects 2-node cycles. For longer cycles (A → B → C → A), use the `dora query` command with custom SQL. - -**Default:** `--limit 50` - -Query: - -```sql -SELECT - f1.path as path1, - f2.path as path2 -FROM dependencies d1 -JOIN dependencies d2 ON d1.from_file_id = d2.to_file_id - AND d1.to_file_id = d2.from_file_id -JOIN files f1 ON f1.id = d1.from_file_id -JOIN files f2 ON f2.id = d1.to_file_id -WHERE f1.path < f2.path -- avoid duplicates -ORDER BY f1.path, f2.path -LIMIT ?; -``` - -Output: - -```json -{ - "cycles": [ - { - "files": [ - "src/billing.ts", - "src/billing-subscription.ts", - "src/billing.ts" - ], - "length": 2 - } - ] -} -``` - -**Interpretation:** - -- Empty result = No bidirectional dependencies -- Cycles found = Refactor to break the cycle (extract shared types, merge files, or make dependency one-way) - -**Related:** Use `dora coupling` to see how many symbols are shared between bidirectional dependencies. - ---- - -### dora coupling [--threshold N] - -Find tightly coupled file pairs (bidirectional dependencies). - -**Purpose:** Identify files that import symbols from each other, indicating potential for refactoring. - -Query: - -```sql -SELECT - f1.path as file1, - f2.path as file2, - d1.symbol_count as symbols_1_to_2, - d2.symbol_count as symbols_2_to_1, - (d1.symbol_count + d2.symbol_count) as total_coupling -FROM dependencies d1 -JOIN dependencies d2 ON d1.from_file_id = d2.to_file_id - AND d1.to_file_id = d2.from_file_id -JOIN files f1 ON f1.id = d1.from_file_id -JOIN files f2 ON f2.id = d1.to_file_id -WHERE f1.path < f2.path - AND (d1.symbol_count + d2.symbol_count) >= ? -ORDER BY total_coupling DESC; -``` - -Default threshold: 5 - -Output: - -```json -{ - "threshold": 5, - "coupled_files": [ - { - "file1": "src/billing.ts", - "file2": "src/billing-subscription.ts", - "symbols_1_to_2": 2, - "symbols_2_to_1": 1, - "total_coupling": 3 - } - ] -} -``` - -**Interpretation:** - -- Low coupling (< 5) = Files share a few types, normal -- High coupling (> 20) = Consider merging or extracting shared module - ---- - -### dora complexity [--sort metric] - -Show file complexity metrics for refactoring prioritization. - -**Purpose:** Identify files that are risky to change based on size, dependencies, and impact. - -Query: - -```sql -SELECT - f.path, - f.symbol_count, - f.dependency_count as outgoing_deps, - f.dependent_count as incoming_deps, - CAST(f.dependent_count AS FLOAT) / NULLIF(f.dependency_count, 1) as stability_ratio, - (f.symbol_count * f.dependent_count) as complexity_score -FROM files f -ORDER BY [selected_metric] DESC -LIMIT 20; -``` - -Flags: --sort (complexity | symbols | stability) - -**Metrics:** - -- `symbol_count` - Proxy for lines of code -- `outgoing_deps` - Files this file imports from -- `incoming_deps` - Files that import from this file (fan-in) -- `stability_ratio` - incoming / outgoing (high = stable, hard to change) -- `complexity_score` - symbols × incoming deps (high = risky to change) - -Output: - -```json -{ - "sort_by": "complexity", - "files": [ - { - "path": "src/types.ts", - "symbol_count": 180, - "outgoing_deps": 0, - "incoming_deps": 52, - "stability_ratio": null, - "complexity_score": 9360 - } - ] -} -``` - -**Interpretation:** - -- High complexity score (> 5000) = High-impact file, changes affect many files -- High stability ratio (> 5) = Stable interface, expensive to change -- Low incoming deps (< 3) = Good refactoring candidate - ---- - -## Additional Commands - -### dora refs - -Find all references to a symbol across the codebase. - -**Performance:** Uses single optimized query with GROUP_CONCAT (no N+1 queries). - -Output includes definition location and all files that reference the symbol. - ---- - -### dora lost [--limit N] - -Find potentially unused symbols (zero references). - -**Performance:** Uses denormalized `reference_count` field for instant results. - -Query: - -```sql -SELECT s.name, s.kind, f.path, s.start_line, s.end_line -FROM symbols s -JOIN files f ON f.id = s.file_id -WHERE s.is_local = 0 - AND s.reference_count = 0 - AND s.kind NOT IN ('module', 'parameter') -ORDER BY f.path, s.start_line -LIMIT ?; -``` - ---- - -### dora treasure [--limit N] - -Show most referenced files and files with most dependencies. - -**Performance:** Uses denormalized `dependent_count` and `dependency_count` fields. - -Output: - -```json -{ - "most_referenced": [{ "file": "src/types.ts", "count": 52 }], - "most_dependencies": [{ "file": "src/app.tsx", "count": 27 }] -} -``` - ---- - -### dora changes [ref] - -Show files changed since git ref and their impact. - ---- - -### dora exports - -List exported symbols from a file or package. - ---- - -### dora imports - -Show all imports for a file. - ---- - -### dora leaves [--max-dependents N] - -Find leaf nodes (files with few dependents but have dependencies). - -**Default:** `--max-dependents 0` (files with zero dependents) - ---- - -### dora graph [--direction] [--depth] - -Generate dependency graph data. - ---- - -### dora schema - -Show the complete database schema including tables, columns, types, and indexes. - -**Purpose:** Provides the schema needed for AI agents to write custom SQL queries using `dora query`. - -Output: - -```json -{ - "tables": [ - { - "name": "files", - "columns": [ - { - "name": "id", - "type": "INTEGER", - "nullable": true, - "primary_key": true - }, - { - "name": "path", - "type": "TEXT", - "nullable": false, - "primary_key": false - } - ], - "indexes": ["CREATE INDEX idx_files_path ON files(path)"] - } - ] -} -``` - -**Key Tables:** - -- `files` - File metadata (path, language, mtime, symbol_count, dependency_count, dependent_count) -- `symbols` - Symbol definitions (name, kind, file_id, location, is_local, reference_count) -- `dependencies` - File-to-file dependencies (from_file_id, to_file_id, symbol_count, symbols) -- `symbol_references` - Symbol usage tracking (symbol_id, file_id, line) -- `packages` - External packages (name, manager, version, symbol_count) -- `metadata` - System metadata (key-value pairs) - ---- - -### dora cookbook show [recipe] - -Show query pattern cookbook with examples and tips for common SQL patterns. - -**Purpose:** Provides ready-to-use SQL query patterns for AI agents and users who want to explore the database without needing to learn the schema first. All recipes include real examples tested on actual codebases. - -**Flags:** - -- `[recipe]` - Optional recipe name. Omit to see all available recipes. - -**Available Recipes:** - -- `quickstart` - Complete walkthrough exploring a codebase from scratch with real-world workflows -- `methods` - Finding class methods by name, finding all methods in a class, counting method usages -- `references` - Tracking symbol usage, finding most referenced symbols, identifying dead code -- `exports` - Distinguishing exported symbols from internal ones, finding public API functions/types -- `agent-setup` - Setting up dora hooks, extensions, and skills for AI agents (Claude Code, pi, OpenCode, Cursor, Windsurf) - -**Output:** - -```json -{ - "recipe": "quickstart", - "content": "# Dora Quickstart: Exploring a Codebase\n\nA practical walkthrough..." -} -``` - -**Usage:** - -```bash -# Show all available recipes -dora cookbook list - -# Show quickstart guide -dora cookbook show quickstart - -# Show methods recipe -dora cookbook show methods - -# Show references recipe -dora cookbook show references - -# Show exports recipe -dora cookbook show exports -``` - -**Use Cases:** - -- **New to dora?** Start with `dora cookbook quickstart` for a complete walkthrough -- Discovering query patterns for common tasks -- Learning how to use `dora query` effectively -- Finding SQL examples for specific use cases -- Understanding how to query methods, references, and exported symbols - -**Integration with Other Commands:** - -- Use `dora schema` to understand the database structure -- Use `dora query` to execute the SQL patterns shown in recipes -- Copy-paste SQL examples directly from cookbook output into `dora query` - -**Custom Cookbooks:** -You can add your own cookbook recipes by creating markdown files in `.dora/cookbooks/`. Each file becomes a recipe that can be accessed with `dora cookbook show ` (without the .md extension). - -Example: Create `.dora/cookbooks/my-patterns.md` with your custom SQL patterns, then access it with `dora cookbook show my-patterns`. - ---- - -### dora query "" - -Execute arbitrary SQL queries against the database (read-only). - -**Purpose:** Enables ad-hoc analysis and custom queries not covered by built-in commands. AI agents can use this to explore the database and answer complex questions about the codebase. - -**Safety:** Only SELECT queries are allowed. INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, TRUNCATE, and REPLACE operations are blocked. - -**Usage:** - -```bash -# Find files with most symbols -dora query "SELECT path, symbol_count FROM files ORDER BY symbol_count DESC LIMIT 10" - -# Count symbols by kind -dora query "SELECT kind, COUNT(*) as count FROM symbols WHERE is_local = 0 GROUP BY kind ORDER BY count DESC" - -# Find files with bidirectional dependencies -dora query "SELECT f1.path as file1, f2.path as file2 FROM dependencies d1 JOIN dependencies d2 ON d1.from_file_id = d2.to_file_id AND d1.to_file_id = d2.from_file_id JOIN files f1 ON f1.id = d1.from_file_id JOIN files f2 ON f2.id = d1.to_file_id WHERE f1.path < f2.path" - -# Analyze symbol distribution per file -dora query "SELECT f.path, COUNT(s.id) as symbols, AVG(s.reference_count) as avg_refs FROM files f JOIN symbols s ON s.file_id = f.id WHERE s.is_local = 0 GROUP BY f.path ORDER BY symbols DESC LIMIT 20" -``` - -Output: - -```json -{ - "query": "SELECT path, symbol_count FROM files ORDER BY symbol_count DESC LIMIT 5", - "rows": [ - { "path": "src/converter/scip_pb.ts", "symbol_count": 1640 }, - { "path": "src/proto/scip.proto", "symbol_count": 86 } - ], - "row_count": 2, - "columns": ["path", "symbol_count"] -} -``` - -**Tips for AI Agents:** - -- Use `dora schema` first to understand the database structure -- Filter local symbols with `WHERE is_local = 0` for cleaner results -- Use denormalized fields (`reference_count`, `dependent_count`, `dependency_count`) for fast queries -- JOIN tables to correlate symbols, files, and dependencies -- Use GROUP BY and aggregates (COUNT, SUM, AVG) for statistical analysis - -## CLI Entry Point - -The CLI uses the Commander library for argument parsing: - -```typescript -// src/index.ts (simplified) -import { Command } from "commander"; - -const program = new Command(); - -program - .name("dora") - .description("Code Context CLI for AI Agents") - .version("1.0.0"); - -// Setup commands -// init, index, status, overview - -// Query commands -// file, symbol, refs, deps, rdeps, path - -// Analysis commands -// cycles, coupling, complexity, hotspots, unused, leaves - -// Additional commands -// changes, exports, imports, graph - -program.parse(); -``` - -## Output Rules - -1. Always output valid JSON to stdout -2. Errors go to stderr as JSON: `{"error": "message"}` -3. No extra logging or formatting -4. Exit code 0 on success, 1 on error - -## Dependencies - -```json -{ - "name": "dora", - "type": "module", - "bin": { - "dora": "./src/index.ts" - }, - "dependencies": {}, - "devDependencies": { - "@types/bun": "latest", - "typescript": "^5.0.0" - } -} -``` - -## Debug Logging - -The CLI uses the [`debug`](https://www.npmjs.com/package/debug) library for logging. Control logging via the `DEBUG` environment variable: - -```bash -# Show all dora debug output -DEBUG=dora:* dora index - -# Show only converter logs -DEBUG=dora:converter dora index - -# Show multiple namespaces -DEBUG=dora:index,dora:converter dora index - -# Show all debug logs from all libraries -DEBUG=* dora index -``` - -Available namespaces: - -- `dora:index` - Index command logs -- `dora:converter` - SCIP to DB conversion logs -- `dora:db` - Database operation logs -- `dora:config` - Configuration logs - -## Performance Optimizations - -The doraCLI uses several optimization strategies for fast queries on large codebases: - -### 1. Denormalized Fields - -Pre-computed aggregates stored in the database for instant lookups: - -- `files.symbol_count` - Number of symbols per file -- `files.dependency_count` - Outgoing dependencies -- `files.dependent_count` - Incoming dependencies (fan-in) -- `symbols.reference_count` - Number of references to each symbol - -**Impact:** 10-50x faster queries. No expensive COUNT() aggregations at query time. - -### 2. Symbol Kind Extraction - -Since scip-typescript doesn't populate the `kind` field, we extract symbol kinds from documentation strings: - -- Pattern matching on documentation like `"interface Logger"`, `"(property) name: string"` -- Supports: class, interface, type, function, method, property, parameter, variable, enum, etc. -- Stored in indexed `symbols.kind` column for fast filtering - -### 3. Local Symbol Filtering - -Local symbols (function parameters, closure variables) are flagged with `is_local = 1`: - -- Reduces noise in symbol searches -- Indexed boolean column for fast filtering -- ~15-20% of symbols are local and filtered by default - -### 4. Optimized Queries - -- Symbol references: Single JOIN query with GROUP_CONCAT (no N+1 queries) -- Unused symbols: Index lookup on `reference_count = 0` -- Hotspots: Direct lookup on denormalized counts -- Cycles: Recursive CTE with visit tracking - -### 5. Incremental Indexing - -- Only reindex files that changed (based on mtime) -- Full reindex only when forced or on first run -- Denormalized fields updated after each indexing operation - -## Notes - -- The database schema is defined in `src/converter/schema.sql` and created in `src/converter/convert.ts` -- SCIP protobuf files are parsed directly using `@bufbuild/protobuf` -- Source text is NOT in the database - use the Read tool to get file contents -- All paths in the database are relative to repo root -- Symbol strings (SCIP identifiers) look like: `scip-typescript npm @package/name 1.0.0 src/\`file.ts\`/SymbolName#` -- The converter supports both full and incremental builds based on git state and file mtimes -- Database uses optimized schema with denormalized data for fast queries -- Pre-computed dependencies and symbol references for O(1) lookups - -## Typical Workflow for AI Agents - -```bash -# 1. Initialize in a TypeScript project -dora init - -# 2. Index the codebase -dora index - -# 3. Understand the codebase structure -dora map -dora treasure --limit 20 - -# 4. Find specific code -dora symbol AuthService -dora file src/auth/service.ts - -# 5. Analyze architecture -dora cycles # Check for circular dependencies -dora coupling --threshold 5 # Find tightly coupled files -dora complexity --sort complexity # Find high-impact files - -# 6. Impact analysis before changes -dora rdeps src/types.ts --depth 2 # What depends on this? -dora lost --limit 50 # Find dead code - -# 7. Navigate dependencies -dora deps src/app.ts --depth 2 -dora adventure src/component.tsx src/utils.ts - -# 8. Advanced custom queries -dora cookbook show methods # Learn how to query methods -dora query "" # Execute custom SQL queries -``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5e4775..9f18431 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,120 +1,58 @@ # Contributing to dora -Thank you for your interest in contributing to dora! This document provides guidelines and information for developers. +## Development setup -## Getting Started - -### Prerequisites - -- [Bun](https://bun.sh) 1.0+ (for development) -- A SCIP-compatible indexer for your language (e.g., scip-typescript for TypeScript/JavaScript) - -### Development Setup +Requires [Bun](https://bun.sh) 1.0+. ```bash -# Clone repository git clone https://github.com/butttons/dora.git cd dora - -# Install dependencies bun install - -# Run CLI directly with Bun -bun src/index.ts - -# Run tests -bun test - -# Build and test the binary -bun run build -./dist/dora --help - -# Link for local development -bun link +bun link # makes `dora` point to src/index.ts via bun ``` -For detailed architecture and development guidelines, see [CLAUDE.md](./CLAUDE.md). - -## Building - -Build standalone binaries for distribution: +Run any command directly: ```bash -# Build for your current platform -bun run build - -# Build for specific platforms -bun run build:linux # Linux x64 -bun run build:macos # macOS Intel -bun run build:macos-arm # macOS ARM (M1/M2/M3) -bun run build:windows # Windows x64 - -# Build for all platforms -bun run build:all - -# Binaries will be in the dist/ directory +bun src/index.ts status +bun src/index.ts symbol AuthService ``` -Binary sizes: - -- **macOS/Linux**: ~57MB (includes Bun runtime) -- **Windows**: ~58MB (includes Bun runtime) - -The binaries are completely standalone and don't require Bun or Node.js to be installed. - ## Testing -The project includes comprehensive test coverage: - ```bash -# Run all tests -bun test - -# Run specific test files -bun test src/utils/paths.test.ts -bun test src/utils/config.test.ts -bun test src/db/queries.test.ts -bun test src/commands/commands.test.ts -bun test src/commands/index.test.ts -bun test src/converter/scip-parser.test.ts +bun test ./test/ # full suite +bun test test/tree-sitter/ # tree-sitter tests only +bun run type-check # tsc +bun run biome:format # format src/ and test/ ``` -Test coverage: - -- **Unit Tests**: Path utilities, config management, error handling -- **Integration Tests**: Database queries with example database -- **Command Tests**: CLI commands and initialization -- **Parser Tests**: SCIP protobuf parsing and conversion -- **Index Tests**: Database schema and denormalized fields - -## Debug Logging +Tests use real fixture data from `test/fixtures/`. The tree-sitter tests are pure unit tests — they mock `Parser.QueryCapture[]` objects and run against the actual parse functions without loading any wasm grammar. -The CLI uses the [`debug`](https://www.npmjs.com/package/debug) library for verbose logging during development and troubleshooting. Enable debug output using the `DEBUG` environment variable: +## Building ```bash -# Show all dora debug output -DEBUG=dora:* dora index +bun run build # standalone binary for current platform → dist/dora +bun run build:npm # bun-target JS bundle → dist/index.js (used by npm package) +bun run build:all # all platform binaries (linux-x64, darwin-x64, darwin-arm64, windows-x64) +``` -# Show only converter logs (useful for performance debugging) -DEBUG=dora:converter dora index +Binaries are ~57MB on macOS/Linux, ~58MB on Windows. They include the Bun runtime and have no external dependencies. -# Show only index command logs -DEBUG=dora:index dora index +## Debug logging -# Show multiple namespaces -DEBUG=dora:index,dora:converter dora index +```bash +DEBUG=dora:* dora index # all namespaces +DEBUG=dora:converter dora index # SCIP parsing and DB conversion +DEBUG=dora:index dora index # index command only +DEBUG=dora:db dora symbol Foo # database queries ``` -**Available namespaces:** - -- `dora:index` - Index command progress and timing -- `dora:converter` - SCIP parsing and database conversion details -- `dora:db` - Database operations and queries -- `dora:config` - Configuration loading and validation +Available namespaces: `dora:index`, `dora:converter`, `dora:db`, `dora:config`. -**Example output:** +Example output: -```bash +``` $ DEBUG=dora:* dora index dora:index Loading configuration... +0ms dora:index Config loaded: root=/path/to/project +2ms @@ -125,109 +63,74 @@ $ DEBUG=dora:* dora index dora:converter Processing files: 412/412 (100%) +265ms ``` -## Code Style - -- Use TypeScript for all source code -- Follow existing code formatting (we use Bun's default formatter) -- Write descriptive variable and function names -- Add JSDoc comments for public APIs -- Keep functions focused and under 50 lines when possible +## Code conventions -## Pull Request Process +- Single object parameter — never multiple positional params +- No inline comments, no section separators, no file headers +- No `any` — use `unknown` or proper types +- Boolean variables prefixed with `is` or `has` +- Use `type` not `interface` +- Output JSON to stdout, errors to stderr as `{"error": "message"}`, exit 1 on error -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/my-feature`) -3. Make your changes with tests -4. Run the test suite (`bun test`) -5. Build the binaries (`bun run build:all`) -6. Commit your changes (`git commit -am 'Add my feature'`) -7. Push to the branch (`git push origin feature/my-feature`) -8. Create a Pull Request +## Adding a command -### PR Requirements +1. Create `src/commands/mycommand.ts` and export a function +2. Register it in `src/index.ts` +3. Add MCP tool definition in `src/mcp/metadata.ts` and handler in `src/mcp/handlers.ts` +4. Add tests in `test/commands/` -- All tests must pass -- New features should include tests -- Update documentation (README.md, CLAUDE.md) if needed -- Follow the existing code style -- Provide a clear description of the changes +## Adding a tree-sitter language -## Architecture +1. Create `src/tree-sitter/languages/mylang.ts` — export `functionQueryString`, `classQueryString`, `parseFunctionCaptures`, `parseClassCaptures` +2. Register it in `src/tree-sitter/languages/registry.ts` with its grammar name and file extensions +3. Add tests in `test/tree-sitter/mylang-captures.test.ts` — mock `Parser.QueryCapture[]` objects, test all capture variants, deduplication, and edge cases. See `test/tree-sitter/function-captures.test.ts` as the reference implementation. -dora is built around SCIP (Source Code Intelligence Protocol) indexes and SQLite for fast querying: +The grammar wasm file is resolved automatically from local `node_modules`, global bun packages, or an explicit path in `.dora/config.json` under `treeSitter.grammars.`. -### Key Components - -- **src/commands/** - CLI command implementations -- **src/converter/** - SCIP protobuf parser and SQLite converter -- **src/db/** - Database schema and queries -- **src/utils/** - Shared utilities (config, paths, errors) -- **src/types.ts** - TypeScript type definitions - -### Database Schema - -The database uses denormalized fields for performance: - -- **files** - File metadata with symbol/dependency counts -- **symbols** - Symbol definitions with location and kind -- **dependencies** - File-to-file dependencies with symbol lists -- **symbol_references** - Where symbols are used -- **packages** - External package information -- **metadata** - System metadata (last indexed, counts) - -For detailed schema and query patterns, see [CLAUDE.md](./CLAUDE.md). - -## Common Development Tasks - -### Adding a New Command - -1. Create a new file in `src/commands/` (e.g., `mynewcommand.ts`) -2. Implement the command function -3. Export the command in `src/index.ts` -4. Add tests in `src/commands/mynewcommand.test.ts` -5. Update README.md command reference - -### Adding a New Query +## Adding a query 1. Add the query function in `src/db/queries.ts` -2. Add tests in `src/db/queries.test.ts` -3. Use the query in your command +2. Add tests in `test/db/queries.test.ts` +3. Use it in your command -### Modifying the Database Schema +## Modifying the database schema 1. Update `src/converter/schema.sql` 2. Update conversion logic in `src/converter/convert.ts` -3. Increment the schema version if needed -4. Add migration logic if backward compatibility is required +3. Add migration logic if backward compatibility is required -## Troubleshooting Development Issues - -### Tests Failing +## Architecture -- Ensure you have the latest dependencies: `bun install` -- Check that `.dora/dora.db` exists: `dora index` -- Run tests in verbose mode: `DEBUG=* bun test` +``` +src/ +├── commands/ # one file per CLI command +├── converter/ # SCIP protobuf parser + SQLite converter +├── db/ # schema and all SQL queries +├── mcp/ # MCP server, tool definitions, handlers +├── schemas/ # Zod schemas and inferred types +├── tree-sitter/ # grammar discovery, parser, language implementations +└── utils/ # config, errors, output formatting +``` -### Build Issues +Key tables: `files`, `symbols`, `dependencies`, `symbol_references`, `packages`, `documents`, `metadata`. -- Clear the dist directory: `rm -rf dist` -- Reinstall Bun if needed -- Check Bun version: `bun --version` (should be 1.0+) +Denormalized counts (`symbol_count`, `dependency_count`, `dependent_count`, `reference_count`) are updated after every index run and make most queries O(1) lookups. -### Local Development +For detailed schema and query patterns, see [CLAUDE.md](./CLAUDE.md). -- Use `bun link` to link the development version -- Test with `dora --version` to ensure you're using the dev version -- Unlink with `bun unlink` when done +## Troubleshooting -## Questions? +**Tests failing:** run `bun install` to sync deps, ensure `.dora/dora.db` exists (`dora index`). -- Open an issue for bugs or feature requests -- Start a discussion for architecture questions -- Check [CLAUDE.md](./CLAUDE.md) for detailed implementation notes +**Build issues:** clear `dist/` and retry. Check `bun --version` is 1.0+. -> `dora` is intentionally minimal. Before proposing new features, consider if the problem can be solved with existing commands or `dora query` +**Local dev:** `bun link` points the `dora` binary at the source. `bun unlink` to restore. -## License +## Pull request checklist -By contributing, you agree that your contributions will be licensed under the MIT License. +- `bun test ./test/` passes +- `bun run type-check` passes +- `bun run biome:format` applied +- New commands have tests +- New tree-sitter languages have capture tests +- [CLAUDE.md](./CLAUDE.md) updated if architecture changed diff --git a/README.md b/README.md index f0b59d9..ed9dd02 100644 --- a/README.md +++ b/README.md @@ -1,584 +1,177 @@ -# dora - Code Context CLI for AI Agents +# dora -Stop wasting tokens on grep/find/glob. Give your AI agent fast, structured code intelligence. +A CLI that turns a SCIP index into a queryable SQLite database. Gives AI agents structured answers about your codebase instead of making them grep files and read imports. -## Features +## Why -- **Instant answers** - Pre-computed aggregates mean no waiting for grep/find/glob to finish or tokens wasted on file reads -- **Understand relationships** - See what depends on what without reading import statements or parsing code -- **Find issues fast** - Detect circular dependencies, coupling, and complexity hotspots with pre-indexed data -- **Track usage** - Know where every symbol is used across your codebase in milliseconds, not minutes -- **Language-agnostic** - Works with any SCIP-compatible indexer (TypeScript, Java, Rust, Python, etc.) +When an AI agent needs to understand code, it typically reads files, searches for patterns, and traces imports manually. This is slow, burns tokens, and doesn't scale past a few hundred files. -## See It In Action +dora pre-indexes your codebase into SQLite. Questions like "what depends on this file?", "where is this symbol used?", and "which files are most coupled?" become millisecond queries instead of multi-step explorations. -### Typical Workflow Without dora +## Setup -![Baseline CLI workflow showing grep/find approach](docs/public/baseline-cli.gif) +### Install -### With dora CLI - -![dora CLI workflow showing fast structured queries](docs/public/dora-cli.gif) - -## System Requirements - -- **Binary users**: No dependencies - standalone executable -- **From source**: Bun 1.0+ required -- **SCIP indexer**: Language-specific (e.g., scip-typescript for TS/JS) -- **Supported OS**: macOS, Linux, Windows -- **Disk space**: ~5-50MB for index (varies by codebase size) - -## Installation - -### Option 1: Download Pre-built Binary (Recommended) - -Download the latest binary for your platform from the [releases page](https://github.com/butttons/dora/releases): +Download the latest binary from the [releases page](https://github.com/butttons/dora/releases): ```bash -# macOS (ARM64) +# macOS (ARM) curl -L https://github.com/butttons/dora/releases/latest/download/dora-darwin-arm64 -o dora -chmod +x dora -sudo mv dora /usr/local/bin/ +chmod +x dora && sudo mv dora /usr/local/bin/ # macOS (Intel) curl -L https://github.com/butttons/dora/releases/latest/download/dora-darwin-x64 -o dora -chmod +x dora -sudo mv dora /usr/local/bin/ +chmod +x dora && sudo mv dora /usr/local/bin/ # Linux curl -L https://github.com/butttons/dora/releases/latest/download/dora-linux-x64 -o dora -chmod +x dora -sudo mv dora /usr/local/bin/ - -# Windows -# Download dora-windows-x64.exe and add to PATH +chmod +x dora && sudo mv dora /usr/local/bin/ ``` -### Option 2: Install via npm - -Requires [Bun](https://bun.sh) runtime installed. +Or via npm (requires [Bun](https://bun.sh)): ```bash bun install -g @butttons/dora ``` -Or run without installing: - -```bash -bunx @butttons/dora -``` - -### Option 3: Build from Source - -```bash -# Install Bun (if not already installed) -curl -fsSL https://bun.sh/install | bash - -# Clone the repository -git clone https://github.com/butttons/dora.git -cd dora - -# Install dependencies -bun install - -# Build the binary -bun run build - -# The binary will be at dist/dora -# Move it to your PATH -sudo mv dist/dora /usr/local/bin/ -``` - -### Install SCIP Indexer +### Install a SCIP indexer -You'll need a SCIP indexer for your language. For TypeScript/JavaScript: +dora needs a SCIP index to work. Install one for your language: ```bash -# Install scip-typescript globally +# TypeScript / JavaScript npm install -g @sourcegraph/scip-typescript - -# Verify installation -scip-typescript --help ``` -For other languages, see [SCIP Indexers](#scip-indexers). - -## AI Agent Integration - -**→ See [AGENTS.README.md](AGENTS.README.md) for complete integration guides** for: +Other languages: [scip-java](https://github.com/sourcegraph/scip-java), [rust-analyzer](https://github.com/rust-lang/rust-analyzer), [scip-python](https://github.com/sourcegraph/scip-python), [scip-ruby](https://github.com/sourcegraph/scip-ruby), [scip-clang](https://github.com/sourcegraph/scip-clang), [scip-dotnet](https://github.com/sourcegraph/scip-dotnet), [scip-dart](https://github.com/Workiva/scip-dart). -- **Claude Code** - Skills, hooks, auto-indexing -- **OpenCode** - Agent system integration -- **Cursor** - Custom commands and rules -- **Windsurf** - Skills, AGENTS.md, and rules -- **Other AI agents** - Generic integration using SKILL.md and SNIPPET.md - -Quick start for any agent: - -```bash -dora init && dora index # Initialize and index your codebase -dora cookbook show agent-setup --format markdown # Get setup instructions for your agent -dora status # Verify index is ready -``` - -## Claude Code Integration - -dora integrates with Claude Code via settings and optional skill configuration. Just add these files to your project: - -**1. Add to `.claude/settings.json`** (enables auto-indexing and permissions): - -```json -{ - "permissions": { - "allow": ["Bash(dora:*)", "Skill(dora)"] - }, - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "dora status 2>/dev/null && (dora index > /tmp/dora-index.log 2>&1 &) || echo 'dora not initialized. Run: dora init && dora index'" - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "(dora index > /tmp/dora-index.log 2>&1 &) || true" - } - ] - } - ] - } -} -``` - -**2. (Optional) Add the dora skill** at `.claude/skills/dora/SKILL.md`: - -After running `dora init`, create a symlink: - -```bash -mkdir -p .claude/skills/dora -ln -s ../../../.dora/docs/SKILL.md .claude/skills/dora/SKILL.md -``` - -This enables the `/dora` command in Claude Code. [View the skill file](https://github.com/butttons/dora/blob/main/src/templates/docs/SKILL.md). - -**3. Add to CLAUDE.md** (after running `dora init`): - -```bash -cat .dora/docs/SNIPPET.md >> CLAUDE.md -``` - -This gives Claude quick access to dora commands and guidance on when to use dora for code exploration. The snippet includes command reference and best practices. - -**4. Initialize dora:** +### Initialize ```bash +cd your-project dora init dora index ``` -**What this gives you:** - -- Auto-indexing after each Claude turn -- Pre-approved permissions (no prompts for dora commands) -- Session startup checks -- CLAUDE.md context for better code exploration - -**Troubleshooting:** - -- **Index not updating?** Check `/tmp/dora-index.log` for errors -- **dora not found?** Ensure dora is in PATH: `which dora` - -## MCP Server +`dora init` creates `.dora/config.json`. `dora index` runs the SCIP indexer and converts the output to SQLite. -dora can run as an MCP (Model Context Protocol) server. +## Usage -### Quick Start +### Exploring a codebase ```bash -# Start MCP server (runs in foreground) -dora mcp +dora map # file count, symbol count, packages +dora ls src/ # files in a directory with symbol/dep counts +dora status # index health and grammar availability ``` -### Claude Code - -Add the MCP server with one command: +### Finding code ```bash -claude mcp add --transport stdio dora -- dora mcp +dora symbol AuthService # find symbols by name +dora file src/auth/service.ts # symbols, dependencies, dependents for a file +dora refs validateToken # all references to a symbol across the codebase ``` -### Other MCP Clients - -Add to your MCP client configuration: - -```json -{ - "mcpServers": { - "dora": { - "type": "stdio", - "command": "dora", - "args": ["mcp"] - } - } -} -``` - -### What You Get - -All dora commands are available as MCP tools: - -- `dora_status` - Check index health -- `dora_map` - Get codebase overview -- `dora_symbol` - Search for symbols -- `dora_file` - Analyze files with dependencies -- `dora_deps` / `dora_rdeps` - Explore dependencies -- And all other dora commands - -## Quick Start - -### 1. Initialize +### Understanding dependencies ```bash -dora init -``` - -This creates a `.dora/` directory with a default config. - -### 2. Configure Commands - -Edit `.dora/config.json` to configure your SCIP indexer: - -**For TypeScript/JavaScript:** - -```json -{ - "commands": { - "index": "scip-typescript index --output .dora/index.scip" - } -} +dora deps src/auth/service.ts --depth 2 # what this file imports +dora rdeps src/auth/service.ts --depth 2 # what imports this file +dora adventure src/a.ts src/b.ts # shortest path between two files ``` -**For Rust:** - -```json -{ - "commands": { - "index": "rust-analyzer scip . --output .dora/index.scip" - } -} -``` +### Tree-sitter analysis (TypeScript / JavaScript) -### 3. Index Your Codebase +These commands parse source directly without needing an index: ```bash -# If commands are configured: -dora index - -# Or manually: -scip-typescript index --output .dora/index.scip +dora fn src/auth/service.ts # functions with complexity, params, return type, LOC +dora class src/auth/service.ts # classes with methods, implements, decorators +dora smells src/auth/service.ts # high complexity, long functions, too many params, TODOs ``` -### 4. Try It Out +Install a grammar to enable these: ```bash -# Check index status -dora status - -# Get codebase overview -dora map - -# Find a symbol -dora symbol Logger +bun add -g tree-sitter-typescript ``` -### 5. Example Workflow +### Architecture ```bash -# Find a class definition -dora symbol AuthService - -# Explore the file -dora file src/auth/service.ts - -# See what depends on it -dora rdeps src/auth/service.ts --depth 2 - -# Check for circular dependencies -dora cycles +dora cycles # bidirectional dependencies (A imports B, B imports A) +dora coupling --threshold 5 # file pairs with high symbol sharing +dora complexity --sort complexity # files ranked by change risk +dora treasure # most imported files +dora lost # symbols with zero references ``` -### 6. Learn Custom Queries - -New to dora? The cookbook has recipes with real examples: +### Documentation ```bash -# Start here - complete walkthrough -dora cookbook show quickstart - -# Find class methods -dora cookbook show methods - -# Track symbol references -dora cookbook show references - -# Find exported APIs -dora cookbook show exports +dora docs # list indexed markdown/text files +dora docs search "authentication" # full-text search across docs +dora docs show docs/api.md # which symbols and files a doc references ``` -All recipes include tested SQL patterns from real codebases. - -## Commands Overview - -### Setup & Status +### Custom queries ```bash -dora init # Initialize in repo -dora index # Index codebase -dora status # Show index health -dora map # High-level statistics +dora schema # database schema +dora cookbook show quickstart # walkthrough with real SQL examples +dora cookbook show methods # query patterns for finding methods +dora query "SELECT path, symbol_count FROM files ORDER BY symbol_count DESC LIMIT 10" ``` -### Code Navigation +### MCP server ```bash -dora ls [directory] # List files in directory with metadata -dora symbol # Find symbols by name -dora file # File info with dependencies -dora refs # Find all references -dora deps --depth 2 # Show dependencies -dora rdeps --depth 2 # Show dependents -dora adventure # Find shortest path -``` - -### Documentation +dora mcp # start MCP server (stdio) -```bash -dora docs # List all documentation files -dora docs --type md # Filter by document type -dora docs search # Search documentation content -dora docs show # Show document details +# Claude Code +claude mcp add --transport stdio dora -- dora mcp ``` -### Architecture Analysis - -```bash -dora cycles # Find bidirectional dependencies -dora coupling --threshold 5 # Find tightly coupled files -dora complexity --sort complexity # High-impact files -dora treasure --limit 20 # Most referenced files -dora lost --limit 50 # Potentially dead code -dora leaves --max-dependents 3 # Leaf nodes -``` +### Output format -### Advanced Queries +All commands output [TOON](https://github.com/toon-format/toon) by default — a compact JSON encoding optimized for LLM token usage. Pass `--json` for standard JSON. ```bash -dora schema # Show database schema -dora cookbook show [recipe] # Show query pattern examples -dora query "" # Execute raw SQL (read-only) -dora changes # Changed/impacted files -dora exports # List exports -dora imports # Show imports -dora graph # Dependency graph +dora status --json ``` -## Command Reference - -Quick reference for all commands with common flags: - -### Setup Commands +## AI agent integration -| Command | Description | Common Flags | -| ------------- | ----------------------------- | --------------------------------------------- | -| `dora init` | Initialize dora in repository | - | -| `dora index` | Build/update index | `--full`, `--skip-scip`, `--ignore ` | -| `dora status` | Check index status | - | -| `dora map` | High-level statistics | - | - -### Code Navigation - -| Command | Description | Common Flags | -| ---------------------------- | ------------------------------ | ----------------------------- | -| `dora ls [directory]` | List files in directory | `--limit N`, `--sort ` | -| `dora file ` | Analyze file with dependencies | - | -| `dora symbol ` | Search for symbols | `--kind `, `--limit N` | -| `dora refs ` | Find all references | - | -| `dora deps ` | Show dependencies | `--depth N` (default: 1) | -| `dora rdeps ` | Show reverse dependencies | `--depth N` (default: 1) | -| `dora adventure ` | Find dependency path | - | - -### Documentation - -| Command | Description | Common Flags | -| -------------------------- | ---------------------------- | ---------------------------------- | -| `dora docs` | List all documentation files | `--type ` (md, txt) | -| `dora docs search ` | Search documentation content | `--limit N` (default: 20) | -| `dora docs show ` | Show document metadata | `--content` (include full content) | - -### Architecture Analysis - -| Command | Description | Common Flags | -| ----------------- | ------------------------------- | ---------------------------- | -| `dora cycles` | Find bidirectional dependencies | `--limit N` (default: 50) | -| `dora coupling` | Find tightly coupled files | `--threshold N` (default: 5) | -| `dora complexity` | Show complexity metrics | `--sort ` | -| `dora treasure` | Most referenced files | `--limit N` (default: 10) | -| `dora lost` | Find unused symbols | `--limit N` (default: 50) | -| `dora leaves` | Find leaf nodes | `--max-dependents N` | - -### Advanced Commands - -| Command | Description | Common Flags | -| ----------------------------- | --------------------------- | ------------------------------------------ | -| `dora schema` | Show database schema | - | -| `dora cookbook show [recipe]` | Query pattern cookbook | `quickstart`, `methods`, `refs`, `exports` | -| `dora query ""` | Execute raw SQL (read-only) | - | -| `dora changes ` | Git impact analysis | - | -| `dora exports ` | List exported symbols | - | -| `dora imports ` | Show file imports | - | -| `dora graph ` | Dependency graph | `--depth N`, `--direction` | - -## SCIP Indexers - -- [scip-typescript](https://github.com/sourcegraph/scip-typescript): TypeScript, JavaScript -- [scip-java](https://github.com/sourcegraph/scip-java): Java, Scala, Kotlin -- [rust-analyzer](https://github.com/rust-lang/rust-analyzer): Rust -- [scip-clang](https://github.com/sourcegraph/scip-clang): C++, C -- [scip-ruby](https://github.com/sourcegraph/scip-ruby): Ruby -- [scip-python](https://github.com/sourcegraph/scip-python): Python -- [scip-dotnet](https://github.com/sourcegraph/scip-dotnet): C#, Visual Basic -- [scip-dart](https://github.com/Workiva/scip-dart): Dart -- [scip-php](https://github.com/davidrjenni/scip-php): PHP - -## Output Format - -All commands output [TOON](https://github.com/toon-format/toon) (Token-Oriented Object Notation) by default. TOON is a compact, human-readable encoding of JSON that minimizes tokens for LLM consumption. Pass `--json` to any command for JSON output. +For agent-specific setup (hooks, skills, AGENTS.md snippets) for Claude Code, OpenCode, Cursor, and Windsurf: ```bash -# Default: TOON output -dora status - -# JSON output -dora --json status -dora status --json +dora cookbook show agent-setup ``` -Errors always go to stderr as JSON with exit code 1. - -### TOON vs JSON size comparison - -Measured on dora's own codebase (79 files, 3167 symbols): - -| Command | JSON | TOON | Savings | -|---|---|---|---| -| `status` | 206 B | 176 B | 15% | -| `map` | 68 B | 62 B | 9% | -| `ls src/commands` | 2,258 B | 975 B | **57%** | -| `ls` (all files) | 6,324 B | 2,644 B | **58%** | -| `file src/index.ts` | 6,486 B | 6,799 B | -5% | -| `symbol setupCommand` | 130 B | 130 B | 0% | -| `refs wrapCommand` | 510 B | 549 B | -8% | -| `deps (depth 2)` | 2,158 B | 1,332 B | **38%** | -| `rdeps (depth 2)` | 1,254 B | 802 B | **36%** | -| `adventure` | 110 B | 97 B | 12% | -| `leaves` | 142 B | 129 B | 9% | -| `exports` | 488 B | 511 B | -5% | -| `imports` | 1,978 B | 1,998 B | -1% | -| `lost` | 1,876 B | 1,987 B | -6% | -| `treasure` | 893 B | 577 B | **35%** | -| `cycles` | 14 B | 11 B | 21% | -| `coupling` | 35 B | 31 B | 11% | -| `complexity` | 2,716 B | 932 B | **66%** | -| `schema` | 6,267 B | 4,389 B | **30%** | -| `query` | 692 B | 464 B | **33%** | -| `docs` | 1,840 B | 745 B | **60%** | -| `docs search` | 277 B | 171 B | **38%** | -| `docs show` | 820 B | 870 B | -6% | -| `graph` | 2,434 B | 1,894 B | **22%** | -| `changes` | 1,112 B | 1,026 B | 8% | - -Commands with uniform arrays of objects (ls, complexity, docs, treasure) see 35-66% reduction. Nested or non-uniform outputs (file, refs, exports) are roughly equal or slightly larger. - -## Debug Logging - -For debug logging, testing, building, and development instructions, see [CONTRIBUTING.md](./CONTRIBUTING.md). - -## Troubleshooting - -### Common Issues +Or see [AGENTS.README.md](./AGENTS.README.md). -| Issue | Solution | -| -------------------------- | ------------------------------------------------------------ | -| **Database not found** | Run `dora index` to create the database | -| **File not in index** | Check if file is in .gitignore, run `dora index` | -| **Stale results** | Run `dora index` to rebuild | -| **Slow queries** | Use `--depth 1` when possible, reduce `--limit` | -| **Symbol not found** | Ensure index is up to date: `dora status`, then `dora index` | -| **dora command not found** | Ensure dora is in PATH: `which dora`, reinstall if needed | +## How it works -### Integration Issues +dora has two layers: -**Claude Code index not updating:** +**SCIP layer** — runs your configured indexer (e.g. `scip-typescript`) to produce a `.scip` protobuf file, then parses it and loads it into SQLite. This gives you symbol definitions, references, and file-to-file dependencies derived from actual import resolution. -- Check `/tmp/dora-index.log` for errors -- Verify dora is in PATH: `which dora` -- Test manually: `dora index` -- Ensure `dora index` is in the `allow` permissions list in `.claude/settings.json` +**Tree-sitter layer** — parses source files directly using WebAssembly grammars. This runs on-demand per file and extracts things SCIP doesn't cover: function signatures, cyclomatic complexity, class hierarchy details, and code smells. -**Stop hook not firing:** +The SQLite schema uses denormalized counts (`symbol_count`, `dependency_count`, `dependent_count`, `reference_count`) so most queries are index lookups rather than aggregations. -- Verify `.claude/settings.json` syntax is correct (valid JSON) -- Check that the hook runs by viewing verbose logs -- Try manually running the hook command - -**Want to see indexing progress:** - -- Edit `.claude/settings.json` Stop hook -- Change command to: `"DEBUG=dora:* dora index 2>&1 || true"` (removes background `&`) -- You'll see progress after each turn, but will wait 15-30s - -### Performance Issues - -**Index takes too long:** - -- Run SCIP indexer separately if it supports caching -- Use background indexing mode in Claude Code integration -- Check if your SCIP indexer can be optimized - -**Queries are slow:** - -- Use `--depth 1` instead of deep traversals -- Reduce `--limit` for large result sets -- Ensure database indexes are created (automatic) -- Run `dora index` if database is corrupted +``` +.dora/ +├── config.json # indexer command, ignore patterns, grammar paths +├── index.scip # raw SCIP protobuf output +└── dora.db # SQLite database (the thing dora actually queries) +``` ## Contributing -Contributions are welcome! For development setup, testing, building binaries, and code style guidelines, see [CONTRIBUTING.md](./CONTRIBUTING.md). - -Quick start: - -1. Fork the repository -2. Create a feature branch -3. Make your changes with tests (`bun test`) -4. Submit a pull request - -For detailed architecture and development guidelines, see [CLAUDE.md](./CLAUDE.md). +See [CONTRIBUTING.md](./CONTRIBUTING.md). ## License MIT - -## Links - -- **AI Agent Integration**: [AGENTS.README.md](./AGENTS.README.md) - Integration guides for Claude Code, OpenCode, Cursor, Windsurf -- **GitHub**: [https://github.com/butttons/dora](https://github.com/butttons/dora) -- **SCIP Protocol**: [https://github.com/sourcegraph/scip](https://github.com/sourcegraph/scip) -- **Claude Code**: [https://claude.ai/code](https://claude.ai/code) From 6912afd10be96dde3790abbd3d881e7898fd25f8 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 4 Mar 2026 10:20:20 +0700 Subject: [PATCH 18/31] docs(website): add tree-sitter commands, fix output format, update AGENTS.md --- docs/AGENTS.md | 42 +++ docs/CLAUDE.md | 550 ---------------------------------- docs/src/pages/commands.astro | 19 +- docs/src/pages/docs.astro | 14 +- 4 files changed, 69 insertions(+), 556 deletions(-) create mode 100644 docs/AGENTS.md delete mode 100644 docs/CLAUDE.md diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..b2a5969 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,42 @@ +# docs/AGENTS.md + +Documentation website for dora CLI at https://dora-cli.dev. Built with Astro, deployed to Cloudflare Workers. + +**For dora CLI context, commands, and schema, see `../AGENTS.md`.** + +## Stack + +- Framework: Astro 5.x +- Styling: Tailwind CSS 4.x via `@tailwindcss/vite` +- Icons: lucide-astro +- Deployment: Cloudflare Workers via `@astrojs/cloudflare` + +## Structure + +``` +src/ +├── pages/ +│ ├── index.astro # Landing page +│ ├── docs.astro # Full documentation +│ ├── commands.astro # Command reference +│ └── og-image.astro # OG image (SSR) +├── components/ # Shared components +└── layouts/ + └── Layout.astro # Base layout, nav, footer +``` + +## Dev + +```bash +bun run dev # http://localhost:4321 +bun run build # production build → dist/ +bun run deploy # deploy to Cloudflare Workers +``` + +## Keeping content in sync + +When adding or changing dora CLI commands, update `commands.astro` to match. The command reference should mirror what `dora --help` outputs. + +## Styling + +Dark theme: `zinc-950` page background, `zinc-900` cards, `zinc-800` borders. Primary: `blue-400/500`. Body text: `zinc-300`. diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md deleted file mode 100644 index b09174f..0000000 --- a/docs/CLAUDE.md +++ /dev/null @@ -1,550 +0,0 @@ -# docs/CLAUDE.md - -This file provides guidance for maintaining and updating the dora CLI documentation website. - -**Parent Context:** For dora CLI tool context, commands, and database schema, see `../CLAUDE.md` in the project root. - -## Purpose - -The `docs/` directory contains the documentation website for dora CLI at https://dora-cli.dev. This is a static site built with Astro that provides: - -- Landing page with quick start instructions -- Comprehensive documentation for users -- Command reference guide -- Architecture and design philosophy -- AI agent integration examples - -## Tech Stack - -- **Framework:** Astro 5.x (static site generator) -- **Styling:** Tailwind CSS 4.x with `@tailwindcss/vite` plugin -- **Icons:** lucide-astro (Terminal, Zap, Database, etc.) -- **Font:** DM Sans (loaded from Google Fonts) -- **Deployment:** Cloudflare Workers via @astrojs/cloudflare adapter -- **Build Tool:** Bun (package manager and runtime) - -## Project Structure - -``` -docs/ -├── src/ -│ ├── pages/ -│ │ ├── index.astro # Landing page (hero, features, quick start) -│ │ ├── docs.astro # Full documentation page -│ │ ├── commands.astro # Command reference -│ │ ├── architecture.astro # Design philosophy & use cases -│ │ └── og-image.astro # Open Graph image generator -│ └── layouts/ -│ └── Layout.astro # Base layout (head tags, nav, footer) -├── public/ # Static assets -├── package.json -├── tsconfig.json -├── tailwind.config.js # Tailwind CSS 4.x config -└── astro.config.mjs # Astro + Cloudflare config -``` - -## Pages Overview - -### 1. Landing Page (`src/pages/index.astro`) - -The main entry point with several key sections: - -**Hero Section:** -- Large "dora" title with Terminal icon -- Tagline: "the explorer for your codebase" -- Two CTAs: "Quick Start" and "Documentation" - -**AI Agent Comparison:** -- Side-by-side comparison of typical AI workflows vs. dora commands -- Examples: finding classes, analyzing dependencies, understanding architecture -- Uses monospace font and color-coded boxes (zinc-900 vs. blue-900) - -**Feature Cards:** -- Grid layout with icon, title, and description -- Features: Fast queries, dependency tracking, symbol search, etc. -- Icons from lucide-astro (Database, Network, Search, etc.) - -**Quick Start Section:** -- Dynamic platform detection (macOS/Linux/Windows) -- Language selector for SCIP indexer installation (9 languages) -- Installation command generation based on selections -- Step-by-step workflow examples - -**Platform/Language Detection Logic:** -The landing page includes client-side JavaScript for dynamic installation instructions: - -```javascript -// Platform detection (macOS, Linux, Windows) -const platform = navigator.platform.includes('Mac') ? 'macos' - : navigator.platform.includes('Win') ? 'windows' - : 'linux'; - -// Language selector (TypeScript, JavaScript, Go, etc.) -const languages = ['typescript', 'javascript', 'go', 'rust', 'java', - 'python', 'ruby', 'c', 'cpp']; -``` - -### 2. Documentation Page (`src/pages/docs.astro`) - -Comprehensive user guide with sections: - -- **What is dora?** - Overview and purpose -- **Installation** - Platform-specific instructions -- **Quick Start** - Initialize, index, basic queries -- **Core Concepts** - SCIP indexes, symbols, dependencies -- **Command Overview** - Brief description of all command categories -- **AI Agent Integration** - How AI agents should use dora -- **Common Workflows** - Real-world usage patterns -- **Troubleshooting** - FAQ and common issues - -**Styling Pattern:** -- Prose sections with `text-zinc-300` for readability -- Code blocks with `bg-zinc-900 border-zinc-800` background -- Headers with gradient text (`text-blue-400`) -- Links with `text-blue-400 hover:text-blue-300` transitions - -### 3. Command Reference (`src/pages/commands.astro`) - -Organized into 5 command categories: - -1. **Setup & Status** - init, index, status, map, ls -2. **Query Commands** - file, symbol, refs, deps, rdeps, adventure -3. **Analysis Commands** - cycles, coupling, complexity, treasure, lost, leaves -4. **Graph & Export** - graph, exports, imports -5. **Advanced** - schema, query, changes - -**Command Card Structure:** -```astro -
-

- dora command [args] -

-

Description of what it does

-
- Example usage -
-
-``` - -### 4. Architecture Page (`src/pages/architecture.astro`) - -Explains the design philosophy and technical implementation: - -**Sections:** -- **Design Philosophy** - Why dora exists, goals, principles -- **How It Works** - SCIP indexing, protobuf parsing, SQLite storage -- **Performance** - Denormalized fields, incremental indexing -- **Use Cases** - AI agents, code exploration, refactoring, onboarding - -**Visual Hierarchy:** -- Large section headers with bottom borders -- Subsections with icon bullets -- Code examples with syntax highlighting -- Callout boxes for important notes - -### 5. OG Image (`src/pages/og-image.astro`) - -Generates Open Graph images for social media sharing: -- Simple text-based design with "dora" title -- Blue gradient background matching brand colors -- Used in meta tags for link previews - -## Styling Guidelines - -### Color Scheme - -**Primary Colors:** -- Blue: `blue-500` (primary actions), `blue-400` (text/links), `blue-300` (hover) -- Background: `zinc-950` (page bg), `zinc-900` (cards), `zinc-800` (borders) -- Text: `zinc-300` (body), `zinc-400` (muted), `zinc-500` (disabled) - -**Usage:** -```astro - - -``` - -**Feature Card:** -```astro -
- -

Fast Queries

-

Description text

-
-``` - -**Code Comparison (Before/After):** -```astro -
-
-
# Without dora
-
grep -rn "class Logger"
-
-
-
# With dora
-
dora symbol Logger
-
-
-``` - -## Dynamic Features - -### Platform Detection - -The landing page detects the user's OS to show appropriate installation instructions: - -**Implementation:** -```javascript -// In index.astro