diff --git a/gitnexus/bench/import-target/baselines.json b/gitnexus/bench/import-target/baselines.json index 7b9a9932d..317f56898 100644 --- a/gitnexus/bench/import-target/baselines.json +++ b/gitnexus/bench/import-target/baselines.json @@ -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, diff --git a/gitnexus/bench/import-target/measure.mjs b/gitnexus/bench/import-target/measure.mjs index b66932d77..eb72ca703 100644 --- a/gitnexus/bench/import-target/measure.mjs +++ b/gitnexus/bench/import-target/measure.mjs @@ -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', diff --git a/gitnexus/src/core/ingestion/languages/dart/import-target.ts b/gitnexus/src/core/ingestion/languages/dart/import-target.ts index fd6c5224a..2834710d3 100644 --- a/gitnexus/src/core/ingestion/languages/dart/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/dart/import-target.ts @@ -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, + 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/` before bare ``. - 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. diff --git a/gitnexus/src/core/ingestion/languages/dart/index.ts b/gitnexus/src/core/ingestion/languages/dart/index.ts index 9059cf108..dbb6d2cf3 100644 --- a/gitnexus/src/core/ingestion/languages/dart/index.ts +++ b/gitnexus/src/core/ingestion/languages/dart/index.ts @@ -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) diff --git a/gitnexus/src/core/ingestion/languages/dart/package-config.ts b/gitnexus/src/core/ingestion/languages/dart/package-config.ts new file mode 100644 index 000000000..82d73d9e9 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/dart/package-config.ts @@ -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; + readonly manifestsByName: ReadonlyMap; +} + +/** Discover only in-repository packages; never follow dependency paths or symlinks. */ +export async function loadDartPackageConfig(repoPath: string): Promise { + 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(); + const manifestsByName = new Map(); + const maxManifestSize = Math.min(1024 * 1024, getMaxFileSizeBytes()); + const ambiguous = new Set(); + 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).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 }; +} diff --git a/gitnexus/src/core/ingestion/languages/dart/package-dependencies.ts b/gitnexus/src/core/ingestion/languages/dart/package-dependencies.ts new file mode 100644 index 000000000..d69c22fe1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/dart/package-dependencies.ts @@ -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>(); + let dependencyCount = 0; + const addConsumer = (consumers: Set, 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>(); + 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', + }); + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts index bc46ad452..84a1ad8bf 100644 --- a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts @@ -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` diff --git a/gitnexus/test/fixtures/lang-resolution/dart-package-imports/lib/http.dart b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/lib/http.dart new file mode 100644 index 000000000..4393bc505 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/lib/http.dart @@ -0,0 +1,4 @@ +void localHttpDecoy() {} +void loadOwn() {} +void loadData() {} +void loadRelative() {} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-package-imports/lib/main.dart b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/lib/main.dart new file mode 100644 index 000000000..5612f961e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/lib/main.dart @@ -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(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-package-imports/lib/models.dart b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/lib/models.dart new file mode 100644 index 000000000..edcd68fe2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/lib/models.dart @@ -0,0 +1 @@ +void loadOwn() {} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-package-imports/lib/relative.dart b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/lib/relative.dart new file mode 100644 index 000000000..98b0f0f3f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/lib/relative.dart @@ -0,0 +1 @@ +void loadRelative() {} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-package-imports/packages/data/lib/models.dart b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/packages/data/lib/models.dart new file mode 100644 index 000000000..72512f1ac --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/packages/data/lib/models.dart @@ -0,0 +1 @@ +void loadData() {} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-package-imports/packages/data/pubspec.yaml b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/packages/data/pubspec.yaml new file mode 100644 index 000000000..8eeadd27e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/packages/data/pubspec.yaml @@ -0,0 +1 @@ +name: data diff --git a/gitnexus/test/fixtures/lang-resolution/dart-package-imports/pubspec.yaml b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/pubspec.yaml new file mode 100644 index 000000000..f628ca137 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/pubspec.yaml @@ -0,0 +1,5 @@ +name: app +dependencies: + http: ^1.0.0 + data: + path: packages/data diff --git a/gitnexus/test/fixtures/lang-resolution/dart-package-imports/tool/run.dart b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/tool/run.dart new file mode 100644 index 000000000..50ca7fe28 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-package-imports/tool/run.dart @@ -0,0 +1 @@ +void nonLibraryDecoy() {} diff --git a/gitnexus/test/integration/dart-import-index-reuse.test.ts b/gitnexus/test/integration/dart-import-index-reuse.test.ts index e868f3d9f..7ca667c5c 100644 --- a/gitnexus/test/integration/dart-import-index-reuse.test.ts +++ b/gitnexus/test/integration/dart-import-index-reuse.test.ts @@ -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/` and one - * only as bare ``, 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/`, a bare-`` 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/`. - 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 ``, reached only after - // `lib/` 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'); diff --git a/gitnexus/test/integration/resolvers/dart.test.ts b/gitnexus/test/integration/resolvers/dart.test.ts index e362edc33..1ce5cd423 100644 --- a/gitnexus/test/integration/resolvers/dart.test.ts +++ b/gitnexus/test/integration/resolvers/dart.test.ts @@ -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', () => { diff --git a/gitnexus/test/unit/dart-package-dependencies.test.ts b/gitnexus/test/unit/dart-package-dependencies.test.ts new file mode 100644 index 000000000..034d0d9c1 --- /dev/null +++ b/gitnexus/test/unit/dart-package-dependencies.test.ts @@ -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']); + }); +}); diff --git a/gitnexus/test/unit/dart-package-imports.test.ts b/gitnexus/test/unit/dart-package-imports.test.ts new file mode 100644 index 000000000..e92c4e464 --- /dev/null +++ b/gitnexus/test/unit/dart-package-imports.test.ts @@ -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): Promise { + 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)', + ); + }); +}); diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index df24e963d..755350ed6 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -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; diff --git a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts index 3e56563f1..bf73a6a76 100644 --- a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts +++ b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts @@ -318,7 +318,7 @@ const CASES: ReadonlyMap = 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 = new Map([ * TypeScript one, then deleting its line here. */ const KNOWN_GAPS: ReadonlyMap = new Map([ - [SupportedLanguages.Dart, '`package:http/http.dart` -> `lib/http.dart`'], [SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'], [SupportedLanguages.CPlusPlus, '`cstdio.h` -> `src/cstdio.h`'], ]); diff --git a/gitnexus/test/unit/scope-resolution/import-target-index-parity.test.ts b/gitnexus/test/unit/scope-resolution/import-target-index-parity.test.ts index a3ecd5693..bc8384257 100644 --- a/gitnexus/test/unit/scope-resolution/import-target-index-parity.test.ts +++ b/gitnexus/test/unit/scope-resolution/import-target-index-parity.test.ts @@ -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/` fully before bare ``, 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/` beats bare `` 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 `` is reached only after `lib/` 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.` 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); }); diff --git a/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts b/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts index 775452ba7..0f2a9f2e3 100644 --- a/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts +++ b/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts @@ -519,10 +519,9 @@ const FIXTURES: ReadonlyMap = new Map< files: ['lib/models.dart', 'tool/generate.dart', 'lib/main.dart'], fromFile: 'lib/main.dart', resolutionConfig: undefined, - // An external package: both `lib/` and bare `` 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, diff --git a/gitnexus/test/unit/stream-graph-emit-config.test.ts b/gitnexus/test/unit/stream-graph-emit-config.test.ts index e6311df99..878698b8c 100644 --- a/gitnexus/test/unit/stream-graph-emit-config.test.ts +++ b/gitnexus/test/unit/stream-graph-emit-config.test.ts @@ -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([]);