GitNexus/gitnexus/test/unit/import-resolver-factory.test.ts
Gergő Magyar 4b787be835
fix(csharp): stop spurious IMPORTS edges from ungated using-resolution (#1881) (#1908)
* fix(csharp): eliminate O(S·D) BindingRef OOM in namespace siblings

Types declared in the C# global (default) namespace are visible from
every file, so the previous per-scope augmentation materialized
O(scopes × defs) BindingRefs — on large Unity solutions (tens of
thousands of global types) this caused severe slowness and OOM.

Route global-namespace types through a single workspace-level binding
channel (workspaceFqnBindings, consulted by lookupBindingsAt) for O(D)
memory. Also fix quadratic costs in the non-global path: append defs in
place instead of copying (was O(D²) per bucket), pre-index the first
scope per file (was O(S²·D)), and seed de-dup sets instead of repeated
.some scans.

Add csharp-pipeline-benchmark.test.ts (mirrors the PHP benchmark) with
spread and concentrated-global-namespace scenarios to track elapsedMs,
peakHeapMB, nodeCount, and edgeCount. Post-fix runs show linear scaling
and stable heap.

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

* perf(csharp): scanner fallback for namespace siblings on the worker path

Worker threads can't return tree-sitter Trees across MessageChannels, so
the cross-phase tree cache is empty for worker-parsed files. The C#
same-namespace pass (populateCsharpNamespaceSiblings -> extractFileStructure)
then re-parsed every file with tree-sitter to find namespace / using-static
nodes — effectively parsing a large solution a second time during scope
resolution.

Add a line-scanner fallback (extractCsharpStructureViaScanner) used only
when no cached Tree is available, mirroring PHP's fix for issue #1741. It
extracts the same namespaces / usingStaticPaths the AST walk produces for
the common line-anchored forms (file-scoped + block namespaces, plain /
global / aliased `using static`). The AST walk stays authoritative on the
sequential / warm-cache path.

Micro-benchmark over 3000 synthetic files: scanner is ~188x faster than
parse+walk (0.001 vs 0.251 ms/file) with identical output on the parity
spot-check; real-world files are larger, so the worker-path saving is
bigger. Adds csharp-namespace-extraction.test.ts (12 cases) covering all
declaration forms plus negative cases (using var, plain using, comments).

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

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

* fix(csharp): cover global-namespace workspaceFqnBindings path + doc + using-static perf

Addresses the production-readiness review of the namespace-siblings OOM fix.

- Add a unit test proving global-(default-)namespace C# types route to
  indexes.workspaceFqnBindings (one entry per simple name) with ZERO
  bindingAugmentations — pinning the O(D) invariant behind the #1871
  Unity-scale OOM fix and guarding against a revert to per-scope
  O(scopes x defs) augmentation. (The csharp-hooks mock now supplies
  workspaceFqnBindings, which the global fast path reads directly.)
- Correct the workspaceFqnBindings doc comment: it is shared by PHP
  (backslash-FQN keys) and C# (global-namespace simple-name keys); the two
  key formats are disjoint.
- Pre-index parsedFiles by path before the `using static` member-injection
  loop, replacing an O(files) find-per-import with an O(1) Map lookup.

Verified: tsc --noEmit clean; csharp-hooks + csharp-namespace-extraction
suites pass (38 tests); prettier clean; eslint 0 errors.

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

* fix(csharp): apply PR-review polish to namespace-siblings (tests, types, docs)

Addresses the multi-agent code review of this PR — the concrete, defensible
findings. Two items intentionally deferred (below).

- namespace-siblings.ts: couple the augmentation bucket + its de-dup set into
  one nullable lifecycle, removing the seen!/bucketArr! non-null assertions
  (identical runtime, still lazy).
- validate-bindings-immutability.ts: extend the dev-mode immutability validator
  to the third channel (workspaceFqnBindings) + a test; complete the validator
  test mock with workspaceFqnBindings.
- walkers.ts: document that namesAtScope deliberately excludes the
  scope-independent workspaceFqnBindings channel (enumerating workspace names at
  every scope would flood per-scope callers; lookupBindingsAt still consults it
  when resolving a specific name).
- scope-resolution-indexes.ts: reframe the workspaceFqnBindings doc to describe
  the key-format contract language-neutrally (examples, not language branching).
- csharp-hooks.test.ts: assert workspace entries carry origin:'namespace'; add a
  partial-class test (same simple name, distinct nodeIds across global files →
  both kept); rename the stale "parses" cache-miss test to "scans".
- csharp-pipeline-benchmark.test.ts: clearTimeout the Promise.race budget timer
  (dangling handle when the pipeline won the race).
- csharp.test.ts: correct the #1066 comment — extractFileStructure no longer
  re-parses on cache miss (line scanner); only emitCsharpScopeCaptures re-parses.

Deferred (surfaced, not applied): (1) worker-path scanner mis-reads
namespace/using-static inside block comments and verbatim/raw strings — an
explicitly documented trade-off mirroring the PHP scanner; hardening it to track
comment/string state is a separate decision. (2) workspaceFqnBindings is read
via an `as Map` cast; a type-safe mutable handle from finalize-orchestrator is a
cross-module contract change.

Verified: tsc --noEmit clean; 49 unit tests pass (incl. 3 new); prettier clean;
eslint 0 errors.

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

* fix(csharp): harden worker-path scanner + localize workspace-map cast

Addresses the two deferred PR-review findings plus the remaining test gap.

#1 — Worker-path scanner false positives: the line scanner now tracks block-
comment and string state across lines (advanceCsScanState), so a `namespace` /
`using static` keyword at the start of a line inside a block comment, verbatim
string (@"..."), or raw string literal ("""...""") is no longer mistaken for a
declaration on the worker cache-miss path. It matches only at code-state line
starts. 5 new scanner tests cover the block-comment / raw / verbatim cases.

#4 — workspaceFqnBindings type safety: the ReadonlyMap->Map cast is localized
to one documented line, and global-namespace writes go through a new
getWorkspaceBucket helper (mirroring getAugmentationBucket) rather than an
inline `.set()` at the mutation site.

#2 — lookupBindingsAt workspace-channel coverage: walkers-augmentations.test.ts
now exercises the third (workspace) channel: workspace-only, append-after-
finalized/augmented, and dedup-loses-to-finalized/augmented precedence.

#5 — OOM CI guard: the deterministic O(D) invariant (zero per-scope
augmentation for global types) is already asserted by the always-on
csharp-hooks unit tests added earlier; the scale/time benchmark stays
appropriately opt-in (skipIf).

Verified: tsc --noEmit clean; 69 unit tests (4 suites) + 210 C# integration
resolver tests pass; prettier clean; eslint 0 errors.

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

* perf(csharp): replace remaining O(A) .some dedup scans with seeded Sets

The using-static member-injection loop and the cross-namespace import loop both
de-duped via `bucketArr.some((b) => b.def.nodeId === ...)` — O(A) per item. Both
now use a per-file `Map<simpleName, Set<nodeId>>`, seeded lazily from the
augmentation bucket (capturing entries from earlier passes), matching the
global and named-namespace paths. Same dedup semantics, O(1) amortized.

Verified: tsc --noEmit clean; csharp-hooks unit (27) + C# integration resolver
(210) tests pass; prettier + eslint clean.

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

* fix(csharp): gate suffix-fallback import resolution to declared namespaces (#1881)

C# `using` directives were resolving via an ungated suffix match, so a BCL
using like `System.Threading.Tasks` matched a coincidental local `Tasks.cs`
and emitted spurious IMPORTS edges. Add a declared-namespace gate that only
permits suffix-fallback when the import plausibly refers to an in-repo
namespace (exact, immediate-parent-declared, or ancestor-of a declared
namespace anchored at an in-repo root). Both resolution legs — the legacy
DAG and the registry-primary scope resolver — thread the same evidence to
the gate, including the no-csproj path.

Declared namespaces are collected with #1905's comment/string-aware scanner
(extractCsharpStructureViaScanner, lazily imported) instead of a regex, so
`namespace` tokens in comments/strings can't seed phantom namespaces. Scan
truncation or unreadable subtrees fail OPEN (gate disabled) and are logged.

Stacked on #1905 (fix/csharp-namespace-scope-oom).

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

* fix(csharp): cap per-file size in namespace scan; fail open on skip (#1881)

scanCSharpProject read every .cs/.csproj in full with no size guard and
issued per-directory reads with no concurrency bound, an OOM/FD-exhaustion
vector on large or generated repos. Add an fs.stat size guard before each
read, reusing getMaxFileSizeBytes() (the same 512KB cap the Phase-1 walker
uses). An oversized or unreadable .cs now signals truncation so the #1881
suffix-fallback gate fails OPEN rather than wrongly suppressing an import
whose declaring namespace lived in the skipped file (previously a silent
return left the scan looking complete). Adds a size-cap scan test.

* fix(csharp): bound per-directory read concurrency in namespace scan (#1881)

The scan issued every .cs/.csproj read in a directory at once via
Promise.all, so in-flight file descriptors scaled with the largest
directory's file count. Issue reads in bounded windows (32, mirroring
the Phase-1 filesystem-walker) via Promise.allSettled; an unexpected
read/scan rejection now trips truncation (fail open) instead of
rejecting the whole scan. Behavior-preserving for namespace collection
(C# scope-resolution parity passes on both legs).

* style(csharp): apply prettier to #1881 files to clear quality/format gate (#1908)

Reflow hand-wrapped lines in scope-resolver.ts and the csharp integration
test that prettier collapses under printWidth 100. Formatting only, no
behavioral change; clears the failing quality/format CI gate.

* fix(csharp): stream namespace scan so large generated files don't disable the #1881 gate (#1908)

Code-review follow-up. The scan read each .cs fully into a string behind a
512KB size cap (the tree-sitter parse budget); a single larger generated file
(*.g.cs, EF/gRPC output) tripped `truncated`, making the #1881 suffix-fallback
gate fail open repo-wide and silently undoing the fix on real repos.

Stream each .cs line-by-line via createReadStream + readline into a new
incremental scanner (createCsharpStructureScanner) instead of buffering the
whole file. Memory is now constant regardless of file size, so the per-file
size cap is dropped for the namespace line-scan and large generated files are
fully collected. extractCsharpStructureViaScanner is reimplemented on the same
incremental scanner (byte-identical; C# parity 2/2). collectDeclaredNamespaces
returns 'ok' | 'truncated' (truncation now only from an unreadable file) and the
truncation warn lists its real causes. csproj reads keep their size guard.

Prior art: ripgrep/ctags/Node readline stream rather than cap for line scans;
GitHub (384KB) and Sourcegraph (1MB) cap only their full-content indexes.

* fix(csharp): cap .csproj read via stream, not stat-then-read, to clear CodeQL TOCTOU (#1908)

CodeQL js/file-system-race flagged the fs.stat + fs.readFile size guard in
readCsprojConfig as a check-then-use filesystem race. Replace it with a
length-capped createReadStream (readFileTextCapped) — same memory bound on
untrusted input, no stat-then-read race, and consistent with the streamed
.cs scan. Behavior is unchanged for real .csproj files (parity 2/2).

* fix(csharp): keep BCL/external roots gated through scan truncation (#1908, Codex F1)

A single scan truncation (unreadable dir/file, depth/dir cap) set one
repo-wide `truncated` flag that made csharpSuffixFallbackAllowed fail
open for EVERY import, silently re-enabling the #1881 BCL->local suffix
matches. Add a CSHARP_EXTERNAL_ROOTS denylist (System/Microsoft/...): an
external-rooted using that does not align with an in-repo declared
namespace stays BLOCKED even under truncation, while genuinely
local-looking usings still fail open. A repo that declares the root is
allowed via the alignment escape hatch. Shared predicate, so both legs
inherit it.

* fix(csharp): gate the registry no-csproj direct-match path (#1908, Codex F2)

In the no-csproj branch of resolveCsharpImportTarget, resolveDirectMatch
ran BEFORE the gate, so a path-aligned Legacy/System/Threading/Tasks.cs
satisfied 'using System.Threading.Tasks;' even though System.* is not a
declared in-repo namespace — while the legacy leg (gate-first) blocked
it, so the legs were not equivalent. Run csharpSuffixFallbackAllowed
first (return null on fail), then direct-match, then progressive
stripping — mirroring the legacy ordering. Adds a no-csproj fixture with
a deep path-aligned Tasks.cs and dual-leg integration describes (registry
+ forced-legacy), plus a path-aligned unit case. Parity 2/2.

* fix(csharp): flag scanner-uncaptured namespaces incomplete; Unicode/@ matchers (#1908, Codex F3)

The line scanner treated its output as complete even when it missed valid
C# namespace forms, so the gate failed CLOSED and over-blocked legit
imports. Make CS_NAMESPACE_RE/CS_USING_STATIC_RE Unicode-aware (\p{L}\p{N}
+ u flag) and strip leading/segment @ so verbatim/Unicode identifiers are
captured to match the AST. For forms the regex still can't capture (split
across lines, not at line start, attributed), set a per-file 'incomplete'
flag; collectDeclaredNamespaces returns 'truncated' for such files so the
#1881 gate fails OPEN instead of dropping the namespace. High-precision
detectors + guard tests keep ordinary forms (incl. // namespace comments)
from tripping incomplete.

* fix(csharp): stream the .csproj RootNamespace read, no byte cap (#1908, Codex F4)

readCsprojConfig read only the first 512KB of a .csproj and, on a
match-miss, couldn't tell 'no RootNamespace' from 'RootNamespace past
the cap' — both synthesized a filename root. A wrong authoritative root
makes imports under the real root resolve to nothing AND suppresses the
fallback. Replace the capped read with a streamed early-stop search
(findCsprojRootNamespace) that reads until the tag or EOF: filename
fallback ONLY on genuine read-to-EOF absence; on a soft-budget cap-hit or
unreadable file, OMIT the config so the no-csproj fallback stays
reachable. Removes the now-unused readFileTextCapped + getMaxFileSizeBytes
cap from the scan. Parity 2/2.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:56:26 +01:00

630 lines
26 KiB
TypeScript

/**
* Unit tests for the import resolver factory and per-strategy composition.
*
* Validates that:
* - createImportResolver chains strategies in order
* - First non-null result wins
* - Empty files array stops the chain (absorbing sentinel)
* - All per-language configs produce valid resolvers
*/
import { describe, it, expect } from 'vitest';
import { createImportResolver } from '../../src/core/ingestion/import-resolvers/resolver-factory.js';
import { createStandardStrategy } from '../../src/core/ingestion/import-resolvers/standard.js';
import type {
ImportResolutionConfig,
ImportResolverStrategy,
ResolveCtx,
} from '../../src/core/ingestion/import-resolvers/types.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildSuffixIndex } from '../../src/core/ingestion/import-resolvers/utils.js';
// ── Per-language strategy imports (from config files) ──────────────────
import { goPackageStrategy } from '../../src/core/ingestion/import-resolvers/configs/go.js';
import {
javaJvmStrategy,
kotlinJvmStrategy,
} from '../../src/core/ingestion/import-resolvers/configs/jvm.js';
import { pythonImportStrategy } from '../../src/core/ingestion/import-resolvers/configs/python.js';
import { csharpNamespaceStrategy } from '../../src/core/ingestion/import-resolvers/configs/csharp.js';
import { dartPackageStrategy } from '../../src/core/ingestion/import-resolvers/configs/dart.js';
import { rustModuleStrategy } from '../../src/core/ingestion/import-resolvers/configs/rust.js';
import { phpPsr4Strategy } from '../../src/core/ingestion/import-resolvers/configs/php.js';
import { swiftPackageStrategy } from '../../src/core/ingestion/import-resolvers/configs/swift.js';
import { rubyRequireStrategy } from '../../src/core/ingestion/import-resolvers/configs/ruby.js';
// ── Per-language config imports ────────────────────────────────────────
import {
typescriptImportConfig,
javascriptImportConfig,
vueImportConfig,
} from '../../src/core/ingestion/import-resolvers/configs/typescript-javascript.js';
import {
cImportConfig,
cppImportConfig,
} from '../../src/core/ingestion/import-resolvers/configs/c-cpp.js';
import { goImportConfig } from '../../src/core/ingestion/import-resolvers/configs/go.js';
import {
javaImportConfig,
kotlinImportConfig,
} from '../../src/core/ingestion/import-resolvers/configs/jvm.js';
import { pythonImportConfig } from '../../src/core/ingestion/import-resolvers/configs/python.js';
import { rustImportConfig } from '../../src/core/ingestion/import-resolvers/configs/rust.js';
import { csharpImportConfig } from '../../src/core/ingestion/import-resolvers/configs/csharp.js';
import { phpImportConfig } from '../../src/core/ingestion/import-resolvers/configs/php.js';
import { swiftImportConfig } from '../../src/core/ingestion/import-resolvers/configs/swift.js';
import { dartImportConfig } from '../../src/core/ingestion/import-resolvers/configs/dart.js';
import { rubyImportConfig } from '../../src/core/ingestion/import-resolvers/configs/ruby.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeCtx(files: string[], overrides: Partial<ResolveCtx['configs']> = {}): ResolveCtx {
const allFileList = files;
const normalizedFileList = files.map((p) => p.replace(/\\/g, '/'));
const allFilePaths = new Set(allFileList);
const index = buildSuffixIndex(normalizedFileList, allFileList);
return {
allFilePaths,
allFileList,
normalizedFileList,
index,
resolveCache: new Map(),
configs: {
tsconfigPaths: null,
goModule: null,
composerConfig: null,
swiftPackageConfig: null,
csharpConfigs: [],
...overrides,
},
};
}
// ---------------------------------------------------------------------------
// createImportResolver — factory behavior
// ---------------------------------------------------------------------------
describe('createImportResolver', () => {
it('returns null when no strategies match', () => {
const config: ImportResolutionConfig = {
language: SupportedLanguages.TypeScript,
strategies: [],
};
const resolver = createImportResolver(config);
expect(resolver('./foo', 'src/index.ts', makeCtx([]))).toBeNull();
});
it('returns first non-null result from strategy chain', () => {
const strategyA: ImportResolverStrategy = () => null;
const strategyB: ImportResolverStrategy = () => ({ kind: 'files', files: ['b.ts'] });
const strategyC: ImportResolverStrategy = () => ({ kind: 'files', files: ['c.ts'] });
const resolver = createImportResolver({
language: SupportedLanguages.TypeScript,
strategies: [strategyA, strategyB, strategyC],
});
const result = resolver('./foo', 'src/index.ts', makeCtx(['b.ts', 'c.ts']));
expect(result).toEqual({ kind: 'files', files: ['b.ts'] });
});
it('stops chain when strategy returns result with empty files (absorbing sentinel)', () => {
const absorber: ImportResolverStrategy = () => ({ kind: 'files', files: [] });
const shouldNotRun: ImportResolverStrategy = () => ({
kind: 'files',
files: ['should-not.ts'],
});
const resolver = createImportResolver({
language: SupportedLanguages.TypeScript,
strategies: [absorber, shouldNotRun],
});
const result = resolver('./foo', 'src/index.ts', makeCtx([]));
expect(result).toEqual({ kind: 'files', files: [] });
});
it('passes correct arguments to strategies', () => {
const ctx = makeCtx(['src/utils.ts']);
const captured: { raw: string; fp: string }[] = [];
const spy: ImportResolverStrategy = (raw, fp) => {
captured.push({ raw, fp });
return null;
};
const resolver = createImportResolver({
language: SupportedLanguages.TypeScript,
strategies: [spy],
});
resolver('./utils', 'src/index.ts', ctx);
expect(captured).toEqual([{ raw: './utils', fp: 'src/index.ts' }]);
});
});
// ---------------------------------------------------------------------------
// Per-language strategies — behavioral coverage
//
// The previous `typeof strategy === 'function'` assertions were tautological:
// TypeScript's `ImportResolverStrategy` type enforces the function shape at
// compile time, so those tests could never fail. Each strategy now has at
// least one behavioral assertion below; the Go and C# full-chain tests also
// double as strategy-order guards because their `kind: 'package'` output is
// unreachable through `createStandardStrategy`.
// ---------------------------------------------------------------------------
describe('goPackageStrategy', () => {
it('resolves go.mod package imports to a package result with dirSuffix', () => {
const files = ['cmd/server/main.go', 'cmd/server/handler.go'];
const ctx = makeCtx(files, { goModule: { modulePath: 'example.com/app' } });
const result = goPackageStrategy('example.com/app/cmd/server', 'main.go', ctx);
// `kind: 'package'` + `dirSuffix` is unique to goPackageStrategy — the
// standard strategy always returns `kind: 'files'`. Asserting the exact
// shape makes format regressions observable (e.g. prefix/suffix slash
// normalization) and makes strategy ordering observable via the
// full-chain test in `goImportConfig` below.
expect(result).toEqual({
kind: 'package',
files: expect.arrayContaining(files),
dirSuffix: '/cmd/server/',
});
});
it('returns null for imports outside the go module (allows chain to continue)', () => {
const ctx = makeCtx(['vendor/other/pkg/foo.go'], {
goModule: { modulePath: 'example.com/app' },
});
const result = goPackageStrategy('github.com/other/pkg', 'main.go', ctx);
expect(result).toBeNull();
});
it('returns null when goModule is not configured', () => {
const ctx = makeCtx(['cmd/server/main.go']);
expect(ctx.configs.goModule).toBeNull();
const result = goPackageStrategy('example.com/app/cmd/server', 'main.go', ctx);
expect(result).toBeNull();
});
it('returns null when module prefix matches but package directory has no .go files', () => {
// Documented fall-through in configs/go.ts:27 — when resolveGoPackageDir
// returns non-null but resolveGoPackage returns an empty files list
// (external module coincidentally sharing the prefix, or a subpackage with
// no .go sources), the strategy returns null so the chain can continue.
const ctx = makeCtx(['README.md', 'cmd/server/config.yaml'], {
goModule: { modulePath: 'example.com/app' },
});
const result = goPackageStrategy('example.com/app/cmd/server', 'main.go', ctx);
expect(result).toBeNull();
});
it('goImportConfig full chain produces the package-kind result (strategy-order guard)', () => {
const files = ['cmd/server/main.go', 'cmd/server/handler.go'];
const ctx = makeCtx(files, { goModule: { modulePath: 'example.com/app' } });
const resolver = createImportResolver(goImportConfig);
const result = resolver('example.com/app/cmd/server', 'main.go', ctx);
// If goPackageStrategy were moved after createStandardStrategy, the
// standard strategy's suffix resolution would return a single file with
// `kind: 'files'` (or null), not `kind: 'package'` with a dirSuffix.
// Asserting the full shape (not just kind) guards against a future
// regression that emits `kind: 'package'` with mismatched files.
expect(result).toEqual({
kind: 'package',
files: expect.arrayContaining(files),
dirSuffix: '/cmd/server/',
});
});
});
describe('javaJvmStrategy', () => {
it('resolves wildcard imports to .java files in the package directory', () => {
const files = [
'src/main/java/com/example/foo/Bar.java',
'src/main/java/com/example/foo/Baz.java',
'src/main/java/com/example/other/Unrelated.java',
];
const ctx = makeCtx(files);
const result = javaJvmStrategy('com.example.foo.*', 'App.java', ctx);
expect(result?.kind).toBe('files');
expect(result?.files).toEqual(
expect.arrayContaining([
'src/main/java/com/example/foo/Bar.java',
'src/main/java/com/example/foo/Baz.java',
]),
);
expect(result?.files).not.toContain('src/main/java/com/example/other/Unrelated.java');
});
it('resolves member imports to the owning class file', () => {
const files = ['src/main/java/com/example/Constants.java'];
const ctx = makeCtx(files);
// JVM member import: last segment is an ALL_CAPS constant / lowercase
// member; resolver walks back to the class path `com/example/Constants`.
const result = javaJvmStrategy('com.example.Constants.VALUE', 'App.java', ctx);
expect(result).toEqual({ kind: 'files', files });
});
it('returns null when no matching .java files exist', () => {
const ctx = makeCtx(['src/main/java/other/Foo.java']);
const result = javaJvmStrategy('com.example.missing.*', 'App.java', ctx);
expect(result).toBeNull();
});
});
describe('kotlinJvmStrategy', () => {
it('resolves wildcard imports to files in the package directory', () => {
const files = [
'src/main/kotlin/com/example/foo/Bar.kt',
'src/main/kotlin/com/example/foo/Baz.kt',
'src/main/kotlin/com/example/other/Unrelated.kt',
];
const ctx = makeCtx(files);
const result = kotlinJvmStrategy('com.example.foo.*', 'App.kt', ctx);
expect(result?.kind).toBe('files');
expect(result?.files).toEqual(
expect.arrayContaining([
'src/main/kotlin/com/example/foo/Bar.kt',
'src/main/kotlin/com/example/foo/Baz.kt',
]),
);
expect(result?.files).not.toContain('src/main/kotlin/com/example/other/Unrelated.kt');
});
it('resolves member imports to the owning class file', () => {
const files = ['src/main/kotlin/com/example/Constants.kt'];
const ctx = makeCtx(files);
const result = kotlinJvmStrategy('com.example.Constants.VALUE', 'App.kt', ctx);
expect(result).toEqual({ kind: 'files', files });
});
it('returns null for wildcard with no matching files (allows chain to continue)', () => {
const ctx = makeCtx(['src/main/kotlin/com/example/other/Foo.kt']);
const result = kotlinJvmStrategy('com.example.missing.*', 'App.kt', ctx);
expect(result).toBeNull();
});
it('kotlinImportConfig full chain resolves wildcard via the JVM strategy', () => {
// Note: this is a behavioral smoke test, NOT a strategy-order guard.
// `createStandardStrategy(Kotlin)` explicitly returns null for any import
// ending in `.*` (see standard.ts:137), so reordering `kotlinImportConfig`
// strategies would still produce the same result — the standard strategy
// would return null and the chain would fall through to kotlinJvmStrategy
// regardless of position. A genuine ordering guard for Kotlin would need
// a non-wildcard fixture where both strategies resolve to different files.
const files = ['src/main/kotlin/com/example/foo/Bar.kt'];
const ctx = makeCtx(files);
const resolver = createImportResolver(kotlinImportConfig);
const result = resolver('com.example.foo.*', 'App.kt', ctx);
expect(result).toEqual({ kind: 'files', files });
});
});
describe('rustModuleStrategy', () => {
it('resolves scoped grouped imports to per-item files', () => {
const files = ['src/models/User.rs', 'src/models/Repo.rs'];
const ctx = makeCtx(files);
// `use crate::models::{User, Repo}` — the strategy resolves each item
// individually against the allFilePaths set via resolveRustImportInternal.
const result = rustModuleStrategy('crate::models::{User, Repo}', 'src/lib.rs', ctx);
// Exact file discovery depends on resolveRustImportInternal's search
// heuristics; we assert the shape + that at least one item resolved.
expect(result?.kind).toBe('files');
expect(result?.files.length).toBeGreaterThan(0);
});
it('returns null for non-grouped imports (delegates to standard fallback)', () => {
const ctx = makeCtx(['src/models.rs']);
// Plain `use crate::models` — rustModuleStrategy only handles grouped
// forms; standard strategy handles the crate:: -> path translation.
const result = rustModuleStrategy('crate::models', 'src/lib.rs', ctx);
expect(result).toBeNull();
});
});
describe('csharpNamespaceStrategy', () => {
it('resolves namespace imports via .csproj root-namespace mapping', () => {
const files = ['src/Services/Auth/AuthService.cs', 'src/Services/Auth/TokenService.cs'];
const ctx = makeCtx(files, {
csharpConfigs: [{ rootNamespace: 'MyCo', projectDir: 'src' }],
});
const result = csharpNamespaceStrategy('MyCo.Services.Auth', 'App.cs', ctx);
// Multi-file namespace resolution produces `kind: 'package'` with
// dirSuffix — unique to csharpNamespaceStrategy; the standard strategy
// always emits `kind: 'files'`. Asserting the full shape (including the
// exact dirSuffix format) guards against format regressions.
expect(result).toEqual({
kind: 'package',
files: expect.arrayContaining(files),
dirSuffix: '/src/Services/Auth/',
});
});
it('returns null when no csharpConfigs are configured', () => {
const ctx = makeCtx(['src/Services/Auth/AuthService.cs']);
expect(ctx.configs.csharpConfigs).toEqual([]);
const result = csharpNamespaceStrategy('MyCo.Services.Auth', 'App.cs', ctx);
expect(result).toBeNull();
});
it('no csproj + non-aligned BCL import: stops the chain instead of the ungated standard strategy (#2)', () => {
// Parity with the registry leg's no-csproj path. Without csproj configs the
// generic strategy would suffix-match `System.Threading.Tasks` onto the
// coincidental local `Legacy/Tasks.cs`. The gate sees the import aligns
// with no declared namespace, so the strategy returns an absorbing sentinel
// (`{ kind: 'files', files: [] }`) that STOPS the chain — the standard
// strategy never runs and no spurious edge is emitted.
const ctx = makeCtx(['Services/OrderService.cs', 'Legacy/Tasks.cs'], {
csharpNamespaces: {
declaredNamespaces: new Set(['MyApp.Services', 'MyApp.Legacy']),
rootNamespaces: new Set(['MyApp']),
truncated: false,
},
});
const result = csharpNamespaceStrategy(
'System.Threading.Tasks',
'Services/OrderService.cs',
ctx,
);
expect(result).toEqual({ kind: 'files', files: [] });
});
it('no csproj + in-repo-aligned import: keeps delegating to the standard strategy (#2)', () => {
// An import that DOES align with a declared namespace must keep returning
// null so the generic strategy resolves it — legitimate no-csproj behavior
// is unchanged; only non-aligned (BCL) imports are stopped.
const ctx = makeCtx(['Services/OrderService.cs', 'Models/User.cs'], {
csharpNamespaces: {
declaredNamespaces: new Set(['MyApp.Models', 'MyApp.Services']),
rootNamespaces: new Set(['MyApp']),
truncated: false,
},
});
const result = csharpNamespaceStrategy('MyApp.Models', 'Services/OrderService.cs', ctx);
expect(result).toBeNull();
});
it('returns an empty files result (chain-stop) for a gated BCL import when csproj configs exist (#1881, #8)', () => {
// Legacy DAG leg of #1881: with csproj configs present, a BCL using like
// `System.Threading.Tasks` must NOT suffix-match the coincidental local
// `Legacy/Tasks.cs`. The strategy returns `{ kind: 'files', files: [] }`
// (absorbing sentinel) to STOP the chain, NOT null — null would let the
// generic suffix fallback re-introduce the spurious edge.
const ctx = makeCtx(['Services/OrderService.cs', 'Legacy/Tasks.cs'], {
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
csharpNamespaces: {
declaredNamespaces: new Set(['MyApp.Services', 'MyApp.Legacy']),
rootNamespaces: new Set(['MyApp']),
truncated: false,
},
});
const result = csharpNamespaceStrategy(
'System.Threading.Tasks',
'Services/OrderService.cs',
ctx,
);
expect(result).toEqual({ kind: 'files', files: [] });
});
it('csharpImportConfig full chain produces package-kind (strategy-order guard)', () => {
const files = ['src/Services/Auth/AuthService.cs', 'src/Services/Auth/TokenService.cs'];
const ctx = makeCtx(files, {
csharpConfigs: [{ rootNamespace: 'MyCo', projectDir: 'src' }],
});
const resolver = createImportResolver(csharpImportConfig);
const result = resolver('MyCo.Services.Auth', 'App.cs', ctx);
// If csharpNamespaceStrategy were reordered after createStandardStrategy,
// the result would be `kind: 'files'` (suffix match) or null, never
// `kind: 'package'` with a dirSuffix. Asserting the full shape (not just
// kind) guards against a regression that emits `kind: 'package'` with
// mismatched files.
expect(result).toEqual({
kind: 'package',
files: expect.arrayContaining(files),
dirSuffix: '/src/Services/Auth/',
});
});
});
describe('phpPsr4Strategy', () => {
it('resolves PSR-4 namespace imports via composer.json autoload map', () => {
const files = ['app/Services/UserService.php'];
const ctx = makeCtx(files, {
composerConfig: {
psr4: new Map([['App\\', 'app/']]),
},
});
const result = phpPsr4Strategy('App\\Services\\UserService', 'index.php', ctx);
expect(result).toEqual({ kind: 'files', files });
});
it('returns null when no file matches the namespace via PSR-4 or suffix fallback', () => {
const ctx = makeCtx(['app/Services/OtherService.php'], {
composerConfig: { psr4: new Map([['App\\', 'app/']]) },
});
const result = phpPsr4Strategy('App\\Services\\UserService', 'index.php', ctx);
expect(result).toBeNull();
});
});
describe('swiftPackageStrategy', () => {
it('resolves SwiftPM target imports to files in the target directory', () => {
const files = [
'Package/Sources/SiuperModel/Foo.swift',
'Package/Sources/SiuperModel/Bar.swift',
'Package/Sources/Other/Unrelated.swift',
];
const ctx = makeCtx(files, {
swiftPackageConfig: {
targets: new Map([['SiuperModel', 'Package/Sources/SiuperModel']]),
},
});
const result = swiftPackageStrategy('SiuperModel', 'App.swift', ctx);
expect(result?.kind).toBe('files');
expect(result?.files).toEqual(
expect.arrayContaining([
'Package/Sources/SiuperModel/Foo.swift',
'Package/Sources/SiuperModel/Bar.swift',
]),
);
expect(result?.files).not.toContain('Package/Sources/Other/Unrelated.swift');
});
it('returns null for unknown targets (external frameworks like Foundation)', () => {
const ctx = makeCtx(['Package/Sources/SiuperModel/Foo.swift'], {
swiftPackageConfig: {
targets: new Map([['SiuperModel', 'Package/Sources/SiuperModel']]),
},
});
const result = swiftPackageStrategy('Foundation', 'App.swift', ctx);
expect(result).toBeNull();
});
});
describe('rubyRequireStrategy', () => {
it('resolves require_relative paths via suffix matching', () => {
const files = ['lib/models/user.rb'];
const ctx = makeCtx(files);
const result = rubyRequireStrategy('./models/user', 'lib/app.rb', ctx);
expect(result).toEqual({ kind: 'files', files });
});
it('returns null when no file matches the require path', () => {
const ctx = makeCtx(['lib/models/user.rb']);
const result = rubyRequireStrategy('./missing/file', 'lib/app.rb', ctx);
expect(result).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Per-language configs — all construct cleanly
// ---------------------------------------------------------------------------
describe('per-language import configs', () => {
const configs: { name: string; config: ImportResolutionConfig }[] = [
{ name: 'TypeScript', config: typescriptImportConfig },
{ name: 'JavaScript', config: javascriptImportConfig },
{ name: 'Vue', config: vueImportConfig },
{ name: 'C', config: cImportConfig },
{ name: 'C++', config: cppImportConfig },
{ name: 'Go', config: goImportConfig },
{ name: 'Java', config: javaImportConfig },
{ name: 'Kotlin', config: kotlinImportConfig },
{ name: 'Python', config: pythonImportConfig },
{ name: 'Rust', config: rustImportConfig },
{ name: 'C#', config: csharpImportConfig },
{ name: 'PHP', config: phpImportConfig },
{ name: 'Swift', config: swiftImportConfig },
{ name: 'Dart', config: dartImportConfig },
{ name: 'Ruby', config: rubyImportConfig },
];
for (const { name, config } of configs) {
it(`${name} config has strategies and constructs a resolver`, () => {
expect(config.strategies.length).toBeGreaterThan(0);
expect(() => createImportResolver(config)).not.toThrow();
});
}
});
// ---------------------------------------------------------------------------
// createStandardStrategy — reusable across languages
// ---------------------------------------------------------------------------
describe('createStandardStrategy', () => {
it('creates a reusable strategy from a language', () => {
const strategy = createStandardStrategy(SupportedLanguages.TypeScript);
expect(typeof strategy).toBe('function');
});
it('resolves relative imports', () => {
const strategy = createStandardStrategy(SupportedLanguages.TypeScript);
const ctx = makeCtx(['src/utils.ts']);
const result = strategy('./utils', 'src/index.ts', ctx);
expect(result).toEqual({ kind: 'files', files: ['src/utils.ts'] });
});
it('returns null for unresolvable imports', () => {
const strategy = createStandardStrategy(SupportedLanguages.TypeScript);
const ctx = makeCtx([]);
const result = strategy('./nonexistent', 'src/index.ts', ctx);
expect(result).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Python strategy — absorbs unresolved relative imports
// ---------------------------------------------------------------------------
describe('pythonImportStrategy', () => {
it('absorbs unresolved relative imports with empty-files sentinel', () => {
const ctx = makeCtx([]);
const result = pythonImportStrategy('.nonexistent', 'src/app.py', ctx);
expect(result).toEqual({ kind: 'files', files: [] });
});
it('returns null for non-relative imports (allows chain to continue)', () => {
const ctx = makeCtx([]);
const result = pythonImportStrategy('os', 'src/app.py', ctx);
expect(result).toBeNull();
});
it('absorbs unresolved external dotted imports instead of suffix-matching local basename files', () => {
const ctx = makeCtx(['accounts/apps.py', 'billing/apps.py']);
const result = pythonImportStrategy('django.apps', 'accounts/apps.py', ctx);
expect(result).toEqual({ kind: 'files', files: [] });
});
it('absorbs unresolved nested external dotted imports like django.urls and django.core.*', () => {
const ctx = makeCtx(['config/asgi.py', 'config/urls.py', 'config/wsgi.py']);
expect(pythonImportStrategy('django.urls', 'config/urls.py', ctx)).toEqual({
kind: 'files',
files: [],
});
expect(pythonImportStrategy('django.core.asgi', 'config/asgi.py', ctx)).toEqual({
kind: 'files',
files: [],
});
expect(pythonImportStrategy('django.core.wsgi', 'config/wsgi.py', ctx)).toEqual({
kind: 'files',
files: [],
});
});
it('keeps dotted internal imports unresolved here when the leading package exists in-repo', () => {
const ctx = makeCtx(['accounts/models.py', 'billing/models.py']);
const result = pythonImportStrategy('accounts.models', 'billing/models.py', ctx);
expect(result).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Dart strategies — absorbs SDK / external package imports
// ---------------------------------------------------------------------------
describe('dartPackageStrategy', () => {
it('absorbs dart: SDK imports', () => {
const ctx = makeCtx([]);
const result = dartPackageStrategy("'dart:async'", 'lib/main.dart', ctx);
expect(result).toEqual({ kind: 'files', files: [] });
});
it('absorbs external package: imports', () => {
const ctx = makeCtx([]);
const result = dartPackageStrategy("'package:http/http.dart'", 'lib/main.dart', ctx);
expect(result).toEqual({ kind: 'files', files: [] });
});
it('returns null for relative imports (chains to dartRelativeStrategy)', () => {
const ctx = makeCtx([]);
const result = dartPackageStrategy("'models.dart'", 'lib/main.dart', ctx);
expect(result).toBeNull();
});
});