From 3c896cdbcdbe3200c7560a7518338700acc302ea Mon Sep 17 00:00:00 2001 From: Flavius Miron <155723308+xFlaviews@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:45:24 +0800 Subject: [PATCH] fix: close remaining Dart language support gaps (#524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: close remaining Dart language support gaps Four issues that were not addressed in PR #204: 1. extractFunctionName: add function_signature/method_signature handlers and add both to FUNCTION_NODE_TYPES. Without this, findEnclosingFunctionId cannot resolve Dart function scopes — all calls inside Dart functions have no sourceId, breaking CALLS edge attribution. 2. formal_parameter_list: add to paramListTypes in extractMethodSignature. Dart's tree-sitter grammar uses this node type (not formal_parameters), so parameter counting returns 0 for all Dart functions. 3. Write-access queries: add @assignment patterns for obj.field = value and this.field = value. Without these, no ACCESSES write edges are emitted for Dart code. 4. initialized_identifier guard in extractDartDeclaration: comma-separated declarations (String a, b, c) produce initialized_identifier nodes which are in DART_DECLARATION_NODE_TYPES but were unhandled — the type lives on the parent node. Also adds Dart column to the feature matrix in type-resolution-system.md. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(dart): field-type resolution, call attribution, import resolution, and integration tests Fixes five Dart language support gaps with integration tests and architectural alignment: **Tree-sitter queries** — Add field declaration patterns for typed and nullable class fields (`String name = ''`, `String? name`). Without these, Dart class fields were invisible to the pipeline (zero Property nodes, zero HAS_PROPERTY edges). **Import resolution** — Dart relative imports (`import 'models.dart'`) don't use a leading `./`. The standard resolver only recognises paths starting with `.` as relative; bare paths fell through to a Java-style dot-to-slash conversion that mangled `models.dart` into `models/dart`. Fix: prepend `./` before calling resolveStandard. **Call attribution** — Dart's tree-sitter grammar places `function_body` as a sibling of `function_signature`, not as a child wrapping both. The `findEnclosingFunction` parent-walk never found the function because the call lives inside `function_body` which is a sibling of the signature. Fix: add `enclosingFunctionFinder` hook to LanguageProvider interface (following the same strategy pattern as `labelOverride`), with the Dart-specific logic in `languages/dart.ts`. Both `parse-worker.ts` and `call-processor.ts` consume the hook generically — no Dart-specific code in the generic processors. **Receiver chain extraction** — Add `unconditional_assignable_selector` to `MEMBER_ACCESS_NODE_TYPES` so `inferCallForm` returns `'member'` for Dart method calls. Add Dart-specific receiver extraction blocks in `extractReceiverName`, `extractReceiverNode`, and a `selector` handler in `extractMixedChain` for Dart's flat sibling-selector model (vs the nested member-expression model used by all other languages). **Integration tests** — New `dart.test.ts` with field-type resolution and call-result-binding describe blocks. Fixtures: `dart-field-types/` (models.dart + app.dart) and `dart-call-result-binding/` (models.dart + app.dart). 9 passing tests, 1 skipped (ACCESSES edges for field reads depend on type-env parameter binding propagation — tracked for follow-up). --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Gergo Magyar --- gitnexus/src/core/ingestion/call-processor.ts | 20 +++ .../core/ingestion/import-resolvers/dart.ts | 10 +- .../src/core/ingestion/language-provider.ts | 11 ++ gitnexus/src/core/ingestion/languages/dart.ts | 24 +++ .../src/core/ingestion/tree-sitter-queries.ts | 30 ++++ .../core/ingestion/type-extractors/dart.ts | 19 +++ .../src/core/ingestion/utils/ast-helpers.ts | 32 ++++ .../src/core/ingestion/utils/call-analysis.ts | 40 +++++ .../core/ingestion/workers/parse-worker.ts | 17 ++ .../dart-call-result-binding/app.dart | 6 + .../dart-call-result-binding/models.dart | 11 ++ .../lang-resolution/dart-field-types/app.dart | 5 + .../dart-field-types/models.dart | 16 ++ .../test/integration/resolvers/dart.test.ts | 147 ++++++++++++++++++ type-resolution-system.md | 42 ++--- 15 files changed, 407 insertions(+), 23 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-call-result-binding/app.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-call-result-binding/models.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-field-types/app.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-field-types/models.dart create mode 100644 gitnexus/test/integration/resolvers/dart.test.ts diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index f0599a748..53f7b1e8c 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -209,6 +209,26 @@ const findEnclosingFunction = ( return generateId(finalLabel, `${filePath}:${funcName}`); } } + + // Language-specific enclosing function resolution (e.g., Dart where + // function_body is a sibling of function_signature, not a child). + if (provider.enclosingFunctionFinder) { + const customResult = provider.enclosingFunctionFinder(current); + if (customResult) { + // Try SymbolTable first (same pattern as the FUNCTION_NODE_TYPES branch above). + const resolved = ctx.resolve(customResult.funcName, filePath); + if (resolved?.tier === 'same-file' && resolved.candidates.length > 0) { + return resolved.candidates[0].nodeId; + } + let finalLabel = customResult.label; + if (provider.labelOverride) { + const override = provider.labelOverride(current.previousSibling!, finalLabel); + if (override !== null) finalLabel = override; + } + return generateId(finalLabel, `${filePath}:${customResult.funcName}`); + } + } + current = current.parent; } diff --git a/gitnexus/src/core/ingestion/import-resolvers/dart.ts b/gitnexus/src/core/ingestion/import-resolvers/dart.ts index 0a9511c80..1cdde9a98 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/dart.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/dart.ts @@ -39,6 +39,12 @@ export function resolveDartImport( return null; } - // Relative imports — use standard resolution - return resolveStandard(stripped, filePath, ctx, SupportedLanguages.Dart); + // Relative imports — use standard resolution. + // Dart relative imports don't require a leading "./" (e.g. `import 'models.dart'`). + // The standard resolver only recognises paths starting with "." as relative, so + // prepend "./" when the path doesn't already start with "." to ensure correct + // same-directory resolution (without this, "models.dart" would be mangled by the + // generic dot-to-slash conversion intended for Java-style package imports). + const relPath = stripped.startsWith('.') ? stripped : './' + stripped; + return resolveStandard(relPath, filePath, ctx, SupportedLanguages.Dart); } diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 8243598ae..2c47dd447 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -79,6 +79,17 @@ interface LanguageProviderConfig { projectConfig: unknown, ) => void; + // ── Enclosing function resolution ─────────────────────────────── + /** Resolve the enclosing function name + label from an AST ancestor node + * that is NOT a standard FUNCTION_NODE_TYPE. For languages where the + * function body is a sibling of the signature (e.g. Dart: function_body ↔ + * function_signature are siblings under program/class_body), the default + * parent walk cannot find the enclosing function. This hook lets the + * language provider inspect each ancestor and return the resolved result. + * Return null to continue the default walk. + * Default: undefined (standard parent walk only). */ + readonly enclosingFunctionFinder?: (ancestorNode: SyntaxNode) => { funcName: string; label: NodeLabel } | null; + // ── Labels ──────────────────────────────────────────────────────── /** Override the default node label for definition.function captures. * Return null to skip (C/C++ duplicate), a different label to reclassify diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index 79fa3d20a..ddd8bc047 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -5,8 +5,14 @@ * - importSemantics: 'wildcard' (Dart imports bring everything public into scope) * - exportChecker: public if no leading underscore * - Dart SDK imports (dart:*) and external packages are skipped + * - enclosingFunctionFinder: Dart's tree-sitter grammar places function_body + * as a sibling of function_signature/method_signature (not as a child). + * The hook resolves the enclosing function by inspecting the previous sibling. */ +import type { SyntaxNode } from '../utils/ast-helpers.js'; +import type { NodeLabel } from '../../graph/types.js'; +import { FUNCTION_NODE_TYPES, extractFunctionName } from '../utils/ast-helpers.js'; import { SupportedLanguages } from '../../../config/supported-languages.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as dartConfig } from '../type-extractors/dart.js'; @@ -14,6 +20,23 @@ import { dartExportChecker } from '../export-detection.js'; import { resolveDartImport } from '../import-resolvers/dart.js'; import { DART_QUERIES } from '../tree-sitter-queries.js'; +/** + * Resolve the enclosing function from a `function_body` node by looking at its + * previous sibling. In Dart's tree-sitter grammar, function_signature and + * function_body are siblings under program or class_body, unlike most languages + * where the function declaration wraps both. + * + * Delegates name extraction to the shared `extractFunctionName` which already + * handles Dart's function_signature and method_signature node types. + */ +const dartEnclosingFunctionFinder = (node: SyntaxNode): { funcName: string; label: NodeLabel } | null => { + if (node.type !== 'function_body') return null; + const prev = node.previousSibling; + if (!prev || !FUNCTION_NODE_TYPES.has(prev.type)) return null; + const { funcName, label } = extractFunctionName(prev); + return funcName ? { funcName, label } : null; +}; + const BUILT_INS: ReadonlySet = new Set([ 'setState', 'mounted', 'debugPrint', 'runApp', 'showDialog', 'showModalBottomSheet', @@ -31,5 +54,6 @@ export const dartProvider = defineLanguage({ exportChecker: dartExportChecker, importResolver: resolveDartImport, importSemantics: 'wildcard', + enclosingFunctionFinder: dartEnclosingFunctionFinder, builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 42e995a03..7ed918ad5 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -1054,6 +1054,20 @@ export const DART_QUERIES = ` (factory_constructor_signature (identifier) @name . (formal_parameter_list))) @definition.constructor +; ── Field declarations (String name = '', Address address = Address()) ────── +(declaration + (type_identifier) + (initialized_identifier_list + (initialized_identifier + (identifier) @name))) @definition.property + +; ── Nullable field declarations (String? name) ────────────────────────────── +(declaration + (nullable_type) + (initialized_identifier_list + (initialized_identifier + (identifier) @name))) @definition.property + ; ── Getters ────────────────────────────────────────────────────────────────── (method_signature (getter_signature @@ -1097,6 +1111,22 @@ export const DART_QUERIES = ` (library_export (configurable_uri) @import.source)) @import +; ── Write access: obj.field = value ────────────────────────────────────────── +(assignment_expression + left: (assignable_expression + (identifier) @assignment.receiver + (unconditional_assignable_selector + (identifier) @assignment.property)) + right: (_)) @assignment + +; ── Write access: this.field = value ───────────────────────────────────────── +(assignment_expression + left: (assignable_expression + (this) @assignment.receiver + (unconditional_assignable_selector + (identifier) @assignment.property)) + right: (_)) @assignment + ; ── Heritage: extends ──────────────────────────────────────────────────────── (class_definition name: (identifier) @heritage.class diff --git a/gitnexus/src/core/ingestion/type-extractors/dart.ts b/gitnexus/src/core/ingestion/type-extractors/dart.ts index 394730ad4..70cc0ee58 100644 --- a/gitnexus/src/core/ingestion/type-extractors/dart.ts +++ b/gitnexus/src/core/ingestion/type-extractors/dart.ts @@ -111,6 +111,25 @@ function hasDartTypeAnnotation(node: SyntaxNode): boolean { // ── Tier 0: Explicit Type Annotations ─────────────────────────────────── const extractDartDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + // initialized_identifier: comma-separated variable (String a, b, c) — type is on parent + if (node.type === 'initialized_identifier') { + const parent = node.parent; + if (!parent) return; + let typeNode = findChild(parent, 'type_identifier'); + if (!typeNode) { + const nullable = findChild(parent, 'nullable_type'); + if (nullable) typeNode = findChild(nullable, 'type_identifier'); + } + if (!typeNode) return; + const typeName = extractSimpleTypeName(typeNode); + if (!typeName || typeName === 'dynamic') return; + const nameNode = findChild(node, 'identifier'); + if (!nameNode) return; + const varName = extractVarName(nameNode); + if (varName) env.set(varName, typeName); + return; + } + let typeNode = findChild(node, 'type_identifier'); if (!typeNode) { const nullable = findChild(node, 'nullable_type'); diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 778699de0..acdf85684 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -82,6 +82,9 @@ export const FUNCTION_NODE_TYPES = new Set([ // Ruby 'method', // def foo 'singleton_method', // def self.foo + // Dart + 'function_signature', + 'method_signature', ]); /** @@ -430,6 +433,34 @@ export const extractFunctionName = (node: SyntaxNode): { funcName: string | null } funcName = nameNode?.text; label = 'Method'; + } else if (node.type === 'function_signature') { + // Dart: top-level function signatures + let nameNode = node.childForFieldName?.('name'); + if (!nameNode) { + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c?.type === 'identifier') { nameNode = c; break; } + } + } + funcName = nameNode?.text ?? null; + } else if (node.type === 'method_signature') { + // Dart: method_signature wraps function_signature + let funcSig: SyntaxNode | null = null; + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c?.type === 'function_signature') { funcSig = c; break; } + } + if (funcSig) { + let nameNode = funcSig.childForFieldName?.('name'); + if (!nameNode) { + for (let i = 0; i < funcSig.childCount; i++) { + const c = funcSig.child(i); + if (c?.type === 'identifier') { nameNode = c; break; } + } + } + funcName = nameNode?.text ?? null; + } + label = 'Method'; } return { funcName, label }; @@ -471,6 +502,7 @@ export const extractMethodSignature = (node: SyntaxNode | null | undefined): Met const paramListTypes = new Set([ 'formal_parameters', 'parameters', 'parameter_list', 'function_parameters', 'method_parameters', 'function_value_parameters', + 'formal_parameter_list', // Dart ]); // Node types that indicate variadic/rest parameters diff --git a/gitnexus/src/core/ingestion/utils/call-analysis.ts b/gitnexus/src/core/ingestion/utils/call-analysis.ts index 0506098a9..275a00797 100644 --- a/gitnexus/src/core/ingestion/utils/call-analysis.ts +++ b/gitnexus/src/core/ingestion/utils/call-analysis.ts @@ -64,6 +64,7 @@ const MEMBER_ACCESS_NODE_TYPES = new Set([ 'selector_expression', // Go: obj.Method() 'navigation_suffix', // Kotlin/Swift: obj.method() — nameNode sits inside navigation_suffix 'member_binding_expression', // C#: user?.Method() — null-conditional access + 'unconditional_assignable_selector', // Dart: obj.method() — nameNode inside selector > unconditional_assignable_selector ]); /** @@ -208,6 +209,16 @@ export const extractReceiverName = ( } } + // Dart: unconditional_assignable_selector is inside a `selector`, which is a sibling + // of the receiver in the expression_statement. For `user.save()`, the previous named + // sibling of the `selector` is `identifier [user]`. + if (!receiver && parent.type === 'unconditional_assignable_selector') { + const selectorNode = parent.parent; // selector [.save] + if (selectorNode) { + receiver = selectorNode.previousNamedSibling; + } + } + // C# null-conditional: user?.Save() → conditional_access_expression wraps member_binding_expression if (!receiver && parent.type === 'member_binding_expression') { const condAccess = parent.parent; @@ -291,6 +302,14 @@ export const extractReceiverNode = ( } } + // Dart: unconditional_assignable_selector — receiver is previous sibling of the selector + if (!receiver && parent.type === 'unconditional_assignable_selector') { + const selectorNode = parent.parent; + if (selectorNode) { + receiver = selectorNode.previousNamedSibling; + } + } + if (!receiver && parent.type === 'member_binding_expression') { const condAccess = parent.parent; if (condAccess?.type === 'conditional_access_expression') { @@ -527,6 +546,27 @@ export function extractMixedChain( } else { return { chain, baseReceiverName: innerObject.text || undefined }; } + } else if (current.type === 'selector') { + // ── Dart: flat selector siblings (user.address.save() uses selector nodes) ── + // Extract field name from unconditional_assignable_selector child + const uas = current.namedChildren?.find( + (c: SyntaxNode) => c.type === 'unconditional_assignable_selector', + ); + const propertyName = uas?.namedChildren?.find( + (c: SyntaxNode) => c.type === 'identifier', + )?.text; + if (!propertyName) break; + chain.unshift({ kind: 'field', name: propertyName }); + + // Walk to previous sibling for the next step in the chain + const prev = current.previousNamedSibling; + if (!prev) break; + if (prev.type === 'selector') { + current = prev; + } else { + // Base receiver (identifier or other terminal) + return { chain, baseReceiverName: prev.text || undefined }; + } } else { // Simple identifier — this is the base receiver return chain.length > 0 diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index d24fc07fc..ae8dff9ae 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -318,6 +318,23 @@ const findEnclosingFunctionId = (node: any, filePath: string, provider: Language return result; } } + + // Language-specific enclosing function resolution (e.g., Dart where + // function_body is a sibling of function_signature, not a child). + if (provider.enclosingFunctionFinder) { + const customResult = provider.enclosingFunctionFinder(current); + if (customResult) { + let finalLabel: NodeLabel = customResult.label; + if (provider.labelOverride) { + const override = provider.labelOverride(current.previousSibling, finalLabel); + if (override !== null) finalLabel = override; + } + const result = generateId(finalLabel, `${filePath}:${customResult.funcName}`); + functionIdCache.set(node, result); + return result; + } + } + current = current.parent; } functionIdCache.set(node, null); diff --git a/gitnexus/test/fixtures/lang-resolution/dart-call-result-binding/app.dart b/gitnexus/test/fixtures/lang-resolution/dart-call-result-binding/app.dart new file mode 100644 index 000000000..444ef3b78 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-call-result-binding/app.dart @@ -0,0 +1,6 @@ +import 'models.dart'; + +void processUser() { + var user = getUser('alice'); + user.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-call-result-binding/models.dart b/gitnexus/test/fixtures/lang-resolution/dart-call-result-binding/models.dart new file mode 100644 index 000000000..d481296a1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-call-result-binding/models.dart @@ -0,0 +1,11 @@ +class User { + String name = ''; + + bool save() { + return true; + } +} + +User getUser(String name) { + return User(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-field-types/app.dart b/gitnexus/test/fixtures/lang-resolution/dart-field-types/app.dart new file mode 100644 index 000000000..f56dda429 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-field-types/app.dart @@ -0,0 +1,5 @@ +import 'models.dart'; + +void processUser(User user) { + user.address.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-field-types/models.dart b/gitnexus/test/fixtures/lang-resolution/dart-field-types/models.dart new file mode 100644 index 000000000..5fe96c04f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-field-types/models.dart @@ -0,0 +1,16 @@ +class Address { + String city = ''; + + void save() { + // persist address + } +} + +class User { + String name = ''; + Address address = Address(); + + String greet() { + return name; + } +} diff --git a/gitnexus/test/integration/resolvers/dart.test.ts b/gitnexus/test/integration/resolvers/dart.test.ts new file mode 100644 index 000000000..65a87d348 --- /dev/null +++ b/gitnexus/test/integration/resolvers/dart.test.ts @@ -0,0 +1,147 @@ +/** + * 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. + * + * Remaining known Dart gaps (field-chain ACCESSES) are documented as + * it.todo() tests to be filled when the pipeline is extended. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, getRelationships, getNodesByLabel, edgeSet, + runPipelineFromRepo, type PipelineResult, +} from './helpers.js'; +import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../../src/config/supported-languages.js'; + +const dartAvailable = isLanguageAvailable(SupportedLanguages.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); + }); + + // Dart field-chain ACCESSES edges require the call-processor's chain-resolution + // tier (Step 1c) to fire. This needs the type-env's scoped parameter binding + // (processUser's `user: User`) to propagate to processCallsFromExtracted so + // walkMixedChain can resolve User → address → Address and emit ACCESSES. + // The chain extraction (extractMixedChain) and member detection + // (MEMBER_ACCESS_NODE_TYPES) are wired, but the base receiver type lookup + // from the type-env currently returns undefined for Dart function parameters + // in the call-processor context. Tracked for follow-up. + it.skip('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'); + } + }); +}); diff --git a/type-resolution-system.md b/type-resolution-system.md index 702e04dca..bfd77ac58 100644 --- a/type-resolution-system.md +++ b/type-resolution-system.md @@ -375,27 +375,27 @@ So return-type-aware receiver inference already exists in a constrained downstre ## Language Feature Matrix -| Feature | TS | JS | Java | Kotlin | C# | Go | Rust | Python | PHP | Ruby | Swift | C++ | C | -|---------|:--:|:--:|:----:|:------:|:--:|:--:|:----:|:------:|:---:|:----:|:-----:|:---:|:-:| -| Declarations | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Parameters | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Initializer / constructor inference | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Constructor binding scan | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| For-loop element types | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes††† | Yes | Yes | -| Pattern binding | Yes | Yes | Yes | Yes | No | Yes | Yes | No | No | No | Partial‡‡‡ | No | No | -| Assignment chains | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes | Yes | Yes | -| Field/property type resolution | Yes | No† | Yes | Yes | Yes | Yes | Yes | Yes* | Yes | YARD | No | Yes | No‡ | -| Comment-based types | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No | -| Return type extraction | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No | -| Call-result variable binding | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes¶ | Yes††† | Yes | No | -| Field access binding | Yes | No† | Yes | Yes | Yes | Yes | Yes | No‖ | Yes | N/A | Yes††† | Yes | No | -| Method-call-result binding | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes¶ | Yes††† | Yes | No | -| Write access (ACCESSES write) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes§ | Yes | Yes | Yes | No | -| Parameter types extracted | Yes** | No | Yes | Yes | Yes | Yes | Yes | Partial†† | No | No | No | Yes | No | -| Method overload disambiguation | Yes** | No | Yes | Yes | Yes | No | No | No | No | No | No | Yes | No | -| Constructor-visible virtual dispatch | Yes | No | Yes | Yes‡‡ | Yes | No | No | No | No | No | No | Yes§§ | No | -| Optional parameter arity resolution | Yes | No | No | Yes | Yes | No | No | Yes | Yes | Yes | No | Yes | No | -| Cross-file binding propagation | Yes | Yes | Yes‖‖ | Yes | Yes¶¶ | Yes*** | Yes | Yes | Partial | Yes*** | Yes*** | Yes*** | Yes*** | +| Feature | TS | JS | Java | Kotlin | C# | Go | Rust | Python | PHP | Ruby | Swift | C++ | C | Dart | +|---------|:--:|:--:|:----:|:------:|:--:|:--:|:----:|:------:|:---:|:----:|:-----:|:---:|:-:|:----:| +| Declarations | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Parameters | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Initializer / constructor inference | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Constructor binding scan | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| For-loop element types | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes††† | Yes | Yes | Yes | +| Pattern binding | Yes | Yes | Yes | Yes | No | Yes | Yes | No | No | No | Partial‡‡‡ | No | No | No | +| Assignment chains | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes | Yes | Yes | Yes | +| Field/property type resolution | Yes | No† | Yes | Yes | Yes | Yes | Yes | Yes* | Yes | YARD | No | Yes | No‡ | No | +| Comment-based types | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No | No | +| Return type extraction | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No | No | +| Call-result variable binding | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes¶ | Yes††† | Yes | No | Yes | +| Field access binding | Yes | No† | Yes | Yes | Yes | Yes | Yes | No‖ | Yes | N/A | Yes††† | Yes | No | Yes | +| Method-call-result binding | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes¶ | Yes††† | Yes | No | Yes | +| Write access (ACCESSES write) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes§ | Yes | Yes | Yes | No | Yes | +| Parameter types extracted | Yes** | No | Yes | Yes | Yes | Yes | Yes | Partial†† | No | No | No | Yes | No | No | +| Method overload disambiguation | Yes** | No | Yes | Yes | Yes | No | No | No | No | No | No | Yes | No | No | +| Constructor-visible virtual dispatch | Yes | No | Yes | Yes‡‡ | Yes | No | No | No | No | No | No | Yes§§ | No | Yes | +| Optional parameter arity resolution | Yes | No | No | Yes | Yes | No | No | Yes | Yes | Yes | No | Yes | No | No | +| Cross-file binding propagation | Yes | Yes | Yes‖‖ | Yes | Yes¶¶ | Yes*** | Yes | Yes | Partial | Yes*** | Yes*** | Yes*** | Yes*** | Yes*** | \* Python class-level annotated attributes (`address: Address`) now resolve `declaredType` correctly. The `self.x` instance attribute pattern is not yet supported.