diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 12f45c433..b7a56aac7 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -49,7 +49,7 @@ jobs: - name: Run Automated AI Review id: automated-review - uses: anthropics/claude-code-action@64de744025ca9e24df2b88204b4f1e968f39f009 + uses: anthropics/claude-code-action@28f83620103c48a57093dcc2837eec89e036bb9f with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} model: claude-sonnet-4-6 @@ -208,7 +208,7 @@ jobs: - name: Run Interactive AI Assistant id: interactive-claude - uses: anthropics/claude-code-action@64de744025ca9e24df2b88204b4f1e968f39f009 + uses: anthropics/claude-code-action@28f83620103c48a57093dcc2837eec89e036bb9f with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} model: claude-sonnet-4-6 diff --git a/package-lock.json b/package-lock.json index b5d6b42b0..77e1e8fe3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7423,7 +7423,7 @@ }, "node_modules/tree-sitter-erlang": { "version": "0.0.0", - "resolved": "git+ssh://git@github.com/WhatsApp/tree-sitter-erlang.git#836aa2b6c3af2c7cef3f84049b0ed6d44485a870", + "resolved": "git+ssh://git@github.com/WhatsApp/tree-sitter-erlang.git#e446ec60022a7cafe157805742b41c04b499cc5d", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -7485,9 +7485,10 @@ }, "node_modules/tree-sitter-gleam": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/gleam-lang/tree-sitter-gleam.git#0ae061fea67cb39639b47c7acc46cbf5d109fda1", + "resolved": "git+ssh://git@github.com/gleam-lang/tree-sitter-gleam.git#4e4643c2215c2b2343d9ec179c798818c132c9cc", "integrity": "sha512-Wn3hmgf637qVAHOX0YcF9v/DKkkRviR9+ptEgJ/mP+ekD5L+hz5WSWZged9i7SueF4M8UVNQGzBNsesX8XZHjg==", "dev": true, + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "nan": "^2.18.0" diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index 729624281..859f0f4a3 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -502,6 +502,34 @@ function buildCallEdges( ]), ) : new Map(); + + // Phase 8.3f: seed typeMap[callee::restName] = { type: argName } from + // objectRestParamBindings × paramBindings, mirroring buildObjectRestParamPostPass. + // Scoped keys prevent same-name rest-param collisions when two functions in + // the same file both use `...rest` (#1358). The unscoped key is also seeded + // when only one callee uses a given rest name, preserving resolution when + // callerName is null (findCaller couldn't identify the enclosing function). + if (symbols.objectRestParamBindings?.length && symbols.paramBindings?.length) { + const restNameCallees = new Map>(); + for (const orpb of symbols.objectRestParamBindings) { + if (!restNameCallees.has(orpb.restName)) restNameCallees.set(orpb.restName, new Set()); + restNameCallees.get(orpb.restName)!.add(orpb.callee); + } + for (const orpb of symbols.objectRestParamBindings) { + for (const pb of symbols.paramBindings) { + if (pb.callee === orpb.callee && pb.argIndex === orpb.argIndex) { + const scopedKey = `${orpb.callee}::${orpb.restName}`; + if (!typeMap.has(scopedKey)) { + typeMap.set(scopedKey, { type: pb.argName, confidence: 0.65 }); + if (restNameCallees.get(orpb.restName)!.size === 1 && !typeMap.has(orpb.restName)) { + typeMap.set(orpb.restName, { type: pb.argName, confidence: 0.65 }); + } + } + } + } + } + } + const seenCallEdges = new Set(); const lookup = makeIncrementalLookup(db, stmts); let edgesAdded = 0; @@ -510,7 +538,7 @@ function buildCallEdges( if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue; const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow); - const { targets, importedFrom } = resolveCallTargets( + const { targets: initialTargets, importedFrom } = resolveCallTargets( lookup, call, relPath, @@ -518,6 +546,51 @@ function buildCallEdges( typeMap, caller.callerName, ); + let targets = initialTargets; + + if (targets.length === 0 && call.receiver === 'this' && caller.callerName != null) { + const dotIdx = caller.callerName.indexOf('.'); + if (dotIdx > 0) { + const className = caller.callerName.slice(0, dotIdx); + const qualifiedName = `${className}.${call.name}`; + const qualified = lookup + .byNameAndFile(qualifiedName, relPath) + .filter((n) => n.kind === 'method'); + if (qualified.length > 0) { + targets = qualified; + } + } + } + + if ( + targets.length === 0 && + call.receiver === 'this' && + caller.callerName != null && + symbols.definePropertyReceivers + ) { + const receiverVarName = symbols.definePropertyReceivers.get(caller.callerName); + if (receiverVarName) { + const typeEntry = typeMap.get(receiverVarName); + const typeName = typeEntry + ? typeof typeEntry === 'string' + ? typeEntry + : (typeEntry as { type?: string }).type + : null; + if (typeName) { + const qualifiedName = `${typeName}.${call.name}`; + const qualified = lookup.byNameAndFile(qualifiedName, relPath); + if (qualified.length > 0) { + targets = [...qualified]; + } + } + if (targets.length === 0) { + const sameFile = lookup.byNameAndFile(call.name, relPath); + if (sameFile.length > 0) { + targets = [...sameFile]; + } + } + } + } for (const t of targets) { const edgeKey = `${caller.id}|${t.id}`; diff --git a/src/domain/parser.ts b/src/domain/parser.ts index 663c26b20..54c9f28b9 100644 --- a/src/domain/parser.ts +++ b/src/domain/parser.ts @@ -168,14 +168,20 @@ const COMMON_QUERY_PATTERNS: string[] = [ '(expression_statement (assignment_expression left: (member_expression) @assign_left right: (_) @assign_right)) @assign_node', ]; -// JS: class name is (identifier) -const JS_CLASS_PATTERN: string = '(class_declaration name: (identifier) @cls_name) @cls_node'; +// JS: class name is (identifier) — declarations and expressions +const JS_CLASS_PATTERNS: string[] = [ + '(class_declaration name: (identifier) @cls_name) @cls_node', + // class expressions: `return class Foo extends Bar { ... }` or `const X = class Foo { ... }` + '(class name: (identifier) @cls_name) @cls_node', +]; // TS/TSX: class name is (type_identifier), plus interface and type alias // abstract_class_declaration is a separate node type in tree-sitter-typescript const TS_EXTRA_PATTERNS: string[] = [ '(class_declaration name: (type_identifier) @cls_name) @cls_node', '(abstract_class_declaration name: (type_identifier) @cls_name) @cls_node', + // class expressions: `return class Foo extends Bar { ... }` + '(class name: (type_identifier) @cls_name) @cls_node', '(interface_declaration name: (type_identifier) @iface_name) @iface_node', '(type_alias_declaration name: (type_identifier) @type_name) @type_node', ]; @@ -206,7 +212,7 @@ async function doLoadLanguage(entry: LanguageRegistryEntry): Promise { const isTS = entry.id === 'typescript' || entry.id === 'tsx'; const patterns = isTS ? [...COMMON_QUERY_PATTERNS, ...TS_EXTRA_PATTERNS] - : [...COMMON_QUERY_PATTERNS, JS_CLASS_PATTERN]; + : [...COMMON_QUERY_PATTERNS, ...JS_CLASS_PATTERNS]; _queryCache.set(entry.id, new Query(lang, patterns.join('\n'))); } } catch (e: unknown) { diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 22580c797..246a14fdb 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -734,6 +734,8 @@ function walkJavaScriptNode(node: TreeSitterNode, ctx: ExtractorOutput): void { break; case 'class_declaration': case 'abstract_class_declaration': + // class expressions: `return class Foo extends Bar { ... }` or `const X = class Foo { ... }` + case 'class': handleClassDecl(node, ctx); break; case 'method_definition': @@ -874,7 +876,7 @@ function handleStaticBlock(node: TreeSitterNode, definitions: Definition[]): voi if (!className) return; definitions.push({ name: `${className}.`, - kind: 'function', + kind: 'method', line: nodeStartLine(node), endLine: nodeEndLine(node), }); diff --git a/src/infrastructure/native.ts b/src/infrastructure/native.ts index b313f0ffe..841f7b99d 100644 --- a/src/infrastructure/native.ts +++ b/src/infrastructure/native.ts @@ -6,11 +6,13 @@ * to the existing WASM pipeline. */ +import { existsSync } from 'node:fs'; import { createRequire } from 'node:module'; import os from 'node:os'; +import { fileURLToPath } from 'node:url'; import { EngineError, toErrorMessage } from '../shared/errors.js'; import type { NativeAddon } from '../types.js'; -import { debug } from './logger.js'; +import { debug, warn } from './logger.js'; let _cached: NativeAddon | null | undefined; // undefined = not yet tried, null = failed, NativeAddon = module let _loadError: Error | null = null; @@ -44,31 +46,107 @@ const PLATFORM_PACKAGES: Record = { 'win32-x64': '@optave/codegraph-win32-x64-msvc', }; +/** + * Map of (platform-arch[-libc]) → locally compiled binary filename. + * Checked before the npm package so that locally compiled Rust changes + * are picked up immediately in development without publishing a new release. + */ +const PLATFORM_LOCAL_BINARIES: Record = { + 'linux-x64-gnu': 'codegraph-core.linux-x64-gnu.node', + 'linux-x64-musl': 'codegraph-core.linux-x64-musl.node', + 'linux-arm64-gnu': 'codegraph-core.linux-arm64-gnu.node', + 'linux-arm64-musl': 'codegraph-core.linux-arm64-musl.node', + 'darwin-arm64': 'codegraph-core.darwin-arm64.node', + 'darwin-x64': 'codegraph-core.darwin-x64.node', + 'win32-x64': 'codegraph-core.win32-x64-msvc.node', +}; + +/** Compute the platform key used to index PLATFORM_PACKAGES / PLATFORM_LOCAL_BINARIES. */ +function resolvePlatformKey(): string { + const platform = os.platform(); + const arch = os.arch(); + return platform === 'linux' ? `${platform}-${arch}-${detectLibc()}` : `${platform}-${arch}`; +} + /** * Resolve the platform-specific npm package name for the native addon. * Returns null if the current platform is not supported. */ function resolvePlatformPackage(): string | null { - const platform = os.platform(); - const arch = os.arch(); - const key = platform === 'linux' ? `${platform}-${arch}-${detectLibc()}` : `${platform}-${arch}`; - return PLATFORM_PACKAGES[key] || null; + return PLATFORM_PACKAGES[resolvePlatformKey()] ?? null; } /** * Try to load the native napi addon. * Returns the module on success, null on failure. + * + * Load order: + * 1. NAPI_RS_NATIVE_LIBRARY_PATH env var (explicit override) + * 2. locally compiled binary in crates/codegraph-core/ (dev mode — preferred + * over the npm package so that Rust changes are picked up immediately + * without publishing a new release) + * 3. npm platform package (production path) */ export function loadNative(): NativeAddon | null { if (_cached !== undefined) return _cached; + const platformKey = resolvePlatformKey(); + + // 1. Explicit path override — highest priority. Failure is fatal: if the + // operator set this variable, silently loading a different binary would + // be harder to diagnose than an explicit error. + const envPath = process.env.NAPI_RS_NATIVE_LIBRARY_PATH; + if (envPath) { + try { + _cached = _require(envPath) as NativeAddon; + debug(`loadNative: loaded from NAPI_RS_NATIVE_LIBRARY_PATH: ${envPath}`); + return _cached; + } catch (err) { + _loadError = err as Error; + warn( + `loadNative: NAPI_RS_NATIVE_LIBRARY_PATH is set but failed to load "${envPath}": ${toErrorMessage(err as Error)}`, + ); + _cached = null; + return null; + } + } + + // 2. Locally compiled dev binary — preferred over npm package so that Rust + // changes are visible without publishing. Only used when the file exists. + // If the file exists but fails to load (e.g. stale ABI), we warn and halt + // rather than silently falling through to the npm package — that would + // defeat the purpose of this priority order. + const localFile = PLATFORM_LOCAL_BINARIES[platformKey]; + if (localFile) { + const localPath = fileURLToPath( + new URL(`../../crates/codegraph-core/${localFile}`, import.meta.url), + ); + if (existsSync(localPath)) { + try { + _cached = _require(localPath) as NativeAddon; + debug(`loadNative: loaded local dev binary: ${localPath}`); + return _cached; + } catch (err) { + _loadError = err as Error; + warn( + `loadNative: local dev binary exists but failed to load "${localPath}": ${toErrorMessage(err as Error)}`, + ); + _cached = null; + return null; + } + } + } + + // 3. Published npm platform package — production path. const pkg = resolvePlatformPackage(); if (pkg) { try { _cached = _require(pkg) as NativeAddon; + debug(`loadNative: loaded npm package: ${pkg}`); return _cached; } catch (err) { _loadError = err as Error; + debug(`loadNative: npm package ${pkg} not available: ${toErrorMessage(err as Error)}`); } } else { _loadError = new Error(`Unsupported platform: ${os.platform()}-${os.arch()}`); @@ -88,6 +166,10 @@ export function isNativeAvailable(): boolean { /** * Read the version from the platform-specific npm package.json. * Returns null if the package is not installed or has no version. + * + * Note: always reports the npm package version. When the local dev binary or + * NAPI_RS_NATIVE_LIBRARY_PATH is loaded instead, this version may not match + * the running binary. */ export function getNativePackageVersion(): string | null { const pkg = resolvePlatformPackage(); diff --git a/tests/integration/issue-1369-incremental-rest-param-callerName.test.ts b/tests/integration/issue-1369-incremental-rest-param-callerName.test.ts new file mode 100644 index 000000000..8a0b3a55d --- /dev/null +++ b/tests/integration/issue-1369-incremental-rest-param-callerName.test.ts @@ -0,0 +1,100 @@ +/** + * Integration test for #1369: incremental rebuild path must pass callerName to + * resolveCallTargets so the scoped Phase 8.3f typeMap key (`callee::restName`) + * can be resolved correctly after a file edit. + * + * Scenario (same fixture as #1358 — two functions sharing a rest-param name): + * function f1({ a, ...rest }) { rest.m1(); } + * function f2({ b, ...rest }) { rest.m2(); } + * f1(obj1); f2(obj2); + * + * A full build produces f1→m1 and f2→m2. Without the fix, touching the file + * and doing an incremental rebuild would drop f2→m2 because: + * 1. The incremental buildCallEdges didn't seed callee::restName scoped keys. + * 2. Even after adding the seeding, it didn't pass caller.callerName to + * resolveCallTargets, so resolveByMethodOrGlobal's scoped-key fallback + * (`typeMap.get(`${callerName}::${effectiveReceiver}`)`) never fired. + * + * Fix (this PR / #1369): seed scoped keys + pass callerName in incremental path. + */ + +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, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; + +const FIXTURE_CODE = ` +function m1() {} +function m2() {} + +var obj1 = { m1 }; +var obj2 = { m2 }; + +function f1({ a, ...rest }) { + rest.m1(); +} + +function f2({ b, ...rest }) { + rest.m2(); +} + +f1(obj1); +f2(obj2); +`; + +const FIXTURE_CODE_TOUCHED = `${FIXTURE_CODE} +// touched to trigger incremental rebuild +`; + +let tmpDir: string; + +beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1369-')); + fs.writeFileSync(path.join(tmpDir, 'collision.js'), FIXTURE_CODE); + await buildGraph(tmpDir, { incremental: false, skipRegistry: true }); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function readCallEdges(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS src, n2.name AS tgt + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind = 'calls' + ORDER BY n1.name, n2.name`, + ) + .all() as Array<{ src: string; tgt: string }>; + } finally { + db.close(); + } +} + +describe('Issue #1369: incremental rebuild preserves scoped rest-param resolution', () => { + it('full build: f1 → m1 and f2 → m2 resolve correctly', () => { + const edges = readCallEdges(path.join(tmpDir, '.codegraph', 'graph.db')); + expect(edges.find((e) => e.src === 'f1' && e.tgt === 'm1')).toBeDefined(); + expect(edges.find((e) => e.src === 'f2' && e.tgt === 'm2')).toBeDefined(); + expect(edges.find((e) => e.src === 'f1' && e.tgt === 'm2')).toBeUndefined(); + expect(edges.find((e) => e.src === 'f2' && e.tgt === 'm1')).toBeUndefined(); + }); + + it('incremental rebuild: f1 → m1 and f2 → m2 still resolve after file touch', async () => { + fs.writeFileSync(path.join(tmpDir, 'collision.js'), FIXTURE_CODE_TOUCHED); + await buildGraph(tmpDir, { incremental: true, skipRegistry: true }); + + const edges = readCallEdges(path.join(tmpDir, '.codegraph', 'graph.db')); + expect(edges.find((e) => e.src === 'f1' && e.tgt === 'm1')).toBeDefined(); + expect(edges.find((e) => e.src === 'f2' && e.tgt === 'm2')).toBeDefined(); + expect(edges.find((e) => e.src === 'f1' && e.tgt === 'm2')).toBeUndefined(); + expect(edges.find((e) => e.src === 'f2' && e.tgt === 'm1')).toBeUndefined(); + }); +}); diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 7e7428fd3..fedb48b09 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -105,12 +105,12 @@ describe('JavaScript parser', () => { ); }); - it('extracts static blocks as function definitions', () => { + it('extracts static blocks as method definitions', () => { const symbols = parseJS(`class C6 { static { f1(); } static { f2(); } }`); const staticDefs = symbols.definitions.filter((d) => d.name === 'C6.'); expect(staticDefs).toHaveLength(2); - expect(staticDefs[0]).toMatchObject({ kind: 'function' }); - expect(staticDefs[1]).toMatchObject({ kind: 'function' }); + expect(staticDefs[0]).toMatchObject({ kind: 'method' }); + expect(staticDefs[1]).toMatchObject({ kind: 'method' }); }); it('extracts import statements', () => { @@ -1142,4 +1142,52 @@ describe('JavaScript parser', () => { ); }); }); + + describe('class expression extends + static block + field def extraction', () => { + it('extracts extends relationship from named class expression', () => { + const symbols = parseJS( + `function make() { return class Child extends Parent { m() { super.m(); } } }`, + ); + expect(symbols.classes).toContainEqual( + expect.objectContaining({ name: 'Child', extends: 'Parent' }), + ); + }); + + it('extracts methods from named class expression', () => { + const symbols = parseJS(`const X = class Foo extends Base { bar() { return 1; } }`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'Foo.bar', kind: 'method' }), + ); + }); + + it('records super.method() call with receiver=super from class expression method', () => { + const symbols = parseJS(`const X = class Child extends Parent { m() { super.m(); } }`); + const superCall = symbols.calls.find((c) => c.name === 'm' && c.receiver === 'super'); + expect(superCall).toBeDefined(); + }); + + it('creates ClassName. definition for class static block', () => { + const symbols = parseJS(`class A extends B {\n static {\n super.init();\n }\n}`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'A.', kind: 'method' }), + ); + }); + + it('attributes super.method() call inside static block to ClassName.', () => { + const symbols = parseJS(`class A extends B {\n static {\n super.init();\n }\n}`); + const staticDef = symbols.definitions.find((d) => d.name === 'A.'); + expect(staticDef).toBeDefined(); + const superCall = symbols.calls.find((c) => c.name === 'init' && c.receiver === 'super'); + expect(superCall).toBeDefined(); + expect(superCall!.line).toBeGreaterThanOrEqual(staticDef!.line); + expect(superCall!.line).toBeLessThanOrEqual(staticDef!.endLine!); + }); + + it('extracts class field arrow function as callable ClassName.fieldName method', () => { + const symbols = parseJS(`class A {\n static f = () => {\n doSomething();\n };\n}`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'A.f', kind: 'method' }), + ); + }); + }); });