GitNexus/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts
azizur100389 5fc518d2cb
fix(dart): resolve package imports by pubspec identity (#3369)
* fix(dart): resolve package imports by pubspec identity

* fix(dart): keep package-identity edges out of the cycle check

Pubspec identity edges invalidate importers when a manifest changes. They cannot form an init cycle, so the cycle query excludes them before the row cap. Discovery reads each manifest once, with a size bound, and resolution shares one package-URI parser with those edges.

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

* Address PR review feedback (#3369)

- Reject package URIs with an empty library path so they do not emit identity edges
- Skip the pubspec permission test where chmod cannot deny reads
- Document that the Dart heap probe is not a uniqueTarget spelling

Note: pre-existing failure in test/unit/incremental-index-extension-dml-gate.test.ts not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): list package directories through a no-follow descriptor

A directory replaced by a symlink between the parent listing and the next visit must not be traversed. The walk opens it with O_DIRECTORY|O_NOFOLLOW and lists that inode.

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

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(mcp): keep the Dart identity reason out of MCP startup

The cycle query still excludes the same reason string. The constant now lives with the other non-initializing import reasons, so MCP startup does not load a language provider.

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

* Address PR review feedback (#3369)

Open discovered pubspecs and child directories through the parent directory inode on Linux, so replacing that directory with a symlink cannot redirect the walk.

Note: pre-existing failure in test/unit/incremental-index-extension-dml-gate.test.ts (worker pool startup timeout) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): reject Windows junctions during pubspec walk

* Address PR review feedback (#3369)

Refuse pubspec discovery that cannot set O_NOFOLLOW, and verify macOS child opens against the pinned directory chain instead of reopening a mutable path.

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

* fix(dart): bound live pubspec descriptors and close the macOS check-then-open

A deep directory chain held one descriptor per level until open failed with EMFILE, and macOS child opens statted the path before using it. Refuse the next directory at 64 live handles, and stat only the descriptor opened with O_NOFOLLOW.

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

* fix(dart): open pubspecs non-blocking so a FIFO cannot hang discovery

A listed pubspec can be replaced by a FIFO before open. O_RDONLY alone waits inside open for a writer, so the file-type check never runs. O_NONBLOCK returns immediately and the walk rejects the non-regular file.

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

* fix(dart): cap names read from each pubspec directory

readdir kept every entry before the visit budget could run, so one huge directory could allocate without bound. Read the listing one name at a time and fail closed past 100,000 entries.

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

* fix(dart): reject a pubspec that grows while its descriptor is read

The size cap was taken from the stat before the read, so a file that grew in that window could be parsed from a short prefix. Re-stat the same descriptor afterward and fail closed when the size no longer matches the bytes captured.

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

* fix(ci): rebaseline the Dart scope-capture fingerprint for package-import fixtures

The benchmark hashes every dart-* fixture. The new package-import corpus adds six Dart files and 33 capture groups. Parking that directory restores the previous fingerprint, so this is corpus growth, not a capture change.

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

---------

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: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-26 13:21:09 +01:00

529 lines
19 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,
{
files: ['src/stdio.h', 'include/util.h', 'src/main.c'],
fromFile: 'src/main.c',
resolutionConfig: undefined,
external: 'stdio.h',
decoy: 'src/stdio.h',
reachesDecoy: 'util.h',
},
],
[
SupportedLanguages.CPlusPlus,
{
files: ['src/cstdio.h', 'include/util.hpp', 'src/main.cpp'],
fromFile: 'src/main.cpp',
resolutionConfig: undefined,
// `cstdio` with no extension would miss the decoy on spelling alone and
// post a pass that measures nothing; the header spelling is the real test.
external: 'cstdio.h',
decoy: 'src/cstdio.h',
reachesDecoy: 'util.hpp',
},
],
[
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>([
[SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'],
[SupportedLanguages.CPlusPlus, '`cstdio.h` -> `src/cstdio.h`'],
]);
/**
* 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([]);
});
});