mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
* feat(scope-resolution): migrate Dart to registry-primary call resolution (#939) Add a Dart scope-resolution module (languages/dart/) mirroring the Swift template and flip Dart to registry-primary. Resolution edges (CALLS/IMPORTS/ACCESSES/EXTENDS/IMPLEMENTS/METHOD_IMPLEMENTS) now route through the shared registry pipeline with byte-for-byte parity against the legacy DAG: test/integration/resolvers/dart.test.ts passes 53/53 under both REGISTRY_PRIMARY_DART=0 and =1 (scripts/run-parity.ts --language dart: 2/2). Dart-specific handling: - Function scopes are synthesized to span signature..body (tree-sitter function_signature/function_body are siblings, not parent/child). - extends rides @reference.inherits (EXTENDS via the generic pre-pass); implements/with are carried as __heritage__ side-effect imports and emitted as IMPLEMENTS, since Dart `implements <class>` must be IMPLEMENTS regardless of the target's symbol kind. - imports are wildcard (whole-library) with expandsWildcardTo so imported return types propagate cross-file (var u = getUser(); u.save()). - getInnerSignature now self-returns a bare signature node so top-level function params/return/name extract (legacy-safe: legacy only ever passes method_signature/declaration wrappers). Also: add Dart scope-capture bench coverage (linear ~0.99 scaling); update two tests that used Dart as a non-migrated control (Vue / forced legacy). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(scope-resolution): close Dart registry-primary parity gaps from review Adversarial review of #1970 surfaced real divergences from the legacy DAG on constructs the 10 fixtures don't exercise. All fixed; parity gate still 2/2 (now 55/55 each mode): - Implicit-constructor construction (`Foo()` with no explicit ctor): the legacy DAG emits `caller -> Foo` (Class) but registry emitted nothing (callee tagged @reference.call.free never reaches constructorCallTargetsClass). Re-tag UpperCamelCase free-callees to @reference.call.constructor (Dart types are UpperCamelCase) so they link to the Class. Locked in with a regression fixture + test that passes in BOTH modes. - Cascade calls (`list..add(1)..sort()`) were dropped — cascade_section has no `selector` wrapper, so the reference walk never saw them while legacy emitted them as free calls. Add a cascade_section handler. - BUILT_INS (setState/then/push/pop/listen/...) were not suppressed on the registry path, so a user symbol shadowing one produced a spurious CALLS edge the legacy DAG suppresses. Skip built-in-named call refs at capture time (extract the set to a leaf module shared with the provider). - Enhanced-enum methods mis-parented to Module (no enum scope). Add `(enum_declaration) @scope.class` so enum members are owned by the enum. Re-baseline the Dart scope-capture fingerprint (linear ~0.95). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(scope-resolution): apply issue #1926 F24/F25 findings to the Dart scope path Issue #1926 catalogs Dart parsing-layer coverage gaps. Apply the two that the registry-primary scope-resolution path owns (call edges + call attribution), registered as legacy-expected-failures since they are scope-resolver-only wins. - F24: the scope path's unified tree-walk already captures member calls (obj.method()) in return / list-literal / named-argument / arrow-body contexts — the legacy DAG only captures them under expression_statement / initialized_variable_definition. Lock it with the dart-member-call-contexts fixture + tests. - F25 (constructor portion): a constructor's body is a sibling of the WRAPPING method_signature (class_body > method_signature > constructor_signature, then function_body), so findFunctionBody now walks up to the method_signature wrapper. Constructor bodies get a Function scope and their body-calls attribute to the Constructor (a valid caller anchor) instead of the class. Add the dart-constructor-body fixture + test. Switch dart.test.ts to createResolverParityIt('dart') and add the dart entry to LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES (5 wins). Both modes pass: run-parity --language dart → 2/2 (registry 60/60; legacy 55 pass + 5 skipped). Not applicable to the scope path (structure-phase / shared-pipeline, tracked by #1926's legacy fix): F25 getter/setter (Property is not a caller anchor) and operator (no Method node emitted by the structure phase) bodies; F26 (static field Property nodes); F27 (no generic_type reference in the scope module); F28/F29 (typedef/variable node extraction). Re-baseline the Dart scope-capture fingerprint (linear ~1.0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(scope-resolution): fix Dart named-constructor file-drop + container-name mis-binding (tri-review) Multi-engine tri-review (GitNexus + CE personas + Codex gpt-5.5) of #1970 found a P0 the parity gate missed plus a P2 wrong-edge: - P0 (file drop): a named constructor with a body (`class A { A.named() {…} }`, idiomatic Dart) parses as ONE constructor_signature carrying multiple `name:` fields, so the scope query matched it more than once and synthesized two identical-range @scope.function captures → ScopeTreeInvariantError(duplicate- scope-id) → extractParsedFile swallowed it → the WHOLE file was dropped from registry-primary resolution (CALLS=0 vs legacy CALLS=2). Introduced by the #1926 F25 findFunctionBody change that started giving constructors body scopes. Fix: dedup function-like declarations by their statement node so each is emitted once. Add dart-named-constructor-body fixture + a parity guard test (both modes) that fails if the file is dropped, plus the named-ctor F25 attribution win (registry-only). - P2 (wrong edge): normalizeDartType's Future<X>/List<X> unwrap is unreachable (generic args are stripped upstream to a bare `Future`/`List`), so a return/ field type binding to the bare container name let a same-named user class (`class Stream {…}`) capture the receiver — a wrong CALLS edge legacy didn't emit. Suppress type bindings that normalize to a bare container name (leaving the call unresolved, matching legacy) instead of binding to the container. Both modes still pass: run-parity --language dart → 2/2 (registry 62/62; legacy 56 + 6 skipped). Re-baseline the Dart scope-capture fingerprint. Also: refresh the captures.ts module doc (constructors get scopes; cascade calls). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(scope-resolution): address Dart tri-review follow-ups (heritage collision + polish) - P2 heritage cross-file name collision: emitDartHeritageEdges resolved both child and base by a global last-write-wins simple-name map, so two files each declaring `class Logger` (one `implements Logger`) produced a wrong-file IMPLEMENTS edge. Resolve with same-file affinity (prefer a same-file class, then a workspace-unique match, else refuse to guess) — the #1951 file-affinity pattern. Add dart-heritage-name-collision fixture + a parity test (both modes resolve same-file). Also reason-qualify the dedup key so `implements X` + `with X` keep distinct edges. - Polish: buildDartMro uses Sets instead of Array.includes-in-loop; merge-bindings uses named tier constants matching swift; drop the dead no-op stripQuotes in import-target (targetRaw already arrives quote-stripped). Both modes pass: run-parity --language dart → 2/2 (registry 63/63; legacy 57 + 6 skipped). Re-baseline the Dart scope-capture fingerprint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
744 lines
29 KiB
TypeScript
744 lines
29 KiB
TypeScript
/**
|
|
* Dart: field-type resolution and call-result binding.
|
|
* Verifies that class fields are captured as Property nodes with HAS_PROPERTY
|
|
* edges, and that calls (including chained and call-result-bound) are resolved.
|
|
*
|
|
* All Dart pipeline features are covered: Property nodes, HAS_PROPERTY edges,
|
|
* CALLS chain resolution, IMPORTS, call attribution, and ACCESSES field reads.
|
|
*/
|
|
import { describe, expect, beforeAll } from 'vitest';
|
|
import path from 'path';
|
|
import {
|
|
FIXTURES,
|
|
getRelationships,
|
|
getNodesByLabel,
|
|
getNodesByLabelFull,
|
|
edgeSet,
|
|
runPipelineFromRepo,
|
|
createResolverParityIt,
|
|
type PipelineResult,
|
|
} from './helpers.js';
|
|
import {
|
|
isLanguageAvailable,
|
|
loadParser,
|
|
loadLanguage,
|
|
} from '../../../src/core/tree-sitter/parser-loader.js';
|
|
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
|
|
|
|
// isLanguageAvailable only checks whether the module loaded — it does NOT verify
|
|
// that the native binary works at runtime (tree-sitter-dart can fail on setLanguage).
|
|
// Probe the parser to get a reliable skip guard.
|
|
let dartAvailable = isLanguageAvailable(SupportedLanguages.Dart);
|
|
if (dartAvailable) {
|
|
try {
|
|
await loadParser();
|
|
await loadLanguage(SupportedLanguages.Dart);
|
|
} catch {
|
|
dartAvailable = false;
|
|
}
|
|
}
|
|
|
|
// Parity-aware `it`: tests whose names are registered in
|
|
// LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES['dart'] (registry-primary-only
|
|
// correctness wins) are skipped under REGISTRY_PRIMARY_DART=0 and asserted
|
|
// under =1, keeping the dual-mode parity gate green.
|
|
const it = createResolverParityIt('dart');
|
|
|
|
// ── Phase 8: Field-type resolution ──────────────────────────────────────
|
|
|
|
describe.skipIf(!dartAvailable)('Dart field-type resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-field-types'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects classes and their properties', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual(expect.arrayContaining(['Address', 'User']));
|
|
const properties = getNodesByLabel(result, 'Property');
|
|
expect(properties).toContain('address');
|
|
expect(properties).toContain('city');
|
|
expect(properties).toContain('name');
|
|
});
|
|
|
|
it('emits HAS_PROPERTY edges from class to field', () => {
|
|
const propEdges = getRelationships(result, 'HAS_PROPERTY');
|
|
expect(edgeSet(propEdges)).toEqual(
|
|
expect.arrayContaining(['User → address', 'User → name', 'Address → city']),
|
|
);
|
|
});
|
|
|
|
it('resolves save() call from field-chain user.address.save()', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
// Dart attributes calls to the enclosing Function
|
|
const saveCalls = calls.filter(
|
|
(c) => c.target === 'save' && c.sourceFilePath.includes('app.dart'),
|
|
);
|
|
expect(saveCalls.length).toBe(1);
|
|
expect(saveCalls[0]!.targetFilePath).toContain('models.dart');
|
|
});
|
|
|
|
it('attributes save() call source to processUser, not File', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCalls = calls.filter(
|
|
(c) => c.target === 'save' && c.sourceFilePath.includes('app.dart'),
|
|
);
|
|
expect(saveCalls.length).toBe(1);
|
|
expect(saveCalls[0]!.source).toBe('processUser');
|
|
expect(saveCalls[0]!.sourceLabel).toBe('Function');
|
|
});
|
|
|
|
it('creates IMPORTS edge between app.dart and models.dart', () => {
|
|
const imports = getRelationships(result, 'IMPORTS');
|
|
const appImports = imports.filter(
|
|
(e) => e.sourceFilePath.includes('app.dart') && e.targetFilePath.includes('models.dart'),
|
|
);
|
|
expect(appImports.length).toBe(1);
|
|
});
|
|
|
|
it('emits ACCESSES edges for field reads in chains', () => {
|
|
const accesses = getRelationships(result, 'ACCESSES');
|
|
const addressReads = accesses.filter((e) => e.target === 'address' && e.rel.reason === 'read');
|
|
expect(addressReads.length).toBe(1);
|
|
expect(addressReads[0]!.source).toBe('processUser');
|
|
expect(addressReads[0]!.targetLabel).toBe('Property');
|
|
});
|
|
});
|
|
|
|
// ── Phase 9: Call-result binding ────────────────────────────────────────
|
|
|
|
describe.skipIf(!dartAvailable)('Dart call-result binding', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-call-result-binding'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects classes, methods, and functions', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Function')).toEqual(
|
|
expect.arrayContaining(['getUser', 'processUser']),
|
|
);
|
|
});
|
|
|
|
it('resolves save() call via call-result binding', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
// Dart attributes calls to the enclosing Function
|
|
const saveCalls = calls.filter(
|
|
(c) => c.target === 'save' && c.sourceFilePath.includes('app.dart'),
|
|
);
|
|
expect(saveCalls.length).toBe(1);
|
|
expect(saveCalls[0]!.targetFilePath).toContain('models.dart');
|
|
});
|
|
|
|
it('resolves getUser() call', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const getUserCalls = calls.filter(
|
|
(c) => c.target === 'getUser' && c.sourceFilePath.includes('app.dart'),
|
|
);
|
|
expect(getUserCalls.length).toBe(1);
|
|
});
|
|
|
|
it('attributes calls to processUser, not File', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const appCalls = calls.filter((c) => c.sourceFilePath.includes('app.dart'));
|
|
for (const call of appCalls) {
|
|
expect(call.source).toBe('processUser');
|
|
expect(call.sourceLabel).toBe('Function');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 16: Method enrichment (isAbstract, isStatic, annotations)
|
|
// animal.dart: abstract Animal with abstract speak(), static classify(), breathe()
|
|
// Dog extends Animal, @override speak()
|
|
// app.dart: dog.speak(), Animal.classify("dog")
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!dartAvailable)('Dart method enrichment', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-method-enrichment'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects Animal and Dog classes', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('Animal');
|
|
expect(classes).toContain('Dog');
|
|
});
|
|
|
|
it('emits HAS_METHOD edges for Animal methods', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
const animalMethods = hasMethod
|
|
.filter((e) => e.source === 'Animal')
|
|
.map((e) => e.target)
|
|
.sort();
|
|
expect(animalMethods).toContain('speak');
|
|
expect(animalMethods).toContain('classify');
|
|
expect(animalMethods).toContain('breathe');
|
|
});
|
|
|
|
it('emits HAS_METHOD edge for Dog.speak', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
const dogSpeak = hasMethod.find((e) => e.source === 'Dog' && e.target === 'speak');
|
|
expect(dogSpeak).toBeDefined();
|
|
});
|
|
|
|
it('emits EXTENDS edge Dog -> Animal', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
const dogExtends = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal');
|
|
expect(dogExtends).toBeDefined();
|
|
});
|
|
|
|
it('marks abstract speak as isAbstract', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const speak = methods.find(
|
|
(n) => n.name === 'speak' && n.properties.filePath === 'animal.dart',
|
|
);
|
|
expect(speak).toBeDefined();
|
|
expect(speak!.properties.isAbstract).toBe(true);
|
|
});
|
|
|
|
it('marks breathe as NOT isAbstract', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const breathe = methods.find((n) => n.name === 'breathe');
|
|
expect(breathe).toBeDefined();
|
|
expect(breathe!.properties.isAbstract).toBe(false);
|
|
});
|
|
|
|
it('marks classify as isStatic', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const classify = methods.find((n) => n.name === 'classify');
|
|
expect(classify).toBeDefined();
|
|
expect(classify!.properties.isStatic).toBe(true);
|
|
});
|
|
|
|
it('marks breathe as NOT isStatic', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const breathe = methods.find((n) => n.name === 'breathe');
|
|
expect(breathe).toBeDefined();
|
|
expect(breathe!.properties.isStatic).toBe(false);
|
|
});
|
|
|
|
it('abstract Animal.speak has isAbstract=true and concrete breathe does not', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const speak = methods.find((n) => n.name === 'speak');
|
|
expect(speak).toBeDefined();
|
|
expect(speak!.properties.isAbstract).toBe(true);
|
|
const breathe = methods.find((n) => n.name === 'breathe');
|
|
expect(breathe).toBeDefined();
|
|
expect(breathe!.properties.isAbstract).toBe(false);
|
|
});
|
|
|
|
it('populates parameterTypes for classify', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const classify = methods.find((n) => n.name === 'classify');
|
|
expect(classify).toBeDefined();
|
|
const params = classify!.properties.parameterTypes;
|
|
expect(params).toContain('String');
|
|
});
|
|
|
|
it('resolves dog.speak() CALLS edge', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const speakCall = calls.find(
|
|
(c) => c.target === 'speak' && c.sourceFilePath.includes('app.dart'),
|
|
);
|
|
expect(speakCall).toBeDefined();
|
|
});
|
|
|
|
it('resolves Animal.classify("dog") static CALLS edge', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const classifyCall = calls.find(
|
|
(c) => c.target === 'classify' && c.sourceFilePath.includes('app.dart'),
|
|
);
|
|
expect(classifyCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Basic arity-based call resolution (Dart catch-up)
|
|
// one.dart: writeAudit(String message), zero.dart: writeAuditSimple()
|
|
// app.dart: writeAudit("hello"), writeAuditSimple()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!dartAvailable)('Dart calls', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-calls'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects top-level functions', () => {
|
|
const functions = getNodesByLabel(result, 'Function');
|
|
expect(functions).toContain('writeAudit');
|
|
expect(functions).toContain('writeAuditSimple');
|
|
expect(functions).toContain('run');
|
|
});
|
|
|
|
it('resolves writeAudit("hello") CALLS edge', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const auditCall = calls.find(
|
|
(c) => c.target === 'writeAudit' && c.sourceFilePath.includes('app.dart'),
|
|
);
|
|
expect(auditCall).toBeDefined();
|
|
expect(auditCall!.targetFilePath).toContain('one.dart');
|
|
});
|
|
|
|
it('resolves writeAuditSimple() CALLS edge', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const simpleCall = calls.find(
|
|
(c) => c.target === 'writeAuditSimple' && c.sourceFilePath.includes('app.dart'),
|
|
);
|
|
expect(simpleCall).toBeDefined();
|
|
expect(simpleCall!.targetFilePath).toContain('zero.dart');
|
|
});
|
|
|
|
it('attributes calls to run, not File', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const appCalls = calls.filter((c) => c.sourceFilePath.includes('app.dart'));
|
|
for (const call of appCalls) {
|
|
expect(call.source).toBe('run');
|
|
expect(call.sourceLabel).toBe('Function');
|
|
}
|
|
});
|
|
|
|
it('creates IMPORTS edges from app.dart', () => {
|
|
const imports = getRelationships(result, 'IMPORTS');
|
|
const appImports = imports.filter((e) => e.sourceFilePath.includes('app.dart'));
|
|
const targetFiles = appImports.map((e) => e.targetFilePath).sort();
|
|
expect(targetFiles).toEqual(expect.arrayContaining(['one.dart', 'zero.dart']));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Member calls: receiver-type resolution via constructor inference
|
|
// user.dart: User { save() }, app.dart: var user = User(); user.save()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!dartAvailable)('Dart member calls', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-member-calls'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User class and save method', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('save');
|
|
expect(getNodesByLabel(result, 'Function')).toContain('processUser');
|
|
});
|
|
|
|
it('emits HAS_METHOD edge User -> save', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
const userSave = hasMethod.find((e) => e.source === 'User' && e.target === 'save');
|
|
expect(userSave).toBeDefined();
|
|
});
|
|
|
|
it('resolves user.save() CALLS edge via constructor inference', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find(
|
|
(c) => c.target === 'save' && c.sourceFilePath.includes('app.dart'),
|
|
);
|
|
expect(saveCall).toBeDefined();
|
|
expect(saveCall!.targetFilePath).toContain('user.dart');
|
|
});
|
|
|
|
it('attributes save() call to processUser, not File', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find(
|
|
(c) => c.target === 'save' && c.sourceFilePath.includes('app.dart'),
|
|
);
|
|
expect(saveCall).toBeDefined();
|
|
expect(saveCall!.source).toBe('processUser');
|
|
expect(saveCall!.sourceLabel).toBe('Function');
|
|
});
|
|
|
|
it('creates IMPORTS edge from app.dart to user.dart', () => {
|
|
const imports = getRelationships(result, 'IMPORTS');
|
|
const appImport = imports.find(
|
|
(e) => e.sourceFilePath.includes('app.dart') && e.targetFilePath.includes('user.dart'),
|
|
);
|
|
expect(appImport).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dart async / async* / sync* method detection
|
|
// Verifies isDartAsync correctly identifies all three async-like forms
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!dartAvailable)('Dart async method detection', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-async-methods'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects DataService class', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('DataService');
|
|
});
|
|
|
|
it('emits HAS_METHOD edges for all DataService methods', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
const methods = hasMethod
|
|
.filter((e) => e.source === 'DataService')
|
|
.map((e) => e.target)
|
|
.sort();
|
|
expect(methods).toContain('fetchUser');
|
|
expect(methods).toContain('countUp');
|
|
expect(methods).toContain('generateNames');
|
|
expect(methods).toContain('formatName');
|
|
});
|
|
|
|
it('marks async method fetchUser as isAsync=true', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const fetchUser = methods.find((n) => n.name === 'fetchUser');
|
|
expect(fetchUser).toBeDefined();
|
|
expect(fetchUser!.properties.isAsync).toBe(true);
|
|
});
|
|
|
|
it('marks async* generator countUp as isAsync=true', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const countUp = methods.find((n) => n.name === 'countUp');
|
|
expect(countUp).toBeDefined();
|
|
expect(countUp!.properties.isAsync).toBe(true);
|
|
});
|
|
|
|
it('marks sync* generator generateNames as isAsync=true', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const generateNames = methods.find((n) => n.name === 'generateNames');
|
|
expect(generateNames).toBeDefined();
|
|
expect(generateNames!.properties.isAsync).toBe(true);
|
|
});
|
|
|
|
it('marks regular sync method formatName as isAsync=false', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const formatName = methods.find((n) => n.name === 'formatName');
|
|
expect(formatName).toBeDefined();
|
|
// buildMethodProps only sets isAsync when truthy; for sync methods the
|
|
// property is absent (undefined), which is equivalent to false.
|
|
expect(formatName!.properties.isAsync ?? false).toBe(false);
|
|
});
|
|
|
|
it('populates parameterTypes for fetchUser(int id)', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const fetchUser = methods.find((n) => n.name === 'fetchUser');
|
|
expect(fetchUser).toBeDefined();
|
|
expect(fetchUser!.properties.parameterTypes).toContain('int');
|
|
});
|
|
|
|
it('populates returnType for formatName', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const formatName = methods.find((n) => n.name === 'formatName');
|
|
expect(formatName).toBeDefined();
|
|
expect(formatName!.properties.returnType).toBe('String');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Interface dispatch: METHOD_IMPLEMENTS edges from concrete → abstract methods
|
|
// abstract Repository with find/save, SqlRepository implements them
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!dartAvailable)('Dart interface dispatch (METHOD_IMPLEMENTS)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-interface-dispatch'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects Repository class and SqlRepository class', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('Repository');
|
|
expect(classes).toContain('SqlRepository');
|
|
});
|
|
|
|
it('emits IMPLEMENTS edge SqlRepository → Repository', () => {
|
|
const impl = getRelationships(result, 'IMPLEMENTS');
|
|
const edge = impl.find((e) => e.source === 'SqlRepository' && e.target === 'Repository');
|
|
expect(edge).toBeDefined();
|
|
});
|
|
|
|
it('emits METHOD_IMPLEMENTS edges for find and save', () => {
|
|
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
|
|
const findEdge = mi.find(
|
|
(e) =>
|
|
e.source === 'find' &&
|
|
e.target === 'find' &&
|
|
e.sourceFilePath.includes('sql_repository') &&
|
|
e.targetFilePath.includes('repository'),
|
|
);
|
|
const saveEdge = mi.find(
|
|
(e) =>
|
|
e.source === 'save' &&
|
|
e.target === 'save' &&
|
|
e.sourceFilePath.includes('sql_repository') &&
|
|
e.targetFilePath.includes('repository'),
|
|
);
|
|
expect(findEdge).toBeDefined();
|
|
expect(saveEdge).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Dart first-wins
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!dartAvailable)(
|
|
'Dart Child extends Parent — inherited method resolution (SM-9)',
|
|
() => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'dart-child-extends-parent'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('detects Parent and Child classes', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('Parent');
|
|
expect(classes).toContain('Child');
|
|
});
|
|
|
|
it('emits EXTENDS edge: Child → Parent', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
expect(edgeSet(extends_)).toContain('Child → Parent');
|
|
});
|
|
|
|
it('resolves c.parentMethod() to Parent.parentMethod via first-wins MRO walk', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const parentMethodCall = calls.find(
|
|
(c) => c.target === 'parentMethod' && c.targetFilePath.includes('parent.dart'),
|
|
);
|
|
expect(parentMethodCall).toBeDefined();
|
|
expect(parentMethodCall!.source).toBe('run');
|
|
});
|
|
},
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// await call patterns: await fetchUser(), await processData()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!dartAvailable)('Dart await call resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-await-calls'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects fetchUser and processData as functions', () => {
|
|
const fns = getNodesByLabel(result, 'Function');
|
|
expect(fns).toContain('fetchUser');
|
|
expect(fns).toContain('processData');
|
|
});
|
|
|
|
it('resolves run → fetchUser via await direct call', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edge = calls.find((c) => c.source === 'run' && c.target === 'fetchUser');
|
|
expect(edge).toBeDefined();
|
|
});
|
|
|
|
it('resolves run → processData via await direct call', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edge = calls.find((c) => c.source === 'run' && c.target === 'processData');
|
|
expect(edge).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Widget-tree call patterns: named argument and list literal
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!dartAvailable)('Dart widget-tree call resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-widget-tree-calls'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects buildHeader, buildBody, buildFooter as functions', () => {
|
|
const fns = getNodesByLabel(result, 'Function');
|
|
expect(fns).toContain('buildHeader');
|
|
expect(fns).toContain('buildBody');
|
|
expect(fns).toContain('buildFooter');
|
|
});
|
|
|
|
it('resolves buildPage → buildHeader via named argument call', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edge = calls.find((c) => c.source === 'buildPage' && c.target === 'buildHeader');
|
|
expect(edge).toBeDefined();
|
|
});
|
|
|
|
it('resolves buildPage → buildBody via list literal call', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edge = calls.find((c) => c.source === 'buildPage' && c.target === 'buildBody');
|
|
expect(edge).toBeDefined();
|
|
});
|
|
|
|
it('resolves buildPage → buildFooter via list literal call', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edge = calls.find((c) => c.source === 'buildPage' && c.target === 'buildFooter');
|
|
expect(edge).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Implicit-constructor construction edge (PR #1970 review regression)
|
|
// build() { return Widget(); } where Widget has only an implicit constructor.
|
|
// Both the legacy DAG and the registry-primary path must emit a CALLS edge
|
|
// from the enclosing function to the constructed Class.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!dartAvailable)('Dart implicit-constructor construction', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-construct-cascade'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects the Widget class and the build function', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('Widget');
|
|
expect(getNodesByLabel(result, 'Function')).toContain('build');
|
|
});
|
|
|
|
it('emits a CALLS edge build → Widget for the implicit-constructor call', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const ctorCall = calls.find((c) => c.source === 'build' && c.target === 'Widget');
|
|
expect(ctorCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// F24 (issue #1926): member calls (obj.method()) in return / list-literal /
|
|
// named-argument / arrow-body contexts. The legacy DAG only captures member
|
|
// calls under expression_statement / initialized_variable_definition, so these
|
|
// are registry-primary-only wins (registered in LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!dartAvailable)('Dart member-call contexts (F24)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-member-call-contexts'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves a member call in a return statement (svc.compute())', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edge = calls.find((c) => c.source === 'inReturn' && c.target === 'compute');
|
|
expect(edge).toBeDefined();
|
|
expect(edge!.targetFilePath).toContain('models.dart');
|
|
});
|
|
|
|
it('resolves member calls inside a list literal', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
expect(calls.find((c) => c.source === 'inList' && c.target === 'first')).toBeDefined();
|
|
expect(calls.find((c) => c.source === 'inList' && c.target === 'second')).toBeDefined();
|
|
});
|
|
|
|
it('resolves a member call in a named argument', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edge = calls.find((c) => c.source === 'inNamedArg' && c.target === 'load');
|
|
expect(edge).toBeDefined();
|
|
});
|
|
|
|
it('resolves a member call in an arrow body', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edge = calls.find((c) => c.source === 'inArrow' && c.target === 'run');
|
|
expect(edge).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// F25 (issue #1926): calls inside a constructor body are mis-attributed by the
|
|
// legacy enclosing-function finder (it only unwraps function_signature, not the
|
|
// constructor_signature whose body is a sibling of the wrapping method_signature).
|
|
// The registry-primary scope path synthesizes a Function scope for the
|
|
// constructor body, and the Constructor def is a valid caller anchor, so the
|
|
// call attributes to the constructor. Registry-primary-only win.
|
|
// (Getter/setter and operator bodies are out of scope: Property is not a caller
|
|
// anchor, and the structure phase emits no Method node for operators — see the
|
|
// helpers.ts expected-failures comment.)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!dartAvailable)('Dart constructor body call attribution (F25)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-constructor-body'), () => {});
|
|
}, 60000);
|
|
|
|
it('attributes a call inside a constructor body to the constructor', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const edge = calls.find((c) => c.target === 'setup' && c.sourceLabel === 'Constructor');
|
|
expect(edge).toBeDefined();
|
|
expect(edge!.source).toBe('Vector');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Named constructor with a body (PR #1970 tri-review P0 regression).
|
|
// `class A { A.named() { ... } }` parses as a constructor_signature with
|
|
// multiple name: fields, double-matching the scope query. Without dedup, two
|
|
// identical-range Function scopes throw ScopeTreeInvariantError and the WHOLE
|
|
// file is dropped from registry-primary resolution. Test 1 (parity) guards
|
|
// against the file-drop in both modes; test 2 is the F25 constructor-attribution
|
|
// win (registry-only, like the dart-constructor-body case).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!dartAvailable)('Dart named-constructor body (no file drop)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'dart-named-constructor-body'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('still resolves other calls in a class that has a named constructor with a body', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const greetCall = calls.find((c) => c.source === 'greet' && c.target === 'setup');
|
|
expect(greetCall).toBeDefined();
|
|
});
|
|
|
|
it('attributes a call inside a named-constructor body to the constructor', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const ctorCall = calls.find((c) => c.target === 'setup' && c.sourceLabel === 'Constructor');
|
|
expect(ctorCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Heritage cross-file simple-name collision (PR #1970 tri-review P2).
|
|
// console_logger.dart and file_logger.dart each declare `class Logger`; each
|
|
// file's service `implements Logger`. emitDartHeritageEdges resolves the base
|
|
// with same-file affinity, so each IMPLEMENTS edge must target its OWN file's
|
|
// Logger (not a globally last-written one). Parity — both modes resolve same-file.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!dartAvailable)('Dart heritage cross-file name collision', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'dart-heritage-name-collision'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('resolves implements to the same-file class on a name collision', () => {
|
|
const impl = getRelationships(result, 'IMPLEMENTS');
|
|
const consoleEdge = impl.find((e) => e.source === 'ConsoleService' && e.target === 'Logger');
|
|
expect(consoleEdge).toBeDefined();
|
|
expect(consoleEdge!.targetFilePath).toContain('console_logger.dart');
|
|
|
|
const fileEdge = impl.find((e) => e.source === 'FileService' && e.target === 'Logger');
|
|
expect(fileEdge).toBeDefined();
|
|
expect(fileEdge!.targetFilePath).toContain('file_logger.dart');
|
|
});
|
|
});
|