GitNexus/gitnexus/test/unit/scope-resolution/external-import-conformance.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

544 lines
20 KiB
TypeScript

/**
* One property, asserted for EVERY registered `ScopeResolver` (#2953).
*
* A specifier that names something OUTSIDE the repository must not resolve to
* a file inside it.
*
* This is the property #2953 was filed against. Its violation is not a missing
* edge but a fabricated one: `@acme/telemetry/nest` resolving to the repo's
* only path ending in `nest/index.ts` produces an `IMPORTS` edge at full
* confidence between two files that have no relationship, and `impact` then
* reports a blast radius into code that cannot be affected. The reporter
* measured 44 of 74 cross-package edges landing on two such files.
*
* The mechanism is shared, which is why this file is: `import-resolvers/
* utils.ts:suffixResolve` asks "does any file in this repo have a path ending
* in this specifier?", retrying with each leading segment dropped, and most
* languages still end their resolution chain there.
*
* ## The decoy is the whole test
*
* Every case pairs an external specifier with a DECOY — an unrelated in-repo
* file whose path ends the way the specifier does. Without one, a resolver that
* simply found nothing would pass while still holding no property at all: the
* question is not "did it return null" but "did it return null when a tempting
* wrong answer was sitting in the file set". Each case therefore also asserts
* the decoy is genuinely reachable, by resolving the spelling that SHOULD find
* it, so a typo in a fixture cannot manufacture a pass.
*
* ## `KNOWN_GAPS` is a work list, not an allowance
*
* A language in that map does not hold the property today. The entry records
* what its resolver currently answers, so the follow-up that fixes it deletes
* one line and the arm below starts enforcing it. Both arms run for every
* language either way — a gap that silently closes fails just as loudly as one
* that opens, because a resolver quietly starting to answer `null` for the
* paired positive case is a regression wearing the fix's clothes.
*/
import { describe, expect, it } from 'vitest';
import type { ParsedFile, ParsedImport, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { SCOPE_RESOLVERS } from '../../../src/core/ingestion/scope-resolution/pipeline/registry.js';
import type { ComposerConfig } from '../../../src/core/ingestion/language-config.js';
import {
clearJavaPackageFacts,
setJavaPackageFact,
} from '../../../src/core/ingestion/languages/java/package-facts.js';
import {
clearKotlinPackageFacts,
setKotlinPackageFact,
} from '../../../src/core/ingestion/languages/kotlin/package-facts.js';
/** The `composer.json` PSR-4 map `loadPhpComposerConfig` would have produced. */
const PHP_COMPOSER: ComposerConfig = { psr4: new Map([['App', 'app']]) };
/** The value `loadGoModulePath` produces for a repo with a `go.mod`. */
const GO_MODULE = { modulePath: 'example.com/mod' };
/** The root dependency scope `loadRubyResolutionConfig` would have produced. */
const RUBY_GEMS = {
scopesByDirectory: new Map([
[
'',
{
externalRequirePrefixes: new Set(['rails']),
localLoadRootsByPrefix: new Map(),
},
],
]),
};
/** What `scanCSharpProject` would report for the C# workspace below — the
* in-repo namespace evidence the #1881 suffix-fallback gate reads. */
const CSHARP_NAMESPACES = {
namespaces: {
declaredNamespaces: new Set(['App', 'App.Models']),
rootNamespaces: new Set(['App']),
truncated: false,
},
};
interface ConformanceCase {
/** The workspace, including the decoy. */
readonly files: readonly string[];
readonly fromFile: string;
readonly resolutionConfig: unknown;
/** A specifier naming something outside the repo. Must resolve to nothing. */
readonly external: string;
/**
* The in-repo file the external specifier is tempting: its path ends the way
* `external` does, so a suffix matcher lands on it.
*/
readonly decoy: string;
/**
* A specifier that SHOULD reach {@link decoy} — the paired positive proving
* the decoy is live, so a `null` below is a refusal rather than an empty
* corpus.
*
* Required only for a language that HOLDS the property. For one in
* {@link KNOWN_GAPS} the gap assertion already resolves `external` TO the
* decoy, which is that proof — and for several of them no other spelling
* exists: Swift's `Foundation` and COBOL's `EXTERNAL` name the in-repo
* directory and copybook as well as the external module, which is precisely
* why those resolvers cannot tell the two apart.
*/
readonly reachesDecoy?: string;
readonly parsedImport?: (targetRaw: string) => ParsedImport | undefined;
/**
* Declarations the resolver reads from the parsed workspace rather than from
* path shape. Java resolves entirely this way since #2953 — a specifier names
* a type in a DECLARED package — so a case that supplied only paths would
* measure a resolver with no workspace at all, and every arm would pass for
* the wrong reason.
*/
readonly declare?: () => void;
readonly parsedFile?: (filePath: string) => ParsedFile;
}
function kotlinParsedFile(filePath: string): ParsedFile {
const name = filePath.slice(filePath.lastIndexOf('/') + 1, filePath.lastIndexOf('.'));
const moduleScope = `module:${filePath}` as ScopeId;
const def: SymbolDefinition = {
nodeId: `Class:${filePath}:${name}`,
filePath,
type: 'Class',
qualifiedName: name,
};
return {
filePath,
moduleScope,
scopes: [
{
id: moduleScope,
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 1, endCol: 1 },
filePath,
bindings: new Map([[name, [{ def, origin: 'local' }]]]),
ownedDefs: [def],
imports: [],
typeBindings: new Map(),
},
],
parsedImports: [],
localDefs: [def],
referenceSites: [],
};
}
const PHP_FUNCTION_IMPORT = (targetRaw: string): ParsedImport => ({
kind: 'named',
localName: 'imported',
importedName: 'imported',
targetRaw,
importedSymbolKind: 'function',
});
const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
[
SupportedLanguages.TypeScript,
{
files: ['apps/web/src/main.ts', 'packages/inner/src/nest/index.ts', 'apps/web/src/nest.ts'],
fromFile: 'apps/web/src/main.ts',
resolutionConfig: undefined,
external: '@acme/telemetry/nest',
decoy: 'packages/inner/src/nest/index.ts',
reachesDecoy: '../../../packages/inner/src/nest',
},
],
[
SupportedLanguages.JavaScript,
{
files: ['apps/web/src/main.js', 'packages/inner/src/nest/index.js'],
fromFile: 'apps/web/src/main.js',
resolutionConfig: undefined,
external: '@acme/telemetry/nest',
decoy: 'packages/inner/src/nest/index.js',
reachesDecoy: '../../../packages/inner/src/nest',
},
],
[
SupportedLanguages.Vue,
{
files: ['apps/web/src/main.ts', 'packages/inner/src/nest/index.ts'],
fromFile: 'apps/web/src/main.ts',
resolutionConfig: undefined,
external: '@acme/telemetry/nest',
decoy: 'packages/inner/src/nest/index.ts',
reachesDecoy: '../../../packages/inner/src/nest',
},
],
[
SupportedLanguages.Python,
{
// #898's exact shape: `django.apps` beside an unrelated `accounts/apps.py`.
files: ['accounts/apps.py', 'accounts/models.py', 'billing/models.py'],
fromFile: 'accounts/models.py',
resolutionConfig: undefined,
external: 'django.apps',
decoy: 'accounts/apps.py',
reachesDecoy: 'accounts.apps',
},
],
[
SupportedLanguages.CSharp,
{
// #1881's shape: a BCL using beside a same-named local file.
files: ['App/Tasks.cs', 'App/Models/User.cs', 'App/Program.cs'],
fromFile: 'App/Program.cs',
// The #1881 gate is evidence-driven and fails OPEN without any, so a
// `undefined` config here would record a gap C# does not have.
resolutionConfig: CSHARP_NAMESPACES,
external: 'System.Threading.Tasks',
decoy: 'App/Tasks.cs',
reachesDecoy: 'App.Tasks',
},
],
[
SupportedLanguages.Java,
{
files: [
'vendor/util/List.java',
'com/example/model/User.java',
'src/main/java/com/example/App.java',
],
fromFile: 'src/main/java/com/example/App.java',
resolutionConfig: undefined,
// `vendor/util/List.java` declares package `vendor.util`, so
// `vendor.util.List` is a real import of it and `java.util.List` is not.
// The two differ ONLY by what the workspace declares — both specifiers
// end in `util.List`, and both would match the same path suffix.
declare: () => {
clearJavaPackageFacts();
setJavaPackageFact('vendor/util/List.java', {
status: 'known',
packageName: 'vendor.util',
});
setJavaPackageFact('com/example/model/User.java', {
status: 'known',
packageName: 'com.example.model',
});
setJavaPackageFact('src/main/java/com/example/App.java', {
status: 'known',
packageName: 'com.example',
});
},
external: 'java.util.List',
decoy: 'vendor/util/List.java',
reachesDecoy: 'vendor.util.List',
},
],
[
SupportedLanguages.Kotlin,
{
files: ['src/main/kotlin/vendor/Assert.kt', 'src/main/kotlin/com/example/App.kt'],
fromFile: 'src/main/kotlin/com/example/App.kt',
resolutionConfig: undefined,
declare: () => {
clearKotlinPackageFacts();
setKotlinPackageFact('src/main/kotlin/vendor/Assert.kt', {
status: 'known',
packageName: 'vendor',
});
setKotlinPackageFact('src/main/kotlin/com/example/App.kt', {
status: 'known',
packageName: 'com.example',
});
},
parsedFile: kotlinParsedFile,
external: 'org.junit.Assert',
decoy: 'src/main/kotlin/vendor/Assert.kt',
reachesDecoy: 'vendor.Assert',
},
],
[
SupportedLanguages.Go,
{
files: ['internal/models/user.go', 'main.go'],
fromFile: 'main.go',
resolutionConfig: GO_MODULE,
external: 'github.com/vendor/dep/internal/models',
decoy: 'internal/models/user.go',
reachesDecoy: 'example.com/mod/internal/models',
},
],
[
SupportedLanguages.Ruby,
{
files: ['lib/app/models/user.rb', 'lib/generators.rb', 'lib/main.rb'],
fromFile: 'lib/main.rb',
resolutionConfig: RUBY_GEMS,
external: 'rails/generators',
decoy: 'lib/generators.rb',
reachesDecoy: 'generators',
},
],
[
SupportedLanguages.Rust,
{
files: ['src/de.rs', 'src/models.rs', 'src/main.rs'],
fromFile: 'src/main.rs',
resolutionConfig: undefined,
external: 'serde::de',
decoy: 'src/de.rs',
reachesDecoy: 'crate::de',
},
],
[
SupportedLanguages.PHP,
{
files: ['app/Ghost/Missing.php', 'app/Models/User.php', 'app/Main.php'],
fromFile: 'app/Main.php',
resolutionConfig: PHP_COMPOSER,
external: 'Vendor\\Ghost\\Missing',
decoy: 'app/Ghost/Missing.php',
reachesDecoy: 'App\\Ghost\\Missing',
parsedImport: PHP_FUNCTION_IMPORT,
},
],
[
SupportedLanguages.Dart,
{
files: ['lib/http.dart', 'lib/models.dart', 'lib/main.dart'],
fromFile: 'lib/main.dart',
resolutionConfig: { packages: new Map([['app', 'lib']]) },
external: 'package:http/http.dart',
decoy: 'lib/http.dart',
reachesDecoy: 'package:app/http.dart',
},
],
[
SupportedLanguages.Swift,
{
files: [
'Sources/Foundation/Thing.swift',
'Sources/Models/User.swift',
'Sources/App/main.swift',
],
fromFile: 'Sources/App/main.swift',
resolutionConfig: undefined,
external: 'Foundation',
decoy: 'Sources/Foundation/Thing.swift',
reachesDecoy: 'Sources',
},
],
[
SupportedLanguages.C,
{
// Workspace has a local `src/stdio.h` that shadows the system header.
// `#include <stdio.h>` (angle-bracket, isSystem:true) must NOT resolve to
// it. `#include "./stdio.h"` (quoted relative form, isSystem:false) from
// `src/main.c` DOES reach it via sibling lookup — the decoy-reachability
// proof. Using './stdio.h' for reachesDecoy vs 'stdio.h' for external lets
// the parsedImport factory distinguish the two arms.
files: ['src/stdio.h', 'include/util.h', 'src/main.c'],
fromFile: 'src/main.c',
resolutionConfig: undefined,
external: 'stdio.h',
decoy: 'src/stdio.h',
reachesDecoy: './stdio.h',
parsedImport: (targetRaw) => ({
kind: 'wildcard',
targetRaw,
// Bare name → angle-bracket system include; relative path → quoted local.
isSystem: !targetRaw.startsWith('.'),
}),
},
],
[
SupportedLanguages.CPlusPlus,
{
// Same shape as C: a local `src/cstdio.h` that shadows the C++ system
// header. `#include <cstdio.h>` (isSystem:true) must NOT resolve to it;
// `#include "./cstdio.h"` (isSystem:false) from `src/main.cpp` DOES.
files: ['src/cstdio.h', 'include/util.hpp', 'src/main.cpp'],
fromFile: 'src/main.cpp',
resolutionConfig: undefined,
external: 'cstdio.h',
decoy: 'src/cstdio.h',
reachesDecoy: './cstdio.h',
parsedImport: (targetRaw) => ({
kind: 'wildcard',
targetRaw,
isSystem: !targetRaw.startsWith('.'),
}),
},
],
[
SupportedLanguages.Cobol,
{
files: ['copybooks/CUSTREC.cpy', 'vendor/EXTERNAL.cpy', 'src/PROG.cbl'],
fromFile: 'src/PROG.cbl',
resolutionConfig: undefined,
external: 'EXTERNAL',
// After the copybook-dir preference, vendor/EXTERNAL.cpy is intentionally
// unreachable (that is the #2967 fix). The reachable decoy is the in-repo
// copybook; vendor/EXTERNAL.cpy stays in `files` so EXTERNAL→[] is not a
// vacuous miss of an empty workspace.
decoy: 'copybooks/CUSTREC.cpy',
reachesDecoy: 'CUSTREC',
},
],
[
SupportedLanguages.Zig,
{
// `@import("std")` is the standard library, and Zig's resolver answers
// null for the stdlib names outright — it never suffix-matches a bare
// name against the file set, so a repo file that happens to be called
// `std.zig` is not a candidate. The same file IS reachable through the
// filesystem-relative spelling, which is what the decoy arm proves.
files: ['src/std.zig', 'src/util.zig', 'src/main.zig'],
fromFile: 'src/main.zig',
resolutionConfig: undefined,
external: 'std',
decoy: 'src/std.zig',
reachesDecoy: 'std.zig',
},
],
[
SupportedLanguages.ObjectiveC,
{
files: ['Headers/Foundation.h', 'Headers/Widget.h', 'Sources/main.m'],
fromFile: 'Sources/main.m',
resolutionConfig: undefined,
external: 'Foundation',
decoy: 'Headers/Foundation.h',
reachesDecoy: 'Foundation.h',
},
],
]);
/**
* Languages that do NOT hold the property yet, with what they answer instead.
*
* Each entry is a follow-up, not a decision: the resolver ends its chain in
* `suffixResolve` and so cannot tell an external module from a path suffix.
* Fixing one means giving that language its real algorithm the way #2953 gave
* TypeScript one, then deleting its line here.
*/
const KNOWN_GAPS: ReadonlyMap<SupportedLanguages, string> = new Map<SupportedLanguages, string>([]);
/**
* The six that hold it, and what earns each one.
*
* Not a list to maintain by hand — it is derived below — but worth reading
* once, because the three mechanisms are different and only one of them
* generalizes:
*
* - TypeScript / JavaScript / Vue resolve against declared config only and
* have no suffix fallback at all (#2953). This is the shape the ten above
* need.
* - Python (#898) and C# (#1881) kept the fallback and put a gate in front of
* it, keyed on whether the specifier's leading segment names anything
* in-repo. Cheaper, and it holds — but it is a filter on a guess rather
* than a resolution rule, so it answers "probably not external" instead of
* "here is what the language says this means".
* - Rust holds it for a spelling reason: `::` is not `/` or `.`, so
* `serde::de` never decomposes into a path suffix that could match
* `src/de.rs`. The decoy-reachability arm proves the file IS reachable via
* `crate::de`, so this is a real pass — but it is contingent on the
* separator, not on Rust knowing what a crate is.
*/
function resolveWith(
language: SupportedLanguages,
testCase: ConformanceCase,
targetRaw: string,
): string | readonly string[] | null {
const resolver = SCOPE_RESOLVERS.get(language)!;
const files = new Set(testCase.files);
testCase.declare?.();
// The parsed workspace, supplied only to a case that declares one. A minimal
// `{ filePath }` stand-in is exactly right for a resolver that reads the file
// list plus its own fact store (Java), and WRONG for one that reads other
// ParsedFile fields (PHP's `filesByDirectory`), which would silently resolve
// differently against stubs than against real parsed files.
const parsedFiles =
testCase.declare === undefined
? []
: testCase.files.map(
testCase.parsedFile ?? ((filePath) => ({ filePath }) as unknown as ParsedFile),
);
return resolver.resolveImportTarget(
targetRaw,
testCase.fromFile,
files,
testCase.resolutionConfig,
{
parsedFiles,
parsedImport: testCase.parsedImport?.(targetRaw),
},
);
}
/** Every file an answer names, whatever shape the resolver used. */
function filesOf(answer: string | readonly string[] | null): readonly string[] {
if (answer === null) return [];
return typeof answer === 'string' ? [answer] : answer;
}
describe('external imports never resolve into the repository (#2953)', () => {
it('covers every registered scope resolver', () => {
// A new language lands here before it lands anywhere else: it either gets a
// case or an explicit gap entry, and both are edits someone has to justify.
expect([...CASES.keys()].sort()).toEqual([...SCOPE_RESOLVERS.keys()].sort());
});
it.each([...CASES.keys()].filter((language) => !KNOWN_GAPS.has(language)))(
'%s: the decoy is reachable, so the arm below means something',
(language) => {
const testCase = CASES.get(language)!;
const reached = filesOf(resolveWith(language, testCase, testCase.reachesDecoy!));
// The DECOY specifically, not merely something. Asserting non-empty let a
// case pair `reachesDecoy` with a different file than `decoy` and still
// pass, which proves the resolver can reach SOME file and says nothing
// about whether the tempting wrong answer below was ever reachable.
expect(
reached,
`${language}: '${testCase.reachesDecoy}' did not reach the decoy '${testCase.decoy}', so this workspace proves nothing about '${testCase.external}'`,
).toContain(testCase.decoy);
},
);
it.each([...CASES.keys()])('%s: an external specifier resolves to nothing', (language) => {
const testCase = CASES.get(language)!;
const resolved = filesOf(resolveWith(language, testCase, testCase.external));
const gap = KNOWN_GAPS.get(language);
if (gap !== undefined) {
// The gap is asserted, not skipped, and asserted as the RECORDED answer
// rather than as "something": a resolver returning an unrelated in-repo
// file would otherwise keep the entry green while the map's description
// of what it does went stale. A language that starts holding the property
// fails here too, which is how the entry gets deleted deliberately.
expect(
resolved,
`${language}: KNOWN_GAPS records '${testCase.external}' resolving to '${testCase.decoy}', but it resolved to ${JSON.stringify(resolved)} — update the entry or delete it`,
).toEqual([testCase.decoy]);
return;
}
expect(
resolved,
`${language}: '${testCase.external}' names nothing in this repo, but resolved to ${JSON.stringify(resolved)}`,
).toEqual([]);
});
});