feat(resolution): add C/C++ include path resolution (#453)

* feat(resolution): add C/C++ include path resolution

Add full import resolution pipeline for C and C++ #include directives,
connecting extracted import nodes to actual header files in the project.

- Add C/C++ extension resolution (.h, .hpp, .hxx, .cpp, .cc, .cxx)
- Add system header filtering with ~80 C and ~80 C++ stdlib headers
- Add extractCppImports() for #include import mapping extraction
- Add compile_commands.json parsing for -I/-isystem include directories
- Add heuristic include dir discovery (include/, src/, lib/, api/)
- Add resolveCppIncludePath() for include directory search
- Add C/C++ built-in symbol filtering (printf, malloc, std::*, etc.)
- Wire getCppIncludeDirs into ResolutionContext
- Add 13 new tests for C/C++ import resolution and extraction

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* review: wire #include resolution into pipeline + fix builtin filter

The PR landed the include-dir scan logic (loadCppIncludeDirs +
resolveCppIncludePath) but the indexer never reached it: imports
references with referenceName='X.h' fell into resolveViaImport's
symbol-lookup branch (matched extractCppImports' basename-without-ext
localName via .startsWith, then tried to find a symbol named like the
extension and failed). End result on bitcoin-core: 0 new file→file
imports vs main, despite the include-dir scan resolving paths correctly
when probed directly. resolveViaImport now has a C/C++ imports branch
that resolves the include path to the actual file node and returns
that — skipping the irrelevant symbol scan. Measured on bitcoin-core:
+2,059 newly resolved file→file imports (6,027 → 8,086, +34%).

The unconditional CPP_BUILT_INS / C_BUILT_INS filter also misfired:
C/C++ codebases routinely shadow stdlib names (bitcoin's mp::move,
custom allocators with free/malloc, stream classes with read/write/
close/open, logging libs wrapping printf). Filtering those names
killed legitimate edges — 1,179 → 0 for move(), 33 → 0 for free(),
149 → 7 for write() on bitcoin. The filter now defers to
hasAnyPossibleMatch: only filter when no user-defined symbol with the
name exists. std:: prefix stays unconditional (never user-shadowed in
practice). After: printf/free/open/close/read/write/swap all preserved
at main's counts; the std::move-binds-to-mp::move false-positives still
drop (correctly: −2,154 C/C++ calls).

Also: drop the duplicate 'FILE' in C_BUILT_INS; add an end-to-end test
that asserts `#include "X.h"` produces a file→file imports edge in the
real indexing pipeline (not just direct resolver probes); add a test
documenting the cross-language `.h` heuristic claim (Obj-C dirs are
intentionally allowed as C/C++ include dirs); add CHANGELOG entry
under [Unreleased] with measured numbers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Colby McHenry <me@colbymchenry.com>
This commit is contained in:
g122622
2026-05-26 19:56:25 -05:00
committed by GitHub
co-authored by Claude Opus 4.7 Colby McHenry
parent 893256b88e
commit 48eebe1e3e
6 changed files with 799 additions and 4 deletions
+21
View File
@@ -2070,6 +2070,27 @@ end
expect(names).toContain('vector');
expect(names).toContain('config.h');
});
it('should create unresolved references for local includes', () => {
const code = `#include "myheader.h"`;
const result = extractFromSource('main.cpp', code);
const importRef = result.unresolvedReferences.find(
(r) => r.referenceKind === 'imports' && r.referenceName === 'myheader.h'
);
expect(importRef).toBeDefined();
expect(importRef?.line).toBe(1);
});
it('should create unresolved references for system includes', () => {
const code = `#include <iostream>`;
const result = extractFromSource('main.cpp', code);
const importRef = result.unresolvedReferences.find(
(r) => r.referenceKind === 'imports' && r.referenceName === 'iostream'
);
expect(importRef).toBeDefined();
});
});
describe('Dart imports', () => {
+355 -1
View File
@@ -12,7 +12,7 @@ import { CodeGraph } from '../src';
import { Node, UnresolvedReference } from '../src/types';
import { ReferenceResolver, createResolver, ResolutionContext } from '../src/resolution';
import { matchReference } from '../src/resolution/name-matcher';
import { resolveImportPath, extractImportMappings } from '../src/resolution/import-resolver';
import { resolveImportPath, extractImportMappings, loadCppIncludeDirs, clearCppIncludeDirCache } from '../src/resolution/import-resolver';
import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks';
import { QueryBuilder } from '../src/db/queries';
import { DatabaseConnection } from '../src/db';
@@ -1138,4 +1138,358 @@ func main() {
expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true);
});
});
describe('C/C++ Import Resolution', () => {
afterEach(() => {
clearCppIncludeDirCache();
});
it('should resolve C include to header in same directory', () => {
const context: ResolutionContext = {
getNodesInFile: () => [],
getNodesByName: () => [],
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: (p) => p === 'utils.h',
readFile: () => null,
getProjectRoot: () => '',
getAllFiles: () => ['utils.h', 'main.c'],
};
const result = resolveImportPath(
'utils.h',
'main.c',
'c',
context
);
expect(result).toBe('utils.h');
});
it('should resolve C++ include with .hpp extension', () => {
const context: ResolutionContext = {
getNodesInFile: () => [],
getNodesByName: () => [],
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: (p) => p === 'include/myclass.hpp',
readFile: () => null,
getProjectRoot: () => '',
getAllFiles: () => ['include/myclass.hpp', 'src/main.cpp'],
getCppIncludeDirs: () => ['include'],
};
const result = resolveImportPath(
'myclass.hpp',
'src/main.cpp',
'cpp',
context
);
expect(result).toBe('include/myclass.hpp');
});
it('should resolve include with subdirectory path', () => {
const context: ResolutionContext = {
getNodesInFile: () => [],
getNodesByName: () => [],
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: (p) => p === 'utils/helpers.h',
readFile: () => null,
getProjectRoot: () => '',
getAllFiles: () => ['utils/helpers.h', 'main.c'],
};
const result = resolveImportPath(
'utils/helpers.h',
'main.c',
'c',
context
);
expect(result).toBe('utils/helpers.h');
});
it('should resolve include via include directories', () => {
const context: ResolutionContext = {
getNodesInFile: () => [],
getNodesByName: () => [],
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: (p) => p === 'include/myheader.h',
readFile: () => null,
getProjectRoot: () => '',
getAllFiles: () => ['include/myheader.h', 'src/main.cpp'],
getCppIncludeDirs: () => ['include'],
};
const result = resolveImportPath(
'myheader.h',
'src/main.cpp',
'cpp',
context
);
expect(result).toBe('include/myheader.h');
});
it('should resolve include trying multiple extensions', () => {
const context: ResolutionContext = {
getNodesInFile: () => [],
getNodesByName: () => [],
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
// myclass.h does not exist, but myclass.hpp does
fileExists: (p) => p === 'include/myclass.hpp',
readFile: () => null,
getProjectRoot: () => '',
getAllFiles: () => ['include/myclass.hpp', 'src/main.cpp'],
getCppIncludeDirs: () => ['include'],
};
const result = resolveImportPath(
'myclass',
'src/main.cpp',
'cpp',
context
);
expect(result).toBe('include/myclass.hpp');
});
it('should return null for system headers', () => {
const context: ResolutionContext = {
getNodesInFile: () => [],
getNodesByName: () => [],
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: () => true,
readFile: () => null,
getProjectRoot: () => '',
getAllFiles: () => [],
};
// C standard library header
expect(resolveImportPath('stdio.h', 'main.c', 'c', context)).toBeNull();
// C++ standard library header
expect(resolveImportPath('vector', 'main.cpp', 'cpp', context)).toBeNull();
// C++ C-wrapper header
expect(resolveImportPath('cstdio', 'main.cpp', 'cpp', context)).toBeNull();
});
it('should return null for single-component third-party paths that cannot be resolved', () => {
const context: ResolutionContext = {
getNodesInFile: () => [],
getNodesByName: () => [],
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: () => false,
readFile: () => null,
getProjectRoot: () => '',
getAllFiles: () => [],
getCppIncludeDirs: () => [],
};
// Third-party bare header without path — not resolvable, returns null
const result = resolveImportPath(
'openssl/ssl.h',
'main.cpp',
'cpp',
context
);
expect(result).toBeNull();
});
it('should not filter project headers with path separators', () => {
const context: ResolutionContext = {
getNodesInFile: () => [],
getNodesByName: () => [],
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: (p) => p === 'mylib/utils.h',
readFile: () => null,
getProjectRoot: () => '',
getAllFiles: () => ['mylib/utils.h'],
};
// Path with separator should NOT be filtered as external
const result = resolveImportPath(
'mylib/utils.h',
'main.c',
'c',
context
);
expect(result).toBe('mylib/utils.h');
});
it('should extract C/C++ import mappings from #include directives', () => {
const code = `#include <iostream>
#include "myheader.h"
#include "utils/helpers.hpp"`;
const mappings = extractImportMappings('main.cpp', code, 'cpp');
expect(mappings.length).toBe(3);
expect(mappings[0]).toEqual({
localName: 'iostream',
exportedName: '*',
source: 'iostream',
isDefault: false,
isNamespace: true,
});
expect(mappings[1]).toEqual({
localName: 'myheader',
exportedName: '*',
source: 'myheader.h',
isDefault: false,
isNamespace: true,
});
expect(mappings[2]).toEqual({
localName: 'helpers',
exportedName: '*',
source: 'utils/helpers.hpp',
isDefault: false,
isNamespace: true,
});
});
it('should discover include directories from compile_commands.json', () => {
// Create a temp project with compile_commands.json
const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-test-'));
try {
const compileDb = [
{
directory: tempProject,
command: 'g++ -Iinclude -Isrc/lib -isystem /usr/include -c src/main.cpp',
file: 'src/main.cpp',
},
];
fs.writeFileSync(
path.join(tempProject, 'compile_commands.json'),
JSON.stringify(compileDb)
);
// Create the include dirs so they exist
fs.mkdirSync(path.join(tempProject, 'include'), { recursive: true });
fs.mkdirSync(path.join(tempProject, 'src', 'lib'), { recursive: true });
clearCppIncludeDirCache();
const dirs = loadCppIncludeDirs(tempProject);
// Should find include and src/lib (relative to project root)
// /usr/include is absolute and outside project, should be excluded
expect(dirs).toContain('include');
expect(dirs).toContain('src/lib');
expect(dirs.some(d => d.includes('usr'))).toBe(false);
} finally {
fs.rmSync(tempProject, { recursive: true });
}
});
it('should fall back to heuristic include dirs when no compile_commands.json', () => {
const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-test-'));
try {
// Create include/ and src/ directories with headers
fs.mkdirSync(path.join(tempProject, 'include'), { recursive: true });
fs.writeFileSync(path.join(tempProject, 'include', 'types.h'), '');
fs.mkdirSync(path.join(tempProject, 'src'), { recursive: true });
fs.writeFileSync(path.join(tempProject, 'src', 'main.cpp'), '');
// Create a directory without headers — should not be included
fs.mkdirSync(path.join(tempProject, 'docs'), { recursive: true });
clearCppIncludeDirCache();
const dirs = loadCppIncludeDirs(tempProject);
expect(dirs).toContain('include');
expect(dirs).toContain('src');
expect(dirs).not.toContain('docs');
} finally {
fs.rmSync(tempProject, { recursive: true });
}
});
// Documents the cross-language `.h` behavior. Objective-C and C++ share
// the `.h` extension, so in a mixed iOS-style project an Obj-C header
// dir gets claimed as a C/C++ include dir too. That's intentional — a
// C++ file legitimately can `#include "Foo.h"` against an Obj-C header
// (Obj-C++ / .mm callers), and false-positive inclusion is far cheaper
// than missing real resolutions. The test pins this so a later
// "exclude objc dirs" refactor breaks loudly and reviewers see the
// trade-off explicitly.
it('heuristic claims any top-level dir containing .h files, including Obj-C', () => {
const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-test-'));
try {
// C++ side: an `cppmod` dir with a .hpp (C++-only extension)
fs.mkdirSync(path.join(tempProject, 'cppmod'), { recursive: true });
fs.writeFileSync(path.join(tempProject, 'cppmod', 'shared.hpp'), '');
// Obj-C side: an `iosmod` dir with .h + .m (no .cpp/.hpp).
fs.mkdirSync(path.join(tempProject, 'iosmod'), { recursive: true });
fs.writeFileSync(path.join(tempProject, 'iosmod', 'View.h'), '');
fs.writeFileSync(path.join(tempProject, 'iosmod', 'View.m'), '');
clearCppIncludeDirCache();
const dirs = loadCppIncludeDirs(tempProject);
// Both included — Obj-C dirs are intentionally allowed.
expect(dirs).toContain('cppmod');
expect(dirs).toContain('iosmod');
} finally {
fs.rmSync(tempProject, { recursive: true });
}
});
// End-to-end: ensure `#include "X.h"` produces a file→file `imports` edge
// in the actual indexing pipeline (not just a phantom file→import-node
// edge). This pins the include-dir resolution path so the headline PR
// feature can't silently regress to a no-op in the indexing flow.
it('connects #include to the real header file via include-dir scan (end-to-end)', async () => {
const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-e2e-'));
try {
fs.mkdirSync(path.join(tempProject, 'include'), { recursive: true });
fs.mkdirSync(path.join(tempProject, 'src'), { recursive: true });
fs.writeFileSync(
path.join(tempProject, 'include', 'utils.h'),
`#ifndef UTILS_H\n#define UTILS_H\nint add(int, int);\n#endif\n`
);
fs.writeFileSync(
path.join(tempProject, 'src', 'main.cpp'),
`#include "utils.h"\n#include <vector>\nint main(){ return add(1,2); }\n`
);
clearCppIncludeDirCache();
cg = await CodeGraph.init(tempProject, { index: true });
// Sanity: file nodes exist for the header and the cpp.
const allFiles = cg.getStats();
expect(allFiles.fileCount).toBe(2);
// The `#include "utils.h"` edge should target the real
// `include/utils.h` file node — not a floating `import` node
// living inside main.cpp.
const db = DatabaseConnection.open(path.join(tempProject, '.codegraph', 'codegraph.db'));
const rows = db.getDb().prepare(`
select dst.kind as dstKind, dst.file_path as dstPath
from edges e
join nodes src on e.source = src.id
join nodes dst on e.target = dst.id
where e.kind = 'imports'
and src.kind = 'file'
and src.file_path = 'src/main.cpp'
`).all() as Array<{ dstKind: string; dstPath: string }>;
const resolvedToHeader = rows.find(
(r) => r.dstKind === 'file' && r.dstPath === 'include/utils.h'
);
expect(resolvedToHeader, 'main.cpp → include/utils.h imports edge missing').toBeDefined();
// `<vector>` should NOT produce a file edge — it's a stdlib header.
const stdlibFile = rows.find(
(r) => r.dstKind === 'file' && r.dstPath && r.dstPath.endsWith('vector')
);
expect(stdlibFile).toBeUndefined();
} finally {
fs.rmSync(tempProject, { recursive: true, force: true });
}
});
});
});