mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* fix(dart): resolve package imports by pubspec identity * fix(dart): keep package-identity edges out of the cycle check Pubspec identity edges invalidate importers when a manifest changes. They cannot form an init cycle, so the cycle query excludes them before the row cap. Discovery reads each manifest once, with a size bound, and resolution shares one package-URI parser with those edges. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3369) - Reject package URIs with an empty library path so they do not emit identity edges - Skip the pubspec permission test where chmod cannot deny reads - Document that the Dart heap probe is not a uniqueTarget spelling Note: pre-existing failure in test/unit/incremental-index-extension-dml-gate.test.ts not addressed by this PR. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(dart): list package directories through a no-follow descriptor A directory replaced by a symlink between the parent listing and the next visit must not be traversed. The walk opens it with O_DIRECTORY|O_NOFOLLOW and lists that inode. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(mcp): keep the Dart identity reason out of MCP startup The cycle query still excludes the same reason string. The constant now lives with the other non-initializing import reasons, so MCP startup does not load a language provider. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3369) Open discovered pubspecs and child directories through the parent directory inode on Linux, so replacing that directory with a symlink cannot redirect the walk. Note: pre-existing failure in test/unit/incremental-index-extension-dml-gate.test.ts (worker pool startup timeout) not addressed by this PR. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(dart): reject Windows junctions during pubspec walk * Address PR review feedback (#3369) Refuse pubspec discovery that cannot set O_NOFOLLOW, and verify macOS child opens against the pinned directory chain instead of reopening a mutable path. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(dart): bound live pubspec descriptors and close the macOS check-then-open A deep directory chain held one descriptor per level until open failed with EMFILE, and macOS child opens statted the path before using it. Refuse the next directory at 64 live handles, and stat only the descriptor opened with O_NOFOLLOW. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(dart): open pubspecs non-blocking so a FIFO cannot hang discovery A listed pubspec can be replaced by a FIFO before open. O_RDONLY alone waits inside open for a writer, so the file-type check never runs. O_NONBLOCK returns immediately and the walk rejects the non-regular file. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(dart): cap names read from each pubspec directory readdir kept every entry before the visit budget could run, so one huge directory could allocate without bound. Read the listing one name at a time and fail closed past 100,000 entries. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(dart): reject a pubspec that grows while its descriptor is read The size cap was taken from the stat before the read, so a file that grew in that window could be parsed from a short prefix. Re-stat the same descriptor afterward and fail closed when the size no longer matches the bytes captured. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): rebaseline the Dart scope-capture fingerprint for package-import fixtures The benchmark hashes every dart-* fixture. The new package-import corpus adds six Dart files and 33 capture groups. Parking that directory restores the previous fingerprint, so this is corpus growth, not a capture change. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
911 lines
36 KiB
TypeScript
911 lines
36 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, it, expect, beforeAll } from 'vitest';
|
|
import path from 'path';
|
|
import {
|
|
FIXTURES,
|
|
getRelationships,
|
|
getNodesByLabel,
|
|
getNodesByLabelFull,
|
|
edgeSet,
|
|
runPipelineFromRepo,
|
|
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';
|
|
import { pubspecWalkAnchored } from '../../../src/core/ingestion/languages/dart/package-config.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;
|
|
}
|
|
}
|
|
|
|
describe.skipIf(!dartAvailable || !pubspecWalkAnchored())(
|
|
'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', () => {
|
|
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: inherited method resolution — Dart first-wins inheritance walk
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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 (removed in #942) only
|
|
// captured member calls under expression_statement /
|
|
// initialized_variable_definition; scope-resolution now owns and resolves these
|
|
// broader contexts.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// F26 (issue #1919): static const / static final class fields.
|
|
// `static const`/`static final` fields parse with a static_final_declaration_list
|
|
// (not initialized_identifier_list), so the legacy field rules missed them and
|
|
// no Property node was created end-to-end. They must surface as Property nodes
|
|
// marked static + readonly, one per name in a multi-name declaration.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!dartAvailable)('Dart static const/final fields (F26)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-static-fields'), () => {});
|
|
}, 60000);
|
|
|
|
it('captures static const and static final fields as Properties', () => {
|
|
const properties = getNodesByLabel(result, 'Property');
|
|
expect(properties).toContain('maxRetries'); // static const
|
|
expect(properties).toContain('host'); // static final, name 1 of 2
|
|
expect(properties).toContain('scheme'); // static final, name 2 of 2
|
|
expect(properties).toContain('port'); // instance field (regression)
|
|
expect(properties).toContain('_secret'); // private static const
|
|
});
|
|
|
|
it('emits HAS_PROPERTY edges for static fields', () => {
|
|
const propEdges = getRelationships(result, 'HAS_PROPERTY');
|
|
expect(edgeSet(propEdges)).toEqual(
|
|
expect.arrayContaining([
|
|
'Config → maxRetries',
|
|
'Config → host',
|
|
'Config → scheme',
|
|
'Config → port',
|
|
'Config → _secret',
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('marks static const/final fields as static + readonly', () => {
|
|
const props = getNodesByLabelFull(result, 'Property');
|
|
for (const name of ['maxRetries', 'host', 'scheme', '_secret']) {
|
|
const p = props.find((n) => n.name === name);
|
|
expect(p, name).toBeDefined();
|
|
expect(p!.properties.isStatic, name).toBe(true);
|
|
expect(p!.properties.isReadonly, name).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('keeps the instance field non-static, non-readonly (regression)', () => {
|
|
const props = getNodesByLabelFull(result, 'Property');
|
|
const port = props.find((n) => n.name === 'port');
|
|
expect(port).toBeDefined();
|
|
expect(port!.properties.isStatic).toBe(false);
|
|
expect(port!.properties.isReadonly).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// F29 (issue #1919): top-level Dart variables. Top-level vars are loose
|
|
// siblings under `program` (no `declaration` wrapper), so the structure query
|
|
// never captured them and no Variable node existed end-to-end. They must now
|
|
// surface as Variable nodes with the real type/const metadata read from the
|
|
// captured container's leading siblings (not a phantom `type` field).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!dartAvailable)('Dart top-level variables (F29)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-toplevel-vars'), () => {});
|
|
}, 60000);
|
|
|
|
it('captures top-level variables (typed final, inferred var, multi-name const)', () => {
|
|
const vars = getNodesByLabel(result, 'Variable');
|
|
expect(vars).toContain('count'); // final int count = 3; (covers F29)
|
|
expect(vars).toContain('name'); // var name = 'x';
|
|
expect(vars).toContain('a'); // const a = 1, b = 2;
|
|
expect(vars).toContain('b');
|
|
});
|
|
|
|
it('reads the real type for a typed final and leaves an inferred var untyped', () => {
|
|
const vars = getNodesByLabelFull(result, 'Variable');
|
|
const count = vars.find((n) => n.name === 'count');
|
|
expect(count).toBeDefined();
|
|
expect(count!.properties.declaredType).toBe('int');
|
|
expect(count!.properties.isConst).toBe(true);
|
|
|
|
const name = vars.find((n) => n.name === 'name');
|
|
expect(name).toBeDefined();
|
|
// inferred `var` → no declaredType from a phantom field; mutable.
|
|
expect(name!.properties.declaredType).toBeUndefined();
|
|
expect(name!.properties.isMutable).toBe(true);
|
|
});
|
|
|
|
it('keeps the class instance field as a Property, not a Variable (regression)', () => {
|
|
const vars = getNodesByLabel(result, 'Variable');
|
|
expect(vars).not.toContain('z');
|
|
const props = getNodesByLabel(result, 'Property');
|
|
expect(props).toContain('z');
|
|
});
|
|
|
|
it('does NOT emit top-level vars as Property nodes (#1919 review CF4)', () => {
|
|
// Guards the `(program …)` vs `(declaration …)` anchor split: top-level
|
|
// siblings under `program` must surface as Variable, never Property. If the
|
|
// top-level anchor regressed to the class-field `(declaration …)` rule, these
|
|
// names would mis-classify as class Properties.
|
|
const props = getNodesByLabel(result, 'Property');
|
|
for (const name of ['count', 'a', 'b', 'name']) {
|
|
expect(props).not.toContain(name);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CF3 (#1919 review): Dart class getters/setters keep their class owner edge.
|
|
// ---------------------------------------------------------------------------
|
|
// A Dart accessor's name lives under `method_signature`; the CF3 owner-strip
|
|
// guard must NOT treat that signature as an executable body and strip the
|
|
// HAS_PROPERTY owner (the over-strip regression this guards against).
|
|
describe('CF3 — Dart class accessors keep their class owner', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-accessor-owner'), () => {});
|
|
}, 60000);
|
|
|
|
it('owns the getter/setter property `answer` and the stored field under Box', () => {
|
|
const owned = getRelationships(result, 'HAS_PROPERTY')
|
|
.filter((e) => e.source === 'Box')
|
|
.map((e) => e.target)
|
|
.sort();
|
|
expect(owned).toContain('answer');
|
|
expect(owned).toContain('normalField');
|
|
});
|
|
});
|