diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index b6249bb8f..1d1e8a238 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -73,15 +73,40 @@ export function resolveByMethodOrGlobal( ? call.receiver.slice('this.'.length) : call.receiver; const typeEntry = typeMap.get(effectiveReceiver) ?? typeMap.get(call.receiver); - const typeName = typeEntry + let typeName = typeEntry ? typeof typeEntry === 'string' ? typeEntry : (typeEntry as { type?: string }).type : null; + + // Handle inline new-expression receivers: `(new Foo).bar()` or `(new Foo()).bar()`. + // extractReceiverName returns the raw node text for non-identifier nodes, so `(new A).t()` + // produces receiver='(new A)'. Extract the constructor name directly. + if (!typeName && call.receiver) { + const m = /^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/.exec(call.receiver); + if (m?.[1]) typeName = m[1]; + } + if (typeName) { const typed = lookup.byName(`${typeName}.${call.name}`).filter((n) => n.kind === 'method'); if (typed.length > 0) return typed; + + // Prototype alias: `Foo.prototype.bar = identifier` seeds typeMap['Foo.bar'] = { type: identifier }. + // Checked after the symbol-DB lookup so an actual method definition always wins. + const protoEntry = typeMap.get(`${typeName}.${call.name}`); + const protoTarget = protoEntry + ? typeof protoEntry === 'string' + ? protoEntry + : (protoEntry as { type?: string }).type + : null; + if (protoTarget) { + const resolved = lookup + .byName(protoTarget) + .filter((t) => computeConfidence(relPath, t.file, null) >= 0.5); + if (resolved.length > 0) return resolved; + } } + // Phase 8.3d: composite pts key — `obj.prop = fn` seeds typeMap['obj.prop'] = { type: 'fn' }. // When a call site references `obj.prop` as a callback, resolve directly to the target fn. const compositeEntry = typeMap.get(`${call.receiver}.${call.name}`); diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index cab82ee11..100cdf22a 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -78,7 +78,7 @@ interface NativeEdge { } /** Phase 8.5: confidence penalty applied to CHA-dispatch edges. */ -const CHA_DISPATCH_PENALTY = 0.1; +export const CHA_DISPATCH_PENALTY = 0.1; // ── Node lookup setup ─────────────────────────────────────────────────── diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index a3903cd3b..565a27abd 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -52,6 +52,7 @@ import { readFileSafe, } from '../helpers.js'; import { NativeDbProxy } from '../native-db-proxy.js'; +import { CHA_DISPATCH_PENALTY } from './build-edges.js'; import { closeNativeDb } from './native-db-lifecycle.js'; // ── Native orchestrator types ────────────────────────────────────────── @@ -467,7 +468,7 @@ function runPostNativeCha(db: BetterSqlite3Database): Set { // Find existing call edges targeting qualified methods (e.g., 'IWorker.doWork'). // Include the caller node's file so confidence can be computed file-pair-aware, - // matching the WASM path's computeConfidence(callerFile, targetFile, null) - 0.1 formula. + // matching the WASM path's computeConfidence(callerFile, targetFile, null) - CHA_DISPATCH_PENALTY formula. const callToMethods = db .prepare(` SELECT e.source_id, tgt.name AS method_name, src.file AS caller_file @@ -534,10 +535,11 @@ function runPostNativeCha(db: BetterSqlite3Database): Set { const key = `${source_id}|${methodNode.id}`; if (seen.has(key)) continue; seen.add(key); - // Compute confidence file-pair-aware (mirrors WASM path: computeConfidence - 0.1 penalty) + // Compute confidence file-pair-aware (mirrors WASM path: computeConfidence - CHA_DISPATCH_PENALTY) // Skip zero-confidence edges to match buildFileCallEdges / buildChaPostPass behaviour. const conf = - computeConfidence(caller_file ?? '', methodNode.method_file ?? '', null) - 0.1; + computeConfidence(caller_file ?? '', methodNode.method_file ?? '', null) - + CHA_DISPATCH_PENALTY; if (conf <= 0) continue; newEdges.push([source_id, methodNode.id, 'calls', conf, 0, 'cha']); newTargetIds.add(methodNode.id); diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 06aa48c55..4b14c67d6 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -345,6 +345,9 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr // Extract typeMap with intra-file return-type propagation extractTypeMapWalk(tree.rootNode, typeMap, returnTypeMap, callAssignments, fnRefBindings); + // Prototype-based method definitions: `Foo.prototype.bar = fn` and `Foo.prototype = { bar: fn }` + extractPrototypeMethodsWalk(tree.rootNode, definitions, typeMap); + // Phase 8.3c: Extract call-site argument bindings for parameter-flow pts analysis extractParamBindingsWalk(tree.rootNode, paramBindings); @@ -476,7 +479,7 @@ function extractConstDeclarators(declNode: TreeSitterNode, definitions: Definiti if (declarator?.type !== 'variable_declarator') continue; const nameN = declarator.childForFieldName('name'); const valueN = declarator.childForFieldName('value'); - if (!nameN || nameN.type !== 'identifier' || !valueN) continue; + if (nameN?.type !== 'identifier' || !valueN) continue; // Skip functions — already captured by query patterns const valType = valueN.type; if (valType === 'arrow_function' || valType === 'function_expression' || valType === 'function') @@ -612,6 +615,8 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput { ctx.callAssignments, ctx.fnRefBindings, ); + // Prototype-based method definitions: `Foo.prototype.bar = fn` and `Foo.prototype = { bar: fn }` + extractPrototypeMethodsWalk(tree.rootNode, ctx.definitions, ctx.typeMap!); // Phase 8.3c: Extract call-site argument bindings for parameter-flow pts analysis extractParamBindingsWalk(tree.rootNode, ctx.paramBindings!); // Phase 8.5: collect all `new X()` constructor names for RTA instantiation tracking @@ -1162,7 +1167,7 @@ function extractSimpleTypeName(typeAnnotationNode: TreeSitterNode): string | nul } function extractNewExprTypeName(newExprNode: TreeSitterNode): string | null { - if (!newExprNode || newExprNode.type !== 'new_expression') return null; + if (newExprNode?.type !== 'new_expression') return null; const ctor = newExprNode.childForFieldName('constructor') || newExprNode.child(1); if (!ctor) return null; if (ctor.type === 'identifier') return ctor.text; @@ -1275,7 +1280,7 @@ function storeReturnType( function findReturnNewExprType(bodyNode: TreeSitterNode): string | null { for (let i = 0; i < bodyNode.childCount; i++) { const child = bodyNode.child(i); - if (!child || child.type !== 'return_statement') continue; + if (child?.type !== 'return_statement') continue; for (let j = 0; j < child.childCount; j++) { const expr = child.child(j); if (expr?.type === 'new_expression') return extractNewExprTypeName(expr); @@ -1442,7 +1447,7 @@ function handleVarDeclaratorTypeMap( fnRefBindings?: FnRefBinding[], ): void { const nameN = node.childForFieldName('name'); - if (!nameN || nameN.type !== 'identifier') return; + if (nameN?.type !== 'identifier') return; const typeAnno = findChild(node, 'type_annotation'); const valueN = node.childForFieldName('value'); @@ -1531,7 +1536,7 @@ function handleVarDeclaratorTypeMap( function handleParamTypeMap(node: TreeSitterNode, typeMap: Map): void { const nameNode = node.childForFieldName('pattern') || node.childForFieldName('left') || node.child(0); - if (!nameNode || nameNode.type !== 'identifier') return; + if (nameNode?.type !== 'identifier') return; const typeAnno = findChild(node, 'type_annotation'); if (typeAnno) { const typeName = extractSimpleTypeName(typeAnno); @@ -1924,7 +1929,7 @@ function extractCallbackDefinition( fn?: TreeSitterNode | null, ): Definition | null { if (!fn) fn = callNode.childForFieldName('function'); - if (!fn || fn.type !== 'member_expression') return null; + if (fn?.type !== 'member_expression') return null; const prop = fn.childForFieldName('property'); if (!prop) return null; @@ -2035,7 +2040,7 @@ function extractDynamicImportNames(callNode: TreeSitterNode): string[] { // Skip await_expression wrapper if present if (current && current.type === 'await_expression') current = current.parent; // We should now be at a variable_declarator (or not, if standalone import()) - if (!current || current.type !== 'variable_declarator') return []; + if (current?.type !== 'variable_declarator') return []; const nameNode = current.childForFieldName('name'); if (!nameNode) return []; @@ -2078,3 +2083,152 @@ function extractDynamicImportNames(callNode: TreeSitterNode): string[] { return []; } + +// ── Phase 8.X: Prototype-based method extraction ──────────────────────────── + +/** + * Walk the AST and extract prototype-based method definitions and aliases. + * + * Handles three patterns: + * 1. `Foo.prototype.bar = function(){...}` — emits Foo.bar as method definition + * 2. `Foo.prototype.bar = identifier` — sets typeMap['Foo.bar'] = { type: identifier } + * 3. `Foo.prototype = { bar: fn, ... }` — emits defs and typeMap entries per property + * + * Emitting definitions under the canonical `ClassName.methodName` name lets the + * existing typeMap-based call resolver find them when a typed receiver dispatches + * `instance.method()` (lookup.byName('C.foo') in resolveByMethodOrGlobal). + * + * typeMap entries for identifier aliases (`Foo.bar → { type: 'someId' }`) are + * consumed by the prototype-alias fallback added to resolveByMethodOrGlobal. + */ +function extractPrototypeMethodsWalk( + rootNode: TreeSitterNode, + definitions: Definition[], + typeMap: Map, +): void { + function walk(node: TreeSitterNode, depth: number): void { + if (depth >= MAX_WALK_DEPTH) return; + if (node.type === 'expression_statement') { + const expr = node.child(0); + if (expr?.type === 'assignment_expression') { + const lhs = expr.childForFieldName('left'); + const rhs = expr.childForFieldName('right'); + if (lhs && rhs) handlePrototypeAssignment(lhs, rhs, definitions, typeMap); + } + } + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i)!, depth + 1); + } + } + walk(rootNode, 0); +} + +/** + * Handle an assignment_expression that may be a prototype assignment. + * + * Matches: + * - `Foo.prototype.bar = rhs` (lhs ends in .prototype.bar) + * - `Foo.prototype = { ... }` (lhs ends in .prototype, rhs is object literal) + */ +function handlePrototypeAssignment( + lhs: TreeSitterNode, + rhs: TreeSitterNode, + definitions: Definition[], + typeMap: Map, +): void { + if (lhs.type !== 'member_expression') return; + + const lhsObj = lhs.childForFieldName('object'); + const lhsProp = lhs.childForFieldName('property'); + if (!lhsObj || !lhsProp) return; + + // Pattern 1: `Foo.prototype.bar = rhs` + // lhs.object is `Foo.prototype` (member_expression), lhs.property is `bar` + if ( + lhsObj.type === 'member_expression' && + (lhsProp.type === 'property_identifier' || lhsProp.type === 'identifier') + ) { + const protoObj = lhsObj.childForFieldName('object'); + const protoProp = lhsObj.childForFieldName('property'); + if ( + protoObj?.type === 'identifier' && + protoProp?.text === 'prototype' && + !BUILTIN_GLOBALS.has(protoObj.text) + ) { + emitPrototypeMethod(protoObj.text, lhsProp.text, rhs, definitions, typeMap); + } + return; + } + + // Pattern 2: `Foo.prototype = { bar: fn, ... }` + // lhs.object is `Foo` (identifier), lhs.property is `prototype` + if ( + lhsObj.type === 'identifier' && + lhsProp.text === 'prototype' && + !BUILTIN_GLOBALS.has(lhsObj.text) && + rhs.type === 'object' + ) { + extractPrototypeObjectLiteral(lhsObj.text, rhs, definitions, typeMap); + } +} + +/** Emit one prototype method definition or typeMap alias for `ClassName.methodName = rhs`. */ +function emitPrototypeMethod( + className: string, + methodName: string, + rhs: TreeSitterNode, + definitions: Definition[], + typeMap: Map, +): void { + const fullName = `${className}.${methodName}`; + if (rhs.type === 'function_expression' || rhs.type === 'arrow_function') { + definitions.push({ + name: fullName, + kind: 'method', + line: nodeStartLine(rhs), + endLine: nodeEndLine(rhs), + }); + } else if (rhs.type === 'identifier' && !BUILTIN_GLOBALS.has(rhs.text)) { + // Prototype alias: `A.prototype.t = f` → typeMap['A.t'] = { type: 'f' } + // Consumed by the prototype-alias fallback in resolveByMethodOrGlobal. + setTypeMapEntry(typeMap, fullName, rhs.text, 0.9); + } +} + +/** Iterate over an object literal assigned to `Foo.prototype` and emit defs/aliases. */ +function extractPrototypeObjectLiteral( + className: string, + objNode: TreeSitterNode, + definitions: Definition[], + typeMap: Map, +): void { + for (let i = 0; i < objNode.childCount; i++) { + const child = objNode.child(i); + if (!child) continue; + + if (child.type === 'method_definition') { + // Shorthand method: `Foo.prototype = { bar() {} }` + const nameNode = child.childForFieldName('name'); + if (nameNode) { + definitions.push({ + name: `${className}.${nameNode.text}`, + kind: 'method', + line: nodeStartLine(child), + endLine: nodeEndLine(child), + }); + } + continue; + } + + if (child.type !== 'pair') continue; + + const keyNode = child.childForFieldName('key'); + const valueNode = child.childForFieldName('value'); + if (!keyNode || !valueNode) continue; + + const methodName = keyNode.type === 'string' ? keyNode.text.replace(/['"]/g, '') : keyNode.text; + if (!methodName) continue; + + emitPrototypeMethod(className, methodName, valueNode, definitions, typeMap); + } +} diff --git a/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json index 68ab3f800..6e06ce7af 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json @@ -128,6 +128,55 @@ "kind": "calls", "mode": "constructor", "notes": "new UserService() — class instantiation tracked as consumption" + }, + { + "source": { "name": "Dog.speak", "file": "inheritance.js" }, + "target": { "name": "Animal.speak", "file": "inheritance.js" }, + "kind": "calls", + "mode": "class-inheritance", + "notes": "super.speak() in Dog.speak resolves to Animal.speak via CHA parents map" + }, + { + "source": { "name": "Puppy.speak", "file": "inheritance.js" }, + "target": { "name": "Dog.speak", "file": "inheritance.js" }, + "kind": "calls", + "mode": "class-inheritance", + "notes": "super.speak() in Puppy.speak resolves to Dog.speak (nearest parent), not Animal.speak" + }, + { + "source": { "name": "DoubleCounter.count", "file": "inheritance.js" }, + "target": { "name": "Counter.count", "file": "inheritance.js" }, + "kind": "calls", + "mode": "class-inheritance", + "notes": "static super.count() in DoubleCounter.count resolves to Counter.count via CHA parents map" + }, + { + "source": { "name": "runPrototypes", "file": "prototypes.js" }, + "target": { "name": "C", "file": "prototypes.js" }, + "kind": "calls", + "mode": "constructor", + "notes": "new C() — constructor call to function-based constructor" + }, + { + "source": { "name": "runPrototypes", "file": "prototypes.js" }, + "target": { "name": "C.foo", "file": "prototypes.js" }, + "kind": "calls", + "mode": "receiver-typed", + "notes": "v.foo() — v typed as C via new C(), C.foo defined via C.prototype = { foo: fn }" + }, + { + "source": { "name": "testPrototypeAlias", "file": "prototypes2.js" }, + "target": { "name": "A", "file": "prototypes2.js" }, + "kind": "calls", + "mode": "constructor", + "notes": "new A() — inline constructor call in new A().t()" + }, + { + "source": { "name": "testPrototypeAlias", "file": "prototypes2.js" }, + "target": { "name": "f", "file": "prototypes2.js" }, + "kind": "calls", + "mode": "receiver-typed", + "notes": "new A().t() — A.prototype.t = f alias; inline new receiver resolved to type A, typeMap['A.t'] = f" } ] } diff --git a/tests/benchmarks/resolution/fixtures/javascript/inheritance.js b/tests/benchmarks/resolution/fixtures/javascript/inheritance.js new file mode 100644 index 000000000..92d167e44 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/javascript/inheritance.js @@ -0,0 +1,34 @@ +// Class hierarchy fixture — tests super.method() dispatch (class-inheritance resolution) + +export class Animal { + speak() { + return 'generic sound'; + } +} + +export class Dog extends Animal { + speak() { + super.speak(); // super.method() → Animal.speak + return 'woof'; + } +} + +export class Puppy extends Dog { + speak() { + super.speak(); // super.method() → Dog.speak (nearest parent, not Animal) + return 'yip'; + } +} + +// Static super.method() — same resolution path as instance methods +export class Counter { + static count() { + return 0; + } +} + +export class DoubleCounter extends Counter { + static count() { + return super.count() * 2; // static super.method() → Counter.count + } +} diff --git a/tests/benchmarks/resolution/fixtures/javascript/prototypes.js b/tests/benchmarks/resolution/fixtures/javascript/prototypes.js new file mode 100644 index 000000000..c641e2fd0 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/javascript/prototypes.js @@ -0,0 +1,11 @@ +// Pre-ES6 OOP via constructor function + prototype object literal. +function C() {} + +C.prototype = { + foo: () => {}, +}; + +export function runPrototypes() { + var v = new C(); + v.foo(); +} diff --git a/tests/benchmarks/resolution/fixtures/javascript/prototypes2.js b/tests/benchmarks/resolution/fixtures/javascript/prototypes2.js new file mode 100644 index 000000000..afd4b88e9 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/javascript/prototypes2.js @@ -0,0 +1,10 @@ +// Pre-ES6 OOP via prototype property assignment with identifier alias. +const f = () => {}; + +class A {} + +A.prototype.t = f; + +export function testPrototypeAlias() { + new A().t(); +} diff --git a/tests/benchmarks/resolution/fixtures/typescript/expected-edges.json b/tests/benchmarks/resolution/fixtures/typescript/expected-edges.json index 35e04c665..312b6c6dc 100644 --- a/tests/benchmarks/resolution/fixtures/typescript/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/typescript/expected-edges.json @@ -303,6 +303,20 @@ "kind": "calls", "mode": "receiver-typed", "notes": "svc.removeUser() — svc typed as UserService via createService() return" + }, + { + "source": { "name": "TimestampLogger.log", "file": "super-dispatch.ts" }, + "target": { "name": "Logger.log", "file": "super-dispatch.ts" }, + "kind": "calls", + "mode": "class-inheritance", + "notes": "super.log() in TimestampLogger.log resolves to Logger.log via CHA parents map" + }, + { + "source": { "name": "PrefixLogger.log", "file": "super-dispatch.ts" }, + "target": { "name": "TimestampLogger.log", "file": "super-dispatch.ts" }, + "kind": "calls", + "mode": "class-inheritance", + "notes": "super.log() in PrefixLogger.log resolves to TimestampLogger.log (nearest parent)" } ] } diff --git a/tests/benchmarks/resolution/fixtures/typescript/super-dispatch.ts b/tests/benchmarks/resolution/fixtures/typescript/super-dispatch.ts new file mode 100644 index 000000000..554b6ebcf --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/typescript/super-dispatch.ts @@ -0,0 +1,21 @@ +// super.method() dispatch fixture — class-inheritance resolution +// Uses a closed hierarchy (no further subclasses) to avoid CHA fan-out +// into unrelated implementations. + +export class Logger { + log(msg: string): void { + console.log(msg); + } +} + +export class TimestampLogger extends Logger { + log(msg: string): void { + super.log(`[${Date.now()}] ${msg}`); // super.method() → Logger.log + } +} + +export class PrefixLogger extends TimestampLogger { + log(msg: string): void { + super.log(`[PREFIX] ${msg}`); // super.method() → TimestampLogger.log (nearest parent) + } +} diff --git a/tests/integration/phase-8.5-cha-dispatch.test.ts b/tests/integration/phase-8.5-cha-dispatch.test.ts index edb51f9ab..d863cb5e4 100644 --- a/tests/integration/phase-8.5-cha-dispatch.test.ts +++ b/tests/integration/phase-8.5-cha-dispatch.test.ts @@ -120,13 +120,19 @@ describe.each(ENGINES)('Phase 8.5 CHA dispatch (%s)', (engine) => { // ── this-dispatch ────────────────────────────────────────────────────── // The WASM path resolves `this.prepare()` through the class hierarchy via - // the inline CHA dispatch in buildFileCallEdges. The native orchestrator - // path does not persist raw call sites to the DB, so this-dispatch is a - // known gap for native — tested only for wasm. - - it.skipIf(engine === 'native')( - 'this-dispatch: emits ConcreteWorker.doWork → ConcreteWorker.prepare', - () => { + // the inline CHA dispatch in buildFileCallEdges. + // + // The native orchestrator path does not persist raw call sites (with receiver + // info) to the DB after the Rust pipeline runs, so this-dispatch and + // super-dispatch cannot be resolved via runPostNativeCha. + // Tracked as a native accuracy gap in issue #1326. + + if (engine === 'native') { + it.todo( + 'this-dispatch: emits ConcreteWorker.doWork → ConcreteWorker.prepare (native gap #1326)', + ); + } else { + it('this-dispatch: emits ConcreteWorker.doWork → ConcreteWorker.prepare', () => { const edge = callEdges.find( (e) => e.caller_name === 'ConcreteWorker.doWork' && @@ -137,25 +143,29 @@ describe.each(ENGINES)('Phase 8.5 CHA dispatch (%s)', (engine) => { edge, `Expected ConcreteWorker.doWork → ConcreteWorker.prepare edge (this-dispatch).\nActual edges:\n${JSON.stringify(callEdges, null, 2)}`, ).toBeDefined(); - }, - ); + }); + } // ── super-dispatch ───────────────────────────────────────────────────── - // Same gap as this-dispatch: super.speak() cannot be resolved from DB edges - // alone in the native orchestrator path. + // Same native gap: super.speak() requires raw call-site receiver info not + // persisted to the DB by the Rust pipeline. See issue #1326. - it.skipIf(engine === 'native')('super-dispatch: emits Lion.speak → Animal.speak', () => { - const edge = callEdges.find( - (e) => - e.caller_name === 'Lion.speak' && - e.callee_name === 'Animal.speak' && - e.callee_file === 'Animal.ts', - ); - expect( - edge, - `Expected Lion.speak → Animal.speak edge (super-dispatch via class hierarchy).\nActual edges:\n${JSON.stringify(callEdges, null, 2)}`, - ).toBeDefined(); - }); + if (engine === 'native') { + it.todo('super-dispatch: emits Lion.speak → Animal.speak (native gap #1326)'); + } else { + it('super-dispatch: emits Lion.speak → Animal.speak', () => { + const edge = callEdges.find( + (e) => + e.caller_name === 'Lion.speak' && + e.callee_name === 'Animal.speak' && + e.callee_file === 'Animal.ts', + ); + expect( + edge, + `Expected Lion.speak → Animal.speak edge (super-dispatch via class hierarchy).\nActual edges:\n${JSON.stringify(callEdges, null, 2)}`, + ).toBeDefined(); + }); + } // ── transitive multi-level CHA (issue #1311) ─────────────────────────── // Hierarchy: IJob → AbstractJob (non-instantiated) → PrintJob / ScanJob @@ -164,11 +174,14 @@ describe.each(ENGINES)('Phase 8.5 CHA dispatch (%s)', (engine) => { // The native path relies on the Rust extractor emitting `implements`/`extends` // edges for `abstract class X implements Y`. The pre-compiled native binary // (v3.11.2) does not yet include the `abstract_class_declaration` fix, so - // transitive CHA tests are WASM-only until the native binary is updated. - - it.skipIf(engine === 'native')( - 'CHA transitive: emits runJob → PrintJob.run (3-level hierarchy)', - () => { + // transitive CHA requires a binary update. Also blocked by the same raw + // call-site gap (issue #1326). + + if (engine === 'native') { + it.todo('CHA transitive: emits runJob → PrintJob.run (3-level hierarchy) (native gap #1326)'); + it.todo('CHA transitive: emits runJob → ScanJob.run (3-level hierarchy) (native gap #1326)'); + } else { + it('CHA transitive: emits runJob → PrintJob.run (3-level hierarchy)', () => { const edge = callEdges.find( (e) => e.caller_name === 'runJob' && @@ -179,12 +192,9 @@ describe.each(ENGINES)('Phase 8.5 CHA dispatch (%s)', (engine) => { edge, `Expected runJob → PrintJob.run edge (transitive CHA through AbstractJob).\nActual edges:\n${JSON.stringify(callEdges, null, 2)}`, ).toBeDefined(); - }, - ); + }); - it.skipIf(engine === 'native')( - 'CHA transitive: emits runJob → ScanJob.run (3-level hierarchy)', - () => { + it('CHA transitive: emits runJob → ScanJob.run (3-level hierarchy)', () => { const edge = callEdges.find( (e) => e.caller_name === 'runJob' && @@ -195,8 +205,8 @@ describe.each(ENGINES)('Phase 8.5 CHA dispatch (%s)', (engine) => { edge, `Expected runJob → ScanJob.run edge (transitive CHA through AbstractJob).\nActual edges:\n${JSON.stringify(callEdges, null, 2)}`, ).toBeDefined(); - }, - ); + }); + } it('CHA transitive: does NOT emit runJob → AbstractJob.run (abstract, never instantiated)', () => { const edge = callEdges.find( diff --git a/tests/search/embedding-regression.test.ts b/tests/search/embedding-regression.test.ts index 420afafa1..ed26139e6 100644 --- a/tests/search/embedding-regression.test.ts +++ b/tests/search/embedding-regression.test.ts @@ -10,7 +10,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import Database from 'better-sqlite3'; -import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { afterAll, beforeAll, describe, expect, type TestContext, test } from 'vitest'; +import { flushDeferredClose } from '../../src/db/index.js'; // Detect whether transformers is available (optional dep) let hasTransformers = false; @@ -81,20 +82,35 @@ describe.skipIf(!hasTransformers)('embedding regression (real model)', () => { }, 240_000); afterAll(() => { - if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + if (!tmpDir) return; + // Flush any deferred DB closes before deleting the temp directory. + // On Windows, SQLite WAL files can remain locked briefly after db.close(), + // causing intermittent EBUSY errors. Retry up to 3 times with a short delay. + flushDeferredClose(); + const sharedBuf = new SharedArrayBuffer(4); + const sharedArr = new Int32Array(sharedBuf); + for (let attempt = 0; attempt < 3; attempt++) { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + return; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EBUSY' || attempt === 2) throw err; + Atomics.wait(sharedArr, 0, 0, 100); + } + } }); describe('smoke tests', () => { - test('stored at least 6 embeddings', () => { - if (rateLimited) return; + test('stored at least 6 embeddings', (ctx: TestContext) => { + if (rateLimited) ctx.skip(); const db = new Database(dbPath, { readonly: true }); const count = db.prepare('SELECT COUNT(*) as c FROM embeddings').get().c; db.close(); expect(count).toBeGreaterThanOrEqual(6); }); - test('metadata records correct model and dimension', () => { - if (rateLimited) return; + test('metadata records correct model and dimension', (ctx: TestContext) => { + if (rateLimited) ctx.skip(); const db = new Database(dbPath, { readonly: true }); const model = db.prepare("SELECT value FROM embedding_meta WHERE key = 'model'").get().value; const dim = db.prepare("SELECT value FROM embedding_meta WHERE key = 'dim'").get().value; @@ -103,8 +119,8 @@ describe.skipIf(!hasTransformers)('embedding regression (real model)', () => { expect(Number(dim)).toBe(384); }); - test('search returns results with positive similarity', async () => { - if (rateLimited) return; + test('search returns results with positive similarity', async (ctx: TestContext) => { + if (rateLimited) ctx.skip(); const data = await searchData('add numbers', dbPath, { minScore: 0.01 }); expect(data).not.toBeNull(); expect(data.results.length).toBeGreaterThan(0); @@ -126,28 +142,28 @@ describe.skipIf(!hasTransformers)('embedding regression (real model)', () => { expect(names).toContain(expectedName); } - test('"add two numbers together" finds add in top 3', async () => { - if (rateLimited) return; + test('"add two numbers together" finds add in top 3', async (ctx: TestContext) => { + if (rateLimited) ctx.skip(); await expectInTopN('add two numbers together', 'add', 3); }); - test('"multiply values" finds multiply in top 3', async () => { - if (rateLimited) return; + test('"multiply values" finds multiply in top 3', async (ctx: TestContext) => { + if (rateLimited) ctx.skip(); await expectInTopN('multiply values', 'multiply', 3); }); - test('"compute the square of a number" finds square in top 3', async () => { - if (rateLimited) return; + test('"compute the square of a number" finds square in top 3', async (ctx: TestContext) => { + if (rateLimited) ctx.skip(); await expectInTopN('compute the square of a number', 'square', 3); }); - test('"sum of squares calculation" finds sumOfSquares in top 3', async () => { - if (rateLimited) return; + test('"sum of squares calculation" finds sumOfSquares in top 3', async (ctx: TestContext) => { + if (rateLimited) ctx.skip(); await expectInTopN('sum of squares calculation', 'sumOfSquares', 3); }); - test('"main entry point function" finds main in top 5', async () => { - if (rateLimited) return; + test('"main entry point function" finds main in top 5', async (ctx: TestContext) => { + if (rateLimited) ctx.skip(); await expectInTopN('main entry point function', 'main', 5); }); });