GitNexus/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts
Sravan Avvaru 86a39202cf
fix(#2965): system headers must not resolve to in-repo files (#3341)
* fix(#2965): system headers must not resolve to in-repo files

C and C++ use two syntactically distinct include forms:
  #include <x.h>   -- angle-bracket: search system include paths only
  #include "x.h"  -- quoted: search relative to the including file first

The old suffix-match fallback in resolveCImportTarget had no awareness
of this distinction, so a repo containing its own stdio.h would capture
every #include <stdio.h> and resolve it to the local file.

Fix:
- Add isSystem?: boolean to the wildcard variant of ParsedImportSyntax
- interpretCImport / interpretCppImport now set isSystem from the
  @import.system tree-sitter capture (present for angle-bracket form)
- resolveImportTarget in both cScopeResolver and cppScopeResolver
  short-circuits to null when context.parsedImport.isSystem is true,
  refusing to suffix-match system headers against workspace files
- Remove C and C++ from KNOWN_GAPS in the conformance test; add proper
  test cases with a parsedImport factory that distinguishes angle-bracket
  (isSystem:true) from quoted (isSystem:false) includes

Test: all 36 external-import-conformance cases pass, including the two
new c/cpp arms that were previously in KNOWN_GAPS.

* fix(#2965): update cpp-imports unit tests for isSystem field

Two test assertions were broken by the interpreter change:

1. Local include: the wildcard ParsedImport now always includes
   isSystem (false for quoted includes). Updated expected object
   to include isSystem:false.

2. System header: the old test asserted interpretCppImport returned
   null for system headers. The refactored design moves the null
   decision to the resolver layer (cppScopeResolver.resolveImportTarget)
   so the call graph and resolution stay separate concerns. The
   interpreter now returns { kind:'wildcard', isSystem:true } and
   the test name/assertion are updated to reflect this.

* fix(#2965): resolve C and C++ includes on search paths

Angle includes follow each translation unit's include roots, so a local
stdio.h no longer captures system headers or another file's -I list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3341)

- Accept in-repo include roots whose names start with `..` while still rejecting parent escapes.
- Correct the import-target bench comments so CONTEXT_LANGS and newPass match C/C++ header passes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(c,cpp): read include config per directory for monorepos (#2965)

Angle includes now resolve only against declared search roots, so config
read only at the repo root left monorepo sub-projects with nothing declared.

- compile_commands.json, compile_flags.txt, .ccls, .clangd and
  c_cpp_properties.json are read in every directory; a file takes the
  nearest one, and a nearer database's entry beats a shallower one (clangd).
- CMake include_directories / target_include_directories are read:
  directory-scoped and PRIVATE roots reach their subtree, PUBLIC and
  INTERFACE roots reach every file. ${CMAKE_CURRENT_SOURCE_DIR} and friends
  expand; unresolvable variables and generator expressions are dropped.
- Declared roots win: implicit include/Headers/inc roots apply only when no
  config speaks for the file, and never to a database-listed file.

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

* fix(c,cpp): give CMake PUBLIC includes only to linked targets (#3341)

target_link_libraries now decides who sees PUBLIC and INTERFACE roots, so an unrelated target no longer resolves another package's headers.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 13:40:54 +01:00

176 lines
7.3 KiB
TypeScript

/**
* Unit tests for C++ import decomposition, interpretation, and target resolution.
*/
import { describe, it, expect } from 'vitest';
import { getCppParser } from '../../../../src/core/ingestion/languages/cpp/query.js';
import {
splitCppInclude,
splitCppUsingDecl,
} from '../../../../src/core/ingestion/languages/cpp/import-decomposer.js';
import { interpretCppImport } from '../../../../src/core/ingestion/languages/cpp/interpret.js';
import { resolveCppImportTarget } from '../../../../src/core/ingestion/languages/cpp/import-target.js';
import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js';
function parseNode(src: string, type: string): SyntaxNode | null {
const tree = getCppParser().parse(src);
const stack: SyntaxNode[] = [tree.rootNode as SyntaxNode];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.type === type) return node;
for (let i = node.namedChildCount - 1; i >= 0; i--) {
const child = node.namedChild(i);
if (child !== null) stack.push(child as SyntaxNode);
}
}
return null;
}
function capt(name: string, text: string) {
return { name, text, range: { startLine: 1, startCol: 1, endLine: 1, endCol: 1 } };
}
// ── #include decomposition ──────────────────────────────────────────────────
describe('C++ include decomposition (splitCppInclude)', () => {
it('decomposes local include "#include \\"foo.h\\""', () => {
const node = parseNode('#include "foo.h"', 'preproc_include');
expect(node).not.toBeNull();
const match = splitCppInclude(node!);
expect(match).not.toBeNull();
expect(match!['@import.source'].text).toBe('foo.h');
expect(match!['@import.kind'].text).toBe('wildcard');
expect(match!['@import.system']).toBeUndefined();
});
it('decomposes system include "#include <iostream>"', () => {
const node = parseNode('#include <iostream>', 'preproc_include');
expect(node).not.toBeNull();
const match = splitCppInclude(node!);
expect(match).not.toBeNull();
expect(match!['@import.source'].text).toBe('iostream');
expect(match!['@import.system']).toBeDefined();
});
it('decomposes C++ header include "#include \\"utils/helpers.hpp\\""', () => {
const node = parseNode('#include "utils/helpers.hpp"', 'preproc_include');
expect(node).not.toBeNull();
const match = splitCppInclude(node!);
expect(match).not.toBeNull();
expect(match!['@import.source'].text).toBe('utils/helpers.hpp');
});
});
// ── using declaration decomposition ─────────────────────────────────────────
describe('C++ using declaration decomposition (splitCppUsingDecl)', () => {
it('does not treat a class-scope member using-declaration as an import', () => {
const node = parseNode('struct Derived : Base { using Base::run; };', 'using_declaration');
expect(node).not.toBeNull();
expect(splitCppUsingDecl(node!)).toBeNull();
});
it('decomposes "using namespace std;" as wildcard import', () => {
const node = parseNode('using namespace std;', 'using_declaration');
expect(node).not.toBeNull();
const match = splitCppUsingDecl(node!);
expect(match).not.toBeNull();
expect(match!['@import.kind'].text).toBe('wildcard');
expect(match!['@import.source'].text).toBe('std');
expect(match!['@import.using-namespace']).toBeDefined();
});
it('decomposes "using std::vector;" as named import', () => {
const node = parseNode('using std::vector;', 'using_declaration');
expect(node).not.toBeNull();
const match = splitCppUsingDecl(node!);
expect(match).not.toBeNull();
expect(match!['@import.kind'].text).toBe('named');
expect(match!['@import.source'].text).toBe('std');
expect(match!['@import.name'].text).toBe('vector');
});
it('decomposes nested namespace "using namespace foo::bar;"', () => {
const node = parseNode('using namespace foo::bar;', 'using_declaration');
expect(node).not.toBeNull();
const match = splitCppUsingDecl(node!);
expect(match).not.toBeNull();
expect(match!['@import.kind'].text).toBe('wildcard');
expect(match!['@import.source'].text).toBe('foo::bar');
});
});
// ── Import interpretation ───────────────────────────────────────────────────
describe('C++ import interpretation (interpretCppImport)', () => {
it('interprets local include as wildcard import', () => {
const result = interpretCppImport({
'@import.kind': capt('@import.kind', 'wildcard'),
'@import.source': capt('@import.source', 'header.hpp'),
});
// isSystem is false for quoted #include "..." (no @import.system capture).
expect(result).toEqual({ kind: 'wildcard', targetRaw: 'header.hpp', isSystem: false });
});
it('interprets system header as wildcard with isSystem:true', () => {
// The interpreter no longer returns null for system headers — it returns a
// ParsedImport with isSystem:true. The resolver (cppScopeResolver) is the
// layer that refuses to suffix-match system headers against workspace files.
const result = interpretCppImport({
'@import.kind': capt('@import.kind', 'wildcard'),
'@import.source': capt('@import.source', 'iostream'),
'@import.system': capt('@import.system', 'true'),
});
expect(result).toEqual({ kind: 'wildcard', targetRaw: 'iostream', isSystem: true });
});
it('interprets named import (using std::vector)', () => {
const result = interpretCppImport({
'@import.kind': capt('@import.kind', 'named'),
'@import.source': capt('@import.source', 'std'),
'@import.name': capt('@import.name', 'vector'),
});
expect(result).not.toBeNull();
expect(result!.kind).toBe('named');
expect(result!.targetRaw).toBe('std');
});
it('returns null when @import.source is missing', () => {
const result = interpretCppImport({
'@import.kind': capt('@import.kind', 'wildcard'),
});
expect(result).toBeNull();
});
});
// ── Import target resolution ────────────────────────────────────────────────
describe('C++ import target resolution (resolveCppImportTarget)', () => {
it('resolves .hpp header', () => {
const result = resolveCppImportTarget('foo.hpp', 'main.cpp', new Set(['foo.hpp', 'bar.cpp']));
expect(result).toBe('foo.hpp');
});
it('resolves .hxx header', () => {
const result = resolveCppImportTarget('foo.hxx', 'main.cpp', new Set(['foo.hxx']));
expect(result).toBe('foo.hxx');
});
it('prefers same-directory sibling', () => {
const result = resolveCppImportTarget(
'bar.hpp',
'src/foo.cpp',
new Set(['include/bar.hpp', 'src/bar.hpp']),
);
expect(result).toBe('src/bar.hpp');
});
it('resolves suffix match with depth tiebreak', () => {
const result = resolveCppImportTarget('foo.h', 'main.cpp', new Set(['a/b/c/foo.h', 'z/foo.h']));
expect(result).toBe('z/foo.h');
});
it('returns null for no match', () => {
expect(resolveCppImportTarget('missing.hpp', 'main.cpp', new Set(['foo.h']))).toBeNull();
});
});