This commit is contained in:
azizur100389 2026-09-25 21:55:23 +03:00 • committed by GitHub
commit c40cb71bdb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1062 additions and 83 deletions

View file

@ -298,42 +298,42 @@
"imports": 3200,
"resolved": 1153,
"distinct_outcomes": 2987,
"fingerprint": "318084f48ffa4eeae4a5b7fc25916d4ad78673d92dba62b7e462d1bb87ca553a"
"fingerprint": "eb36bd1ccab5c4405cc79d48732b7aa272a90351db60664137663dde7b2db73c"
},
"large": {
"files": 1600,
"imports": 12800,
"resolved": 4681,
"distinct_outcomes": 11875,
"fingerprint": "5151cd2498bd4b7698dc9309e2539977d306f9ba82a388c630c89b51fc4a3187"
"fingerprint": "08c288bdeaa6f4518e7f5e32e3a8ac6265a5147ec366d37789db6cb573ed2bbe"
},
"deep": {
"files": 400,
"imports": 3200,
"resolved": 1153,
"distinct_outcomes": 2987,
"fingerprint": "79776ec1c22afa619fd31aeb05dcac567723b436d0461a5782693b9e929f6f74"
"fingerprint": "909c8444e0004692be0d0a8179f56173e87d790ad7aeea38bb54c647ce0d638f"
},
"collide": {
"files": 400,
"imports": 3200,
"resolved": 1153,
"distinct_outcomes": 2999,
"fingerprint": "1b145a4c3b41ffc4efa26f74449c3d44646d6d59728163ac896fc0ee25c6d608"
"fingerprint": "4625f0310442e60b4291fd9f5c9b90333f6e9175ab5b4c7c7eb2ecbd5def500d"
},
"collide_large": {
"files": 1600,
"imports": 12800,
"resolved": 4681,
"distinct_outcomes": 11948,
"fingerprint": "b7e5303220b8fa64e85c7e17622961018316a10c5ef921a02584864309748b52"
"fingerprint": "9e7aa3a503352b1f437a70e51ccfe020dff65da319c6082fb25b1ae4417c3531"
},
"fingerprint": "5151cd2498bd4b7698dc9309e2539977d306f9ba82a388c630c89b51fc4a3187",
"fingerprint": "08c288bdeaa6f4518e7f5e32e3a8ac6265a5147ec366d37789db6cb573ed2bbe",
"heap": {
"files_small": 8000,
"files_large": 32000,
"path_segments": 11,
"probe": "package:ext0/src/thing.dart"
"probe": "./src/thing.dart"
},
"_measured": {
"collide_ms": 1.511,

View file

@ -208,12 +208,14 @@
* workload — identical file, import and resolved counts — laid out the way
* these languages are actually written: `svcN/internal/`, `SrcN/Models/`, a
* `mod0.dart`/`mod0.rb` in every package. Measured on that shape the per-import
* cost is NOT corpus-size-independent for the four resolvers that scan a
* cost is NOT corpus-size-independent for the three resolvers that scan a
* bucket:
*
* - go, csharp and java walk `PackageDirIndex.dirsByLastSegment[seg]`, which
* now holds every directory;
* - dart walks its basename bucket, which now holds every same-named file;
* - dart formerly walked its basename bucket. Since #2963 its package-URI
* arms use exact declared-package paths; the separate heap probe and
* scan-count regressions still exercise its relative-path suffix index;
* - ruby, kotlin, php and cobol answer from keyed maps and are collision-
* IMMUNE, so their collide budgets are the linear ones — that immunity is
* the assertion, and for cobol the arm is also the only one that reaches
@ -849,7 +851,8 @@ function uniqueDir(lang, d, i) {
// and breaks its same-workload invariant. C# therefore exercises the removed
// rule through progressive stripping rather than through its primary query.
if (lang === 'csharp') return d % 7 === 0 ? `src/Ns${d}/Sub/Ns${d}` : `src/Ns${d}`;
if (lang === 'dart') return d % 3 === 0 ? `lib/feature${d}` : `pkg/feature${d}`;
// Keep every synthetic local import valid under app's pubspec lib root.
if (lang === 'dart') return `lib/feature${d}`;
if (lang === 'kotlin') {
return d % 7 === 0
? `mod${d}/src/main/kotlin/com/example/pkg${d}/inner/pkg${d}`
@ -1500,11 +1503,10 @@ function collideTarget(lang, { local, r, d, j, dirs }) {
}
if (lang === 'dart') {
return local
? `package:app/pkg${j % dirs}/lib/src/mod${Math.floor(j / dirs)}.dart`
? `package:pkg${j % dirs}/src/mod${Math.floor(j / dirs)}.dart`
: (r >>> 3) % 3 === 0
? ['dart:core', 'dart:async', 'dart:io'][(r >>> 4) % 3]
: // A repeated basename under a directory nothing carries: both
// candidates walk the whole basename bucket and miss.
: // Foreign package names must miss despite repeated local basenames.
`package:ext${(r >>> 4) % 97}/other/mod${(r >>> 4) % 8}.dart`;
}
if (lang === 'kotlin') {
@ -1740,6 +1742,16 @@ function buildRepo(lang, fileCount, pad = 0, shape = 'unique') {
* hide their build from rep 2 onward and `fastest()` reports the minimum.
*/
function newPass(lang, files, pad = 0) {
if (lang === 'dart') {
// Synthetic pubspec declarations: app at the root, one package per
// collision directory. Package imports no longer suffix-match (#2963).
const packages = new Map([['app', joinBase(tsBaseUrlFor(pad), 'lib')]]);
for (const file of files) {
const match = /(?:^|\/)(pkg\d+)\/lib\//.exec(file);
if (match) packages.set(match[1], joinBase(tsBaseUrlFor(pad), `${match[1]}/lib`));
}
return { allFilePaths: new Set(files), config: { packages } };
}
if (HEADER_EXTENSION[lang] !== undefined) {
const sources = [];
const headers = [];
@ -1803,7 +1815,7 @@ function resolveAll(lang, files, imports, pad = 0) {
function resolveOne(lang, from, target, pass) {
const allFilePaths = pass.allFilePaths;
if (lang === 'go') return resolveGoImportTarget(target, from, allFilePaths, GO_MODULE);
if (lang === 'dart') return resolveDartImportTarget(target, from, allFilePaths);
if (lang === 'dart') return resolveDartImportTarget(target, from, allFilePaths, pass.config);
if (lang === 'ruby') return resolveRubyImportTarget(target, from, allFilePaths);
if (lang === 'kotlin') {
const parsedImport = {
@ -2154,8 +2166,8 @@ const HEAP_PROBE_TARGET = {
// end. Chosen from the miss family that reaches furthest into each cascade:
// - `go` names a missing package inside GO_MODULE, which reaches the
// package-directory lookup and forces `PackageDirIndex`;
// - `dart` is an external package, so BOTH candidate paths miss and both
// walk the basename bucket to completion;
// - `dart` uses a relative miss to retain coverage of its basename index;
// package imports now use exact membership and allocate no file index;
// - `kotlin` misses after building its declared-package/module-binding index;
// - `cobol` misses in both tier maps, `swift` in `byModule`, and `rust`
// probes candidate paths and builds nothing — that last is the reading
@ -2165,7 +2177,7 @@ const HEAP_PROBE_TARGET = {
// bound compares like with like. `vue`'s is bare rather than `@/…`
// because the alias branch rewrites to `src/` and would resolve.
go: 'example.com/mod/repo0/pkg/util',
dart: 'package:ext0/src/thing.dart',
dart: './src/thing.dart',
kotlin: 'com.ghost0.deep.Missing',
cobol: 'VENDOR0',
swift: 'ExternalPkg0',

View file

@ -1,30 +1,27 @@
/**
* `resolveImportTarget` adapter for the Dart `ScopeResolver`. Ports the
* legacy-DAG Dart import logic (`import-resolvers/configs/dart.ts`):
* Dart import logic:
*
* - `dart:` SDK imports → `null` (external, no edge)
* - `package:pkg/path` → `lib/path` (or bare `path`) matched
* against the workspace file set
* - `package:pkg/path` → declared package's exact `lib/path`
* - relative `'foo/bar.dart'` → resolved against the importer's dir
* - `__heritage__:` markers → `null` (synthetic heritage carrier,
* consumed by `emitDartHeritageEdges`)
*
* The `ScopeResolver` hook signature is `(targetRaw, fromFile, allFilePaths)`;
* Package identity comes from the workspace's pubspec resolution config;
* `targetRaw` arrives already quote-stripped from `interpretDartImport`.
*/
import { perFileSet } from '../../import-resolvers/per-file-set.js';
import { DART_HERITAGE_PREFIX } from './interpret.js';
import type { DartPackageConfig } from './package-config.js';
/**
* Basename → files carrying it, in `allFilePaths` iteration order, memoized on
* the Set's identity (#2879).
*
* Both resolution legs answered `fp === candidate || fp.endsWith('/' + candidate)`
* with a full workspace scan, and the `package:` leg ran one scan PER candidate
* — for an external package both candidates miss, so both scans always ran to
* completion. The orchestrator passes the same Set to every import in a pass,
* so the index is built once per run.
* Relative-path suffix fallback uses this index instead of scanning the
* workspace for each import. Package imports use exact Set membership only.
*
* Bucketing by basename is exact rather than a heuristic: a path satisfying
* either arm of the match ends with `candidate`, so its last `/`-delimited
@ -89,6 +86,7 @@ export function resolveDartImportTarget(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
resolutionConfig?: unknown,
): string | readonly string[] | null {
if (targetRaw.startsWith(DART_HERITAGE_PREFIX)) return null;
// `targetRaw` already arrives quote-stripped from `interpretDartImport`.
@ -97,17 +95,22 @@ export function resolveDartImportTarget(
// Dart SDK imports never resolve to a repo file.
if (targetRaw.startsWith('dart:')) return null;
// `package:pkg/path.dart` → `lib/path.dart` (or bare `path.dart`).
// A package URI never falls back to another package's same-named file.
if (targetRaw.startsWith('package:')) {
const slash = targetRaw.indexOf('/');
if (slash === -1) return null;
const packageName = targetRaw.slice('package:'.length, slash);
const config = resolutionConfig as DartPackageConfig | undefined;
const lib = config?.packages?.get(packageName);
if (lib === undefined) return null;
const relPath = targetRaw.slice(slash + 1);
// Candidate priority is load-bearing: `lib/<rel>` before bare `<rel>`.
for (const candidate of [`lib/${relPath}`, relPath]) {
const hit = findByPathSuffix(allFilePaths, candidate);
if (hit !== null) return hit;
}
return null; // external package
if (
/[\\%?#:]/.test(relPath) ||
relPath.split('/').some((part) => part === '' || part === '.' || part === '..')
)
return null;
const candidate = `${lib}/${relPath}`;
return allFilePaths.has(candidate) ? candidate : null;
}
// Relative import.

View file

@ -9,7 +9,9 @@
* - `query.ts` — tree-sitter scope query + lazy parser/query
* - `captures.ts` — `emitDartScopeCaptures` orchestrator
* - `interpret.ts` — capture-match → `ParsedImport` / `ParsedTypeBinding`
* - `import-target.ts` — `(targetRaw, fromFile, allFilePaths) → file path`
* - `import-target.ts` — exact declared-package / relative import targets
* - `package-config.ts` — bounded, ignore-aware in-repo pubspec discovery
* - `package-dependencies.ts` — manifest dependencies for incremental writeback
* - `receiver-binding.ts` — synthesize `this` / `super` type-bindings
* - `signature-bindings.ts`— synthesize parameter / return type-bindings
* - `arity.ts` — Dart arity compatibility (count-primary)

View file

@ -0,0 +1,98 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { JSON_SCHEMA, load } from 'js-yaml';
import { createWatchIgnorePredicate } from '../../../../config/ignore-service.js';
import { logger } from '../../../logger.js';
import { getMaxFileSizeBytes } from '../../utils/max-file-size.js';
export interface DartPackageConfig {
readonly packages: ReadonlyMap<string, string>;
readonly manifestsByName: ReadonlyMap<string, readonly string[]>;
}
/** Discover only in-repository packages; never follow dependency paths or symlinks. */
export async function loadDartPackageConfig(repoPath: string): Promise<DartPackageConfig> {
const warn = (reason: string, relativePath = '.'): void => {
logger.warn(
{ reason, relativePath },
'Dart pubspec discovery could not read a valid package declaration.',
);
};
const incomplete = (reason: string, relativePath = '.'): never => {
warn(reason, relativePath);
throw new Error(`Dart pubspec discovery failed (${reason}): ${relativePath}`);
};
let isIgnored;
try {
isIgnored = await createWatchIgnorePredicate(repoPath);
} catch {
return incomplete('ignore-rules');
}
const packages = new Map<string, string>();
const manifestsByName = new Map<string, string[]>();
const maxManifestSize = Math.min(1024 * 1024, getMaxFileSizeBytes());
const ambiguous = new Set<string>();
const pending = [''];
let visited = 0;
while (pending.length > 0) {
// An incomplete scan cannot establish that package names are unique.
if (++visited > 20_000) return incomplete('directory-limit');
const relative = pending.pop();
if (relative === undefined) break;
const directory = path.join(repoPath, relative);
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch {
return incomplete('read-directory', relative || '.');
}
for (const entry of entries) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (entry.name.startsWith('.') || isIgnored(entryPath, true)) continue;
pending.push(relative ? `${relative}/${entry.name}` : entry.name);
} else if (entry.isFile() && entry.name === 'pubspec.yaml' && !isIgnored(entryPath, false)) {
const manifestPath = relative ? `${relative}/pubspec.yaml` : 'pubspec.yaml';
let size: number;
try {
size = (await stat(entryPath)).size;
} catch {
return incomplete('read-pubspec', manifestPath);
}
if (size > maxManifestSize) return incomplete('manifest-size', manifestPath);
let content: string;
try {
content = await readFile(entryPath, 'utf8');
} catch {
return incomplete('read-pubspec', manifestPath);
}
try {
const manifest: unknown = load(content, {
schema: JSON_SCHEMA,
});
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest))
continue;
const name = (manifest as Record<string, unknown>).name;
if (typeof name !== 'string' || !/^[a-z_][a-z0-9_]*$/.test(name)) continue;
const manifests = manifestsByName.get(name) ?? [];
manifests.push(manifestPath);
// Two deterministic witnesses suffice to prove ambiguity. Retaining
// more would create unbounded duplicate-package dependency fanout.
manifests.sort();
if (manifests.length > 2) manifests.length = 2;
manifestsByName.set(name, manifests);
if (packages.has(name) || ambiguous.has(name)) {
packages.delete(name);
ambiguous.add(name);
} else {
packages.set(name, relative ? `${relative}/lib` : 'lib');
}
} catch {
// Invalid YAML cannot declare a package. Other valid packages remain usable.
warn('invalid-yaml', manifestPath);
}
}
}
}
return { packages, manifestsByName };
}

View file

@ -0,0 +1,89 @@
import type { ParsedFile } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import { generateId } from '../../../../lib/utils.js';
import type { DartPackageConfig } from './package-config.js';
const MAX_DEPENDENCIES = 100_000;
const MAX_TRAVERSALS = 1_000_000;
/** Package identity is an input dependency, including unresolved/ambiguous imports. */
export function emitDartPackageDependencies(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
resolutionConfig?: unknown,
): void {
const config = resolutionConfig as DartPackageConfig | undefined;
if (!config?.manifestsByName) return;
const consumersByManifest = new Map<string, Set<string>>();
let dependencyCount = 0;
const addConsumer = (consumers: Set<string>, sourceId: string): void => {
if (consumers.has(sourceId)) return;
if (++dependencyCount > MAX_DEPENDENCIES) {
throw new Error(
'Dart package dependency limit exceeded; narrow the workspace with .gitnexusignore.',
);
}
consumers.add(sourceId);
};
const fileIds = new Set(parsedFiles.map((parsed) => generateId('File', parsed.filePath)));
for (const parsed of parsedFiles) {
const sourceId = generateId('File', parsed.filePath);
if (!graph.getNode(sourceId)) continue;
for (const imp of parsed.parsedImports) {
const raw = imp.targetRaw;
if (typeof raw !== 'string' || !raw.startsWith('package:')) continue;
const slash = raw.indexOf('/');
if (slash === -1) continue;
for (const manifest of config.manifestsByName.get(raw.slice(8, slash)) ?? []) {
let consumers = consumersByManifest.get(manifest);
if (!consumers) consumersByManifest.set(manifest, (consumers = new Set()));
addConsumer(consumers, sourceId);
}
}
}
if (consumersByManifest.size === 0) return;
// New manifests are not present in the old DB's importer closure. Carry
// identity dependencies through the freshly resolved imports as well, so
// one-hop incremental boundary expansion rewrites downstream consumers.
const importers = new Map<string, Set<string>>();
for (const edge of graph.iterRelationshipsByType('IMPORTS')) {
if (!fileIds.has(edge.sourceId) || !fileIds.has(edge.targetId)) continue;
let sources = importers.get(edge.targetId);
if (!sources) importers.set(edge.targetId, (sources = new Set()));
sources.add(edge.sourceId);
}
let traversals = 0;
for (const [manifest, consumers] of consumersByManifest) {
const targetId = generateId('File', manifest);
if (!graph.getNode(targetId)) {
throw new Error(`Dart package manifest is missing from the indexed file set: ${manifest}`);
}
// Set iteration visits additions and deduplicates cycles without recursion.
for (const sourceId of consumers) {
for (const importer of importers.get(sourceId) ?? []) {
if (++traversals > MAX_TRAVERSALS) {
throw new Error(
'Dart package dependency traversal limit exceeded; narrow the workspace with .gitnexusignore.',
);
}
addConsumer(consumers, importer);
}
}
}
// Validate the complete closure before publishing any dependency edges.
for (const [manifest, consumers] of consumersByManifest) {
const targetId = generateId('File', manifest);
for (const sourceId of consumers) {
graph.addRelationship({
id: generateId('IMPORTS', `${sourceId}->${targetId}`),
sourceId,
targetId,
type: 'IMPORTS',
confidence: 1,
reason: 'dart-scope: package identity dependency',
});
}
}
}

View file

@ -42,6 +42,8 @@ import { typeApplicationArguments } from '../../utils/template-arguments.js';
import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js';
import { expandDartWildcardNames } from './expand-wildcards.js';
import { dartIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
import { loadDartPackageConfig } from './package-config.js';
import { emitDartPackageDependencies } from './package-dependencies.js';
interface ClassDefRef {
readonly graphId: string;
@ -199,8 +201,13 @@ export const dartScopeResolver: ScopeResolver = {
languageProvider: dartProvider,
importEdgeReason: 'dart-scope: import',
resolveImportTarget: (targetRaw, fromFile, allFilePaths) =>
resolveDartImportTarget(targetRaw, fromFile, allFilePaths),
loadResolutionConfig: loadDartPackageConfig,
// Package dependencies use cached ParsedFile facts, never raw source text.
postExtractSourceTextPolicy: 'uncached-files',
emitPostResolutionEdges: (graph, parsedFiles, _nodeLookup, _indexes, ctx) =>
emitDartPackageDependencies(graph, parsedFiles, ctx.resolutionConfig),
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
resolveDartImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
// Dart `import` is whole-library: every public top-level symbol of the
// target enters scope. Enumerating them lets `propagateImportedReturnTypes`

View file

@ -0,0 +1,4 @@
void localHttpDecoy() {}
void loadOwn() {}
void loadData() {}
void loadRelative() {}

View file

@ -0,0 +1,12 @@
import 'package:app/models.dart';
import 'package:data/models.dart';
import 'package:http/http.dart' as http;
import 'package:app/tool/run.dart' as tool;
import 'dart:io';
import './relative.dart';
void main() {
loadOwn();
loadData();
loadRelative();
}

View file

@ -0,0 +1 @@
void loadOwn() {}

View file

@ -0,0 +1 @@
void loadRelative() {}

View file

@ -0,0 +1 @@
void loadData() {}

View file

@ -0,0 +1 @@
name: data

View file

@ -0,0 +1,5 @@
name: app
dependencies:
http: ^1.0.0
data:
path: packages/data

View file

@ -0,0 +1 @@
void nonLibraryDecoy() {}

View file

@ -29,10 +29,7 @@ const { resolveImportTarget } = dartScopeResolver;
const FROM_FILE = 'lib/main.dart';
/**
* A synthetic Dart package: many library files under `lib/src/`, plus the two
* targets the `package:` leg addresses — one reachable as `lib/<rel>` and one
* only as bare `<rel>`, which is the second candidate and therefore the leg
* that used to run a second full scan for every external import.
* A synthetic Dart package with library and non-library targets.
*/
function buildWorkspace(fileCount: number): CountingSet {
const files: string[] = [];
@ -52,13 +49,10 @@ describe('Dart import resolution — index reuse across imports (#2879)', () =>
const resolved: (string | readonly string[] | null)[] = [];
for (let i = 0; i < 200; i++) {
// Three shapes: an in-package hit through `lib/<rel>`, a bare-`<rel>` hit
// that only the SECOND candidate answers, and an external package whose
// two candidates both miss — the case that used to cost two full
// workspace scans per import.
resolved.push(resolveImportTarget('package:app/models.dart', FROM_FILE, files));
resolved.push(resolveImportTarget('package:app/tool/generate.dart', FROM_FILE, files));
resolved.push(resolveImportTarget(`package:vendor${i}/ghost${i}.dart`, FROM_FILE, files));
// Exact relative hits plus misses that must reuse the suffix index.
resolved.push(resolveImportTarget('./models.dart', FROM_FILE, files));
resolved.push(resolveImportTarget('../tool/generate.dart', FROM_FILE, files));
resolved.push(resolveImportTarget(`vendor${i}/ghost${i}.dart`, FROM_FILE, files));
}
expect(files.scans).toBe(1);
@ -74,8 +68,8 @@ describe('Dart import resolution — index reuse across imports (#2879)', () =>
expectDistinctFileSetsGetOwnIndex({
resolveImportTarget,
buildWorkspace: () => buildWorkspace(20),
targetRaw: 'package:app/models.dart',
fromFile: FROM_FILE,
targetRaw: 'models.dart',
fromFile: 'main.dart',
resolutionConfig: undefined,
expected: 'lib/models.dart',
expectedScans: 1,
@ -85,15 +79,14 @@ describe('Dart import resolution — index reuse across imports (#2879)', () =>
it('still resolves real imports correctly (the perf test is not vacuous)', () => {
const files = buildWorkspace(5);
// `package:` leg, first candidate: `lib/<rel>`.
expect(resolveImportTarget('package:app/models.dart', FROM_FILE, files)).toBe(
const config = { packages: new Map([['app', 'lib']]) };
expect(resolveImportTarget('package:app/models.dart', FROM_FILE, files, config)).toBe(
'lib/models.dart',
);
// `package:` leg, second candidate: bare `<rel>`, reached only after
// `lib/<rel>` misses entirely.
expect(resolveImportTarget('package:app/tool/generate.dart', FROM_FILE, files)).toBe(
'tool/generate.dart',
);
// Package imports cannot reach files outside their declared lib directory.
expect(
resolveImportTarget('package:app/tool/generate.dart', FROM_FILE, files, config),
).toBeNull();
// Relative import against the importer's directory.
expect(resolveImportTarget('src/util.dart', FROM_FILE, files)).toBe('lib/src/util.dart');
expect(resolveImportTarget('./src/util.dart', FROM_FILE, files)).toBe('lib/src/util.dart');

View file

@ -37,6 +37,40 @@ if (dartAvailable) {
}
}
describe.skipIf(!dartAvailable)('Dart pubspec package identity (#2963)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-package-imports'), () => {});
}, 60000);
it('emits declared imports and their package identity dependencies', () => {
const imports = getRelationships(result, 'IMPORTS')
.filter((edge) => edge.sourceFilePath === 'lib/main.dart')
.map((edge) => edge.targetFilePath)
.sort();
expect(imports).toEqual([
'lib/models.dart',
'lib/relative.dart',
'packages/data/lib/models.dart',
'packages/data/pubspec.yaml',
'pubspec.yaml',
]);
});
it.each([
['loadOwn', 'lib/models.dart'],
['loadData', 'packages/data/lib/models.dart'],
['loadRelative', 'lib/relative.dart'],
])('resolves %s in the correct library', (name, file) => {
const calls = getRelationships(result, 'CALLS').filter(
(edge) => edge.sourceFilePath === 'lib/main.dart' && edge.target === name,
);
expect(calls).toHaveLength(1);
expect(calls[0]?.targetFilePath).toBe(file);
});
});
// ── Phase 8: Field-type resolution ──────────────────────────────────────
describe.skipIf(!dartAvailable)('Dart field-type resolution', () => {

View file

@ -0,0 +1,221 @@
import { describe, expect, it } from 'vitest';
import type { ParsedFile, ScopeId } from 'gitnexus-shared';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { generateId } from '../../src/lib/utils.js';
import { emitDartPackageDependencies } from '../../src/core/ingestion/languages/dart/package-dependencies.js';
import { computeEffectiveWriteSet } from '../../src/core/incremental/subgraph-extract.js';
import { GraphEmitSink } from '../../src/core/lbug/graph-emit-sink.js';
function consumer(filePath: string, targets: string[]): ParsedFile {
return {
filePath,
moduleScope: `module:${filePath}` as ScopeId,
scopes: [],
parsedImports: targets.map((targetRaw) => ({ kind: 'wildcard', targetRaw })),
localDefs: [],
referenceSites: [],
};
}
function graphWithFiles(paths: string[]) {
const graph = createKnowledgeGraph();
for (const filePath of paths) {
graph.addNode({
id: generateId('File', filePath),
label: 'File',
properties: { name: filePath, filePath },
});
}
return graph;
}
describe('Dart package identity dependencies', () => {
it.each([
{ files: 400, packages: 251, dense: false, limit: 'dependency limit' },
{ files: 150, packages: 100, dense: true, limit: 'dependency traversal limit' },
])(
'fails explicitly before partial emission at the $limit',
({ files, packages, dense, limit }) => {
const paths = Array.from({ length: files }, (_, i) => `f${i}.dart`);
const manifests = Array.from({ length: packages }, (_, i) => `p${i}/pubspec.yaml`);
const graph = graphWithFiles([...paths, ...manifests]);
for (let i = 0; i < files; i++) {
const targets = dense ? paths : [paths[(i + 1) % files]];
for (const target of targets) {
graph.addRelationship({
id: `${paths[i]}->${target}`,
sourceId: `File:${paths[i]}`,
targetId: `File:${target}`,
type: 'IMPORTS',
confidence: 1,
reason: 'dart-scope: import',
});
}
}
const originalCount = graph.relationshipCount;
expect(() =>
emitDartPackageDependencies(
graph,
paths.map((file, i) => consumer(file, [`package:p${i % packages}/model.dart`])),
{
packages: new Map(),
manifestsByName: new Map(manifests.map((file, i) => [`p${i}`, [file]])),
},
),
).toThrow(`Dart package ${limit} exceeded`);
expect(graph.relationshipCount).toBe(originalCount);
},
);
it('preserves transitive dependencies and idempotence through the streaming sink', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'dart-package-stream-'));
const graph = graphWithFiles(['a.dart', 'b.dart', 'pubspec.yaml']);
const sink = new GraphEmitSink(graph, path.join(root, 'csv'));
try {
sink.beginStreaming();
sink.addRelationship({
id: 'IMPORTS:File:b.dart->File:a.dart',
sourceId: 'File:b.dart',
targetId: 'File:a.dart',
type: 'IMPORTS',
confidence: 1,
reason: 'dart-scope: import',
});
const parsed = [
consumer('a.dart', ['package:app/model.dart']),
consumer('b.dart', ['./a.dart']),
];
const config = { packages: new Map(), manifestsByName: new Map([['app', ['pubspec.yaml']]]) };
emitDartPackageDependencies(sink, parsed, config);
emitDartPackageDependencies(sink, parsed, config);
const edges: string[] = [];
sink.forEachRelationshipFields((source, target, type, _confidence, reason) => {
if (type === 'IMPORTS') edges.push(`${source}->${target}:${reason}`);
});
expect(edges.sort()).toEqual([
'File:a.dart->File:pubspec.yaml:dart-scope: package identity dependency',
'File:b.dart->File:a.dart:dart-scope: import',
'File:b.dart->File:pubspec.yaml:dart-scope: package identity dependency',
]);
const manifest = sink.finalize();
expect(manifest.totalRows).toBe(3);
const csv = manifest.relsByPair.get('File|File');
expect(csv?.rows).toBe(3);
if (!csv) throw new Error('Missing File-to-File CSV');
expect((await readFile(csv.csvPath, 'utf8')).trim().split('\n')).toHaveLength(4);
expect(graph.relationshipCount).toBe(0);
} finally {
sink.close();
await rm(root, { recursive: true, force: true });
}
});
it('tracks all duplicate candidates even when no package resolves, idempotently', () => {
const graph = graphWithFiles(['main.dart', 'pubspec.yaml', 'nested/pubspec.yaml']);
const parsed = [consumer('main.dart', ['package:app/a.dart', 'package:app/b.dart'])];
const config = {
packages: new Map(),
manifestsByName: new Map([['app', ['pubspec.yaml', 'nested/pubspec.yaml']]]),
};
emitDartPackageDependencies(graph, parsed, config);
emitDartPackageDependencies(graph, parsed, config);
expect(graph.relationships.map((edge) => [edge.targetId, edge.reason])).toEqual([
['File:pubspec.yaml', 'dart-scope: package identity dependency'],
['File:nested/pubspec.yaml', 'dart-scope: package identity dependency'],
]);
expect(computeEffectiveWriteSet(graph, new Set(['nested/pubspec.yaml']))).toEqual(
new Set(['nested/pubspec.yaml', 'main.dart']),
);
});
it('does not connect unrelated packages, relative imports, SDK imports, or files without imports', () => {
const graph = graphWithFiles([
'a.dart',
'b.dart',
'c.dart',
'pubspec.yaml',
'other/pubspec.yaml',
]);
emitDartPackageDependencies(
graph,
[
consumer('a.dart', ['package:app/missing.dart', 'package:external/http.dart']),
consumer('b.dart', ['./a.dart', 'dart:core']),
consumer('c.dart', []),
],
{
packages: new Map(),
manifestsByName: new Map([
['app', ['pubspec.yaml']],
['other', ['other/pubspec.yaml']],
]),
},
);
expect(graph.relationships.map((edge) => [edge.sourceId, edge.targetId])).toEqual([
['File:a.dart', 'File:pubspec.yaml'],
]);
});
it('includes transitive consumers in fresh-manifest invalidation and terminates on cycles', () => {
const graph = graphWithFiles(['a.dart', 'b.dart', 'c.dart', 'pubspec.yaml']);
for (const [source, target] of [
['b.dart', 'a.dart'],
['c.dart', 'b.dart'],
['a.dart', 'c.dart'],
]) {
graph.addRelationship({
id: `${source}->${target}`,
sourceId: `File:${source}`,
targetId: `File:${target}`,
type: 'IMPORTS',
confidence: 1,
reason: 'dart-scope: import',
});
}
emitDartPackageDependencies(
graph,
[
consumer('a.dart', ['package:app/a.dart']),
consumer('b.dart', ['./a.dart']),
consumer('c.dart', ['./b.dart']),
],
{ packages: new Map(), manifestsByName: new Map([['app', ['pubspec.yaml']]]) },
);
expect(
graph.relationships
.filter((edge) => edge.reason === 'dart-scope: package identity dependency')
.map((edge) => [edge.sourceId, edge.targetId]),
).toEqual([
['File:a.dart', 'File:pubspec.yaml'],
['File:b.dart', 'File:pubspec.yaml'],
['File:c.dart', 'File:pubspec.yaml'],
]);
expect(computeEffectiveWriteSet(graph, new Set(['pubspec.yaml']))).toEqual(
new Set(['pubspec.yaml', 'a.dart', 'b.dart', 'c.dart']),
);
});
it('refuses to persist dependencies on manifests absent from the indexed graph', () => {
const graph = graphWithFiles(['a.dart']);
expect(() =>
emitDartPackageDependencies(graph, [consumer('a.dart', ['package:app/a.dart'])], {
packages: new Map(),
manifestsByName: new Map([['app', ['pubspec.yaml']]]),
}),
).toThrow('Dart package manifest is missing from the indexed file set: pubspec.yaml');
expect(graph.relationships).toEqual([]);
});
it('emits only matching dependencies in a many-package workspace', () => {
const paths = Array.from({ length: 200 }, (_, i) => `p${i}/pubspec.yaml`);
const graph = graphWithFiles([...paths, 'main.dart']);
emitDartPackageDependencies(graph, [consumer('main.dart', ['package:p199/a.dart'])], {
packages: new Map(),
manifestsByName: new Map(paths.map((filePath, i) => [`p${i}`, [filePath]])),
});
expect(graph.relationships.map((edge) => edge.targetId)).toEqual(['File:p199/pubspec.yaml']);
});
});

View file

@ -0,0 +1,341 @@
import { afterEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { dartScopeResolver } from '../../src/core/ingestion/languages/dart/scope-resolver.js';
import { loadDartPackageConfig } from '../../src/core/ingestion/languages/dart/package-config.js';
import { CountingSet } from '../helpers/counting-file-set.js';
import { _captureLogger } from '../../src/core/logger.js';
const files = new Set(['lib/main.dart', 'lib/http.dart', 'lib/models.dart', 'tool/run.dart']);
const config = { packages: new Map([['app', 'lib']]) };
describe('Dart package identity (#2963)', () => {
it('does not resolve a pub dependency to a same-named local file', () => {
expect(
dartScopeResolver.resolveImportTarget(
'package:http/http.dart',
'lib/main.dart',
files,
config,
),
).toBeNull();
});
it('resolves this package through its declared lib directory', () => {
expect(
dartScopeResolver.resolveImportTarget(
'package:app/models.dart',
'lib/main.dart',
files,
config,
),
).toBe('lib/models.dart');
});
it('does not guess package identity when no pubspec config is available', () => {
expect(
dartScopeResolver.resolveImportTarget('package:app/models.dart', 'lib/main.dart', files),
).toBeNull();
});
it('does not fall back to non-library files', () => {
expect(
dartScopeResolver.resolveImportTarget(
'package:app/tool/run.dart',
'lib/main.dart',
files,
config,
),
).toBeNull();
});
it.each([
'',
'../http.dart',
'src/../../http.dart',
'/http.dart',
'src\\http.dart',
'%2e%2e/http.dart',
'http.dart?q',
'http.dart#part',
])('rejects unsupported package paths: %s', (target) => {
expect(
dartScopeResolver.resolveImportTarget(
`package:app/${target}`,
'lib/main.dart',
files,
config,
),
).toBeNull();
});
it('uses exact package roots even with earlier same-suffix files', () => {
const workspace = new Set([
'decoy/lib/models.dart',
'packages/data/lib/models.dart',
'lib/models.dart',
]);
const monorepo = {
packages: new Map([
['app', 'lib'],
['data', 'packages/data/lib'],
]),
};
expect(
dartScopeResolver.resolveImportTarget(
'package:data/models.dart',
'lib/main.dart',
workspace,
monorepo,
),
).toBe('packages/data/lib/models.dart');
expect(
dartScopeResolver.resolveImportTarget(
'package:app/models.dart',
'packages/data/lib/main.dart',
workspace,
monorepo,
),
).toBe('lib/models.dart');
expect(
dartScopeResolver.resolveImportTarget(
'package:missing/models.dart',
'lib/main.dart',
workspace,
monorepo,
),
).toBeNull();
});
it('does not suffix-match a missing file in a known package', () => {
expect(
dartScopeResolver.resolveImportTarget(
'package:app/models.dart',
'lib/main.dart',
new Set(['other/lib/models.dart']),
config,
),
).toBeNull();
});
it('uses no workspace scans for package hits or misses', () => {
const workspace = new CountingSet(files);
for (let i = 0; i < 200; i++) {
expect(
dartScopeResolver.resolveImportTarget(
'package:app/models.dart',
'lib/main.dart',
workspace,
config,
),
).toBe('lib/models.dart');
expect(
dartScopeResolver.resolveImportTarget(
`package:external${i}/http.dart`,
'lib/main.dart',
workspace,
config,
),
).toBeNull();
}
expect(workspace.scans).toBe(0);
});
it('still ignores SDK imports and resolves relative paths without config', () => {
expect(
dartScopeResolver.resolveImportTarget('dart:core', 'lib/main.dart', files, config),
).toBeNull();
expect(dartScopeResolver.resolveImportTarget('./models.dart', 'lib/main.dart', files)).toBe(
'lib/models.dart',
);
expect(dartScopeResolver.resolveImportTarget('../tool/run.dart', 'lib/main.dart', files)).toBe(
'tool/run.dart',
);
});
});
describe('Dart pubspec package discovery', () => {
const roots: string[] = [];
afterEach(async () => {
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
});
async function fixture(manifests: Record<string, string>): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), 'gitnexus-dart-pubspec-'));
roots.push(root);
for (const [relative, content] of Object.entries(manifests)) {
const destination = path.join(root, relative);
await mkdir(path.dirname(destination), { recursive: true });
await writeFile(destination, content);
}
return root;
}
it('loads root and nested package names through the production hook', async () => {
const root = await fixture({
'pubspec.yaml': 'name: "app" # root package\ndependencies:\n http: ^1.0.0\n',
'packages/data/pubspec.yaml': 'name: data\n',
'packages/unnamed/pubspec.yaml': 'description: no package name\n',
});
const loaded = await dartScopeResolver.loadResolutionConfig?.(root);
expect(loaded).toMatchObject({
packages: new Map([
['app', 'lib'],
['data', 'packages/data/lib'],
]),
});
expect(
dartScopeResolver.resolveImportTarget(
'package:data/models.dart',
'lib/main.dart',
new Set(['packages/data/lib/models.dart']),
loaded,
),
).toBe('packages/data/lib/models.dart');
});
it('suppresses duplicate names even across three packages', async () => {
const root = await fixture({
'pubspec.yaml': 'name: app',
'a/pubspec.yaml': 'name: repeated',
'b/pubspec.yaml': 'name: repeated',
'c/pubspec.yaml': 'name: repeated',
});
const loaded = await loadDartPackageConfig(root);
expect(loaded.packages).toEqual(config.packages);
expect(loaded.manifestsByName.get('repeated')).toEqual(['a/pubspec.yaml', 'b/pubspec.yaml']);
});
it('accepts an underscore-prefixed package name', async () => {
const root = await fixture({ 'pubspec.yaml': 'name: _app' });
expect((await loadDartPackageConfig(root)).packages).toEqual(new Map([['_app', 'lib']]));
});
it.each(['.gitignore', '.gitnexusignore'])(
'ignores duplicate names in directories excluded by %s',
async (ignoreFile) => {
const root = await fixture({
'pubspec.yaml': 'name: app',
[ignoreFile]: 'backup/\n',
'backup/pubspec.yaml': 'name: app',
});
expect((await loadDartPackageConfig(root)).packages).toEqual(config.packages);
},
);
it('honors explicitly re-included package directories', async () => {
const root = await fixture({
'pubspec.yaml': 'name: app',
'.gitnexusignore': '!vendor/\n',
'vendor/pubspec.yaml': 'name: local_vendor',
});
expect(await loadDartPackageConfig(root)).toMatchObject({
packages: new Map([
['app', 'lib'],
['local_vendor', 'vendor/lib'],
]),
});
});
it.each(['.gitignore', '.gitnexusignore'])(
'does not read manifests excluded individually by %s',
async (ignoreFile) => {
const root = await fixture({
'pubspec.yaml': 'name: app',
[ignoreFile]: 'backup/pubspec.yaml\nbroken/pubspec.yaml\n',
'backup/pubspec.yaml': 'name: app',
'broken/pubspec.yaml': 'name: [invalid',
});
expect((await loadDartPackageConfig(root)).packages).toEqual(config.packages);
},
);
it('excludes hidden directories just like the production file scanner', async () => {
const root = await fixture({
'pubspec.yaml': 'name: app',
'.backup/pubspec.yaml': 'name: app',
'.broken/pubspec.yaml': 'name: [invalid',
});
expect((await loadDartPackageConfig(root)).packages).toEqual(config.packages);
});
it('reports invalid YAML without exposing contents or discarding valid packages', async () => {
const root = await fixture({
'pubspec.yaml': 'name: app',
'nested/pubspec.yaml': 'name: [private-manifest-content',
});
const capture = _captureLogger();
try {
expect((await loadDartPackageConfig(root)).packages).toEqual(config.packages);
expect(capture.records()).toEqual([
expect.objectContaining({
level: 40,
reason: 'invalid-yaml',
relativePath: 'nested/pubspec.yaml',
msg: 'Dart pubspec discovery could not read a valid package declaration.',
}),
]);
expect(capture.text()).not.toContain('private-manifest-content');
expect(capture.text()).not.toContain(root);
} finally {
capture.restore();
}
});
it.each(['name: [bad', 'name: one\nname: two', '!!js/function function() {}'])(
'does not interpret invalid YAML as a package declaration: %s',
async (manifest) => {
const root = await fixture({ 'pubspec.yaml': 'name: app', 'nested/pubspec.yaml': manifest });
expect((await loadDartPackageConfig(root)).packages).toEqual(config.packages);
},
);
it.each(['null', '- name: app', 'name: 42', 'name: ../app', 'description: app'])(
'does not infer a name from %s',
async (manifest) => {
expect(
(await loadDartPackageConfig(await fixture({ 'pubspec.yaml': manifest }))).packages.size,
).toBe(0);
},
);
it('does not read generated or installed pubspecs', async () => {
const root = await fixture({
'pubspec.yaml': 'name: app',
'.dart_tool/pubspec.yaml': 'name: app',
'.pub-cache/pubspec.yaml': 'name: app',
'node_modules/dependency/pubspec.yaml': 'name: app',
});
expect((await loadDartPackageConfig(root)).packages).toEqual(config.packages);
});
it('does not follow directory links outside the repository', async () => {
const outside = await fixture({ 'pubspec.yaml': 'name: app' });
const root = await fixture({ 'pubspec.yaml': 'name: app' });
await symlink(
outside,
path.join(root, 'linked'),
process.platform === 'win32' ? 'junction' : 'dir',
);
expect((await loadDartPackageConfig(root)).packages).toEqual(config.packages);
});
it('returns no packages when no pubspec is present', async () => {
expect((await loadDartPackageConfig(await fixture({}))).packages.size).toBe(0);
});
it('refuses oversized manifests instead of parsing an unbounded document', async () => {
const root = await fixture({ 'pubspec.yaml': `name: app\n#${'x'.repeat(1024 * 1024)}` });
await expect(loadDartPackageConfig(root)).rejects.toThrow(
'Dart pubspec discovery failed (manifest-size)',
);
});
it('refuses an incomplete scan rather than persisting untracked identity changes', async () => {
const root = await fixture({});
await expect(loadDartPackageConfig(path.join(root, 'missing'))).rejects.toThrow(
'Dart pubspec discovery failed (read-directory)',
);
});
});

View file

@ -43,6 +43,12 @@ import {
} from '../helpers/embedding-seed.js';
import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js';
import { SCHEMA_FINGERPRINT } from '../../src/core/lbug/schema.js';
import {
isLanguageAvailable,
loadParser,
loadLanguage,
} from '../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
import {
SPRING_AOP_FEATURE,
SPRING_BEAN_INVENTORY_FEATURE,
@ -1726,6 +1732,153 @@ describe('runFullAnalysis — incremental orchestration', () => {
* 'exact-scan', which the unit-level wiring pin in
* run-analyze-fts-repair.test.ts covers platform-independently.
*/
let dartAvailable = isLanguageAvailable(SupportedLanguages.Dart);
if (dartAvailable) {
try {
await loadParser();
await loadLanguage(SupportedLanguages.Dart);
} catch {
dartAvailable = false;
}
}
describe.skipIf(!dartAvailable)('Dart pubspec-only incremental persistence (#2963)', () => {
it.each([
{
name: 'rename',
initial: 'name: app',
next: 'name: renamed',
file: 'pubspec.yaml',
nextResolves: false,
},
{
name: 'addition',
initial: null,
next: 'name: app',
file: 'pubspec.yaml',
nextResolves: true,
},
{
name: 'duplicate',
initial: 'name: app',
next: 'name: app',
file: 'nested/pubspec.yaml',
nextResolves: false,
},
{
name: 'repair',
initial: 'name: [invalid',
next: 'name: app',
file: 'pubspec.yaml',
nextResolves: true,
},
])(
'persists $name and its reversal with forced-rebuild parity',
async ({ initial, next, file, nextResolves }) => {
const repo = await createTempDir();
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const options = { skipAgentsMd: true, skipFts: true };
const progress = { onProgress: () => {} };
const readEdges = async () => {
await adapter.initLbug(getStoragePaths(repo.dbPath).lbugPath, { skipFts: true });
try {
return await adapter.executeQuery(
`MATCH (a)-[r:CodeRelation]->(b) WHERE r.type IN ['IMPORTS', 'CALLS'] ` +
`RETURN a.id AS source, b.id AS target, r.type AS type, r.reason AS reason, ` +
`a.filePath AS sourceFile, b.filePath AS targetFile, b.name AS targetName ` +
`ORDER BY source, target, type, reason`,
);
} finally {
await adapter.closeLbug();
}
};
try {
await mkdir(path.join(repo.dbPath, 'lib'), { recursive: true });
await mkdir(path.join(repo.dbPath, 'nested'), { recursive: true });
await writeFile(path.join(repo.dbPath, '.gitignore'), '.gitnexus/\n');
await writeFile(
path.join(repo.dbPath, 'lib/model.dart'),
'class Model { void save() {} }\n',
);
await writeFile(
path.join(repo.dbPath, 'lib/bridge.dart'),
"import 'package:app/model.dart';\nModel buildModel() => Model();\n",
);
await writeFile(
path.join(repo.dbPath, 'lib/main.dart'),
"import './bridge.dart';\nvoid run() { buildModel().save(); }\n",
);
await writeFile(
path.join(repo.dbPath, 'nested/decoy.dart'),
'class Model { void save() {} }\n',
);
if (initial !== null) await writeFile(path.join(repo.dbPath, 'pubspec.yaml'), initial);
execSync('git init -q', { cwd: repo.dbPath, stdio: 'pipe' });
gitCommitAll(repo.dbPath, 'Dart package fixture');
await runFullAnalysis(repo.dbPath, options, progress);
const initialEdges = await readEdges();
expect(
initialEdges.filter(
(edge) =>
edge.type === 'IMPORTS' &&
edge.source === 'File:lib/bridge.dart' &&
edge.target === 'File:lib/model.dart',
),
).toHaveLength(initial === 'name: app' ? 1 : 0);
for (const [content, resolves] of [
[next, nextResolves],
[file === 'pubspec.yaml' ? initial : null, initial === 'name: app'],
] as const) {
const target = path.join(repo.dbPath, file);
if (content === null) await rm(target);
else await writeFile(target, content);
gitCommitAll(repo.dbPath, 'Update package identity');
const incremental = await runFullAnalysis(repo.dbPath, options, progress);
expect(incremental.incrementalStats?.writeMode).toBe('incremental');
const incrementalEdges = await readEdges();
const imports = incrementalEdges.filter(
(edge) =>
edge.type === 'IMPORTS' &&
edge.source === 'File:lib/bridge.dart' &&
edge.target === 'File:lib/model.dart',
);
expect(imports).toHaveLength(resolves ? 1 : 0);
expect(
incrementalEdges.filter(
(edge) =>
edge.type === 'CALLS' &&
edge.sourceFile === 'lib/main.dart' &&
edge.targetFile === 'lib/model.dart' &&
edge.targetName === 'save',
),
).toHaveLength(resolves ? 1 : 0);
const meta = await loadMeta(getStoragePaths(repo.dbPath).storagePath);
expect(Object.hasOwn(meta?.fileHashes ?? {}, file)).toBe(content !== null);
if (content === 'name: app') {
expect(incrementalEdges).toContainEqual({
source: 'File:lib/main.dart',
target: `File:${file}`,
type: 'IMPORTS',
reason: 'dart-scope: package identity dependency',
sourceFile: 'lib/main.dart',
targetFile: file,
targetName: 'pubspec.yaml',
});
}
await runFullAnalysis(repo.dbPath, { ...options, force: true }, progress);
expect(await readEdges()).toEqual(incrementalEdges);
}
} finally {
await adapter.closeLbug();
await repo.cleanup();
}
},
300_000,
);
});
describe('runFullAnalysis — escalated wipe recreates the vector index (#2409, tri-review 4669518496 P1)', () => {
let vectorAvailable = false;
let skipWarned = false;

View file

@ -318,7 +318,7 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
{
files: ['lib/http.dart', 'lib/models.dart', 'lib/main.dart'],
fromFile: 'lib/main.dart',
resolutionConfig: undefined,
resolutionConfig: { packages: new Map([['app', 'lib']]) },
external: 'package:http/http.dart',
decoy: 'lib/http.dart',
reachesDecoy: 'package:app/http.dart',
@ -416,7 +416,6 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
* TypeScript one, then deleting its line here.
*/
const KNOWN_GAPS: ReadonlyMap<SupportedLanguages, string> = new Map<SupportedLanguages, string>([
[SupportedLanguages.Dart, '`package:http/http.dart` -> `lib/http.dart`'],
[SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'],
[SupportedLanguages.CPlusPlus, '`cstdio.h` -> `src/cstdio.h`'],
]);

View file

@ -16,8 +16,8 @@
* - C#'s `resolveDirectMatch` lets a whole-path match win over a suffix match
* found EARLIER in iteration order, while `resolveByProgressiveStripping`
* takes whichever comes first;
* - Dart tries `lib/<rel>` fully before bare `<rel>`, and compares raw paths
* (no backslash normalization) on both legs.
* - Dart's relative suffix fallback compares raw workspace paths. Package
* identity changed in #2963 and is covered by separate boundary tests.
*
* So this file keeps copies of the pre-change implementations and asserts the
* new ones agree with them on a deterministic corpus built to force exactly
@ -396,10 +396,12 @@ const GO_TARGETS = [
const DART_TARGETS = [
'',
'dart:core',
'package:app/models.dart',
'package:app/src/models.dart',
'package:app',
'package:other/lib/util.dart',
// Package identity intentionally changed in #2963; legacy parity applies
// only to relative imports. Package boundaries have dedicated regressions.
'../../lib/models.dart',
'../../lib/src/models.dart',
'../../models.dart',
'../util.dart',
'models.dart',
'./models.dart',
'../models.dart',
@ -621,32 +623,28 @@ describe('import-target index hoist — output parity with the pre-change scans'
},
{
lang: 'dart',
why: '`lib/<rel>` beats bare `<rel>` even when the bare hit comes FIRST in Set order',
why: 'relative suffix lookup keeps the importer directory prefix',
files: ['a/models.dart', 'z/lib/models.dart'],
target: 'package:app/models.dart',
target: 'models.dart',
},
{
lang: 'dart',
why: 'bare `<rel>` is reached only after `lib/<rel>` misses entirely',
why: 'relative root lookup reaches a nested suffix',
files: ['a/models.dart'],
target: 'package:app/models.dart',
target: 'models.dart',
fromFile: 'main.dart',
},
{
lang: 'dart',
why: 'paths are matched RAW — a backslash path is not normalized into a hit',
files: ['win\\dir\\thing.dart'],
target: 'package:app/dir/thing.dart',
target: 'dir/thing.dart',
},
{
// The negative case above pins the guard NEXT DOOR to the one it names:
// the basename bucket lookup misses before the raw comparison is ever
// consulted, so normalizing only the bucket key, or only the comparison,
// still yields null and still matches. This positive twin puts the
// backslashes in the TARGET so a hit depends on both halves staying raw.
lang: 'dart',
why: 'a backslash TARGET matches only because neither the bucket key nor the comparison normalizes',
files: ['dir\\thing.dart'],
target: 'package:app/dir\\thing.dart',
why: 'relative targets normalize backslashes before exact membership lookup',
files: ['lib/dir/thing.dart'],
target: 'dir\\thing.dart',
},
{
lang: 'dart',
@ -745,7 +743,7 @@ describe('import-target index hoist — output parity with the pre-change scans'
if (csharp(t, cs) !== null) hits.csharp++;
}
}
// Measured on this corpus: go 366, dart 75, ruby 259, csharp 220. Ruby and
// Historical corpus counts: go 366, dart 75, ruby 259, csharp 220. Ruby and
// C# gained 40 each from the `win\dir\thing.<ext>` targets — one per repo,
// which is also the floor those two arms now defend. #2881 moved go 364 ->
// 366 and csharp 196 -> 220, from the corpus's `pkg/pkg`, `a/pkg/b/pkg` and
@ -804,7 +802,7 @@ describe('import-target index hoist — built once per file set, not once per im
it('dart builds one index for many imports (#2879)', () => {
const files = countingCorpus(2, '.dart');
for (let i = 0; i < 200; i++) {
resolveDartImportTarget(`package:pkg${i}/ghost${i}.dart`, 'lib/main.dart', files);
resolveDartImportTarget(`pkg${i}/ghost${i}.dart`, 'lib/main.dart', files);
}
expect(files.scans).toBe(1);
});

View file

@ -519,10 +519,9 @@ const FIXTURES: ReadonlyMap<SupportedLanguages, ImportTargetFixture> = new Map<
files: ['lib/models.dart', 'tool/generate.dart', 'lib/main.dart'],
fromFile: 'lib/main.dart',
resolutionConfig: undefined,
// An external package: both `lib/<rel>` and bare `<rel>` miss, which is
// the two-scan case.
missTarget: (i) => `package:vendor${i}/ghost.dart`,
hitTarget: 'package:app/models.dart',
// Package imports are exact lookups; relative misses exercise the index.
missTarget: (i) => `vendor${i}/ghost.dart`,
hitTarget: './models.dart',
parsedImport: IGNORES_CONTEXT,
minimumScans: 1,
minimumParsedFileReads: 0,

View file

@ -168,6 +168,10 @@ describe('RETAINED_REL_TYPES tracks its readers', () => {
// CALLS is read by taintSummaries, which is exactly why the sink answers a
// COMPLETE read instead of retaining it — so it is a known exemption.
readTypes.delete('CALLS');
// Dart package invalidation reads IMPORTS endpoints through the sink's
// complete typed iterator. dart-package-dependencies.test.ts exercises
// transitive closure and idempotence with actual streamed IMPORTS rows.
readTypes.delete('IMPORTS');
const missing = [...readTypes].filter((t) => !RETAINED_REL_TYPES.has(t as RelationshipType));
expect(missing).toEqual([]);