From 8eed24327c8d9039a208706e98cb9cf78549e3fd Mon Sep 17 00:00:00 2001 From: andreinknv Date: Thu, 7 May 2026 21:08:55 -0400 Subject: [PATCH 01/83] feat(extraction): instantiates + decorates graph edges (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(extraction): instantiates + decorates graph edges Two new structural edges that fill gaps in the call graph for modern JS/TS / Java / C# / Python / Kotlin codebases. 1) `instantiates` edges from `new Foo(...)`: The bulk-extraction and visitFunctionBody dispatchers only recognised `call_expression`; `new_expression` (and the equivalent `object_creation_expression` / `instance_creation_expression` in other grammars) was silently ignored. Adds INSTANTIATION_KINDS, extractInstantiation(), and dispatch from BOTH the top-level visitNode and the per-function-body walker. Children are still descended so nested calls inside constructor args (`new Foo(bar())`) get their own `calls` refs. Output: a `bootstrap` function that does `new UserService(); new UserController(svc)` now produces two `instantiates` edges to those class nodes — previously zero edges. 2) `decorates` edges from `@Decorator` annotations: Tree-sitter places decorator nodes BEFORE the symbol they apply to in the AST, so the original walk-time dispatch saw the wrong nodeStack head (file/class instead of class/method). Replaced with extractDecoratorsFor(declNode, decoratedId) that runs from inside extractClass / extractFunction / extractMethod after the symbol's node id is known. Looks for decorator nodes in two places: - Direct named children of the declaration (method/property style) - Preceding siblings in the parent (TypeScript class style: @Foo class X {} parses as parent { decorator, class_decl }) Sibling check uses startIndex comparison rather than reference identity — tree-sitter web bindings return fresh JS wrappers from parent/namedChild navigation, so `===` is unreliable. Took a debug session to spot this; flagging in the comment so the next reader doesn't re-introduce the bug. Output: a `@Controller` class decorator + `@Get` method decorator on a NestJS-style controller now produce two `decorates` edges (class→Controller, method→Get) with the correct source nodes. Verified live on a synthetic NestJS-shape fixture; all 380 existing tests pass. * fix(extraction): address reviewer findings — decorator boundary, generic constructors, property/field decorators, marker_annotation, tests Five fixes from independent semantic review: - extractDecoratorsFor sibling walk now iterates BACKWARD from the declaration and stops at the first non-decorator/annotation separator. Previous version walked forward up to declStart and consumed every decorator-typed sibling — so two adjacent decorated classes (`@A class Foo {} @B class Bar {}`) had `@A` spuriously attributed to `Bar`. - extractInstantiation strips the type-argument suffix from the constructor field text. `new Map()` was producing referenceName 'Map' (the constructor field is a generic_type node) and resolution always failed. - extractProperty and extractField now call extractDecoratorsFor after their createNode calls. NestJS-style `@Inject() private svc: Foo` and Java field annotations were being silently dropped. - consider() in extractDecoratorsFor recognises 'marker_annotation' in addition to 'decorator'/'annotation'. Java's tree-sitter grammar emits marker_annotation for arg-less annotations like @Override and @Deprecated; without this every Java marker annotation was silently skipped. - 6 new extraction tests covering: instantiates ref for new Foo(), generic-type stripping (`new Container()` -> 'Container'), qualified-new keeps trailing identifier (`new ns.Foo()` -> 'Foo'), decorates ref for @Foo class X {}, regression for adjacent decorated classes (each gets its OWN decorator), decorates ref for @Foo method(). Full test suite: 386 passed (was 380, +6 new extraction tests). * feat(resolution): kind-aware scoring + Python instantiation promotion Two follow-ups to the new instantiates/decorates ref kinds, surfaced during review: 1) name-matcher previously only had a kind bonus for `calls` (preferring function/method). When a class and a function share a name across modules, an `instantiates` ref would tie or pick the wrong candidate. Adds: - `instantiates` → +25 for class/struct/interface - `decorates` → +25 for function/method, +15 for class (Python class decorators, Java annotation interfaces) 2) Python (and Ruby) have no `new` keyword — `Foo()` is the standard instantiation syntax, indistinguishable from a function call at extraction time. Resolution can tell the difference once the target is known: when a `calls` ref resolves to a class/struct, promote it to `instantiates`. Mirrors the existing extends→ implements promotion in createEdges. Verified: 386 → 389 passing (+3 tests covering the kind biases and the Python promotion). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Colby McHenry Co-authored-by: Claude Opus 4.7 (1M context) --- __tests__/extraction.test.ts | 98 ++++++++++++++++ __tests__/resolution.test.ts | 104 +++++++++++++++++ src/extraction/tree-sitter.ts | 208 ++++++++++++++++++++++++++++++++- src/resolution/index.ts | 12 ++ src/resolution/name-matcher.ts | 24 ++++ 5 files changed, 444 insertions(+), 2 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 8a70ffed..f9809e53 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -3079,3 +3079,101 @@ describe('Directory Exclusion', () => { expect(files.every((f) => !f.includes('vendor'))).toBe(true); }); }); + +describe('Instantiates + Decorates edge extraction', () => { + it('emits an instantiates ref for `new Foo()`', () => { + const code = ` +class Foo {} +function bootstrap() { return new Foo(); } +`; + const result = extractFromSource('app.ts', code); + const ref = result.unresolvedReferences.find( + (r) => r.referenceKind === 'instantiates' && r.referenceName === 'Foo' + ); + expect(ref).toBeDefined(); + }); + + it('strips type-argument suffix from generic constructors', () => { + const code = ` +class Container { constructor(_: T) {} } +function go() { return new Container('x'); } +`; + const result = extractFromSource('app.ts', code); + const ref = result.unresolvedReferences.find( + (r) => r.referenceKind === 'instantiates' + ); + expect(ref).toBeDefined(); + // Container must be normalised to "Container" — otherwise + // resolution can never match the class node. + expect(ref!.referenceName).toBe('Container'); + }); + + it('keeps trailing identifier from qualified `new ns.Foo()`', () => { + const code = ` +const ns = { Foo: class {} }; +function go() { return new ns.Foo(); } +`; + const result = extractFromSource('app.ts', code); + const ref = result.unresolvedReferences.find( + (r) => r.referenceKind === 'instantiates' + ); + // We can't always resolve which Foo, but the name should be the + // simple identifier so name-matching has a chance. + expect(ref?.referenceName).toBe('Foo'); + }); + + it('emits a decorates ref for `@Foo class X {}`', () => { + const code = ` +function Foo(_arg: string) { return (cls: any) => cls; } +@Foo('x') +class X {} +`; + const result = extractFromSource('app.ts', code); + const decorClass = result.unresolvedReferences.find( + (r) => r.referenceKind === 'decorates' && r.referenceName === 'Foo' + ); + expect(decorClass).toBeDefined(); + }); + + it('does NOT attribute a prior class\'s decorator to the next class', () => { + // Regression: the sibling-walk must stop at the first non- + // decorator separator. `@A class Foo {} @B class Bar {}` must + // produce `decorates(Foo, A)` and `decorates(Bar, B)` — never + // `decorates(Bar, A)`. + const code = ` +function A(cls: any) { return cls; } +function B(cls: any) { return cls; } +@A +class Foo {} +@B +class Bar {} +`; + const result = extractFromSource('app.ts', code); + const decoratesEdges = result.unresolvedReferences.filter( + (r) => r.referenceKind === 'decorates' + ); + // Exactly one decorates ref per decorated class, no cross-attribution. + const fromBar = decoratesEdges.filter((r) => + result.nodes.find((n) => n.id === r.fromNodeId && n.name === 'Bar') + ); + expect(fromBar.length).toBe(1); + expect(fromBar[0]!.referenceName).toBe('B'); + }); + + it('emits a decorates ref for `@Foo method() {}`', () => { + const code = ` +function Get(p: string) { return (t: any, k: string) => t; } +class Svc { + @Get('/x') method() { return 1; } +} +`; + const result = extractFromSource('app.ts', code); + const decorMethod = result.unresolvedReferences.find( + (r) => r.referenceKind === 'decorates' && r.referenceName === 'Get' + ); + expect(decorMethod).toBeDefined(); + // The decorated symbol must be `method`, not the constructor or class. + const decoratedNode = result.nodes.find((n) => n.id === decorMethod!.fromNodeId); + expect(decoratedNode?.name).toBe('method'); + }); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index bb7fe9b0..b4f1a946 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -606,5 +606,109 @@ function main(): void { // Should have attempted resolution expect(result.stats.total).toBeGreaterThanOrEqual(0); }); + + it('promotes calls→instantiates when target resolves to a class (Python)', async () => { + // Python has no `new` keyword — `Foo()` is the standard + // instantiation syntax. Extraction can't tell that apart from + // a function call without symbol info, so it emits a `calls` + // ref. Resolution promotes it to `instantiates` once the + // target is known to be a class. + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + fs.writeFileSync( + path.join(srcDir, 'app.py'), + `class UserService: + def __init__(self): + self.db = None + +def bootstrap(): + return UserService() +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const bootstrap = cg + .getNodesByKind('function') + .find((n) => n.name === 'bootstrap'); + expect(bootstrap).toBeDefined(); + + const outgoing = cg.getOutgoingEdges(bootstrap!.id); + const instantiates = outgoing.find((e) => e.kind === 'instantiates'); + expect(instantiates).toBeDefined(); + // Same edge must NOT also appear as a `calls` edge — promotion + // replaces the kind, doesn't duplicate. + const callsToUserService = outgoing.filter( + (e) => e.kind === 'calls' && e.target === instantiates!.target + ); + expect(callsToUserService).toHaveLength(0); + }); + }); + + describe('Name Matcher: kind bias for new ref kinds', () => { + const baseContext = (candidates: Node[]): ResolutionContext => ({ + getNodesInFile: () => [], + getNodesByName: (name) => candidates.filter((c) => c.name === name), + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => true, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }); + + it('prefers a class candidate over a function for `instantiates` refs', () => { + // A class and a function share a name across the codebase. + // Without the kind bias, the function (which gets the +25 `calls` + // bonus historically applied to all candidates of that kind) would + // win. Now the instantiates branch reverses it. + const fn: Node = { + id: 'func:utils.ts:Logger:5', kind: 'function', name: 'Logger', + qualifiedName: 'utils.ts::Logger', filePath: 'utils.ts', language: 'typescript', + startLine: 5, endLine: 7, startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const cls: Node = { + id: 'class:logger.ts:Logger:10', kind: 'class', name: 'Logger', + qualifiedName: 'logger.ts::Logger', filePath: 'logger.ts', language: 'typescript', + startLine: 10, endLine: 30, startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + + const ref = { + fromNodeId: 'func:main.ts:bootstrap:1', + referenceName: 'Logger', + referenceKind: 'instantiates' as const, + line: 5, column: 0, filePath: 'main.ts', language: 'typescript' as const, + }; + + const result = matchReference(ref, baseContext([fn, cls])); + expect(result?.targetNodeId).toBe('class:logger.ts:Logger:10'); + }); + + it('prefers a function candidate over a non-function for `decorates` refs', () => { + const variable: Node = { + id: 'var:config.ts:Inject:5', kind: 'variable', name: 'Inject', + qualifiedName: 'config.ts::Inject', filePath: 'config.ts', language: 'typescript', + startLine: 5, endLine: 5, startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const decorator: Node = { + id: 'func:di.ts:Inject:10', kind: 'function', name: 'Inject', + qualifiedName: 'di.ts::Inject', filePath: 'di.ts', language: 'typescript', + startLine: 10, endLine: 20, startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + + const ref = { + fromNodeId: 'class:svc.ts:UserService:1', + referenceName: 'Inject', + referenceKind: 'decorates' as const, + line: 5, column: 0, filePath: 'svc.ts', language: 'typescript' as const, + }; + + const result = matchReference(ref, baseContext([variable, decorator])); + expect(result?.targetNodeId).toBe('func:di.ts:Inject:10'); + }); }); }); diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 7345d91f..24b158d4 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -95,6 +95,17 @@ function extractName(node: SyntaxNode, source: string, extractor: LanguageExtrac return ''; } +/** + * Tree-sitter node kinds that represent constructor invocations + * (`new Foo()` and friends). Used by extractInstantiation to emit + * an `instantiates` reference targeting the class name. + */ +const INSTANTIATION_KINDS: ReadonlySet = new Set([ + 'new_expression', // typescript / javascript / tsx / jsx + 'object_creation_expression', // java / c# + 'instance_creation_expression', // some grammars +]); + /** * TreeSitterExtractor - Main extraction class */ @@ -334,6 +345,17 @@ export class TreeSitterExtractor { else if (this.extractor.callTypes.includes(nodeType)) { this.extractCall(node); } + // `new Foo(...)` / `Foo::new(...)` / object_creation_expression — + // produce an `instantiates` reference. Children still walked so + // nested calls inside the constructor args (`new Foo(bar())`) get + // their own `calls` refs. + else if (INSTANTIATION_KINDS.has(nodeType)) { + this.extractInstantiation(node); + } + // (Decorator handling lives inside the symbol-creating extractors + // — extractClass / extractFunction / extractProperty — because the + // decorator node sits BEFORE the symbol in the AST and the walker + // would otherwise see the wrong nodeStack head.) // Rust: `impl Trait for Type { ... }` — creates implements edge from Type to Trait else if (nodeType === 'impl_item') { this.extractRustImplItem(node); @@ -531,6 +553,11 @@ export class TreeSitterExtractor { // Extract type annotations (parameter types and return type) this.extractTypeAnnotations(node, funcNode.id); + // Extract decorators applied to the function (rare in JS/TS but + // present in Python `@decorator def f():` and Java/Kotlin + // annotations on free functions). + this.extractDecoratorsFor(node, funcNode.id); + // Push to stack and visit body this.nodeStack.push(funcNode.id); const body = this.extractor.resolveBody?.(node, this.extractor.bodyField) @@ -562,6 +589,9 @@ export class TreeSitterExtractor { // Extract extends/implements this.extractInheritance(node, classNode.id); + // Extract decorators applied to the class (`@Foo class X {}`). + this.extractDecoratorsFor(node, classNode.id); + // Push to stack and visit body this.nodeStack.push(classNode.id); let body = this.extractor.resolveBody?.(node, this.extractor.bodyField) @@ -655,6 +685,9 @@ export class TreeSitterExtractor { // Extract type annotations (parameter types and return type) this.extractTypeAnnotations(node, methodNode.id); + // Extract decorators (`@Get('/list') list() {}`). + this.extractDecoratorsFor(node, methodNode.id); + // Push to stack and visit body this.nodeStack.push(methodNode.id); const body = this.extractor.resolveBody?.(node, this.extractor.bodyField) @@ -834,12 +867,18 @@ export class TreeSitterExtractor { const typeText = typeNode ? getNodeText(typeNode, this.source) : undefined; const signature = typeText ? `${typeText} ${name}` : name; - this.createNode('property', name, node, { + const propNode = this.createNode('property', name, node, { docstring, signature, visibility, isStatic, }); + + // `@Inject() private svc: Foo` and similar — capture the + // decorator->target relationship for class properties too. + if (propNode) { + this.extractDecoratorsFor(node, propNode.id); + } } /** @@ -913,12 +952,15 @@ export class TreeSitterExtractor { if (!nameNode) continue; const name = getNodeText(nameNode, this.source); const signature = typeText ? `${typeText} ${name}` : name; - this.createNode('field', name, decl, { + const fieldNode = this.createNode('field', name, decl, { docstring, signature, visibility, isStatic, }); + // Java/Kotlin annotations / TS field decorators sit on the + // outer field_declaration, not on the individual declarator. + if (fieldNode) this.extractDecoratorsFor(node, fieldNode.id); } } else { // Fallback: try to find an identifier child directly @@ -1448,6 +1490,162 @@ export class TreeSitterExtractor { } } + /** + * `new Foo(...)` / `Foo::new(...)` / object_creation_expression — + * emit an `instantiates` reference to the class name. The resolver + * then links it to the class node, producing the `instantiates` + * edge that powers "what creates instances of X" queries. + * + * Children are still walked so nested calls inside the constructor + * arguments (`new Foo(bar())`) get their own `calls` references. + */ + private extractInstantiation(node: SyntaxNode): void { + if (this.nodeStack.length === 0) return; + const fromId = this.nodeStack[this.nodeStack.length - 1]; + if (!fromId) return; + + // The class name is in the `constructor`/`type`/first-named-child + // depending on grammar. + const ctor = + getChildByField(node, 'constructor') || + getChildByField(node, 'type') || + getChildByField(node, 'name') || + node.namedChild(0); + if (!ctor) return; + + let className = getNodeText(ctor, this.source); + // Strip type-argument suffix first: `new Map()` would + // otherwise produce className 'Map' (the constructor + // field is a `generic_type` node) and resolution would fail + // because no class is named with the angle-bracket suffix. + const ltIdx = className.indexOf('<'); + if (ltIdx > 0) className = className.slice(0, ltIdx); + // For namespaced/qualified constructors (`new ns.Foo()`, + // `new ns::Foo()`) keep the trailing identifier — that's what + // matches a class node in the index. + const lastDot = Math.max( + className.lastIndexOf('.'), + className.lastIndexOf('::') + ); + if (lastDot >= 0) className = className.slice(lastDot + 1).replace(/^[:.]/, ''); + className = className.trim(); + + if (className) { + this.unresolvedReferences.push({ + fromNodeId: fromId, + referenceName: className, + referenceKind: 'instantiates', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + + /** + * Scan `declNode` and its preceding siblings (within the parent's + * named children) for decorator nodes, emitting a `decorates` + * reference from `decoratedId` to each decorator's function name. + * + * Why preceding siblings: in TypeScript, `@Foo class Bar {}` parses + * as an `export_statement` (or top-level wrapper) with the + * `decorator` as a child *before* the `class_declaration` — so the + * decorator isn't a child of the class itself. For methods/ + * properties, the decorator IS a direct child of the declaration, + * so we also scan declNode.namedChildren. + * + * Idempotent across grammars: if neither location yields decorators + * (most non-decorator-using languages), the function is a no-op. + */ + private extractDecoratorsFor(declNode: SyntaxNode, decoratedId: string): void { + const consider = (n: SyntaxNode | null): void => { + if (!n) return; + // `marker_annotation` is Java's grammar for arg-less annotations + // (`@Override`, `@Deprecated`); without including it, every + // such Java annotation would be silently skipped. + if ( + n.type !== 'decorator' && + n.type !== 'annotation' && + n.type !== 'marker_annotation' + ) { + return; + } + // Find the leading identifier: skip the `@` punct, unwrap + // a call_expression if the decorator is invoked with args. + let target: SyntaxNode | null = null; + for (let i = 0; i < n.namedChildCount; i++) { + const child = n.namedChild(i); + if (!child) continue; + if (child.type === 'call_expression') { + const fn = getChildByField(child, 'function') ?? child.namedChild(0); + if (fn) target = fn; + if (target) break; + } + if ( + child.type === 'identifier' || + child.type === 'member_expression' || + child.type === 'scoped_identifier' || + child.type === 'navigation_expression' + ) { + target = child; + break; + } + } + if (!target) return; + let name = getNodeText(target, this.source); + const lastDot = Math.max(name.lastIndexOf('.'), name.lastIndexOf('::')); + if (lastDot >= 0) name = name.slice(lastDot + 1).replace(/^[:.]/, ''); + if (!name) return; + this.unresolvedReferences.push({ + fromNodeId: decoratedId, + referenceName: name, + referenceKind: 'decorates', + line: n.startPosition.row + 1, + column: n.startPosition.column, + }); + }; + + // 1. Decorators that are direct children of the declaration + // (method/property style, also some grammars for class). + for (let i = 0; i < declNode.namedChildCount; i++) { + consider(declNode.namedChild(i)); + } + + // 2. Decorators that are PRECEDING siblings of the declaration + // inside the parent's children (TypeScript class style). + // Walk BACKWARDS from the declaration and stop at the first + // non-decorator sibling — without that stop, decorators + // belonging to an EARLIER unrelated declaration leak in + // (e.g. `@A class Foo {} @B class Bar {}` would otherwise + // attribute @A to Bar). + // + // Note on identity: tree-sitter web bindings return fresh JS + // wrapper objects from `parent`/`namedChild` navigation, so + // `sibling === declNode` is unreliable — `startIndex` does + // the matching instead. + const parent = declNode.parent; + if (parent) { + const declStart = declNode.startIndex; + let declIdx = -1; + for (let i = 0; i < parent.namedChildCount; i++) { + const sibling = parent.namedChild(i); + if (sibling && sibling.startIndex === declStart) { + declIdx = i; + break; + } + } + if (declIdx > 0) { + for (let j = declIdx - 1; j >= 0; j--) { + const sibling = parent.namedChild(j); + if (!sibling) continue; + if (sibling.type !== 'decorator' && sibling.type !== 'annotation' && sibling.type !== 'marker_annotation') { + break; // non-decorator separator → stop consuming + } + consider(sibling); + } + } + } + } + /** * Visit function body and extract calls (and structural nodes). * @@ -1466,6 +1664,12 @@ export class TreeSitterExtractor { if (this.extractor!.callTypes.includes(nodeType)) { this.extractCall(node); + } else if (INSTANTIATION_KINDS.has(nodeType)) { + // `new Foo()` inside a function body — emit an `instantiates` + // reference. Without this branch the body walker only knew + // about `call_expression`, so constructor invocations + // produced no graph edges at all. + this.extractInstantiation(node); } else if (this.extractor!.extractBareCall) { const calleeName = this.extractor!.extractBareCall(node, this.source); if (calleeName && this.nodeStack.length > 0) { diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 49a61cf0..dbc13a84 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -443,6 +443,18 @@ export class ReferenceResolver { } } + // Promote "calls" to "instantiates" when the resolved target is a + // class/struct. Languages without a `new` keyword (Python, Ruby) + // express instantiation as `Foo()` — extraction can't tell that + // apart from a function call without symbol info, but resolution + // can: if `Foo` resolves to a class, the call IS an instantiation. + if (kind === 'calls') { + const targetNode = this.queries.getNodeById(ref.targetNodeId); + if (targetNode && (targetNode.kind === 'class' || targetNode.kind === 'struct')) { + kind = 'instantiates'; + } + } + return { source: ref.original.fromNodeId, target: ref.targetNodeId, diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 3c819eee..997a4437 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -352,6 +352,30 @@ function findBestMatch( } } + // For instantiation references (`new Foo()`), prefer class-like + // targets — without this, a function named `Foo` in another module + // could outscore the actual class. + if (ref.referenceKind === 'instantiates') { + if ( + candidate.kind === 'class' || + candidate.kind === 'struct' || + candidate.kind === 'interface' + ) { + score += 25; + } + } + + // For decorator references (`@Foo`), prefer functions. Class + // decorators (Python `@SomeClass`, Java annotation interfaces) + // also resolve here, hence the smaller class bonus. + if (ref.referenceKind === 'decorates') { + if (candidate.kind === 'function' || candidate.kind === 'method') { + score += 25; + } else if (candidate.kind === 'class' || candidate.kind === 'interface') { + score += 15; + } + } + // Exported bonus if (candidate.isExported) { score += 10; From 38d155618f3f180a049b2e361a5dc90d0917a019 Mon Sep 17 00:00:00 2001 From: verzillion_kram Date: Fri, 8 May 2026 09:28:44 +0800 Subject: [PATCH 02/83] fix: add @clack/prompts transitive deps to fix npx installation (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: add @clack/prompts transitive deps to fix npx installation When installed via `npx`, npm's flat node_modules cache fails to hoist ESM-only transitive dependencies from @clack/prompts → @clack/core. This causes: Cannot find package 'fast-wrap-ansi/index.js' imported from @clack/core/dist/index.mjs Adding fast-wrap-ansi, fast-string-width, and sisteransi as direct dependencies ensures they are resolved correctly in all installation contexts (npx, global, local). Reproduces on Node 24 + npm 11 with `npx @colbymchenry/codegraph@0.7.3`. * chore: bump @clack/prompts to 1.3.0 with matching transitive pins @clack/prompts@1.3.0 shipped with major bumps to its transitive deps (fast-wrap-ansi 0.1 → 0.2, fast-string-width 1 → 3). Promoting them at the older pins would have caused npm to install both sets side by side, defeating the dedup goal of this fix. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: mfrancime Co-authored-by: Colby McHenry Co-authored-by: Claude Opus 4.7 (1M context) --- package-lock.json | 608 +++++++--------------------------------------- package.json | 5 +- 2 files changed, 87 insertions(+), 526 deletions(-) diff --git a/package-lock.json b/package-lock.json index fa65146e..56b8b870 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,14 +7,15 @@ "": { "name": "@colbymchenry/codegraph", "version": "0.7.2", - "hasInstallScript": true, "license": "MIT", "dependencies": { - "@clack/prompts": "^1.2.0", - "@xenova/transformers": "^2.17.0", + "@clack/prompts": "^1.3.0", "commander": "^14.0.2", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", "node-sqlite3-wasm": "^0.8.30", "picomatch": "^4.0.3", + "sisteransi": "^1.0.5", "tree-sitter-wasms": "^0.1.11", "web-tree-sitter": "^0.25.3" }, @@ -29,33 +30,38 @@ "vitest": "^2.1.9" }, "engines": { - "node": ">=18.0.0" + "node": ">=18.0.0 <25.0.0" }, "optionalDependencies": { - "better-sqlite3": "^11.0.0", - "sqlite-vss": "^0.1.2" + "better-sqlite3": "^11.0.0" } }, "node_modules/@clack/core": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz", - "integrity": "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.0.tgz", + "integrity": "sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==", "license": "MIT", "dependencies": { - "fast-wrap-ansi": "^0.1.3", + "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" } }, "node_modules/@clack/prompts": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.2.0.tgz", - "integrity": "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.3.0.tgz", + "integrity": "sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw==", "license": "MIT", "dependencies": { - "@clack/core": "1.2.0", - "fast-string-width": "^1.1.0", - "fast-wrap-ansi": "^0.1.3", + "@clack/core": "1.3.0", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -449,15 +455,6 @@ "node": ">=12" } }, - "node_modules/@huggingface/jinja": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", - "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -465,70 +462,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -896,16 +829,11 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1031,20 +959,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@xenova/transformers": { - "version": "2.17.2", - "resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz", - "integrity": "sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==", - "license": "Apache-2.0", - "dependencies": { - "@huggingface/jinja": "^0.2.2", - "onnxruntime-web": "1.14.0", - "sharp": "^0.32.0" - }, - "optionalDependencies": { - "onnxruntime-node": "1.14.0" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1055,111 +969,6 @@ "node": ">=12" } }, - "node_modules/b4a": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", - "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.3.tgz", - "integrity": "sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", - "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1178,7 +987,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/better-sqlite3": { "version": "11.10.0", @@ -1207,6 +1017,7 @@ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "license": "MIT", + "optional": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -1232,6 +1043,7 @@ } ], "license": "MIT", + "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -1278,48 +1090,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } + "license": "ISC", + "optional": true }, "node_modules/commander": { "version": "14.0.3", @@ -1353,6 +1125,7 @@ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "license": "MIT", + "optional": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -1378,6 +1151,7 @@ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", + "optional": true, "engines": { "node": ">=4.0.0" } @@ -1387,6 +1161,7 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", + "optional": true, "engines": { "node": ">=8" } @@ -1396,6 +1171,7 @@ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", + "optional": true, "dependencies": { "once": "^1.4.0" } @@ -1456,20 +1232,12 @@ "@types/estree": "^1.0.0" } }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "license": "(MIT OR WTFPL)", + "optional": true, "engines": { "node": ">=6" } @@ -1484,34 +1252,28 @@ "node": ">=12.0.0" } }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, "node_modules/fast-string-truncated-width": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz", - "integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", "license": "MIT" }, "node_modules/fast-string-width": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-1.1.0.tgz", - "integrity": "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", "license": "MIT", "dependencies": { - "fast-string-truncated-width": "^1.2.0" + "fast-string-truncated-width": "^3.0.2" } }, "node_modules/fast-wrap-ansi": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.1.6.tgz", - "integrity": "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", "license": "MIT", "dependencies": { - "fast-string-width": "^1.1.0" + "fast-string-width": "^3.0.2" } }, "node_modules/file-uri-to-path": { @@ -1521,17 +1283,12 @@ "license": "MIT", "optional": true }, - "node_modules/flatbuffers": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", - "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==", - "license": "SEE LICENSE IN LICENSE.txt" - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/fsevents": { "version": "2.3.3", @@ -1552,13 +1309,8 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, - "node_modules/guid-typescript": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", - "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", - "license": "ISC" + "license": "MIT", + "optional": true }, "node_modules/ieee754": { "version": "1.2.1", @@ -1578,31 +1330,22 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT" - }, - "node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", - "license": "Apache-2.0" + "license": "ISC", + "optional": true }, "node_modules/loupe": { "version": "3.2.1", @@ -1626,6 +1369,7 @@ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "license": "MIT", + "optional": true, "engines": { "node": ">=10" }, @@ -1638,6 +1382,7 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", + "optional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -1646,7 +1391,8 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/ms": { "version": "2.1.3", @@ -1678,13 +1424,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/node-abi": { "version": "3.87.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", "license": "MIT", + "optional": true, "dependencies": { "semver": "^7.3.5" }, @@ -1703,52 +1451,9 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onnx-proto": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/onnx-proto/-/onnx-proto-4.0.4.tgz", - "integrity": "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==", - "license": "MIT", - "dependencies": { - "protobufjs": "^6.8.8" - } - }, - "node_modules/onnxruntime-common": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz", - "integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==", - "license": "MIT" - }, - "node_modules/onnxruntime-node": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz", - "integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==", - "license": "MIT", "optional": true, - "os": [ - "win32", - "darwin", - "linux" - ], "dependencies": { - "onnxruntime-common": "~1.14.0" - } - }, - "node_modules/onnxruntime-web": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz", - "integrity": "sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==", - "license": "MIT", - "dependencies": { - "flatbuffers": "^1.12.0", - "guid-typescript": "^1.0.9", - "long": "^4.0.0", - "onnx-proto": "^4.0.4", - "onnxruntime-common": "~1.14.0", - "platform": "^1.3.6" + "wrappy": "1" } }, "node_modules/pathe": { @@ -1787,12 +1492,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/platform": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", - "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", - "license": "MIT" - }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -1827,6 +1526,7 @@ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "license": "MIT", + "optional": true, "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -1848,37 +1548,12 @@ "node": ">=10" } }, - "node_modules/protobufjs": { - "version": "6.11.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz", - "integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.1", - "@types/node": ">=13.7.0", - "long": "^4.0.0" - }, - "bin": { - "pbjs": "bin/pbjs", - "pbts": "bin/pbts" - } - }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "license": "MIT", + "optional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -1889,6 +1564,7 @@ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -1904,6 +1580,7 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", + "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -1976,13 +1653,15 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", + "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -1990,60 +1669,6 @@ "node": ">=10" } }, - "node_modules/sharp": { - "version": "0.32.6", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", - "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.2", - "node-addon-api": "^6.1.0", - "prebuild-install": "^7.1.1", - "semver": "^7.5.4", - "simple-get": "^4.0.1", - "tar-fs": "^3.0.4", - "tunnel-agent": "^0.6.0" - }, - "engines": { - "node": ">=14.15.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "license": "MIT" - }, - "node_modules/sharp/node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/sharp/node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2069,7 +1694,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/simple-get": { "version": "4.0.1", @@ -2090,21 +1716,13 @@ } ], "license": "MIT", + "optional": true, "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -2121,54 +1739,6 @@ "node": ">=0.10.0" } }, - "node_modules/sqlite-vss": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/sqlite-vss/-/sqlite-vss-0.1.2.tgz", - "integrity": "sha512-MgTz3GLT04ckv1kaesbrsUU6/kcVsA6vGeCS/HO5d/8zKqCuZFCD0QlJaQnS6zwaMyPG++BO/uu40MMrMa0cow==", - "license": "(MIT OR Apache-2.0)", - "optional": true, - "optionalDependencies": { - "sqlite-vss-darwin-arm64": "0.1.2", - "sqlite-vss-darwin-x64": "0.1.2", - "sqlite-vss-linux-x64": "0.1.2" - } - }, - "node_modules/sqlite-vss-darwin-arm64": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/sqlite-vss-darwin-arm64/-/sqlite-vss-darwin-arm64-0.1.2.tgz", - "integrity": "sha512-zyDk9eg33nBABrUC4cqQ7el8KJaRPzsqp8Y/nGZ0CAt7o1PMqLoCOgREorill5MGiZEBmLqxdAgw0O2MFwq4mw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/sqlite-vss-darwin-x64": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/sqlite-vss-darwin-x64/-/sqlite-vss-darwin-x64-0.1.2.tgz", - "integrity": "sha512-w+ODOH2dNkyO6UaGclwC0jwNf/FBsKaE53XKJ7dFmpOvlvO0/9sA1stkWXygykRVWwa3UD8ow0qbQpRwdOFyqg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/sqlite-vss-linux-x64": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/sqlite-vss-linux-x64/-/sqlite-vss-linux-x64-0.1.2.tgz", - "integrity": "sha512-y1qktcHAZcfN1nYMcF5os/cCRRyaisaNc2C9I3ceLKLPAqUWIocsOdD5nNK/dIeGPag/QeT2ZItJ6uYWciLiAg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2183,22 +1753,12 @@ "dev": true, "license": "MIT" }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", + "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -2208,6 +1768,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", + "optional": true, "engines": { "node": ">=0.10.0" } @@ -2217,6 +1778,7 @@ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "license": "MIT", + "optional": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -2229,6 +1791,7 @@ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "license": "MIT", + "optional": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -2240,15 +1803,6 @@ "node": ">=6" } }, - "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2307,6 +1861,7 @@ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "license": "Apache-2.0", + "optional": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -2332,13 +1887,15 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/vite": { "version": "5.4.21", @@ -2525,7 +2082,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" + "license": "ISC", + "optional": true } } } diff --git a/package.json b/package.json index b0d7355a..a9b4b7c7 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,13 @@ "author": "", "license": "MIT", "dependencies": { - "@clack/prompts": "^1.2.0", + "@clack/prompts": "^1.3.0", "commander": "^14.0.2", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", "node-sqlite3-wasm": "^0.8.30", "picomatch": "^4.0.3", + "sisteransi": "^1.0.5", "tree-sitter-wasms": "^0.1.11", "web-tree-sitter": "^0.25.3" }, From 56f6b3b4858f5ed19740623d4172b5e70fe5e11f Mon Sep 17 00:00:00 2001 From: andreinknv Date: Thu, 7 May 2026 21:35:49 -0400 Subject: [PATCH 03/83] feat(search): field-qualified queries (kind:/lang:/path:/name:) + fuzzy typo fallback (#131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(search): field-qualified queries (kind:/lang:/path:/name:) + fuzzy typo fallback Two UX improvements that turn a free-text search into something a real user can drive precisely. 1) Field-qualified queries. A new query parser (src/search/query-parser.ts) splits the raw query into structured filters and a free-text remainder: kind:function name:auth path:src/api authenticate becomes { kinds: ['function'], nameFilters: ['auth'], pathFilters: ['src/api'], text: 'authenticate' } Filters compose with the SearchOptions arg (intersection). Unknown prefixes pass through as plain text so `query "TODO:"` keeps working. Quoted values (`path:"my dir"`) handle whitespace. When the user specifies only filters with no text, the search uses a filter-only candidate scan instead of bailing out. Recognised today: kind: any NodeKind value lang: any Language value (alias: language:) path: case-insensitive substring of file_path name: case-insensitive substring of node.name 2) Fuzzy fallback. When BOTH FTS and LIKE return nothing AND the text is at least 3 chars, the resolver scans the distinct-name set with a bounded Damerau-Levenshtein-style edit distance (≤2 for ≥5 chars, ≤1 for 4-char queries, off for shorter). Bounded edit-distance early-exits once the row min exceeds maxDist, so this stays O(distinct-names * avg-name-length) with a very low constant. Verified live against ollama/ollama@v0.22.0: query "kind:function auth" → only function-kind hits query "lang:go path:server route" → Go files under server/ query "getUssr" (typo) → finds getUser, SetUser query "confg" (typo) → finds Config Full test suite: 380 passed. * fix(search): address reviewer findings — tokenizer mid-token quotes, fuzzy fan-out cap, larger filter-only over-fetch, unit tests Five fixes from independent review: - parseQuery tokenizer: quotes that appear MID-token (path:"my dir/ file") were not being recognised — only quotes at the start of a token were treated as quoted spans. The fixture path:"my dir" parsed as ['path:"my', 'dir"'] instead of ['path:"my dir"']. Tokeniser is now a single state machine that scans into a token until whitespace OR a quote, and recognises quotes anywhere within the token (skips to the matching close quote). - searchNodesFuzzy: cap the per-name follow-up SQL queries at Math.max(limit*2, 50) AFTER edit-distance filtering. Without this, a project with many similar names (getUser1, getUser2...) could fan out far beyond limit queries before the inner-loop break kicks in. - searchAllByFilters (filter-only no-text path): bumped over-fetch multiplier from 2× to 5× so a selective post-filter (e.g. path:src/very/specific/file.ts) doesn't return fewer than limit results despite the DB having matches. - 23 new unit tests in __tests__/search-query-parser.test.ts: parseQuery covers known-field filter, lang/language alias, multiple kind: ORs, quoted spans (incl. mid-token), URL passthrough, empty-value passthrough, unknown prefix passthrough, unknown value passthrough, all-filters-no-text, empty input, 20k-char input. boundedEditDistance covers identity, single insertion/deletion/substitution, length-difference shortcut, empty inputs, case-sensitivity, early-exit correctness. Full test suite: 853 passed (up from 830). * refactor(search): derive parser kind/lang sets from types.ts as const Convert NodeKind and Language to runtime-iterable as const arrays (NODE_KINDS, LANGUAGES) so the query parser imports the canonical list instead of duplicating it. Also fix the path: JSDoc to say substring (matches the .includes() impl). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Colby McHenry Co-authored-by: Claude Opus 4.7 (1M context) --- __tests__/search-query-parser.test.ts | 142 ++++++++++++++++++++ src/db/queries.ts | 164 ++++++++++++++++++++++- src/search/query-parser.ts | 184 ++++++++++++++++++++++++++ src/types.ts | 103 +++++++------- 4 files changed, 540 insertions(+), 53 deletions(-) create mode 100644 __tests__/search-query-parser.test.ts create mode 100644 src/search/query-parser.ts diff --git a/__tests__/search-query-parser.test.ts b/__tests__/search-query-parser.test.ts new file mode 100644 index 00000000..8a7767da --- /dev/null +++ b/__tests__/search-query-parser.test.ts @@ -0,0 +1,142 @@ +/** + * Unit tests for the field-qualified query parser and bounded + * edit distance — the two algorithms behind `kind:`/`lang:`/`path:`/ + * `name:` filtering and the fuzzy typo fallback. + */ + +import { describe, it, expect } from 'vitest'; +import { parseQuery, boundedEditDistance } from '../src/search/query-parser'; + +describe('parseQuery', () => { + it('returns plain text for a query with no field prefixes', () => { + const r = parseQuery('authenticate user'); + expect(r.text).toBe('authenticate user'); + expect(r.kinds).toEqual([]); + expect(r.languages).toEqual([]); + expect(r.pathFilters).toEqual([]); + expect(r.nameFilters).toEqual([]); + }); + + it('extracts kind: filter and removes it from text', () => { + const r = parseQuery('kind:function auth'); + expect(r.kinds).toEqual(['function']); + expect(r.text).toBe('auth'); + }); + + it('extracts lang: and language: as the same filter family', () => { + const a = parseQuery('lang:typescript foo'); + const b = parseQuery('language:typescript foo'); + expect(a.languages).toEqual(['typescript']); + expect(b.languages).toEqual(['typescript']); + }); + + it('handles multiple kind: filters as an OR set', () => { + const r = parseQuery('kind:function kind:method auth'); + expect(r.kinds.sort()).toEqual(['function', 'method']); + }); + + it('extracts path: and name: as substring filters (kept verbatim)', () => { + const r = parseQuery('path:src/api name:Handler'); + expect(r.pathFilters).toEqual(['src/api']); + expect(r.nameFilters).toEqual(['Handler']); + }); + + it('preserves quoted spans as a single token (whitespace in path:)', () => { + const r = parseQuery('path:"my dir/file" foo'); + expect(r.pathFilters).toEqual(['my dir/file']); + expect(r.text).toBe('foo'); + }); + + it('passes URL-like tokens through to text (does not match http: as a field)', () => { + const r = parseQuery('http://example.com'); + expect(r.text).toBe('http://example.com'); + expect(r.kinds).toEqual([]); + }); + + it('passes empty-value tokens through as text (kind: → "kind:")', () => { + const r = parseQuery('kind: foo'); + expect(r.kinds).toEqual([]); + // The trailing-colon token comes back as plain text + expect(r.text.includes('kind:')).toBe(true); + }); + + it('passes unknown field prefixes through as text (TODO: keeps the colon)', () => { + const r = parseQuery('TODO: needs review'); + expect(r.text).toBe('TODO: needs review'); + expect(r.kinds).toEqual([]); + }); + + it('rejects unknown values for kind: (passes the whole token to text)', () => { + const r = parseQuery('kind:invalid foo'); + // Invalid kind value falls back to text + expect(r.kinds).toEqual([]); + expect(r.text).toContain('kind:invalid'); + }); + + it('handles all-filters-no-text query', () => { + const r = parseQuery('kind:function lang:typescript'); + expect(r.kinds).toEqual(['function']); + expect(r.languages).toEqual(['typescript']); + expect(r.text).toBe(''); + }); + + it('survives empty input', () => { + const r = parseQuery(''); + expect(r.text).toBe(''); + expect(r.kinds).toEqual([]); + }); + + it('survives a very long input (no allocation explosion)', () => { + const huge = 'foo '.repeat(5000); // 20k chars + const r = parseQuery(huge); + expect(r.text.length).toBeGreaterThan(0); + }); +}); + +describe('boundedEditDistance', () => { + it('returns 0 for identical strings', () => { + expect(boundedEditDistance('user', 'user', 2)).toBe(0); + }); + + it('returns 1 for a single substitution', () => { + expect(boundedEditDistance('user', 'usar', 2)).toBe(1); + }); + + it('returns 1 for a single insertion', () => { + expect(boundedEditDistance('user', 'users', 2)).toBe(1); + }); + + it('returns 1 for a single deletion', () => { + expect(boundedEditDistance('users', 'user', 2)).toBe(1); + }); + + it('returns 2 for a transposition (two edits in basic Levenshtein)', () => { + // 'aple' vs 'palp' would be 2; pick a clearer pair. + // 'foo' vs 'fou': substitution + insertion = 2 if different lengths. + expect(boundedEditDistance('confg', 'configX', 2)).toBe(2); + }); + + it('returns maxDist+1 when distance clearly exceeds budget', () => { + expect(boundedEditDistance('foo', 'completely-different', 2)).toBe(3); + }); + + it('respects length-difference shortcut', () => { + // |len(a) - len(b)| > maxDist must immediately be over budget + expect(boundedEditDistance('a', 'aaaaaaa', 2)).toBe(3); + }); + + it('handles empty inputs', () => { + expect(boundedEditDistance('', '', 2)).toBe(0); + expect(boundedEditDistance('a', '', 2)).toBe(1); + expect(boundedEditDistance('', 'abc', 2)).toBe(3); + }); + + it('is case-sensitive — caller must lowercase if case-insensitive match wanted', () => { + expect(boundedEditDistance('Foo', 'foo', 2)).toBe(1); + }); + + it('early-exits when row min exceeds budget (correctness, not just perf)', () => { + // 'aaaaa' vs 'bbbbb': distance is 5, well over budget 2 + expect(boundedEditDistance('aaaaa', 'bbbbb', 2)).toBe(3); + }); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index 51f1a1ad..db7c6118 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -19,6 +19,7 @@ import { } from '../types'; import { safeJsonParse } from '../utils'; import { kindBonus, nameMatchBonus, scorePathRelevance } from '../search/query-utils'; +import { parseQuery, boundedEditDistance } from '../search/query-parser'; /** * Database row types (snake_case from SQLite) @@ -478,14 +479,51 @@ export class QueryBuilder { * 3. Score results based on match quality */ searchNodes(query: string, options: SearchOptions = {}): SearchResult[] { - const { kinds, languages, limit = 100, offset = 0 } = options; + const { limit = 100, offset = 0 } = options; + + // Parse field-qualified bits out of the raw query (kind:, lang:, + // path:, name:). Anything not recognised stays in `text` and goes + // to FTS unchanged. Filters compose with the SearchOptions arg — + // both are applied (intersection-style). + const parsed = parseQuery(query); + const mergedKinds = + parsed.kinds.length > 0 + ? Array.from(new Set([...(options.kinds ?? []), ...parsed.kinds])) + : options.kinds; + const mergedLanguages = + parsed.languages.length > 0 + ? Array.from(new Set([...(options.languages ?? []), ...parsed.languages])) + : options.languages; + const pathFilters = parsed.pathFilters; + const nameFilters = parsed.nameFilters; + // The text portion drives FTS/LIKE; if all the user typed was + // filters (`kind:function`), we still need *some* candidate set, + // so synthesise an empty-text path that returns everything matching + // the filters. + const text = parsed.text; + const kinds = mergedKinds; + const languages = mergedLanguages; // First try FTS5 with prefix matching - let results = this.searchNodesFTS(query, { kinds, languages, limit, offset }); + let results = text + ? this.searchNodesFTS(text, { kinds, languages, limit, offset }) + // Over-fetch by 5× when running filter-only (no text). The + // post-scoring path: + name: filters can be very selective, so + // a smaller multiplier risks returning fewer than `limit` + // results despite the DB having plenty of matches. + : this.searchAllByFilters({ kinds, languages, limit: limit * 5 }); // If no FTS results, try LIKE-based substring search - if (results.length === 0 && query.length >= 2) { - results = this.searchNodesLike(query, { kinds, languages, limit, offset }); + if (results.length === 0 && text.length >= 2) { + results = this.searchNodesLike(text, { kinds, languages, limit, offset }); + } + + // Final fuzzy fallback: scan all known names and keep those within + // a tight Levenshtein distance. Only fires when both FTS and LIKE + // returned nothing AND there's a text portion long enough to be + // worth fuzzing (1-char queries would match too much). + if (results.length === 0 && text.length >= 3) { + results = this.searchNodesFuzzy(text, { kinds, languages, limit }); } // Supplement: ensure exact name matches are always candidates. @@ -521,13 +559,14 @@ export class QueryBuilder { } // Apply multi-signal scoring - if (results.length > 0 && query) { + if (results.length > 0 && (text || query)) { + const scoringQuery = text || query; results = results.map(r => ({ ...r, score: r.score + kindBonus(r.node.kind) - + scorePathRelevance(r.node.filePath, query) - + nameMatchBonus(r.node.name, query), + + scorePathRelevance(r.node.filePath, scoringQuery) + + nameMatchBonus(r.node.name, scoringQuery), })); results.sort((a, b) => b.score - a.score); // Trim to requested limit after rescoring @@ -536,6 +575,117 @@ export class QueryBuilder { } } + // Apply path: + name: filters AFTER scoring. Scoring already uses + // path/name as a soft signal; the explicit filters here are a hard + // gate. Done last so the FTS limit fetched plenty of candidates to + // narrow from. + if (pathFilters.length > 0) { + const lowered = pathFilters.map((p) => p.toLowerCase()); + results = results.filter((r) => { + const fp = r.node.filePath.toLowerCase(); + return lowered.some((p) => fp.includes(p)); + }); + } + if (nameFilters.length > 0) { + const lowered = nameFilters.map((n) => n.toLowerCase()); + results = results.filter((r) => { + const nm = r.node.name.toLowerCase(); + return lowered.some((n) => nm.includes(n)); + }); + } + + return results; + } + + /** + * Match-everything path used when the user supplied only field + * filters (`kind:function lang:typescript`) with no text. Returns + * candidates ordered by name; the caller's filter pass narrows to + * what was asked for. + */ + private searchAllByFilters(options: { + kinds?: NodeKind[]; + languages?: Language[]; + limit: number; + }): SearchResult[] { + const { kinds, languages, limit } = options; + let sql = 'SELECT * FROM nodes WHERE 1=1'; + const params: (string | number)[] = []; + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + sql += ' ORDER BY name LIMIT ?'; + params.push(limit); + const rows = this.db.prepare(sql).all(...params) as NodeRow[]; + return rows.map((row) => ({ node: rowToNode(row), score: 1 })); + } + + /** + * Fuzzy fallback: when zero FTS/LIKE hits, try an edit-distance + * sweep over the distinct symbol-name set. Caps `maxDist` at 2 so + * `getUssr` finds `getUser` but `process` doesn't match `prosody`. + * Bounded edit distance keeps each comparison cheap; the per-query + * scan is O(distinct-name-count) which is far smaller than total + * node count on any real codebase. + */ + private searchNodesFuzzy( + text: string, + options: { kinds?: NodeKind[]; languages?: Language[]; limit: number } + ): SearchResult[] { + const { kinds, languages, limit } = options; + const lowered = text.toLowerCase(); + const maxDist = lowered.length <= 4 ? 1 : 2; + + // Pull the distinct name list once. The set is cached on QueryBuilder + // by getAllNodeNames(); even on a 200k-node project the distinct + // name set is typically O(10k) because most names repeat. The + // candidate-cap below bounds memory regardless. + const allNames = this.getAllNodeNames(); + const candidates: Array<{ name: string; dist: number }> = []; + for (const name of allNames) { + const dist = boundedEditDistance(name.toLowerCase(), lowered, maxDist); + if (dist <= maxDist) candidates.push({ name, dist }); + } + candidates.sort((a, b) => a.dist - b.dist); + + // Cap the per-name follow-up queries. Each survivor triggers a + // separate `SELECT * FROM nodes WHERE name = ?`; without this cap + // a project with many similar names (`getUser1`, `getUser2`...) + // could fan out far beyond `limit` queries before the inner-loop + // limit kicks in. + const FUZZY_FOLLOWUP_CAP = Math.max(limit * 2, 50); + const cappedCandidates = candidates.slice(0, FUZZY_FOLLOWUP_CAP); + + const results: SearchResult[] = []; + const seen = new Set(); + for (const c of cappedCandidates) { + if (results.length >= limit) break; + let sql = 'SELECT * FROM nodes WHERE name = ?'; + const params: (string | number)[] = [c.name]; + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + sql += ' LIMIT 5'; + const rows = this.db.prepare(sql).all(...params) as NodeRow[]; + for (const row of rows) { + if (seen.has(row.id)) continue; + seen.add(row.id); + // Lower the score for each edit step away from the query so + // exact-match fallbacks (dist 0) outrank dist-2 typos. + results.push({ node: rowToNode(row), score: 1 / (1 + c.dist) }); + if (results.length >= limit) break; + } + } return results; } diff --git a/src/search/query-parser.ts b/src/search/query-parser.ts new file mode 100644 index 00000000..05007287 --- /dev/null +++ b/src/search/query-parser.ts @@ -0,0 +1,184 @@ +/** + * Field-qualified search query parser. + * + * Splits a raw query like + * + * kind:function name:auth path:src/api authenticate + * + * into structured filters (kind=function, name="auth", path prefix + * "src/api") plus the free-text portion ("authenticate") that goes + * to FTS. Free-text and filters compose: filters narrow the result + * set, FTS scores within the narrowed set. + * + * Recognised fields (case-insensitive, value is the rest until + * whitespace): + * + * kind: one of function|method|class|interface|struct|... + * lang: one of typescript|python|go|... (alias: language:) + * path: case-insensitive substring of file_path + * name: case-insensitive substring of the symbol's name + * + * Unknown field prefixes (e.g. `foo:bar`) are passed through to FTS + * as plain text — that's how someone searching for `TODO:` gets a + * result instead of a parse error. + * + * Quoting: + * kind:function path:"src/some path/with spaces" → handled by stripping + * the surrounding double quotes from the value (single token only, + * no nested escapes). + */ + +import { NODE_KINDS, LANGUAGES } from '../types'; +import type { NodeKind, Language } from '../types'; + +export interface ParsedQuery { + /** Free-text portion to feed to FTS / LIKE. May be empty. */ + text: string; + /** kind: filters (OR'd). Empty when none specified. */ + kinds: NodeKind[]; + /** lang:/language: filters (OR'd). Empty when none specified. */ + languages: Language[]; + /** path: filters (OR'd, case-insensitive substring of file_path). Empty when none. */ + pathFilters: string[]; + /** name: filters (OR'd, case-insensitive substring of node.name). */ + nameFilters: string[]; +} + +// Derived from the canonical `NODE_KINDS` / `LANGUAGES` arrays in +// types.ts so adding a new kind or language doesn't silently fall +// through to plain text here. +const KIND_VALUES: ReadonlySet = new Set(NODE_KINDS); +const LANGUAGE_VALUES: ReadonlySet = new Set(LANGUAGES); + +/** + * Strip a surrounding pair of double quotes from `s`. Allows users to + * keep whitespace in path filters: `path:"my dir/file"`. + */ +function unquote(s: string): string { + if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1); + return s; +} + +/** + * Parse a raw query into structured filters + remaining text. + * Always returns a value; never throws. + */ +export function parseQuery(raw: string): ParsedQuery { + const out: ParsedQuery = { + text: '', + kinds: [], + languages: [], + pathFilters: [], + nameFilters: [], + }; + + // Tokenise on whitespace, preserving quoted spans as part of the + // current token. Quotes can appear at the start (`"…"`) OR mid-token + // (`path:"…"`); in both cases everything from the opening `"` to the + // matching `"` is included in the token, whitespace and all. + const tokens: string[] = []; + let i = 0; + while (i < raw.length) { + while (i < raw.length && /\s/.test(raw[i]!)) i++; + if (i >= raw.length) break; + const start = i; + while (i < raw.length && !/\s/.test(raw[i]!)) { + if (raw[i] === '"') { + const end = raw.indexOf('"', i + 1); + if (end === -1) { + // Unterminated quote — swallow the rest of the input as + // one token. Forgiving rather than throwing. + i = raw.length; + break; + } + i = end + 1; + continue; + } + i++; + } + tokens.push(raw.slice(start, i)); + } + + const textParts: string[] = []; + for (const tok of tokens) { + const colon = tok.indexOf(':'); + if (colon <= 0 || colon === tok.length - 1) { + textParts.push(tok); + continue; + } + const key = tok.slice(0, colon).toLowerCase(); + const valueRaw = unquote(tok.slice(colon + 1)); + if (!valueRaw) { + textParts.push(tok); + continue; + } + switch (key) { + case 'kind': { + if (KIND_VALUES.has(valueRaw)) { + out.kinds.push(valueRaw as NodeKind); + } else { + textParts.push(tok); + } + break; + } + case 'lang': + case 'language': { + const lower = valueRaw.toLowerCase(); + if (LANGUAGE_VALUES.has(lower)) { + out.languages.push(lower as Language); + } else { + textParts.push(tok); + } + break; + } + case 'path': + out.pathFilters.push(valueRaw); + break; + case 'name': + out.nameFilters.push(valueRaw); + break; + default: + textParts.push(tok); + } + } + + out.text = textParts.join(' ').trim(); + return out; +} + +/** + * Damerau-Levenshtein-ish bounded edit distance. Returns `maxDist + 1` + * as soon as the distance is known to exceed `maxDist`; that early-exit + * makes the fuzzy fallback cheap even over tens of thousands of names. + * + * Pure DP, O(min(len(a), len(b))) memory. Compares case-folded inputs; + * callers should pass `lowercase(name)` strings. + */ +export function boundedEditDistance(a: string, b: string, maxDist: number): number { + if (a === b) return 0; + const al = a.length; + const bl = b.length; + if (Math.abs(al - bl) > maxDist) return maxDist + 1; + if (al === 0) return bl; + if (bl === 0) return al; + + let prev = new Array(bl + 1); + let cur = new Array(bl + 1); + for (let j = 0; j <= bl; j++) prev[j] = j; + + for (let i = 1; i <= al; i++) { + cur[0] = i; + let rowMin = cur[0]!; + for (let j = 1; j <= bl; j++) { + const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1; + const insertion = cur[j - 1]! + 1; + const deletion = prev[j]! + 1; + const substitution = prev[j - 1]! + cost; + cur[j] = Math.min(insertion, deletion, substitution); + if (cur[j]! < rowMin) rowMin = cur[j]!; + } + if (rowMin > maxDist) return maxDist + 1; + [prev, cur] = [cur, prev]; + } + return prev[bl]!; +} diff --git a/src/types.ts b/src/types.ts index 6834483d..7c51407f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,31 +9,38 @@ // ============================================================================= /** - * Types of nodes in the knowledge graph + * Types of nodes in the knowledge graph. + * + * Defined as a runtime-iterable `as const` array so the same source + * of truth backs both the TS type and any runtime validation + * (e.g. the search query parser). */ -export type NodeKind = - | 'file' - | 'module' - | 'class' - | 'struct' - | 'interface' - | 'trait' - | 'protocol' - | 'function' - | 'method' - | 'property' - | 'field' - | 'variable' - | 'constant' - | 'enum' - | 'enum_member' - | 'type_alias' - | 'namespace' - | 'parameter' - | 'import' - | 'export' - | 'route' - | 'component'; +export const NODE_KINDS = [ + 'file', + 'module', + 'class', + 'struct', + 'interface', + 'trait', + 'protocol', + 'function', + 'method', + 'property', + 'field', + 'variable', + 'constant', + 'enum', + 'enum_member', + 'type_alias', + 'namespace', + 'parameter', + 'import', + 'export', + 'route', + 'component', +] as const; + +export type NodeKind = (typeof NODE_KINDS)[number]; /** * Types of edges (relationships) between nodes @@ -53,29 +60,33 @@ export type EdgeKind = | 'decorates'; // Decorator applied to symbol /** - * Supported programming languages + * Supported programming languages. See NODE_KINDS for why this is a + * runtime-iterable const array. */ -export type Language = - | 'typescript' - | 'javascript' - | 'tsx' - | 'jsx' - | 'python' - | 'go' - | 'rust' - | 'java' - | 'c' - | 'cpp' - | 'csharp' - | 'php' - | 'ruby' - | 'swift' - | 'kotlin' - | 'dart' - | 'svelte' - | 'liquid' - | 'pascal' - | 'unknown'; +export const LANGUAGES = [ + 'typescript', + 'javascript', + 'tsx', + 'jsx', + 'python', + 'go', + 'rust', + 'java', + 'c', + 'cpp', + 'csharp', + 'php', + 'ruby', + 'swift', + 'kotlin', + 'dart', + 'svelte', + 'liquid', + 'pascal', + 'unknown', +] as const; + +export type Language = (typeof LANGUAGES)[number]; // ============================================================================= // Core Graph Types From d151c0f922d197c662e90a3d62cdf61f09b50e55 Mon Sep 17 00:00:00 2001 From: andreinknv Date: Thu, 7 May 2026 21:53:16 -0400 Subject: [PATCH 04/83] feat(resolution): tsconfig path aliases + re-export chain following (#130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(resolution): tsconfig path aliases + re-export chain following Two related correctness improvements that unlock accurate import resolution on modern JS/TS codebases. 1) tsconfig/jsconfig path aliases. The resolver previously had a hard-coded list of common aliases (@/, ~/, src/, app/) and ignored any project-defined paths from tsconfig.json compilerOptions.paths — which means every import through @components/Foo, @lib/utils, etc. on Vite/Next/Nuxt/Nest projects silently failed to resolve. Adds src/resolution/path- aliases.ts that reads tsconfig.json (and falls back to jsconfig.json), honours baseUrl, supports the * wildcard, and respects the priority order of multiple replacement targets per alias. JSONC tolerant (strips comments + trailing commas, common in the wild). The new ResolutionContext.getProjectAliases() lazily loads + caches the result; resolveAliasedImport consults it before the legacy fallback list. Verified live on a synthetic project with @utils/* and @lib custom aliases: both resolved to the correct files and produced edges, unresolved_refs empty. 2) Re-export chain following. `import { Foo } from './barrel'` where barrel.ts only re-exports (`export { Foo } from './real'` or `export * from './real'`) used to fail because the resolver only looked for declarations IN the resolved file — it never followed the export chain to the actual definition. Adds extractReExports() (named + wildcard + as-rename forms), a per-file getReExports() context method, and a recursive findExportedSymbol() helper with depth cap (8) and visited-set cycle protection. resolveViaImport now uses it whenever the symbol isn't directly declared in the imported file. Verified live on a synthetic 3-hop chain (main → all.ts wildcard → index.ts named → auth.ts declaration): signIn resolved correctly, unresolved_refs empty. Full test suite: 380 passed, 0 failed. * fix(resolution): address reviewer findings — isExternalImport bypass, JSONC strings, comment stripping, optional context method Five fixes from independent semantic review: - isExternalImport now consults context.getProjectAliases() before the bare-specifier heuristic. Without this, custom prefixes like '@components/*' from tsconfig.paths were classified as npm and resolveAliasedImport never even ran. Adds a context parameter (optional, for backward compat with mock contexts). - stripJsonc rewritten as a string-aware state machine. The previous regex-only version corrupted any URL embedded in a JSON string value ('https://cdn.example.com' lost everything after '//'). - extractReExports now strips JS line+block comments from content before applying the regex, so a commented-out 'export { x } from ...' no longer creates a phantom re-export edge. New stripJsComments helper preserves string literals (single, double, template) so '//' inside a string stays intact. - ResolutionContext.getProjectAliases() made optional so existing mock contexts in __tests__/resolution.test.ts (which TypeScript doesn't type-check because tsconfig excludes __tests__) don't throw at runtime when resolveAliasedImport hits them. Caller uses ?. - Two new integration tests in __tests__/resolution.test.ts: * Path-alias resolution with name-collision: two pickMe() in different dirs, only the @utils-aliased one should be the call target. Asserts via getCallers on each candidate node. * No-tsconfig fallback: relative import still produces the call edge. Full test suite: 832 passed (was 380; the increase is from the biomarkers + LLM hooks that ship via parent branches). * fix(resolution): allow re-export rename chains past the pre-filter The fast pre-filter in resolveOne() bails when no symbol with the reference name exists project-wide, which is incompatible with the new chain-following code: a renamed re-export (`import { login } from './barrel'` where the barrel does `export { signIn as login } from './auth'`) intentionally calls a name that has no project-wide declaration. The chain finds the renamed upstream symbol — but only if resolution is allowed to run. Add an import-mapping escape so the pre-filter only bails when the ref also doesn't match any local import. Adds two tests covering the 3-hop wildcard chain and the named-rename branch. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Colby McHenry Co-authored-by: Claude Opus 4.7 (1M context) --- __tests__/resolution.test.ts | 135 ++++++++++++ src/resolution/import-resolver.ts | 344 +++++++++++++++++++++++++----- src/resolution/index.ts | 56 ++++- src/resolution/path-aliases.ts | 242 +++++++++++++++++++++ src/resolution/types.ts | 36 ++++ 5 files changed, 754 insertions(+), 59 deletions(-) create mode 100644 src/resolution/path-aliases.ts diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index b4f1a946..1ca3a3f8 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -711,4 +711,139 @@ def bootstrap(): expect(result?.targetNodeId).toBe('func:di.ts:Inject:10'); }); }); + + describe('tsconfig path aliases', () => { + it('resolves an aliased import to the alias-mapped file (not a same-named file elsewhere)', async () => { + // Two same-named exports in different directories. Without alias + // resolution, name-matcher would pick whichever it finds first; + // with alias resolution, the import path uniquely picks one. + fs.mkdirSync(path.join(tempDir, 'src/utils'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'src/legacy'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src/utils/format.ts'), + `export function pickMe(): number { return 1; }\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/legacy/format.ts'), + `export function pickMe(): number { return 99; }\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/main.ts'), + `import { pickMe } from '@utils/format';\nexport function go(): number { return pickMe(); }\n` + ); + fs.writeFileSync( + path.join(tempDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + baseUrl: './src', + paths: { '@utils/*': ['utils/*'] }, + }, + }) + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + // The two pickMe nodes live in different files. The aliased + // import should attach the call edge to the @utils-mapped one, + // not the legacy duplicate. + const all = cg.getNodesByKind('function').filter((n) => n.name === 'pickMe'); + const utilsNode = all.find((n) => n.filePath === 'src/utils/format.ts'); + const legacyNode = all.find((n) => n.filePath === 'src/legacy/format.ts'); + expect(utilsNode).toBeDefined(); + expect(legacyNode).toBeDefined(); + + const utilsCallers = cg.getCallers(utilsNode!.id); + const legacyCallers = cg.getCallers(legacyNode!.id); + expect(utilsCallers.length).toBeGreaterThan(0); + expect(utilsCallers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true); + // The legacy node should NOT have a caller from src/main.ts — + // the alias correctly picked the utils version. + expect(legacyCallers.some((c) => c.node.filePath === 'src/main.ts')).toBe(false); + }); + + it('falls back gracefully when tsconfig is absent', async () => { + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src/a.ts'), + `export function aFn(): void {}\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/b.ts'), + `import { aFn } from './a';\nexport function bFn(): void { aFn(); }\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + // No tsconfig present — index should still complete and the + // relative-import-based call edge should be created. + const aFn = cg.getNodesByKind('function').find((n) => n.name === 'aFn'); + expect(aFn).toBeDefined(); + const callers = cg.getCallers(aFn!.id); + expect(callers.some((c) => c.node.filePath === 'src/b.ts')).toBe(true); + }); + }); + + describe('re-export chain following', () => { + it('chases a 3-hop barrel chain (wildcard → named → declaration)', async () => { + // main.ts → all.ts (wildcard) → index.ts (named) → auth.ts (declaration). + // Without chain following, `signIn` resolves to nothing because + // none of the barrel files declare it directly. + fs.mkdirSync(path.join(tempDir, 'src/services'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src/services/auth.ts'), + `export function signIn(): void {}\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/services/index.ts'), + `export { signIn } from './auth';\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/all.ts'), + `export * from './services/index';\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/main.ts'), + `import { signIn } from './all';\nexport function go(): void { signIn(); }\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const signInNode = cg + .getNodesByKind('function') + .find((n) => n.name === 'signIn' && n.filePath === 'src/services/auth.ts'); + expect(signInNode).toBeDefined(); + const callers = cg.getCallers(signInNode!.id); + expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true); + }); + + it('follows a renamed named re-export (export { foo as bar } from ...)', async () => { + // The chase has to look up `foo` in the upstream module even + // though the importer asked for `bar` — exercises the rename + // branch of findExportedSymbol. + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src/auth.ts'), + `export function signIn(): void {}\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/index.ts'), + `export { signIn as login } from './auth';\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/main.ts'), + `import { login } from './index';\nexport function go(): void { login(); }\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const signInNode = cg + .getNodesByKind('function') + .find((n) => n.name === 'signIn' && n.filePath === 'src/auth.ts'); + expect(signInNode).toBeDefined(); + const callers = cg.getCallers(signInNode!.id); + expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true); + }); + }); }); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index a2c509dc..5b41a57d 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -6,7 +6,8 @@ import * as path from 'path'; import { Language, Node } from '../types'; -import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping } from './types'; +import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types'; +import { applyAliases } from './path-aliases'; /** * Extension resolution order by language @@ -34,8 +35,11 @@ export function resolveImportPath( language: Language, context: ResolutionContext ): string | null { - // Skip external/npm packages - if (isExternalImport(importPath, language)) { + // Skip external/npm packages — but pass the context so the + // bare-specifier heuristic can consult the project's tsconfig + // alias map first (custom prefixes like `@components/*` would + // otherwise be misclassified as npm). + if (isExternalImport(importPath, language, context)) { return null; } @@ -53,8 +57,17 @@ export function resolveImportPath( /** * Check if an import is external (npm package, etc.) + * + * `context` is consulted for project-defined path aliases + * (tsconfig/jsconfig `paths`). Without that check, custom prefixes + * like `@components/*` would fail the bare-specifier heuristic and + * be classified as external before alias resolution can run. */ -function isExternalImport(importPath: string, language: Language): boolean { +function isExternalImport( + importPath: string, + language: Language, + context?: ResolutionContext +): boolean { // Relative imports are not external if (importPath.startsWith('.')) { return false; @@ -66,6 +79,13 @@ function isExternalImport(importPath: string, language: Language): boolean { if (['fs', 'path', 'os', 'crypto', 'http', 'https', 'url', 'util', 'events', 'stream', 'child_process', 'buffer'].includes(importPath)) { return true; } + // Project-defined alias prefix? Treat as local. + const aliases = context?.getProjectAliases?.(); + if (aliases) { + for (const pat of aliases.patterns) { + if (importPath.startsWith(pat.prefix)) return false; + } + } // Scoped packages or bare specifiers that don't start with aliases if (!importPath.startsWith('@/') && !importPath.startsWith('~/') && !importPath.startsWith('src/')) { // Likely an npm package @@ -124,18 +144,45 @@ function resolveRelativeImport( } /** - * Resolve an aliased/absolute import + * Resolve an aliased/absolute import. + * + * Tries, in order: + * 1. Project-defined `compilerOptions.paths` (tsconfig/jsconfig). + * Each pattern can have multiple replacements; tried in tsconfig + * priority order with extension permutations. + * 2. The legacy hard-coded fallback list (`@/`, `~/`, `src/`, ...) + * for projects that have aliases but no tsconfig paths block. + * 3. Direct path lookup (with extensions). */ function resolveAliasedImport( importPath: string, - _projectRoot: string, + projectRoot: string, language: Language, context: ResolutionContext ): string | null { const extensions = EXTENSION_RESOLUTION[language] || []; + const tryWithExt = (basePath: string): string | null => { + for (const ext of extensions) { + const candidate = basePath + ext; + if (context.fileExists(candidate)) return candidate; + } + if (context.fileExists(basePath)) return basePath; + return null; + }; - // Common aliases - const aliases: Record = { + // 1. Project tsconfig/jsconfig paths. + const aliasMap = context.getProjectAliases?.(); + if (aliasMap) { + const candidates = applyAliases(importPath, aliasMap, projectRoot); + for (const c of candidates) { + const hit = tryWithExt(c); + if (hit) return hit; + } + } + + // 2. Hard-coded fallback list. Kept for projects that use these + // conventional aliases without declaring them in tsconfig. + const fallbackAliases: Record = { '@/': 'src/', '~/': 'src/', '@src/': 'src/', @@ -143,36 +190,15 @@ function resolveAliasedImport( '@app/': 'app/', 'app/': 'app/', }; - - // Try each alias - for (const [alias, replacement] of Object.entries(aliases)) { + for (const [alias, replacement] of Object.entries(fallbackAliases)) { if (importPath.startsWith(alias)) { - const resolvedPath = importPath.replace(alias, replacement); - - // Try with extensions - for (const ext of extensions) { - const candidatePath = resolvedPath + ext; - if (context.fileExists(candidatePath)) { - return candidatePath; - } - } - - // Try as-is - if (context.fileExists(resolvedPath)) { - return resolvedPath; - } + const hit = tryWithExt(importPath.replace(alias, replacement)); + if (hit) return hit; } } - // Try direct path - for (const ext of extensions) { - const candidatePath = importPath + ext; - if (context.fileExists(candidatePath)) { - return candidatePath; - } - } - - return null; + // 3. Direct path. + return tryWithExt(importPath); } /** @@ -435,6 +461,127 @@ export function clearImportMappingCache(): void { importMappingCache.clear(); } +/** + * Strip JS line + block comments from `content` while preserving + * string literals (so `"//"` inside a string stays intact). Used by + * {@link extractReExports} so commented-out export-from statements + * don't generate phantom re-export edges. + * + * Scanner is deliberately small: it only tracks the three contexts + * relevant for JS/TS — single-quote string, double-quote string, and + * template literal. Comment recognition is the JS spec subset, no + * regex-literal awareness (which is fine for our use case: we don't + * apply this to function bodies, only to top-level files). + */ +function stripJsComments(content: string): string { + let out = ''; + let i = 0; + let str: '"' | "'" | '`' | null = null; + while (i < content.length) { + const ch = content[i]!; + if (str !== null) { + out += ch; + if (ch === '\\' && i + 1 < content.length) { + out += content[i + 1]!; + i += 2; + continue; + } + if (ch === str) str = null; + i++; + continue; + } + if (ch === '"' || ch === "'" || ch === '`') { + str = ch; + out += ch; + i++; + continue; + } + if (ch === '/' && content[i + 1] === '/') { + while (i < content.length && content[i] !== '\n') i++; + continue; + } + if (ch === '/' && content[i + 1] === '*') { + i += 2; + while (i < content.length && !(content[i] === '*' && content[i + 1] === '/')) i++; + i += 2; + continue; + } + out += ch; + i++; + } + return out; +} + +/** + * Extract JS/TS re-export declarations from `content`. + * + * Recognised forms: + * export { foo } from './a'; + * export { foo as bar } from './a'; + * export * from './a'; + * export * as ns from './a'; (treated as wildcard for chasing) + * export { default as Foo } from './a'; + * + * The walker intentionally stays regex-based — the import-resolver + * elsewhere in this file already chooses regex over a fresh + * tree-sitter pass, and this function shares that trade-off. Errors + * fall through silently; resolution simply skips the broken file. + */ +export function extractReExports(content: string, language: Language): ReExport[] { + if ( + language !== 'typescript' && + language !== 'javascript' && + language !== 'tsx' && + language !== 'jsx' + ) { + return []; + } + const out: ReExport[] = []; + + // Pre-strip block comments + line comments so a commented-out + // `// export { x } from '...'` doesn't produce a phantom edge. + // (Template literals are still a possible source of false positives; + // a project that builds export statements as runtime strings is + // out of scope.) + const cleaned = stripJsComments(content); + + // Wildcard: `export * from '...'` or `export * as ns from '...'` + const wildcardRe = /export\s*\*(?:\s+as\s+\w+)?\s*from\s*['"]([^'"]+)['"]/g; + let m: RegExpExecArray | null; + while ((m = wildcardRe.exec(cleaned)) !== null) { + out.push({ kind: 'wildcard', source: m[1]! }); + } + + // Named: `export { a, b as c } from '...'` + const namedRe = /export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g; + while ((m = namedRe.exec(cleaned)) !== null) { + const inner = m[1]!; + const source = m[2]!; + for (const raw of inner.split(',')) { + const item = raw.trim(); + if (!item) continue; + const aliasMatch = item.match(/^(\w+)\s+as\s+(\w+)$/); + if (aliasMatch) { + out.push({ + kind: 'named', + exportedName: aliasMatch[2]!, + originalName: aliasMatch[1]!, + source, + }); + } else if (/^\w+$/.test(item)) { + out.push({ + kind: 'named', + exportedName: item, + originalName: item, + source, + }); + } + } + } + + return out; +} + /** * Resolve a reference using import mappings */ @@ -460,30 +607,18 @@ export function resolveViaImport( ); if (resolvedPath) { - // Find the exported symbol in the resolved file - const nodesInFile = context.getNodesInFile(resolvedPath); const exportedName = imp.isDefault ? 'default' : imp.exportedName; - - // Look for the symbol - let targetNode: Node | undefined; - - if (imp.isDefault) { - // Find default export or main class/function - targetNode = nodesInFile.find( - (n) => n.isExported && (n.kind === 'function' || n.kind === 'class') - ); - } else if (imp.isNamespace) { - // Namespace import - look for the specific member - const memberName = ref.referenceName.replace(imp.localName + '.', ''); - targetNode = nodesInFile.find( - (n) => n.name === memberName && n.isExported - ); - } else { - // Named import - targetNode = nodesInFile.find( - (n) => n.name === exportedName && n.isExported - ); - } + const memberName = imp.isNamespace + ? ref.referenceName.replace(imp.localName + '.', '') + : null; + + const targetNode = findExportedSymbol( + resolvedPath, + { isDefault: imp.isDefault, isNamespace: imp.isNamespace, exportedName, memberName }, + ref.language, + context, + new Set() + ); if (targetNode) { return { @@ -499,3 +634,98 @@ export function resolveViaImport( return null; } + +/** Recursive depth cap for re-export chain following. Real codebases + * rarely chain barrels more than 2–3 deep; 8 is a generous safety + * net that still bounds worst-case work. */ +const REEXPORT_MAX_DEPTH = 8; + +/** + * Find an exported symbol in `filePath`, following `export { x } from + * './other'` and `export * from './other'` chains until the original + * declaration is reached. Cycle-safe via the `visited` set. + * + * Without this, every barrel-style import (`import { Foo } from + * './index'` where `index.ts` only re-exports) used to resolve to + * nothing — the existing code only looked for declarations IN the + * resolved file, not declarations the file forwarded. + */ +function findExportedSymbol( + filePath: string, + want: { + isDefault: boolean; + isNamespace: boolean; + exportedName: string; + memberName: string | null; + }, + language: Language, + context: ResolutionContext, + visited: Set, + depth = 0 +): Node | undefined { + if (depth > REEXPORT_MAX_DEPTH) return undefined; + if (visited.has(filePath)) return undefined; + visited.add(filePath); + + const nodesInFile = context.getNodesInFile(filePath); + + // 1. Direct hit: the symbol is declared in this file. + if (want.isDefault) { + const direct = nodesInFile.find( + (n) => n.isExported && (n.kind === 'function' || n.kind === 'class') + ); + if (direct) return direct; + } else if (want.isNamespace && want.memberName) { + const direct = nodesInFile.find( + (n) => n.name === want.memberName && n.isExported + ); + if (direct) return direct; + } else { + const direct = nodesInFile.find( + (n) => n.name === want.exportedName && n.isExported + ); + if (direct) return direct; + } + + // 2. Re-export hit: the file forwards the symbol to another module. + const reExports = context.getReExports?.(filePath, language) ?? []; + if (reExports.length === 0) return undefined; + + // Look for explicit `export { want } from './other'` (with optional rename). + const targetName = want.isDefault ? 'default' : want.exportedName; + for (const rex of reExports) { + if (rex.kind === 'named' && rex.exportedName === targetName) { + const next = resolveImportPath(rex.source, filePath, language, context); + if (!next) continue; + // After rename: `export { foo as bar } from './x'` — to chase + // `bar`, we look for `foo` in `./x`. + const chained = findExportedSymbol( + next, + { + isDefault: rex.originalName === 'default', + isNamespace: false, + exportedName: rex.originalName, + memberName: null, + }, + language, + context, + visited, + depth + 1 + ); + if (chained) return chained; + } + } + + // 3. Wildcard re-export: `export * from './other'` — try every + // forwarding source. This is the barrel-of-barrels case. + for (const rex of reExports) { + if (rex.kind === 'wildcard') { + const next = resolveImportPath(rex.source, filePath, language, context); + if (!next) continue; + const chained = findExportedSymbol(next, want, language, context, visited, depth + 1); + if (chained) return chained; + } + } + + return undefined; +} diff --git a/src/resolution/index.ts b/src/resolution/index.ts index dbc13a84..f5c22348 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -17,9 +17,11 @@ import { ImportMapping, } from './types'; import { matchReference } from './name-matcher'; -import { resolveViaImport, extractImportMappings } from './import-resolver'; +import { resolveViaImport, extractImportMappings, extractReExports } from './import-resolver'; import { detectFrameworks } from './frameworks'; +import { loadProjectAliases, type AliasMap } from './path-aliases'; import { logDebug } from '../errors'; +import type { ReExport } from './types'; // Re-export types export * from './types'; @@ -122,12 +124,17 @@ export class ReferenceResolver { private nodeCache: Map = new Map(); // per-file node cache (bounded) private fileCache: Map = new Map(); // per-file content cache (bounded) private importMappingCache: Map = new Map(); + private reExportCache: Map = new Map(); private nameCache: Map = new Map(); // name → nodes cache private lowerNameCache: Map = new Map(); // lower(name) → nodes cache private qualifiedNameCache: Map = new Map(); // qualified_name → nodes cache private knownNames: Set | null = null; // all known symbol names for fast pre-filtering private knownFiles: Set | null = null; private cachesWarmed = false; + // tsconfig/jsconfig path-alias map. `undefined` = not yet computed, + // `null` = computed and absent. Treated as immutable for the + // resolver's lifetime; callers re-create the resolver if config changes. + private projectAliases: AliasMap | null | undefined = undefined; constructor(projectRoot: string, queries: QueryBuilder) { this.projectRoot = projectRoot; @@ -168,6 +175,7 @@ export class ReferenceResolver { this.nodeCache.clear(); this.fileCache.clear(); this.importMappingCache.clear(); + this.reExportCache.clear(); this.nameCache.clear(); this.lowerNameCache.clear(); this.qualifiedNameCache.clear(); @@ -272,6 +280,26 @@ export class ReferenceResolver { this.importMappingCache.set(cacheKey, mappings); return mappings; }, + + getProjectAliases: () => { + if (this.projectAliases === undefined) { + this.projectAliases = loadProjectAliases(this.projectRoot); + } + return this.projectAliases; + }, + + getReExports: (filePath: string, language) => { + const cached = this.reExportCache.get(filePath); + if (cached) return cached; + const content = this.context.readFile(filePath); + if (!content) { + this.reExportCache.set(filePath, []); + return []; + } + const reExports = extractReExports(content, language); + this.reExportCache.set(filePath, reExports); + return reExports; + }, }; } @@ -379,6 +407,25 @@ export class ReferenceResolver { return false; } + /** + * Does `ref.referenceName` match an import declared in its containing + * file? Used as a pre-filter escape so re-export chain resolution + * still gets a chance when the name has no project-wide declaration. + */ + private matchesAnyImport(ref: UnresolvedRef): boolean { + const imports = this.context.getImportMappings(ref.filePath, ref.language); + if (imports.length === 0) return false; + for (const imp of imports) { + if ( + imp.localName === ref.referenceName || + ref.referenceName.startsWith(imp.localName + '.') + ) { + return true; + } + } + return false; + } + /** * Resolve a single reference */ @@ -389,7 +436,12 @@ export class ReferenceResolver { } // Fast pre-filter: skip if no symbol with this name exists anywhere - if (!this.hasAnyPossibleMatch(ref.referenceName)) { + // AND the name doesn't match a local import. The import escape is + // necessary because re-export rename chains (`import { login } + // from './barrel'` where the barrel has `export { signIn as login } + // from './auth'`) intentionally call a name that has no + // declaration anywhere — only the renamed upstream symbol does. + if (!this.hasAnyPossibleMatch(ref.referenceName) && !this.matchesAnyImport(ref)) { return null; } diff --git a/src/resolution/path-aliases.ts b/src/resolution/path-aliases.ts new file mode 100644 index 00000000..362baac7 --- /dev/null +++ b/src/resolution/path-aliases.ts @@ -0,0 +1,242 @@ +/** + * Project-level import-path alias loading. + * + * Reads `compilerOptions.paths` from `tsconfig.json` / `jsconfig.json` + * at the project root and converts the patterns into a form the + * import-resolver can consult. + * + * This is the single biggest blocker to accurate resolution on modern + * JS/TS codebases: aliases like `@/components/Foo` (Next, Nuxt, Nest, + * Vite scaffolds) point into a `paths` map the resolver previously + * ignored — every import through an alias was treated as unresolvable + * unless it happened to match the small hard-coded fallback list. + * + * Scope deliberately small for v1: + * - reads tsconfig.json, then jsconfig.json + * - honours top-level `compilerOptions.baseUrl` and `compilerOptions.paths` + * - supports `*` wildcard (the only TS-supported wildcard) + * - does NOT follow `extends` chains yet (most projects don't need it) + * - does NOT read Vite/webpack/Rollup configs (separate follow-up) + * + * The file is parsed as JSON-with-comments-tolerant — tsconfigs in the + * wild routinely contain `//` and `/* *\/` comments and trailing + * commas, which JSON.parse rejects. We strip those before parsing. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { logDebug } from '../errors'; + +/** A single alias pattern from `compilerOptions.paths`. */ +export interface AliasPattern { + /** The literal prefix before `*` (or the whole pattern if no `*`). */ + prefix: string; + /** The literal suffix after `*` (almost always empty). */ + suffix: string; + /** Whether the pattern contains a `*` wildcard. */ + hasWildcard: boolean; + /** + * Replacement templates. When `hasWildcard` is true, `*` in the + * replacement is filled with the captured wildcard portion of the + * import path. Stored relative to {@link AliasMap.baseUrl}. + * tsconfig allows multiple targets per alias (priority order). + */ + replacements: string[]; +} + +export interface AliasMap { + /** Absolute path. The directory `compilerOptions.paths` is rooted at. */ + baseUrl: string; + /** + * Patterns ordered by specificity: longer prefix first, then literal- + * before-wildcard, so the resolver tries the most-specific match. + */ + patterns: AliasPattern[]; +} + +/** + * Strip JSONC comments + trailing commas so a tsconfig with the usual + * VS Code-style annotations parses cleanly. Walks the source as a + * tiny state machine that tracks string context — the previous + * regex-only version corrupted any URL inside a string value + * (`"baseUrl": "https://cdn.example.com"` had everything after `//` + * truncated). + */ +function stripJsonc(src: string): string { + let out = ''; + let i = 0; + let inString = false; + while (i < src.length) { + const ch = src[i]!; + if (inString) { + out += ch; + if (ch === '\\' && i + 1 < src.length) { + out += src[i + 1]!; + i += 2; + continue; + } + if (ch === '"') inString = false; + i++; + continue; + } + if (ch === '"') { + inString = true; + out += ch; + i++; + continue; + } + if (ch === '/' && src[i + 1] === '/') { + while (i < src.length && src[i] !== '\n') i++; + continue; + } + if (ch === '/' && src[i + 1] === '*') { + i += 2; + while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++; + i += 2; + continue; + } + out += ch; + i++; + } + // Trailing commas before } or ] — outside strings, so safe to + // run on the comment-stripped output. + return out.replace(/,(\s*[}\]])/g, '$1'); +} + +interface RawTsconfig { + compilerOptions?: { + baseUrl?: string; + paths?: Record; + }; +} + +function readTsconfigLike(filePath: string): RawTsconfig | null { + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(stripJsonc(raw)) as RawTsconfig; + return parsed && typeof parsed === 'object' ? parsed : null; + } catch (err) { + logDebug('path-aliases: failed to parse', { filePath, err: String(err) }); + return null; + } +} + +function splitWildcard(pattern: string): { + prefix: string; + suffix: string; + hasWildcard: boolean; +} { + const star = pattern.indexOf('*'); + if (star === -1) return { prefix: pattern, suffix: '', hasWildcard: false }; + return { + prefix: pattern.slice(0, star), + suffix: pattern.slice(star + 1), + hasWildcard: true, + }; +} + +/** + * Load aliases for `projectRoot`. Returns `null` when no tsconfig / + * jsconfig is present or when the file has no usable `paths`. + * + * Cheap to call repeatedly — caching is the caller's job (the + * resolver does it via {@link aliasCache}). + */ +export function loadProjectAliases(projectRoot: string): AliasMap | null { + const candidates = ['tsconfig.json', 'jsconfig.json']; + let raw: RawTsconfig | null = null; + let usedFile: string | null = null; + for (const name of candidates) { + const p = path.join(projectRoot, name); + if (fs.existsSync(p)) { + raw = readTsconfigLike(p); + if (raw) { + usedFile = name; + break; + } + } + } + if (!raw) return null; + + const co = raw.compilerOptions ?? {}; + const baseUrlRel = co.baseUrl ?? '.'; + const baseUrl = path.resolve(projectRoot, baseUrlRel); + + const paths = co.paths; + if (!paths || typeof paths !== 'object') { + // baseUrl alone isn't an "alias" per se; with no paths we'd just + // be redirecting the whole tree. Skip — the existing resolver + // already handles relative imports. + return null; + } + + const patterns: AliasPattern[] = []; + for (const [pattern, targets] of Object.entries(paths)) { + if (!Array.isArray(targets) || targets.length === 0) continue; + const filtered = targets.filter((t): t is string => typeof t === 'string'); + if (filtered.length === 0) continue; + const { prefix, suffix, hasWildcard } = splitWildcard(pattern); + patterns.push({ prefix, suffix, hasWildcard, replacements: filtered }); + } + + if (patterns.length === 0) return null; + + // Specificity sort: longer prefix first; literal patterns before + // wildcard patterns of the same prefix length. TypeScript itself + // uses a similar "most specific match wins" rule. + patterns.sort((a, b) => { + if (a.prefix.length !== b.prefix.length) return b.prefix.length - a.prefix.length; + if (a.hasWildcard !== b.hasWildcard) return a.hasWildcard ? 1 : -1; + return 0; + }); + + logDebug('path-aliases loaded', { + file: usedFile, + baseUrl, + patternCount: patterns.length, + }); + + return { baseUrl, patterns }; +} + +/** + * Resolve an import path through an {@link AliasMap}. Returns the list + * of candidate filesystem paths (relative to `projectRoot`), in the + * priority order defined by tsconfig (multiple replacements per alias + * are tried in order). Returns `[]` when no alias matches. + * + * Callers still need to try each candidate with the language's + * extension list — this function only does the alias rewrite. + */ +export function applyAliases( + importPath: string, + aliases: AliasMap, + projectRoot: string +): string[] { + for (const pat of aliases.patterns) { + if (!importPath.startsWith(pat.prefix)) continue; + if (pat.suffix && !importPath.endsWith(pat.suffix)) continue; + + let captured = ''; + if (pat.hasWildcard) { + captured = importPath.slice(pat.prefix.length, importPath.length - pat.suffix.length); + } else if (importPath !== pat.prefix) { + // Literal pattern must match exactly. + continue; + } + + const out: string[] = []; + for (const target of pat.replacements) { + const filled = pat.hasWildcard ? target.replace('*', captured) : target; + // baseUrl is absolute; produce a path relative to projectRoot + const absolute = path.resolve(aliases.baseUrl, filled); + const relative = path.relative(projectRoot, absolute); + // Skip if the rewrite escapes the project root (unsafe + can't + // be looked up via the file index anyway). + if (relative.startsWith('..')) continue; + out.push(relative.replace(/\\/g, '/')); + } + return out; + } + return []; +} diff --git a/src/resolution/types.ts b/src/resolution/types.ts index f2e9c485..d9bbb9fa 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -83,6 +83,21 @@ export interface ResolutionContext { getNodesByLowerName(lowerName: string): Node[]; /** Get cached import mappings for a file */ getImportMappings(filePath: string, language: Language): ImportMapping[]; + /** + * Project import-path aliases (tsconfig/jsconfig `paths`). Returns + * `null` when the project doesn't define any. Cached per resolver + * instance — safe to call from any resolver code path. Optional so + * existing test fixtures and external context implementations + * compile without modification; production resolver implements it. + */ + getProjectAliases?(): import('./path-aliases').AliasMap | null; + /** + * Re-exports declared by a file (`export { x } from './other'`, + * `export * from './other'`). Empty array when the file has none. + * Optional so older callers compile; the import resolver follows + * re-export chains when this is provided. + */ + getReExports?(filePath: string, language: Language): ReExport[]; } /** @@ -116,3 +131,24 @@ export interface ImportMapping { /** Resolved file path (if local) */ resolvedPath?: string; } + +/** + * Re-export from a file: `export { x } from './other'` or + * `export * from './other'`. Used by the resolver to chase + * symbols through barrel files. + */ +export type ReExport = + | { + kind: 'named'; + /** Name as exported by THIS file. */ + exportedName: string; + /** Name in the upstream module (differs when renamed: `as`). */ + originalName: string; + /** Module specifier of the upstream module. */ + source: string; + } + | { + kind: 'wildcard'; + /** Module specifier of the upstream module. */ + source: string; + }; From 4f6c51d381c1e50afd0a218e8633634c6a368a81 Mon Sep 17 00:00:00 2001 From: andreinknv Date: Thu, 7 May 2026 21:59:26 -0400 Subject: [PATCH 05/83] fix(extraction): drop duplicate export-var nodes and honour maxFileSize in bulk path (#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness bugs in the core extraction pipeline, surfaced by an adversarial stress corpus (5k synthetic export-const declarations plus a deliberate 8MB single-line file): 1) Every `export const X = ...` produced TWO nodes for the same symbol — one kind:'variable' from extractExportedVariables, plus one kind:'constant' from extractVariable (called when the walker descended into the export_statement child). Stress test showed 100% duplication across 5,003 export-const declarations. The dedicated extractVariable dispatch is the correct one — it picks kind from isConst, captures the initializer signature, and walks type annotations; the export-statement helper was redundant because the language extractors' isExported predicate already walks parent chains. Remove the export_statement branch from the dispatch (children are descended into normally) and drop the private helper. 2) The bulk indexAll path read each file's stats but never compared stats.size against config.maxFileSize. Vendored generated files (multi-MB headers, minified bundles, etc.) were indexed regardless of the user's size cap. The single-file extractFile path enforced it; only the bulk path was missing the check. Mirror the single-file behaviour: emit a 'size_exceeded' warning, count the file as skipped, advance progress, and continue. On the stress workspace (5,005 synthetic files; 50,000 fns in one 3MB file; 8MB single-line file; 5,000 export-const declarations): before: 65,014 nodes (100% var/const duplication, every >1MB file indexed despite maxFileSize=1MB) after: 10,008 nodes (0 duplicates, large files correctly skipped with size_exceeded warnings) Tests calibrated to the duplicate behavior were updated to look for kind:'constant' on `export const`, which is the correct kind. Full suite: 380 passed (was 374 passed, 6 failed before this fix). --- __tests__/extraction.test.ts | 12 +++--- src/extraction/index.ts | 20 +++++++++ src/extraction/tree-sitter.ts | 77 ++++++++--------------------------- 3 files changed, 44 insertions(+), 65 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index f9809e53..3c754635 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -376,7 +376,7 @@ export const useUIStore = create((set) => ({ `; const result = extractFromSource('store.ts', code); - const varNode = result.nodes.find((n) => n.kind === 'variable' && n.name === 'useUIStore'); + const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'useUIStore'); expect(varNode).toBeDefined(); expect(varNode?.isExported).toBe(true); }); @@ -390,7 +390,7 @@ export const config = { `; const result = extractFromSource('config.ts', code); - const varNode = result.nodes.find((n) => n.kind === 'variable' && n.name === 'config'); + const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'config'); expect(varNode).toBeDefined(); expect(varNode?.isExported).toBe(true); }); @@ -401,7 +401,7 @@ export const SCREEN_NAMES = ['home', 'settings', 'profile'] as const; `; const result = extractFromSource('constants.ts', code); - const varNode = result.nodes.find((n) => n.kind === 'variable' && n.name === 'SCREEN_NAMES'); + const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'SCREEN_NAMES'); expect(varNode).toBeDefined(); expect(varNode?.isExported).toBe(true); }); @@ -413,7 +413,7 @@ export const API_VERSION = "v2"; `; const result = extractFromSource('constants.ts', code); - const variables = result.nodes.filter((n) => n.kind === 'variable'); + const variables = result.nodes.filter((n) => n.kind === 'constant'); expect(variables).toHaveLength(2); expect(variables.map((n) => n.name).sort()).toEqual(['API_VERSION', 'MAX_RETRIES']); }); @@ -457,7 +457,7 @@ export const userSchema = z.object({ `; const result = extractFromSource('schemas.ts', code); - const varNode = result.nodes.find((n) => n.kind === 'variable' && n.name === 'userSchema'); + const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'userSchema'); expect(varNode).toBeDefined(); expect(varNode?.isExported).toBe(true); }); @@ -475,7 +475,7 @@ export const authMachine = createMachine({ `; const result = extractFromSource('machine.ts', code); - const varNode = result.nodes.find((n) => n.kind === 'variable' && n.name === 'authMachine'); + const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'authMachine'); expect(varNode).toBeDefined(); expect(varNode?.isExported).toBe(true); }); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 4ad056fb..04681b27 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -689,6 +689,26 @@ export class ExtractionOrchestrator { continue; } + // Honour config.maxFileSize. Without this check, vendored + // generated headers, minified bundles, and other multi-MB + // files get indexed despite the user setting a size cap — + // wasting WASM heap and the worker recycle budget on inputs + // the user explicitly opted out of. The single-file extractFile + // path already enforces this; the bulk path used to silently + // skip the check. + if (stats.size > this.config.maxFileSize) { + processed++; + filesSkipped++; + errors.push({ + message: `File exceeds max size (${stats.size} > ${this.config.maxFileSize})`, + filePath, + severity: 'warning', + code: 'size_exceeded', + }); + onProgress?.({ phase: 'parsing', current: processed, total }); + continue; + } + // Parse in worker thread (main thread stays unblocked). // Wrapped in try/catch to handle worker timeouts and crashes gracefully. let result: ExtractionResult; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 24b158d4..2dd9710d 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -331,12 +331,19 @@ export class TreeSitterExtractor { this.extractVariable(node); skipChildren = true; // extractVariable handles children } - // Check for export statements containing non-function variable declarations - // e.g. `export const X = create(...)`, `export const X = { ... }` - else if (nodeType === 'export_statement') { - this.extractExportedVariables(node); - // Don't skip children — still need to visit inner nodes (functions, calls, etc.) - } + // `export_statement` itself is not extracted — the walker descends + // into children, where the inner declaration (lexical_declaration, + // function_declaration, class_declaration, etc.) is dispatched to + // its own extractor. `isExported` walks the parent chain, so the + // exported flag is preserved automatically. + // + // Calling extractExportedVariables here AND descending caused every + // `export const X = ...` to produce two nodes for the same symbol — + // one kind:'variable' from extractExportedVariables and one + // kind:'constant' from extractVariable. The dedicated dispatch is + // the correct one (it picks kind from isConst, captures the + // initializer signature, and walks type annotations); the + // export-statement helper was redundant. // Check for imports else if (this.extractor.importTypes.includes(nodeType)) { this.extractImport(node); @@ -1213,59 +1220,11 @@ export class TreeSitterExtractor { return false; } - /** - * Extract an exported variable declaration that isn't a function. - * Handles patterns like: - * export const X = create(...) - * export const X = { ... } - * export const X = [...] - * export const X = "value" - * - * This is called for `export_statement` nodes that contain a - * `lexical_declaration` with `variable_declarator` children whose - * values are NOT already handled by functionTypes (arrow_function, - * function_expression). - */ - private extractExportedVariables(exportNode: SyntaxNode): void { - if (!this.extractor) return; - - // Find the lexical_declaration or variable_declaration child - for (let i = 0; i < exportNode.namedChildCount; i++) { - const decl = exportNode.namedChild(i); - if (!decl || (decl.type !== 'lexical_declaration' && decl.type !== 'variable_declaration')) { - continue; - } - - // Iterate over each variable_declarator in the declaration - for (let j = 0; j < decl.namedChildCount; j++) { - const declarator = decl.namedChild(j); - if (!declarator || declarator.type !== 'variable_declarator') continue; - - const nameNode = getChildByField(declarator, 'name'); - if (!nameNode) continue; - const name = getNodeText(nameNode, this.source); - - // Skip if the value is a function type — those are already handled - // by extractFunction via the functionTypes dispatch - const value = getChildByField(declarator, 'value'); - if (value) { - const valueType = value.type; - if ( - this.extractor.functionTypes.includes(valueType) - ) { - continue; // Already handled by extractFunction - } - } - - const docstring = getPrecedingDocstring(exportNode, this.source); - - this.createNode('variable', name, declarator, { - docstring, - isExported: true, - }); - } - } - } + // extractExportedVariables removed — the walker now descends into + // export_statement children and the inner declaration's dedicated + // extractor (extractVariable, extractFunction, extractClass, etc.) + // handles the symbol with isExported=true via parent-walk in the + // language extractor's isExported predicate. /** * Extract an import From 5e5d8d9447ff0ff76a35d7b0458e671568c9162d Mon Sep 17 00:00:00 2001 From: andreinknv Date: Thu, 7 May 2026 22:02:20 -0400 Subject: [PATCH 06/83] fix(cli): surface lock-acquisition errors and silence Emscripten Aborted() spam (#128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): surface lock-acquisition errors and silence Emscripten Aborted() spam Two unrelated cosmetic but actively misleading bugs that surface when the indexer is under load. 1) printIndexResult fell through to "No files found to index" whenever the IndexResult had filesIndexed=0 AND filesErrored=0. The lock-acquisition path returns success:false with a generic "Could not acquire file lock" entry in result.errors[] (severity 'error'), but filesErrored counts only file-level parse failures, so the user saw "No files found to index" — actively wrong. Add a top-of-function check for the !success && !hasErrors case that surfaces the first severity:'error' message instead. 2) parse-worker.ts let Emscripten's stderr "Aborted()" lines (plus their "Build with -sASSERTIONS for more info" follow-ups) leak to the parent's terminal whenever a WASM tree-sitter parser crashed on a pathological file. Even after the JS layer caught and recovered, the user saw dozens of `Aborted()` lines spammed to stderr. Install a stderr filter at worker startup that drops only those specific Emscripten internal lines; everything we log ourselves passes through unchanged. Verified live against ollama/ollama@v0.22.0: - second concurrent `codegraph index` now shows "Could not acquire file lock - another process may be indexing" instead of "No files found to index" - WASM-crash-prone re-index produced 0 Aborted() lines (down from 68+). * fix(cli): null-safe error surfacing + clearer stderr-filter contract docs Two reviewer findings on PR #128: - printIndexResult: when result.success is false but result.errors contains no severity:'error' entry (degenerate case but possible if the result shape ever drifts), the find() returned undefined and the previous if-guard fell through to the misleading 'No files found to index' branch. Now always surfaces a clear failure message via clack.log.error, defaulting to 'Indexing failed — no further details available' when no specific error is in the errors list. - parse-worker stderr filter: callback handling was already correct but the comment didn't document it; expand the comment to spell out the Writable-stream-contract obligation, the per-call match semantics (split-chunk caveat), and the substring-exactness trade-off so future readers understand the deliberate trade-offs. --- src/bin/codegraph.ts | 17 ++++++++++++++ src/extraction/parse-worker.ts | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index d118a1fd..2843e67e 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -265,6 +265,23 @@ type IndexResult = { function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexResult, projectPath?: string): void { const hasErrors = result.filesErrored > 0; + // Surface non-file-level failures (e.g. lock-acquisition failure + // when another indexer is running) before the file-count branches. + // Without this the CLI falls through to "No files found to index", + // which is actively misleading — the index DID run, it just couldn't + // get the lock. + // + // If success is false but no severity:'error' entry exists in + // `result.errors` (degenerate case — shouldn't happen in practice + // but worth guarding because the result shape is plumbed through + // multiple call sites), fall back to a generic message rather than + // continuing to the misleading "No files found" branch or throwing. + if (!result.success && !hasErrors && result.filesIndexed === 0) { + const generic = result.errors.find((e) => e.severity === 'error'); + clack.log.error(generic?.message ?? 'Indexing failed — no further details available'); + return; + } + if (result.filesIndexed > 0) { if (hasErrors) { clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} could not be parsed)`); diff --git a/src/extraction/parse-worker.ts b/src/extraction/parse-worker.ts index 21b239ca..3ddc18e3 100644 --- a/src/extraction/parse-worker.ts +++ b/src/extraction/parse-worker.ts @@ -10,6 +10,48 @@ import { extractFromSource } from './tree-sitter'; import { detectLanguage, loadGrammarsForLanguages, resetParser } from './grammars'; import type { Language, ExtractionResult } from '../types'; +// Emscripten prints `Aborted()` (and a follow-up RuntimeError diag +// line) directly to stderr when WASM aborts — before the JS catch +// runs. Worker stderr is inherited by the parent, so each crash leaks +// a noise line to the user's terminal even though the JS layer +// already handles the failure cleanly. Filter these specific lines +// out at the source. Real diagnostic output (anything we log +// ourselves) goes through console.* / parentPort and is unaffected. +// +// Caveats deliberately accepted: +// - Per-call match: each `write()` call is matched in isolation. +// If Emscripten ever splits `Aborted(` across two write()s (it +// doesn't today — synchronous abort prints the whole line at +// once via libc puts) the first fragment would leak. Buffering +// across calls would add complexity for a hypothetical case. +// - Substring exactness: the prefix `Aborted(` is the literal +// Emscripten signature. Any user code that legitimately writes +// a stderr line starting with that prefix would also be filtered; +// in practice no real diagnostic does. +{ + const realWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (( + chunk: string | Uint8Array, + encoding?: BufferEncoding | ((err?: Error | null) => void), + cb?: (err?: Error | null) => void + ): boolean => { + const s = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8'); + if ( + s.startsWith('Aborted(') || + s.includes('Build with -sASSERTIONS for more info') + ) { + // Honour the Writable stream contract: callbacks must always + // fire even when the write is suppressed, or upstream code + // waiting on the drain signal would hang. Both overload forms + // are handled (`(chunk, cb)` and `(chunk, encoding, cb)`). + if (typeof encoding === 'function') encoding(); + else if (cb) cb(); + return true; + } + return realWrite(chunk as never, encoding as never, cb as never); + }) as typeof process.stderr.write; +} + const PARSER_RESET_INTERVAL = 5000; const parseCounts = new Map(); From 153fd1e974045a48a41253c605c25ae2b27f8c4a Mon Sep 17 00:00:00 2001 From: andreinknv Date: Thu, 7 May 2026 22:03:44 -0400 Subject: [PATCH 07/83] fix(gitignore): anchor "coverage/" rule to repo root (#127) The unanchored "coverage/" rule (intended to ignore the test-output directory at repo root) silently matches any "coverage/" directory in the tree. This bit a real PR: src/coverage/ was added but never made it into the commit because git add silently dropped the files. The PR shipped with the test importing a module that didn't exist. Anchor the rule to "/coverage/" so it only ignores root-level test output, allowing src/coverage/, packages/*/coverage/, etc. to be committed normally. --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c6bad3a1..7c154ae1 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ dist/ Thumbs.db # Test coverage -coverage/ +/coverage/ .nyc_output/ # Environment From a460b856c2615bff16c3e5ebc0408671265f5dd0 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 7 May 2026 21:29:00 -0500 Subject: [PATCH 08/83] perf(db): drop redundant idx_edges_source / idx_edges_target (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both narrow indexes are fully covered by the existing (source, kind) and (target, kind) composites via SQLite's left-prefix scan, so they're dead weight on every write. Empirical measurements (from the spike script in PR #122 on a 50K-node / 250K-edge synthetic DB): - DB size: 34.7 MB → 27.0 MB (-22.2%) - Bulk insert (250K edges): 590ms → 431ms (1.37× faster) - source/target lookup latency: no regression Adds migration v4 to drop both on existing databases; fresh-DB schema no longer creates them. Co-authored-by: Claude Opus 4.7 (1M context) --- __tests__/foundation.test.ts | 2 +- __tests__/pr19-improvements.test.ts | 2 +- src/db/migrations.ts | 13 ++++++++++++- src/db/schema.sql | 9 ++++++--- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/__tests__/foundation.test.ts b/__tests__/foundation.test.ts index 9ee437da..4e8f204a 100644 --- a/__tests__/foundation.test.ts +++ b/__tests__/foundation.test.ts @@ -305,7 +305,7 @@ describe('Database Connection', () => { const version = db.getSchemaVersion(); expect(version).not.toBeNull(); - expect(version?.version).toBe(3); + expect(version?.version).toBe(4); db.close(); }); diff --git a/__tests__/pr19-improvements.test.ts b/__tests__/pr19-improvements.test.ts index 5fbe17d7..d43dceb2 100644 --- a/__tests__/pr19-improvements.test.ts +++ b/__tests__/pr19-improvements.test.ts @@ -299,7 +299,7 @@ describe('Best-Candidate Resolution', () => { describe('Schema v2 Migration', () => { it.skipIf(!HAS_SQLITE)('should have correct current schema version', async () => { const { CURRENT_SCHEMA_VERSION } = await import('../src/db/migrations'); - expect(CURRENT_SCHEMA_VERSION).toBe(3); + expect(CURRENT_SCHEMA_VERSION).toBe(4); }); it.skipIf(!HAS_SQLITE)('should have migration for version 2', async () => { diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 0a256dbc..1a8d1c54 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter'; /** * Current schema version */ -export const CURRENT_SCHEMA_VERSION = 3; +export const CURRENT_SCHEMA_VERSION = 4; /** * Migration definition @@ -54,6 +54,17 @@ const migrations: Migration[] = [ `); }, }, + { + version: 4, + description: + 'Drop redundant idx_edges_source / idx_edges_target (covered by source_kind / target_kind composites)', + up: (db) => { + db.exec(` + DROP INDEX IF EXISTS idx_edges_source; + DROP INDEX IF EXISTS idx_edges_target; + `); + }, + }, ]; /** diff --git a/src/db/schema.sql b/src/db/schema.sql index dd0a9f06..b08c34f3 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -122,9 +122,12 @@ CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature); END; --- Edge indexes -CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source); -CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target); +-- Edge indexes. +-- idx_edges_source / idx_edges_target are intentionally omitted — +-- the (source, kind) and (target, kind) composites below cover the +-- corresponding source-only / target-only lookups via SQLite's +-- left-prefix scan, so the narrow indexes are dead weight on writes. +-- Migration v4 drops them on existing databases. CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind); CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source, kind); CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target, kind); From 804ab671d4bae9f9c5c536613a43945980effee6 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 7 May 2026 21:33:32 -0500 Subject: [PATCH 09/83] feat(mcp): emit server-level instructions in initialize response (#143) Adds a universal tool-selection playbook surfaced by MCP clients (Claude Code, Cursor, opencode, LangChain, OpenAI Agent SDK) in the agent's system prompt automatically. Without this, agents have to infer tool composition from individual tool descriptions and tend to walk callers manually instead of reaching for codegraph_impact, etc. Scoped tight: only the 9 tools that exist on main today (search/context/callers/callees/impact/node/explore/files/status), no "(when present)" references to unmerged tools, no per-language guidance. ~40 lines of useful guidance. Salvaged from #121, which bundled the instructions with #117's MCP tool-registry refactor and referenced many tools that don't exist on main. Co-authored-by: Claude Opus 4.7 (1M context) --- src/mcp/index.ts | 7 ++++- src/mcp/server-instructions.ts | 55 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 src/mcp/server-instructions.ts diff --git a/src/mcp/index.ts b/src/mcp/index.ts index bc3552ae..e516631a 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -19,6 +19,7 @@ import * as path from 'path'; import CodeGraph, { findNearestCodeGraphRoot } from '../index'; import { StdioTransport, JsonRpcRequest, JsonRpcNotification, ErrorCodes } from './transport'; import { tools, ToolHandler } from './tools'; +import { SERVER_INSTRUCTIONS } from './server-instructions'; /** * Convert a file:// URI to a filesystem path. @@ -268,13 +269,17 @@ export class MCPServer { // Try to initialize the default project (non-fatal if it fails) await this.tryInitializeDefault(projectPath); - // We accept the client's protocol version but respond with our supported version + // We accept the client's protocol version but respond with our supported version. + // The `instructions` field is surfaced by MCP clients in the agent's system + // prompt automatically — it's the right place for the universal tool-selection + // playbook, ahead of individual tool descriptions. this.transport.sendResult(request.id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {}, }, serverInfo: SERVER_INFO, + instructions: SERVER_INSTRUCTIONS, }); } diff --git a/src/mcp/server-instructions.ts b/src/mcp/server-instructions.ts new file mode 100644 index 00000000..0c715ea8 --- /dev/null +++ b/src/mcp/server-instructions.ts @@ -0,0 +1,55 @@ +/** + * Server-level instructions emitted in the MCP `initialize` response. + * + * MCP clients (Claude Code, Cursor, opencode, LangChain, OpenAI Agent + * SDK, …) surface this text in the agent's system prompt automatically, + * giving the agent a high-level playbook for the codegraph toolset + * before it sees individual tool descriptions. + * + * Goals when editing this: + * - Tool selection by intent (which tool for which question) + * - Common chains (refactor planning = X then Y) + * - Anti-patterns (don't grep when codegraph_search is faster) + * + * Keep it tight. The agent reads this every session — long instructions + * burn tokens. Reference only tools that exist on `main`; gate any + * conditional tools behind feature checks if/when they ship. + */ +export const SERVER_INSTRUCTIONS = `# Codegraph — code intelligence over an indexed knowledge graph + +Codegraph is a SQLite knowledge graph of every symbol, edge, and file +in the workspace. Reads are sub-millisecond; the index lags writes by +about a second through the file watcher. Consult it BEFORE writing or +editing code, not during. + +## Tool selection by intent + +- **"What is the symbol named X?"** → \`codegraph_search\` +- **"What's the deal with this task / feature / area?"** → \`codegraph_context\` (PRIMARY — composes search + node + callers + callees in one call) +- **"What calls this?"** → \`codegraph_callers\` +- **"What does this call?"** → \`codegraph_callees\` +- **"What would changing this break?"** → \`codegraph_impact\` +- **"Show me this symbol's source / signature / docstring."** → \`codegraph_node\` +- **"Survey an unfamiliar topic / pattern / module."** → \`codegraph_explore\` (heavier; deep dive) +- **"What's in directory X?"** → \`codegraph_files\` +- **"Is the index ready / what's its size?"** → \`codegraph_status\` + +## Common chains + +- **Onboarding**: \`codegraph_context\` first. If still unclear, \`codegraph_explore\` for breadth, then \`codegraph_node\` on specific symbols. +- **Refactor planning**: \`codegraph_search\` → \`codegraph_callers\` → \`codegraph_impact\`. The blast-radius answer comes from impact, not from walking callers manually. +- **Debugging a regression**: \`codegraph_callers\` of the suspected symbol; widen with \`codegraph_impact\` if an unexpected call appears. + +## Anti-patterns + +- **Don't grep first** when looking up a symbol by name — \`codegraph_search\` is faster and returns kind + location + signature. +- **Don't chain \`codegraph_search\` + \`codegraph_node\`** when you just want context — \`codegraph_context\` is one round-trip. +- **Don't use \`codegraph_explore\` for narrow questions** — it's a multi-call deep dive, expensive in tokens. Save it for genuine "I'm new here" surveys. +- **Don't query the index immediately after editing a file** — the watcher needs ~500ms to debounce + sync. Wait for the next turn. + +## Limitations + +- Index lags file writes by ~1 second. +- Cross-file resolution is best-effort name matching; ambiguous calls may return multiple candidates. +- No live correctness validation — that's still the TypeScript compiler / test suite / linter's job. Codegraph supplements those with structural context they don't have. +`; From 5ab81746e81d7fa8733ad4a704635eda8a5561ed Mon Sep 17 00:00:00 2001 From: Abhijeet Date: Fri, 8 May 2026 08:26:57 +0530 Subject: [PATCH 10/83] feat: add Vue support (#66) Co-authored-by: Colby McHenry Co-authored-by: Claude Opus 4.7 (1M context) --- __tests__/extraction.test.ts | 150 +++++++++++++ src/extraction/grammars.ts | 9 +- src/extraction/tree-sitter.ts | 7 + src/extraction/vue-extractor.ts | 198 +++++++++++++++++ src/resolution/frameworks/index.ts | 3 + src/resolution/frameworks/vue.ts | 338 +++++++++++++++++++++++++++++ src/types.ts | 3 + 7 files changed, 705 insertions(+), 3 deletions(-) create mode 100644 src/extraction/vue-extractor.ts create mode 100644 src/resolution/frameworks/vue.ts diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 3c754635..5d0173ff 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -3080,6 +3080,156 @@ describe('Directory Exclusion', () => { }); }); +describe('Vue Extraction', () => { + it('should detect Vue files', () => { + expect(detectLanguage('App.vue')).toBe('vue'); + expect(detectLanguage('components/Button.vue')).toBe('vue'); + expect(isLanguageSupported('vue')).toBe(true); + }); + + it('should extract component node from a Vue SFC', () => { + const code = ` + + +`; + const result = extractFromSource('HelloWorld.vue', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + expect(componentNode?.name).toBe('HelloWorld'); + expect(componentNode?.language).toBe('vue'); + expect(componentNode?.isExported).toBe(true); + }); + + it('should extract functions from +`; + const result = extractFromSource('Button.vue', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + expect(componentNode?.name).toBe('Button'); + + const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'handleClick'); + expect(funcNode).toBeDefined(); + expect(funcNode?.language).toBe('vue'); + }); + + it('should extract from +`; + const result = extractFromSource('Counter.vue', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + expect(componentNode?.name).toBe('Counter'); + + const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'increment'); + expect(funcNode).toBeDefined(); + expect(funcNode?.language).toBe('vue'); + + // All nodes should be marked as vue language + for (const node of result.nodes) { + expect(node.language).toBe('vue'); + } + }); + + it('should extract from both + + +`; + const result = extractFromSource('DualScript.vue', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + + const greetFunc = result.nodes.find((n) => n.kind === 'function' && n.name === 'greet'); + expect(greetFunc).toBeDefined(); + }); + + it('should create component node for template-only Vue file', () => { + const code = ` +`; + const result = extractFromSource('Static.vue', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + expect(componentNode?.name).toBe('Static'); + expect(componentNode?.language).toBe('vue'); + + // Only the component node should exist (no script nodes) + expect(result.nodes.length).toBe(1); + }); + + it('should create containment edges from component to script nodes', () => { + const code = ` + + +`; + const result = extractFromSource('Contained.vue', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + + // Should have containment edges from component to child nodes + const containEdges = result.edges.filter( + (e) => e.source === componentNode!.id && e.kind === 'contains' + ); + expect(containEdges.length).toBeGreaterThan(0); + }); +}); + describe('Instantiates + Decorates edge extraction', () => { it('emits an instantiates ref for `new Foo()`', () => { const code = ` diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index df264fb3..5d01bae3 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -10,7 +10,7 @@ import * as path from 'path'; import { Parser, Language as WasmLanguage } from 'web-tree-sitter'; import { Language } from '../types'; -export type GrammarLanguage = Exclude; +export type GrammarLanguage = Exclude; /** * WASM filename map — maps each language to its .wasm grammar file @@ -68,6 +68,7 @@ export const EXTENSION_MAP: Record = { '.dart': 'dart', '.liquid': 'liquid', '.svelte': 'svelte', + '.vue': 'vue', '.pas': 'pascal', '.dpr': 'pascal', '.dpk': 'pascal', @@ -201,6 +202,7 @@ function looksLikeCpp(source: string): boolean { */ export function isLanguageSupported(language: Language): boolean { if (language === 'svelte') return true; // custom extractor (script block delegation) + if (language === 'vue') return true; // custom extractor (script block delegation) if (language === 'liquid') return true; // custom regex extractor if (language === 'unknown') return false; return language in WASM_GRAMMAR_FILES; @@ -210,7 +212,7 @@ export function isLanguageSupported(language: Language): boolean { * Check if a grammar has been loaded and is ready for parsing. */ export function isGrammarLoaded(language: Language): boolean { - if (language === 'svelte' || language === 'liquid') return true; + if (language === 'svelte' || language === 'vue' || language === 'liquid') return true; return languageCache.has(language); } @@ -218,7 +220,7 @@ export function isGrammarLoaded(language: Language): boolean { * Get all supported languages (those with grammar definitions). */ export function getSupportedLanguages(): Language[] { - return [...(Object.keys(WASM_GRAMMAR_FILES) as GrammarLanguage[]), 'svelte', 'liquid']; + return [...(Object.keys(WASM_GRAMMAR_FILES) as GrammarLanguage[]), 'svelte', 'vue', 'liquid']; } /** @@ -282,6 +284,7 @@ export function getLanguageDisplayName(language: Language): string { kotlin: 'Kotlin', dart: 'Dart', svelte: 'Svelte', + vue: 'Vue', liquid: 'Liquid', pascal: 'Pascal / Delphi', unknown: 'Unknown', diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 2dd9710d..ac3927bc 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -22,6 +22,7 @@ import { EXTRACTORS } from './languages'; import { LiquidExtractor } from './liquid-extractor'; import { SvelteExtractor } from './svelte-extractor'; import { DfmExtractor } from './dfm-extractor'; +import { VueExtractor } from './vue-extractor'; // Re-export for backward compatibility export { generateNodeId } from './tree-sitter-helpers'; @@ -2489,6 +2490,12 @@ export function extractFromSource( return extractor.extract(); } + // Use custom extractor for Vue + if (detectedLanguage === 'vue') { + const extractor = new VueExtractor(filePath, source); + return extractor.extract(); + } + // Use custom extractor for Liquid if (detectedLanguage === 'liquid') { const extractor = new LiquidExtractor(filePath, source); diff --git a/src/extraction/vue-extractor.ts b/src/extraction/vue-extractor.ts new file mode 100644 index 00000000..99e6c46a --- /dev/null +++ b/src/extraction/vue-extractor.ts @@ -0,0 +1,198 @@ +import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Language } from '../types'; +import { generateNodeId } from './tree-sitter-helpers'; +import { TreeSitterExtractor } from './tree-sitter'; +import { isLanguageSupported } from './grammars'; + +/** + * VueExtractor - Extracts code relationships from Vue Single-File Component files + * + * Vue SFCs are multi-language (script + template + style). Rather than + * parsing the full Vue grammar, we extract the