diff --git a/gitnexus-web/src/config/supported-languages.ts b/gitnexus-web/src/config/supported-languages.ts index 4c51b56ab..b1c61e745 100644 --- a/gitnexus-web/src/config/supported-languages.ts +++ b/gitnexus-web/src/config/supported-languages.ts @@ -12,4 +12,5 @@ export enum SupportedLanguages { Ruby = 'ruby', Kotlin = 'kotlin', Swift = 'swift', -} \ No newline at end of file + Dart = 'dart', +} diff --git a/gitnexus-web/src/core/ingestion/call-routing.ts b/gitnexus-web/src/core/ingestion/call-routing.ts index 5a112aca1..cf5a3217b 100644 --- a/gitnexus-web/src/core/ingestion/call-routing.ts +++ b/gitnexus-web/src/core/ingestion/call-routing.ts @@ -41,6 +41,7 @@ export const callRouters = { [SupportedLanguages.C]: noRouting, [SupportedLanguages.Ruby]: routeRubyCall, [SupportedLanguages.Kotlin]: noRouting, + [SupportedLanguages.Dart]: noRouting, } satisfies Record; // ── Result types ──────────────────────────────────────────────────────────── diff --git a/gitnexus-web/src/core/ingestion/framework-detection.ts b/gitnexus-web/src/core/ingestion/framework-detection.ts index b190e4baf..0cc76d5d9 100644 --- a/gitnexus-web/src/core/ingestion/framework-detection.ts +++ b/gitnexus-web/src/core/ingestion/framework-detection.ts @@ -315,6 +315,33 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null return { framework: 'ios-router', entryPointMultiplier: 2.0, reason: 'ios-router' }; } + // ========== DART / FLUTTER ========== + + // Flutter main entry point + if (p.endsWith('/main.dart')) { + return { framework: 'flutter', entryPointMultiplier: 3.0, reason: 'flutter-main' }; + } + + // Flutter screens/pages (high priority - route entry points) + if ((p.includes('/lib/screens/') || p.includes('/lib/pages/')) && p.endsWith('.dart')) { + return { framework: 'flutter', entryPointMultiplier: 2.5, reason: 'flutter-screen' }; + } + + // Flutter BLoC / controllers (state management entry points) + if ((p.includes('/lib/bloc/') || p.includes('/lib/controllers/') || p.includes('/lib/cubit/')) && p.endsWith('.dart')) { + return { framework: 'flutter', entryPointMultiplier: 2.0, reason: 'flutter-state-management' }; + } + + // Flutter services + if (p.includes('/lib/services/') && p.endsWith('.dart')) { + return { framework: 'flutter', entryPointMultiplier: 1.8, reason: 'flutter-service' }; + } + + // Flutter widgets (reusable components) + if (p.includes('/lib/widgets/') && p.endsWith('.dart')) { + return { framework: 'flutter', entryPointMultiplier: 1.5, reason: 'flutter-widget' }; + } + // ========== GENERIC PATTERNS ========== // Any language: index files in API folders @@ -366,6 +393,10 @@ export const FRAMEWORK_AST_PATTERNS = { 'axum': ['Router::new'], 'rocket': ['#[get', '#[post'], + // Dart/Flutter + 'flutter': ['StatelessWidget', 'StatefulWidget', 'BuildContext', 'Widget build', + 'ChangeNotifier', 'GetxController', 'Cubit<', 'Bloc<'], + // Swift/iOS 'uikit': ['viewDidLoad', 'viewWillAppear', 'viewDidAppear', 'UIViewController'], 'swiftui': ['@main', 'WindowGroup', 'ContentView', '@StateObject', '@ObservedObject'], diff --git a/gitnexus-web/src/core/ingestion/parsing-processor.ts b/gitnexus-web/src/core/ingestion/parsing-processor.ts index a063db6cc..9421990a1 100644 --- a/gitnexus-web/src/core/ingestion/parsing-processor.ts +++ b/gitnexus-web/src/core/ingestion/parsing-processor.ts @@ -109,6 +109,10 @@ const isNodeExported = (node: any, name: string, language: string): boolean => { case 'ruby': return true; + // Dart: Public if no leading underscore (same convention as Python) + case 'dart': + return !name.startsWith('_'); + default: return false; } diff --git a/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts b/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts index 65b67bcaf..8a5a7eeb2 100644 --- a/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts @@ -483,6 +483,102 @@ export const SWIFT_QUERIES = ` (inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage `; +// Dart queries - works with tree-sitter-dart +export const DART_QUERIES = ` +; ── Classes ────────────────────────────────────────────────────────────────── +(class_definition + name: (identifier) @name) @definition.class + +; ── Mixins ─────────────────────────────────────────────────────────────────── +(mixin_declaration + (identifier) @name) @definition.trait + +; ── Extensions ─────────────────────────────────────────────────────────────── +(extension_declaration + name: (identifier) @name) @definition.class + +; ── Enums ──────────────────────────────────────────────────────────────────── +(enum_declaration + name: (identifier) @name) @definition.enum + +; ── Type aliases ───────────────────────────────────────────────────────────── +; Anchor "=" after the name to avoid capturing the RHS type +(type_alias + (type_identifier) @name + "=") @definition.type + +; ── Top-level functions (parent is program, not method_signature) ──────────── +(program + (function_signature + name: (identifier) @name) @definition.function) + +; ── Abstract method declarations (function_signature inside class body declaration) ── +(declaration + (function_signature + name: (identifier) @name)) @definition.method + +; ── Methods (inside class/mixin/extension bodies) ──────────────────────────── +(method_signature + (function_signature + name: (identifier) @name)) @definition.method + +; ── Constructors ───────────────────────────────────────────────────────────── +(constructor_signature + name: (identifier) @name) @definition.constructor + +; ── Factory constructors ───────────────────────────────────────────────────── +(method_signature + (factory_constructor_signature + (identifier) @name)) @definition.constructor + +; ── Getters ────────────────────────────────────────────────────────────────── +(method_signature + (getter_signature + name: (identifier) @name)) @definition.property + +; ── Setters ────────────────────────────────────────────────────────────────── +(method_signature + (setter_signature + name: (identifier) @name)) @definition.property + +; ── Imports ────────────────────────────────────────────────────────────────── +(import_or_export + (library_import + (import_specification + (configurable_uri) @import.source))) @import + +; ── Calls: direct function/constructor calls (identifier immediately before argument_part) ── +(expression_statement + (identifier) @call.name + . + (selector (argument_part))) @call + +; ── Calls: method calls (obj.method()) ─────────────────────────────────────── +(expression_statement + (selector + (unconditional_assignable_selector + (identifier) @call.name))) @call + +; ── Heritage: extends ──────────────────────────────────────────────────────── +(class_definition + name: (identifier) @heritage.class + superclass: (superclass + (type_identifier) @heritage.extends)) @heritage + +; ── Heritage: implements ───────────────────────────────────────────────────── +(class_definition + name: (identifier) @heritage.class + interfaces: (interfaces + (type_identifier) @heritage.implements)) @heritage.impl + +; ── Heritage: with (mixins) ────────────────────────────────────────────────── +(class_definition + name: (identifier) @heritage.class + superclass: (superclass + (mixins + (type_identifier) @heritage.trait))) @heritage +`; + export const LANGUAGE_QUERIES: Record = { [SupportedLanguages.TypeScript]: TYPESCRIPT_QUERIES, [SupportedLanguages.JavaScript]: JAVASCRIPT_QUERIES, @@ -497,5 +593,6 @@ export const LANGUAGE_QUERIES: Record = { [SupportedLanguages.Ruby]: RUBY_QUERIES, [SupportedLanguages.Kotlin]: '', // Kotlin WASM parser not yet available for web [SupportedLanguages.Swift]: SWIFT_QUERIES, + [SupportedLanguages.Dart]: DART_QUERIES, }; \ No newline at end of file diff --git a/gitnexus-web/src/core/ingestion/utils.ts b/gitnexus-web/src/core/ingestion/utils.ts index 22bb6d72e..0a6a5213f 100644 --- a/gitnexus-web/src/core/ingestion/utils.ts +++ b/gitnexus-web/src/core/ingestion/utils.ts @@ -45,6 +45,7 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages | } // Swift if (filename.endsWith('.swift')) return SupportedLanguages.Swift; + if (filename.endsWith('.dart')) return SupportedLanguages.Dart; return null; }; diff --git a/gitnexus-web/src/core/tree-sitter/parser-loader.ts b/gitnexus-web/src/core/tree-sitter/parser-loader.ts index 44bc7c37d..51142a2c0 100644 --- a/gitnexus-web/src/core/tree-sitter/parser-loader.ts +++ b/gitnexus-web/src/core/tree-sitter/parser-loader.ts @@ -43,6 +43,7 @@ const getWasmPath = (language: SupportedLanguages, filePath?: string): string => [SupportedLanguages.Ruby]: '/wasm/ruby/tree-sitter-ruby.wasm', [SupportedLanguages.Kotlin]: '', // Kotlin WASM parser not yet available for web [SupportedLanguages.Swift]: '/wasm/swift/tree-sitter-swift.wasm', + [SupportedLanguages.Dart]: '/wasm/dart/tree-sitter-dart.wasm', }; return languageFileMap[language]; diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index f7239b237..135312bdc 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -58,6 +58,7 @@ "node": ">=20.0.0" }, "optionalDependencies": { + "tree-sitter-dart": "github:UserNobody14/tree-sitter-dart#0fc19c3a57b1109802af41d2b8f60d8835c5da3a", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-swift": "0.7.1" } @@ -5116,6 +5117,33 @@ } } }, + "node_modules/tree-sitter-dart": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/UserNobody14/tree-sitter-dart.git#0fc19c3a57b1109802af41d2b8f60d8835c5da3a", + "integrity": "sha512-tdKxw1KmBWOT1DrqjKpn2Qj8nCpfza72fiDwKpUncG98YOrQ1A8kfLioYFEsQlXmDhF0yLsQ52LqFoOjOLq9JQ==", + "hasInstallScript": true, + "license": "ISC", + "optional": true, + "dependencies": { + "node-addon-api": "^7.1.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.25.0" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-dart/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, "node_modules/tree-sitter-go": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/tree-sitter-go/-/tree-sitter-go-0.25.0.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index b58767be4..dfb3d2711 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -80,6 +80,7 @@ "uuid": "^13.0.0" }, "optionalDependencies": { + "tree-sitter-dart": "github:UserNobody14/tree-sitter-dart#0fc19c3a57b1109802af41d2b8f60d8835c5da3a", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-swift": "0.7.1" }, diff --git a/gitnexus/src/config/supported-languages.ts b/gitnexus/src/config/supported-languages.ts index 4ddd085fa..a35c3d2b1 100644 --- a/gitnexus/src/config/supported-languages.ts +++ b/gitnexus/src/config/supported-languages.ts @@ -41,4 +41,5 @@ export enum SupportedLanguages { PHP = 'php', Kotlin = 'kotlin', Swift = 'swift', -} \ No newline at end of file + Dart = 'dart', +} diff --git a/gitnexus/src/core/graph/types.ts b/gitnexus/src/core/graph/types.ts index e82729fff..594a94c7e 100644 --- a/gitnexus/src/core/graph/types.ts +++ b/gitnexus/src/core/graph/types.ts @@ -100,6 +100,7 @@ export type RelationshipType = | 'HANDLES_TOOL' // Function/File → Tool (handler implements this tool) | 'ENTRY_POINT_OF' // Route/Tool → Process (this endpoint starts this execution flow) | 'WRAPS' // Function → Function (middleware wrapper chain) — Reserved: future middleware graph traversal (not yet emitted) + | 'QUERIES' // File/Function → CodeElement (ORM query to model/table) export interface GraphNode { id: string, diff --git a/gitnexus/src/core/ingestion/entry-point-scoring.ts b/gitnexus/src/core/ingestion/entry-point-scoring.ts index 4c6f58cbe..f6128f9fd 100644 --- a/gitnexus/src/core/ingestion/entry-point-scoring.ts +++ b/gitnexus/src/core/ingestion/entry-point-scoring.ts @@ -212,6 +212,20 @@ export const ENTRY_POINT_PATTERNS = { /^perform$/, // Background jobs (Sidekiq, ActiveJob) /^execute$/, // Command pattern ], + + // Dart / Flutter + [SupportedLanguages.Dart]: [ + /^main$/, // App entry + /^build$/, // Widget.build — fundamental Flutter render entry point + /^createState$/, // StatefulWidget.createState + /^initState$/, // State lifecycle initialization + /^dispose$/, // State lifecycle teardown + /^didChangeDependencies$/, // State lifecycle — InheritedWidget changes + /^didUpdateWidget$/, // State lifecycle — widget rebuild with new config + /^runApp$/, // App entry point + /^onEvent$/, // BLoC event handler + /^mapEventToState$/, // Legacy BLoC pattern + ], } satisfies Record; /** Pre-computed merged patterns (universal + language-specific) to avoid per-call array allocation. */ diff --git a/gitnexus/src/core/ingestion/export-detection.ts b/gitnexus/src/core/ingestion/export-detection.ts index fba047bd2..cea8908e0 100644 --- a/gitnexus/src/core/ingestion/export-detection.ts +++ b/gitnexus/src/core/ingestion/export-detection.ts @@ -217,3 +217,6 @@ export const swiftExportChecker: ExportChecker = (node, _name) => { /** Ruby: all top-level definitions are public (no export syntax). */ export const rubyExportChecker: ExportChecker = (_node, _name) => true; +/** Dart: public if no leading underscore (convention, same as Python). */ +export const dartExportChecker: ExportChecker = (_node, name) => !name.startsWith('_'); + diff --git a/gitnexus/src/core/ingestion/framework-detection.ts b/gitnexus/src/core/ingestion/framework-detection.ts index 0eb8fca94..348a68d47 100644 --- a/gitnexus/src/core/ingestion/framework-detection.ts +++ b/gitnexus/src/core/ingestion/framework-detection.ts @@ -81,6 +81,17 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null return { framework: 'expo-router', entryPointMultiplier: 2.5, reason: 'expo-screen' }; } + // Prisma schema (ORM data model definition) + if (p.includes('/prisma/') && p.endsWith('schema.prisma')) { + return { framework: 'prisma', entryPointMultiplier: 1.5, reason: 'prisma-schema' }; + } + + // Supabase client files + if ((p.includes('/lib/supabase') || p.includes('/utils/supabase') || p.includes('/supabase/')) && + (p.endsWith('.ts') || p.endsWith('.js'))) { + return { framework: 'supabase', entryPointMultiplier: 1.5, reason: 'supabase-client' }; + } + // Express / Node.js routes if (p.includes('/routes/') && (p.endsWith('.ts') || p.endsWith('.js'))) { return { framework: 'express', entryPointMultiplier: 2.5, reason: 'routes-folder' }; @@ -406,6 +417,38 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null return { framework: 'ios-router', entryPointMultiplier: 2.0, reason: 'ios-router' }; } + // ========== DART / FLUTTER ========== + + // Flutter main/app entry points + if (p.endsWith('/main.dart') || p.endsWith('/app.dart')) { + return { framework: 'flutter', entryPointMultiplier: 3.0, reason: 'flutter-main' }; + } + + // Flutter screens/pages/views (high priority - route entry points) + if ((p.includes('/screens/') || p.includes('/pages/') || p.includes('/views/')) && p.endsWith('.dart')) { + return { framework: 'flutter', entryPointMultiplier: 2.5, reason: 'flutter-screen' }; + } + + // Flutter routes + if (p.includes('/routes/') && p.endsWith('.dart')) { + return { framework: 'flutter', entryPointMultiplier: 2.5, reason: 'flutter-routes' }; + } + + // Flutter BLoC / controllers / presentation (state management entry points) + if ((p.includes('/bloc/') || p.includes('/controllers/') || p.includes('/cubit/') || p.includes('/presentation/')) && p.endsWith('.dart')) { + return { framework: 'flutter', entryPointMultiplier: 2.0, reason: 'flutter-state-management' }; + } + + // Flutter services / domain + if ((p.includes('/services/') || p.includes('/domain/')) && p.endsWith('.dart')) { + return { framework: 'flutter', entryPointMultiplier: 1.8, reason: 'flutter-service' }; + } + + // Flutter widgets (reusable components) + if (p.includes('/widgets/') && p.endsWith('.dart')) { + return { framework: 'flutter', entryPointMultiplier: 1.5, reason: 'flutter-widget' }; + } + // ========== GENERIC PATTERNS ========== // Any language: index files in API folders @@ -456,6 +499,10 @@ export const FRAMEWORK_AST_PATTERNS = { 'fiber': ['fiber.Ctx', 'fiber.New', 'fiber.App'], 'go-grpc': ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'], + // ORM patterns + 'prisma': ['prisma.', 'PrismaClient', '@prisma/client'], + 'supabase': ['supabase.from', 'createClient', '@supabase/supabase-js'], + // PHP/Laravel 'laravel': ['Route::get', 'Route::post', 'Route::put', 'Route::delete', 'Route::resource', 'Route::apiResource', '#[Route('], @@ -478,6 +525,11 @@ export const FRAMEWORK_AST_PATTERNS = { 'rails': ['ApplicationController', 'ApplicationRecord', 'ActiveRecord::Base', 'before_action', 'after_action', 'has_many', 'belongs_to', 'has_one', 'validates'], 'sinatra': ['Sinatra::Base', 'Sinatra::Application'], + + // Dart/Flutter + 'flutter': ['StatelessWidget', 'StatefulWidget', 'BuildContext', 'Widget build', + 'ChangeNotifier', 'GetxController', 'Cubit<', 'Bloc<', 'ConsumerWidget'], + 'riverpod': ['@riverpod', 'ref.watch', 'ref.read', 'AsyncNotifier', 'Notifier'], }; interface AstFrameworkPatternConfig { @@ -545,6 +597,10 @@ export const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = { { framework: 'rails', entryPointMultiplier: 3.0, reason: 'rails-pattern', patterns: FRAMEWORK_AST_PATTERNS.rails }, { framework: 'sinatra', entryPointMultiplier: 2.8, reason: 'sinatra-pattern', patterns: FRAMEWORK_AST_PATTERNS.sinatra }, ], + [SupportedLanguages.Dart]: [ + { framework: 'flutter', entryPointMultiplier: 2.5, reason: 'flutter-widget', patterns: FRAMEWORK_AST_PATTERNS.flutter }, + { framework: 'riverpod', entryPointMultiplier: 2.8, reason: 'riverpod-pattern', patterns: FRAMEWORK_AST_PATTERNS.riverpod }, + ], } satisfies Record; /** Pre-lowercased patterns for O(1) pattern matching at runtime */ diff --git a/gitnexus/src/core/ingestion/import-resolvers/dart.ts b/gitnexus/src/core/ingestion/import-resolvers/dart.ts new file mode 100644 index 000000000..0a9511c80 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/dart.ts @@ -0,0 +1,44 @@ +/** + * Dart import resolution. + * Handles package: imports (local packages) and relative imports. + * SDK imports (dart:*) and external packages are skipped. + */ + +import type { ImportResult, ResolveCtx } from './types.js'; +import { resolveStandard } from './standard.js'; +import { SupportedLanguages } from '../../../config/supported-languages.js'; + +export function resolveDartImport( + rawImportPath: string, + filePath: string, + ctx: ResolveCtx, +): ImportResult { + // Strip surrounding quotes from configurable_uri capture + const stripped = rawImportPath.replace(/^['"]|['"]$/g, ''); + + // Skip dart: SDK imports (dart:async, dart:io, etc.) + if (stripped.startsWith('dart:')) return null; + + // Local package: imports → resolve to lib/ + if (stripped.startsWith('package:')) { + const slashIdx = stripped.indexOf('/'); + if (slashIdx === -1) return null; + const relPath = stripped.slice(slashIdx + 1); + const candidates = [`lib/${relPath}`, relPath]; + const files: string[] = []; + for (const candidate of candidates) { + for (const fp of ctx.allFileList) { + if (fp.endsWith('/' + candidate) || fp === candidate) { + files.push(fp); + break; + } + } + if (files.length > 0) break; + } + if (files.length > 0) return { kind: 'files', files }; + return null; + } + + // Relative imports — use standard resolution + return resolveStandard(stripped, filePath, ctx, SupportedLanguages.Dart); +} diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts new file mode 100644 index 000000000..8b4dd3ee5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -0,0 +1,25 @@ +/** + * Dart Language Provider + * + * Dart traits: + * - importSemantics: 'wildcard' (Dart imports bring everything public into scope) + * - exportChecker: public if no leading underscore + * - Dart SDK imports (dart:*) and external packages are skipped + */ + +import { SupportedLanguages } from '../../../config/supported-languages.js'; +import { defineLanguage } from '../language-provider.js'; +import { typeConfig as dartConfig } from '../type-extractors/dart.js'; +import { dartExportChecker } from '../export-detection.js'; +import { resolveDartImport } from '../import-resolvers/dart.js'; +import { DART_QUERIES } from '../tree-sitter-queries.js'; + +export const dartProvider = defineLanguage({ + id: SupportedLanguages.Dart, + extensions: ['.dart'], + treeSitterQueries: DART_QUERIES, + typeConfig: dartConfig, + exportChecker: dartExportChecker, + importResolver: resolveDartImport, + importSemantics: 'wildcard', +}); diff --git a/gitnexus/src/core/ingestion/languages/index.ts b/gitnexus/src/core/ingestion/languages/index.ts index 8e456cde6..d9984f990 100644 --- a/gitnexus/src/core/ingestion/languages/index.ts +++ b/gitnexus/src/core/ingestion/languages/index.ts @@ -22,6 +22,7 @@ import { cProvider, cppProvider } from './c-cpp.js'; import { phpProvider } from './php.js'; import { rubyProvider } from './ruby.js'; import { swiftProvider } from './swift.js'; +import { dartProvider } from './dart.js'; export const providers = { [SupportedLanguages.JavaScript]: javascriptProvider, @@ -37,6 +38,7 @@ export const providers = { [SupportedLanguages.PHP]: phpProvider, [SupportedLanguages.Ruby]: rubyProvider, [SupportedLanguages.Swift]: swiftProvider, + [SupportedLanguages.Dart]: dartProvider, } satisfies Record; /** Get provider by language enum (always succeeds for SupportedLanguages). */ diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index d815f5bb4..6734aa7f8 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -11,7 +11,7 @@ import { getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSigna import { extractPropertyDeclaredType } from './type-extractors/shared.js'; import { detectFrameworkFromAST } from './framework-detection.js'; import { WorkerPool } from './workers/worker-pool.js'; -import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, ExtractedFetchCall, ExtractedDecoratorRoute, ExtractedToolDef, FileConstructorBindings, FileTypeEnvBindings } from './workers/parse-worker.js'; +import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, ExtractedFetchCall, ExtractedDecoratorRoute, ExtractedToolDef, FileConstructorBindings, FileTypeEnvBindings, ExtractedORMQuery } from './workers/parse-worker.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js'; export type FileProgressCallback = (current: number, total: number, filePath: string) => void; @@ -25,6 +25,7 @@ export interface WorkerExtractedData { fetchCalls: ExtractedFetchCall[]; decoratorRoutes: ExtractedDecoratorRoute[]; toolDefs: ExtractedToolDef[]; + ormQueries: ExtractedORMQuery[]; constructorBindings: FileConstructorBindings[]; typeEnvBindings: FileTypeEnvBindings[]; } @@ -48,7 +49,7 @@ const processParsingWithWorkers = async ( if (lang) parseableFiles.push({ path: file.path, content: file.content }); } - if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], constructorBindings: [], typeEnvBindings: [] }; + if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], constructorBindings: [], typeEnvBindings: [] }; const total = files.length; @@ -69,6 +70,7 @@ const processParsingWithWorkers = async ( const allFetchCalls: ExtractedFetchCall[] = []; const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; const allToolDefs: ExtractedToolDef[] = []; + const allORMQueries: ExtractedORMQuery[] = []; const allConstructorBindings: FileConstructorBindings[] = []; const allTypeEnvBindings: FileTypeEnvBindings[] = []; for (const result of chunkResults) { @@ -103,6 +105,7 @@ const processParsingWithWorkers = async ( allFetchCalls.push(...result.fetchCalls); allDecoratorRoutes.push(...result.decoratorRoutes); allToolDefs.push(...result.toolDefs); + if (result.ormQueries) allORMQueries.push(...result.ormQueries); allConstructorBindings.push(...result.constructorBindings); allTypeEnvBindings.push(...result.typeEnvBindings); } @@ -123,7 +126,7 @@ const processParsingWithWorkers = async ( // Final progress onFileProgress?.(total, total, 'done'); - return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, fetchCalls: allFetchCalls, decoratorRoutes: allDecoratorRoutes, toolDefs: allToolDefs, constructorBindings: allConstructorBindings, typeEnvBindings: allTypeEnvBindings }; + return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, fetchCalls: allFetchCalls, decoratorRoutes: allDecoratorRoutes, toolDefs: allToolDefs, ormQueries: allORMQueries, constructorBindings: allConstructorBindings, typeEnvBindings: allTypeEnvBindings }; }; // ============================================================================ diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index abff1fd88..983f9f774 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -15,7 +15,7 @@ import { phpFileToRouteURL } from './route-extractors/php.js'; import { extractResponseShapes, extractPHPResponseShapes } from './route-extractors/response-shapes.js'; import { extractMiddlewareChain, extractNextjsMiddlewareConfig, compileMatcher, compiledMatcherMatchesRoute } from './route-extractors/middleware.js'; import { generateId } from '../../lib/utils.js'; -import type { ExtractedFetchCall, ExtractedRoute, ExtractedDecoratorRoute, ExtractedToolDef } from './workers/parse-worker.js'; +import type { ExtractedFetchCall, ExtractedRoute, ExtractedDecoratorRoute, ExtractedToolDef, ExtractedORMQuery } from './workers/parse-worker.js'; import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js'; import { computeMRO } from './mro-processor.js'; import { processCommunities } from './community-processor.js'; @@ -514,6 +514,7 @@ async function runChunkedParseAndResolve( allExtractedRoutes: ExtractedRoute[]; allDecoratorRoutes: ExtractedDecoratorRoute[]; allToolDefs: ExtractedToolDef[]; + allORMQueries: ExtractedORMQuery[]; }> { const symbolTable = ctx.symbols; @@ -634,6 +635,7 @@ async function runChunkedParseAndResolve( const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; // Accumulate MCP/RPC tool definitions (@mcp.tool(), @app.tool(), etc.) const allToolDefs: ExtractedToolDef[] = []; + const allORMQueries: ExtractedORMQuery[] = []; try { for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) { @@ -762,6 +764,9 @@ async function runChunkedParseAndResolve( if (chunkWorkerData.toolDefs?.length) { allToolDefs.push(...chunkWorkerData.toolDefs); } + if (chunkWorkerData.ormQueries?.length) { + allORMQueries.push(...chunkWorkerData.ormQueries); + } } else { await processImports(graph, chunkFiles, astCache, ctx, undefined, repoPath, allPaths); sequentialChunkPaths.push(chunkPaths); @@ -797,6 +802,10 @@ async function runChunkedParseAndResolve( if (chunkFetchCalls.length > 0) { allFetchCalls.push(...chunkFetchCalls); } + // Extract ORM queries (sequential path) + for (const f of chunkFiles) { + extractORMQueriesInline(f.path, f.content, allORMQueries); + } astCache.clear(); } @@ -853,7 +862,7 @@ async function runChunkedParseAndResolve( importCtx.index = EMPTY_INDEX; // Release suffix index memory (~30MB for large repos) importCtx.normalizedFileList = []; - return { exportedTypeMap, allFetchCalls, allExtractedRoutes, allDecoratorRoutes, allToolDefs }; + return { exportedTypeMap, allFetchCalls, allExtractedRoutes, allDecoratorRoutes, allToolDefs, allORMQueries }; } /** @@ -1075,7 +1084,7 @@ export const runPipelineFromRepo = async ( const { scannedFiles, allPaths, totalFiles } = await runScanAndStructure(repoPath, graph, onProgress); // Phase 3+4: Chunked parse + resolve (imports, calls, heritage, routes) - const { exportedTypeMap, allFetchCalls, allExtractedRoutes, allDecoratorRoutes, allToolDefs } = await runChunkedParseAndResolve( + const { exportedTypeMap, allFetchCalls, allExtractedRoutes, allDecoratorRoutes, allToolDefs, allORMQueries } = await runChunkedParseAndResolve( graph, ctx, scannedFiles, allPaths, totalFiles, repoPath, pipelineStart, onProgress, ); @@ -1340,6 +1349,11 @@ export const runPipelineFromRepo = async ( } } + // ── Phase 3.7: ORM Dataflow Detection (Prisma + Supabase) ────────── + if (allORMQueries.length > 0) { + processORMQueries(graph, allORMQueries, isDev); + } + // ── Phase 14: Cross-file binding propagation (topological level sort) ── await runCrossFileBindingPropagation( graph, ctx, exportedTypeMap, allPaths, totalFiles, repoPath, pipelineStart, onProgress, @@ -1374,3 +1388,92 @@ export const runPipelineFromRepo = async ( throw error; } }; + +// Inline ORM regex extraction (avoids importing parse-worker which has worker-only code) +const PRISMA_QUERY_RE = /\bprisma\.(\w+)\.(findMany|findFirst|findUnique|findUniqueOrThrow|findFirstOrThrow|create|createMany|update|updateMany|delete|deleteMany|upsert|count|aggregate|groupBy)\s*\(/g; +const SUPABASE_QUERY_RE = /\bsupabase\.from\s*\(\s*['"](\w+)['"]\s*\)\s*\.(select|insert|update|delete|upsert)\s*\(/g; + +function extractORMQueriesInline(filePath: string, content: string, out: ExtractedORMQuery[]): void { + const hasPrisma = content.includes('prisma.'); + const hasSupabase = content.includes('supabase.from'); + if (!hasPrisma && !hasSupabase) return; + + if (hasPrisma) { + PRISMA_QUERY_RE.lastIndex = 0; + let m; + while ((m = PRISMA_QUERY_RE.exec(content)) !== null) { + const model = m[1]; + if (model.startsWith('$')) continue; + out.push({ filePath, orm: 'prisma', model, method: m[2], lineNumber: content.substring(0, m.index).split('\n').length - 1 }); + } + } + + if (hasSupabase) { + SUPABASE_QUERY_RE.lastIndex = 0; + let m; + while ((m = SUPABASE_QUERY_RE.exec(content)) !== null) { + out.push({ filePath, orm: 'supabase', model: m[1], method: m[2], lineNumber: content.substring(0, m.index).split('\n').length - 1 }); + } + } +} + +// ============================================================================ +// ORM Query Processing — creates QUERIES edges from callers to model nodes +// ============================================================================ + +function processORMQueries( + graph: ReturnType, + queries: ExtractedORMQuery[], + isDev: boolean, +): void { + const modelNodes = new Map(); + const seenEdges = new Set(); + let edgesCreated = 0; + + for (const q of queries) { + const modelKey = `${q.orm}:${q.model}`; + let modelNodeId = modelNodes.get(modelKey); + if (!modelNodeId) { + const candidateIds = [ + generateId('Class', `${q.model}`), + generateId('Interface', `${q.model}`), + generateId('CodeElement', `${q.model}`), + ]; + const existing = candidateIds.find(id => graph.getNode(id)); + if (existing) { + modelNodeId = existing; + } else { + modelNodeId = generateId('CodeElement', `${q.orm}:${q.model}`); + graph.addNode({ + id: modelNodeId, + label: 'CodeElement', + properties: { + name: q.model, + filePath: '', + description: `${q.orm} model/table: ${q.model}`, + }, + }); + } + modelNodes.set(modelKey, modelNodeId); + } + + const fileId = generateId('File', q.filePath); + const edgeKey = `${fileId}->${modelNodeId}:${q.method}`; + if (seenEdges.has(edgeKey)) continue; + seenEdges.add(edgeKey); + + graph.addRelationship({ + id: generateId('QUERIES', edgeKey), + sourceId: fileId, + targetId: modelNodeId, + type: 'QUERIES', + confidence: 0.9, + reason: `${q.orm}-${q.method}`, + }); + edgesCreated++; + } + + if (isDev) { + console.log(`ORM dataflow: ${edgesCreated} QUERIES edges, ${modelNodes.size} models (${queries.length} total calls)`); + } +} diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 6bbecbd9c..9b7b45693 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -1001,4 +1001,137 @@ export const SWIFT_QUERIES = ` `; - \ No newline at end of file +// Dart queries - works with tree-sitter-dart (UserNobody14/tree-sitter-dart, ABI 14) +// Note: Dart grammar has function_signature/method_signature as wrappers; +// top-level functions are (program > function_signature), +// methods inside classes are (method_signature > function_signature). +// We match top-level functions via (program (function_signature ...)) to avoid +// double-counting methods that also contain function_signature. +export const DART_QUERIES = ` +; ── Classes ────────────────────────────────────────────────────────────────── +(class_definition + name: (identifier) @name) @definition.class + +; ── Mixins ─────────────────────────────────────────────────────────────────── +(mixin_declaration + (identifier) @name) @definition.trait + +; ── Extensions ─────────────────────────────────────────────────────────────── +(extension_declaration + name: (identifier) @name) @definition.class + +; ── Enums ──────────────────────────────────────────────────────────────────── +(enum_declaration + name: (identifier) @name) @definition.enum + +; ── Type aliases ───────────────────────────────────────────────────────────── +; Anchor "=" after the name to avoid capturing the RHS type +(type_alias + (type_identifier) @name + "=") @definition.type + +; ── Top-level functions (parent is program, not method_signature) ──────────── +(program + (function_signature + name: (identifier) @name) @definition.function) + +; ── Abstract method declarations (function_signature inside class body declaration) ── +(declaration + (function_signature + name: (identifier) @name)) @definition.method + +; ── Methods (inside class/mixin/extension bodies) ──────────────────────────── +(method_signature + (function_signature + name: (identifier) @name)) @definition.method + +; ── Constructors ───────────────────────────────────────────────────────────── +(constructor_signature + name: (identifier) @name) @definition.constructor + +; ── Factory constructors (anchor before param list to capture variant name, not class) ── +(method_signature + (factory_constructor_signature + (identifier) @name . (formal_parameter_list))) @definition.constructor + +; ── Getters ────────────────────────────────────────────────────────────────── +(method_signature + (getter_signature + name: (identifier) @name)) @definition.property + +; ── Setters ────────────────────────────────────────────────────────────────── +(method_signature + (setter_signature + name: (identifier) @name)) @definition.property + +; ── Imports ────────────────────────────────────────────────────────────────── +(import_or_export + (library_import + (import_specification + (configurable_uri) @import.source))) @import + +; ── Calls: direct function/constructor calls (identifier immediately before argument_part) ── +(expression_statement + (identifier) @call.name + . + (selector (argument_part))) @call + +; ── Calls: method calls (obj.method()) ─────────────────────────────────────── +(expression_statement + (selector + (unconditional_assignable_selector + (identifier) @call.name))) @call + +; ── Calls: in return statements (return User()) ───────────────────────────── +(return_statement + (identifier) @call.name + (selector (argument_part))) @call + +; ── Calls: in variable assignments (var x = getUser()) ────────────────────── +(initialized_variable_definition + value: (identifier) @call.name + (selector (argument_part))) @call + +; ── Re-exports (export 'foo.dart') ─────────────────────────────────────────── +(import_or_export + (library_export + (configurable_uri) @import.source)) @import + +; ── Heritage: extends ──────────────────────────────────────────────────────── +(class_definition + name: (identifier) @heritage.class + superclass: (superclass + (type_identifier) @heritage.extends)) @heritage + +; ── Heritage: implements ───────────────────────────────────────────────────── +(class_definition + name: (identifier) @heritage.class + interfaces: (interfaces + (type_identifier) @heritage.implements)) @heritage.impl + +; ── Heritage: with (mixins) ────────────────────────────────────────────────── +(class_definition + name: (identifier) @heritage.class + superclass: (superclass + (mixins + (type_identifier) @heritage.trait))) @heritage +`; + +import { SupportedLanguages } from '../../config/supported-languages.js'; + +export const LANGUAGE_QUERIES: Record = { + [SupportedLanguages.TypeScript]: TYPESCRIPT_QUERIES, + [SupportedLanguages.JavaScript]: JAVASCRIPT_QUERIES, + [SupportedLanguages.Python]: PYTHON_QUERIES, + [SupportedLanguages.Java]: JAVA_QUERIES, + [SupportedLanguages.C]: C_QUERIES, + [SupportedLanguages.Go]: GO_QUERIES, + [SupportedLanguages.CPlusPlus]: CPP_QUERIES, + [SupportedLanguages.CSharp]: CSHARP_QUERIES, + [SupportedLanguages.Rust]: RUST_QUERIES, + [SupportedLanguages.PHP]: PHP_QUERIES, + [SupportedLanguages.Kotlin]: KOTLIN_QUERIES, + [SupportedLanguages.Ruby]: RUBY_QUERIES, + [SupportedLanguages.Swift]: SWIFT_QUERIES, + [SupportedLanguages.Dart]: DART_QUERIES, +}; diff --git a/gitnexus/src/core/ingestion/type-extractors/dart.ts b/gitnexus/src/core/ingestion/type-extractors/dart.ts new file mode 100644 index 000000000..394730ad4 --- /dev/null +++ b/gitnexus/src/core/ingestion/type-extractors/dart.ts @@ -0,0 +1,365 @@ +/** + * Dart type extractor — full implementation following type-resolution-system.md. + * + * Tier 0: Explicit type annotations (User user = ...) + * Tier 0b: For-loop element types (for (var u in users)) + * Tier 1: Constructor/initializer inference (var user = User()) + * Tier 2: Assignment chain propagation (copy, fieldAccess, callResult, methodCallResult) + * + * Handles tree-sitter-dart's flat sibling AST structure: + * identifier + selector + selector (not nested call_expression). + * + * Credit: Type resolution approach adapted from @xFlaviews' PR #83. + */ + +import type { SyntaxNode } from '../utils/ast-helpers.js'; +import type { + LanguageTypeConfig, + ParameterExtractor, + TypeBindingExtractor, + InitializerExtractor, + ClassNameLookup, + ConstructorBindingScanner, + PendingAssignmentExtractor, + ForLoopExtractor, + LiteralTypeInferrer, + ConstructorTypeDetector, +} from './types.js'; +import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, resolveIterableElementType } from './shared.js'; +import { findChild } from '../utils/ast-helpers.js'; + +// ── Node types ────────────────────────────────────────────────────────── + +const DART_DECLARATION_NODE_TYPES: ReadonlySet = new Set([ + 'initialized_variable_definition', + 'initialized_identifier', +]); + +const DART_FOR_LOOP_NODE_TYPES: ReadonlySet = new Set([ + 'for_statement', +]); + +// ── Helpers ───────────────────────────────────────────────────────────── + +interface DartRHS { + callee?: string; + member?: string; + hasCall: boolean; + isAwait: boolean; +} + +function parseDartRHSChildren(children: Iterable): Omit { + let callee: string | undefined; + let member: string | undefined; + let hasCall = false; + + for (const child of children) { + if (child.type === 'identifier' && !callee) { + callee = child.text; + continue; + } + if (child.type === 'selector') { + const uas = findChild(child, 'unconditional_assignable_selector') + ?? findChild(child, 'conditional_assignable_selector'); + if (uas) { + const id = findChild(uas, 'identifier'); + if (id && !member) member = id.text; + continue; + } + if (findChild(child, 'argument_part')) { + hasCall = true; + continue; + } + } + } + + return { callee, member, hasCall }; +} + +function parseDartRHS(node: SyntaxNode): DartRHS { + const rhsChildren: SyntaxNode[] = []; + let foundEquals = false; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child) continue; + if (!child.isNamed && child.text === '=') { foundEquals = true; continue; } + if (foundEquals) rhsChildren.push(child); + } + + if (rhsChildren.length === 0) return { hasCall: false, isAwait: false }; + + const first = rhsChildren[0]; + if (first.type === 'unary_expression') { + const awaitExpr = findChild(first, 'await_expression'); + if (awaitExpr) { + const innerChildren: SyntaxNode[] = []; + for (let i = 0; i < awaitExpr.namedChildCount; i++) { + const c = awaitExpr.namedChild(i); + if (c && c.type !== 'await') innerChildren.push(c); + } + return { ...parseDartRHSChildren(innerChildren), isAwait: true }; + } + } + + return { ...parseDartRHSChildren(rhsChildren), isAwait: false }; +} + +function hasDartTypeAnnotation(node: SyntaxNode): boolean { + return !!(findChild(node, 'type_identifier') || findChild(node, 'nullable_type')); +} + +// ── Tier 0: Explicit Type Annotations ─────────────────────────────────── + +const extractDartDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + let typeNode = findChild(node, 'type_identifier'); + if (!typeNode) { + const nullable = findChild(node, 'nullable_type'); + if (nullable) typeNode = findChild(nullable, 'type_identifier'); + } + if (!typeNode) return; + const typeName = extractSimpleTypeName(typeNode); + if (!typeName || typeName === 'dynamic') return; + const nameNode = node.childForFieldName('name'); + if (!nameNode) return; + const varName = extractVarName(nameNode); + if (varName) env.set(varName, typeName); +}; + +const extractDartParameter: ParameterExtractor = (node: SyntaxNode, env: Map): void => { + let typeNode = findChild(node, 'type_identifier'); + if (!typeNode) { + const nullable = findChild(node, 'nullable_type'); + if (nullable) typeNode = findChild(nullable, 'type_identifier'); + } + if (!typeNode) return; + const typeName = extractSimpleTypeName(typeNode); + if (!typeName || typeName === 'dynamic') return; + const nameNode = node.childForFieldName('name'); + if (!nameNode) return; + const varName = extractVarName(nameNode); + if (varName) env.set(varName, typeName); +}; + +// ── Tier 1: Constructor / Initializer Inference ───────────────────────── + +const extractDartInitializer: InitializerExtractor = (node: SyntaxNode, env: Map, classNames: ClassNameLookup): void => { + if (node.type !== 'initialized_variable_definition') return; + if (hasDartTypeAnnotation(node)) return; + + const nameNode = node.childForFieldName('name'); + if (!nameNode) return; + const varName = extractVarName(nameNode); + if (!varName || env.has(varName)) return; + + const rhs = parseDartRHS(node); + if (!rhs.callee || !rhs.hasCall) return; + + if (!rhs.member && classNames.has(rhs.callee)) { + env.set(varName, rhs.callee); + return; + } + + if (rhs.member && classNames.has(rhs.callee)) { + env.set(varName, rhs.callee); + } +}; + +// ── Constructor Binding Scan ──────────────────────────────────────────── + +const scanDartConstructorBinding: ConstructorBindingScanner = (node) => { + if (node.type !== 'initialized_variable_definition') return undefined; + if (hasDartTypeAnnotation(node)) return undefined; + + const nameNode = node.childForFieldName('name'); + if (!nameNode) return undefined; + const varName = nameNode.text; + if (!varName) return undefined; + + const rhs = parseDartRHS(node); + if (!rhs.callee) return undefined; + + if (rhs.hasCall && !rhs.member) return { varName, calleeName: rhs.callee }; + if (rhs.hasCall && rhs.member) return { varName, calleeName: rhs.member }; + + return undefined; +}; + +// ── Virtual Dispatch ──────────────────────────────────────────────────── + +const detectDartConstructorType: ConstructorTypeDetector = (node, classNames) => { + if (node.type !== 'initialized_variable_definition') return undefined; + + const rhs = parseDartRHS(node); + if (!rhs.callee || !rhs.hasCall) return undefined; + + if (!rhs.member && classNames.has(rhs.callee)) return rhs.callee; + if (rhs.member && classNames.has(rhs.callee)) return rhs.callee; + + return undefined; +}; + +// ── Literal Type Inference ────────────────────────────────────────────── + +const inferDartLiteralType: LiteralTypeInferrer = (node) => { + switch (node.type) { + case 'decimal_integer_literal': + case 'hex_integer_literal': + return 'int'; + case 'decimal_floating_point_literal': + return 'double'; + case 'string_literal': + return 'String'; + case 'true': + case 'false': + return 'bool'; + case 'null_literal': + return 'null'; + default: + return undefined; + } +}; + +// ── Tier 2: Assignment Chain Propagation ───────────────────────────────── + +const extractDartPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => { + if (node.type !== 'initialized_variable_definition') return undefined; + if (hasDartTypeAnnotation(node)) return undefined; + + const nameNode = node.childForFieldName('name'); + if (!nameNode) return undefined; + const lhs = nameNode.text; + if (!lhs || scopeEnv.has(lhs)) return undefined; + + const rhs = parseDartRHS(node); + if (!rhs.callee) return undefined; + + if (!rhs.hasCall && !rhs.member) return { kind: 'copy', lhs, rhs: rhs.callee }; + if (!rhs.hasCall && rhs.member) return { kind: 'fieldAccess', lhs, receiver: rhs.callee, field: rhs.member }; + if (rhs.hasCall && !rhs.member) return { kind: 'callResult', lhs, callee: rhs.callee }; + if (rhs.hasCall && rhs.member) return { kind: 'methodCallResult', lhs, receiver: rhs.callee, method: rhs.member }; + + return undefined; +}; + +// ── For-Loop Element Type Resolution ──────────────────────────────────── + +function extractDartElementTypeFromTypeNode(typeNode: SyntaxNode): string | undefined { + if (typeNode.type === 'type_identifier') { + const parent = typeNode.parent; + if (parent) { + const args = findChild(parent, 'type_arguments'); + if (args && args.namedChildCount >= 1) { + const lastArg = args.namedChild(args.namedChildCount - 1); + if (lastArg) return extractSimpleTypeName(lastArg); + } + } + } + return undefined; +} + +const extractDartForLoopBinding: ForLoopExtractor = (node, ctx): void => { + if (node.type !== 'for_statement') return; + const { scopeEnv, declarationTypeNodes, scope, returnTypeLookup } = ctx; + + const loopParts = findChild(node, 'for_loop_parts'); + if (!loopParts) return; + + const nameNode = loopParts.childForFieldName('name'); + if (!nameNode) return; + const loopVarName = nameNode.text; + if (!loopVarName) return; + + const typeNode = findChild(loopParts, 'type_identifier'); + if (typeNode) { + const typeName = extractSimpleTypeName(typeNode); + if (typeName && !scopeEnv.has(loopVarName)) { + (scopeEnv as Map).set(loopVarName, typeName); + } + return; + } + + const iterableNode = loopParts.childForFieldName('value'); + if (!iterableNode) return; + + let iterableName: string | undefined; + let callExprElementType: string | undefined; + + if (iterableNode.type === 'identifier') { + iterableName = iterableNode.text; + } else if (iterableNode.type === 'unary_expression') { + const awaitExpr = findChild(iterableNode, 'await_expression'); + if (awaitExpr) { + const innerIdent = findChild(awaitExpr, 'identifier'); + if (innerIdent) iterableName = innerIdent.text; + } + if (!iterableName) return; + } + + if (iterableName) { + let hasCallSelector = false; + let memberName: string | undefined; + + const selectorParent = iterableNode.type === 'unary_expression' + ? findChild(iterableNode, 'await_expression') + : loopParts; + if (!selectorParent) return; + + let foundIterable = false; + for (let i = 0; i < selectorParent.childCount; i++) { + const child = selectorParent.child(i); + if (!child) continue; + if (child.type === 'identifier' && child.text === iterableName) { foundIterable = true; continue; } + if (child === iterableNode) { foundIterable = true; continue; } + if (!foundIterable) continue; + if (child.type === 'selector') { + const uas = findChild(child, 'unconditional_assignable_selector') + ?? findChild(child, 'conditional_assignable_selector'); + if (uas) { + const id = findChild(uas, 'identifier'); + if (id) memberName = id.text; + continue; + } + if (findChild(child, 'argument_part')) { + hasCallSelector = true; + } + } + } + + if (hasCallSelector) { + const callee = memberName ?? iterableName; + const rawReturn = returnTypeLookup.lookupRawReturnType(callee); + if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn); + } + } + + if (!iterableName && !callExprElementType) return; + + let elementType: string | undefined; + if (callExprElementType) { + elementType = callExprElementType; + } else if (iterableName) { + elementType = resolveIterableElementType( + iterableName, node, scopeEnv, declarationTypeNodes, scope, + extractDartElementTypeFromTypeNode, + ); + } + + if (elementType && !scopeEnv.has(loopVarName)) { + (scopeEnv as Map).set(loopVarName, elementType); + } +}; + +// ── Export ─────────────────────────────────────────────────────────────── + +export const typeConfig: LanguageTypeConfig = { + declarationNodeTypes: DART_DECLARATION_NODE_TYPES, + forLoopNodeTypes: DART_FOR_LOOP_NODE_TYPES, + extractDeclaration: extractDartDeclaration, + extractParameter: extractDartParameter, + extractInitializer: extractDartInitializer, + scanConstructorBinding: scanDartConstructorBinding, + extractForLoopBinding: extractDartForLoopBinding, + extractPendingAssignment: extractDartPendingAssignment, + inferLiteralType: inferDartLiteralType, + detectConstructorType: detectDartConstructorType, +}; diff --git a/gitnexus/src/core/ingestion/utils/language-detection.ts b/gitnexus/src/core/ingestion/utils/language-detection.ts index 242f4729e..a0c08bfb7 100644 --- a/gitnexus/src/core/ingestion/utils/language-detection.ts +++ b/gitnexus/src/core/ingestion/utils/language-detection.ts @@ -54,5 +54,6 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages | } // Swift (extensions) if (filename.endsWith('.swift')) return SupportedLanguages.Swift; + if (filename.endsWith('.dart')) return SupportedLanguages.Dart; return null; }; diff --git a/gitnexus/src/core/ingestion/utils/noise-filter.ts b/gitnexus/src/core/ingestion/utils/noise-filter.ts index ab2b408ed..b1516b4d9 100644 --- a/gitnexus/src/core/ingestion/utils/noise-filter.ts +++ b/gitnexus/src/core/ingestion/utils/noise-filter.ts @@ -2,7 +2,7 @@ * Built-in name filtering — identifies standard library functions and common noise * that should not be tracked as call targets in the knowledge graph. * - * Covers: JS/TS, Python, Kotlin, C/C++, C#, PHP, Swift, Rust, Ruby standard libraries. + * Covers: JS/TS, Python, Kotlin, C/C++, C#, PHP, Swift, Rust, Ruby, Dart/Flutter standard libraries. */ export const BUILT_IN_NAMES = new Set([ @@ -155,6 +155,14 @@ export const BUILT_IN_NAMES = new Set([ 'lambda', 'proc', 'block_given?', 'nil?', 'is_a?', 'kind_of?', 'instance_of?', 'respond_to?', 'freeze', 'frozen?', 'dup', 'tap', 'yield_self', + // Dart / Flutter + 'setState', 'mounted', 'debugPrint', + 'runApp', 'showDialog', 'showModalBottomSheet', + 'Navigator', 'push', 'pushNamed', 'pushReplacement', 'pop', 'maybePop', + 'ScaffoldMessenger', 'showSnackBar', + 'deactivate', 'reassemble', 'debugDumpApp', 'debugDumpRenderTree', + // Dart async + 'then', 'catchError', 'whenComplete', 'listen', // Ruby enumerables 'each', 'select', 'reject', 'detect', 'collect', 'inject', 'flat_map', 'each_with_object', 'each_with_index', diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 03ea96b4e..1f0e72436 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -21,6 +21,10 @@ const _require = createRequire(import.meta.url); let Swift: any = null; try { Swift = _require('tree-sitter-swift'); } catch {} +// tree-sitter-dart is an optionalDependency — may not be installed +let Dart: any = null; +try { Dart = _require('tree-sitter-dart'); } catch {} + // tree-sitter-kotlin is an optionalDependency — may not be installed let Kotlin: any = null; try { Kotlin = _require('tree-sitter-kotlin'); } catch {} @@ -181,6 +185,14 @@ export interface ExtractedToolDef { lineNumber: number; } +export interface ExtractedORMQuery { + filePath: string; + orm: 'prisma' | 'supabase'; + model: string; + method: string; + lineNumber: number; +} + /** Constructor bindings keyed by filePath for cross-file type resolution */ export interface FileConstructorBindings { filePath: string; @@ -206,6 +218,7 @@ export interface ParseWorkerResult { fetchCalls: ExtractedFetchCall[]; decoratorRoutes: ExtractedDecoratorRoute[]; toolDefs: ExtractedToolDef[]; + ormQueries: ExtractedORMQuery[]; constructorBindings: FileConstructorBindings[]; /** File-scope type bindings from TypeEnv fixpoint for exported symbol collection. */ typeEnvBindings: FileTypeEnvBindings[]; @@ -238,6 +251,7 @@ const languageMap: Record = { ...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}), [SupportedLanguages.PHP]: PHP.php_only, [SupportedLanguages.Ruby]: Ruby, + ...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}), ...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}), }; @@ -353,6 +367,7 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n fetchCalls: [], decoratorRoutes: [], toolDefs: [], + ormQueries: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, @@ -825,6 +840,53 @@ function extractLaravelRoutes(tree: any, filePath: string): ExtractedRoute[] { return routes; } +// ============================================================================ +// ORM Query Detection (Prisma + Supabase) +// ============================================================================ + +const PRISMA_QUERY_RE = /\bprisma\.(\w+)\.(findMany|findFirst|findUnique|findUniqueOrThrow|findFirstOrThrow|create|createMany|update|updateMany|delete|deleteMany|upsert|count|aggregate|groupBy)\s*\(/g; +const SUPABASE_QUERY_RE = /\bsupabase\.from\s*\(\s*['"](\w+)['"]\s*\)\s*\.(select|insert|update|delete|upsert)\s*\(/g; + +/** + * Extract ORM query calls from file content via regex. + * Appends results to the provided array (avoids allocation when no matches). + */ +export function extractORMQueries(filePath: string, content: string, out: ExtractedORMQuery[]): void { + const hasPrisma = content.includes('prisma.'); + const hasSupabase = content.includes('supabase.from'); + if (!hasPrisma && !hasSupabase) return; + + if (hasPrisma) { + PRISMA_QUERY_RE.lastIndex = 0; + let m; + while ((m = PRISMA_QUERY_RE.exec(content)) !== null) { + const model = m[1]; + if (model.startsWith('$')) continue; + out.push({ + filePath, + orm: 'prisma', + model, + method: m[2], + lineNumber: content.substring(0, m.index).split('\n').length - 1, + }); + } + } + + if (hasSupabase) { + SUPABASE_QUERY_RE.lastIndex = 0; + let m; + while ((m = SUPABASE_QUERY_RE.exec(content)) !== null) { + out.push({ + filePath, + orm: 'supabase', + model: m[1], + method: m[2], + lineNumber: content.substring(0, m.index).split('\n').length - 1, + }); + } + } +} + const processFileGroup = ( files: ParseWorkerInput[], language: SupportedLanguages, @@ -1346,6 +1408,9 @@ const processFileGroup = ( const extractedRoutes = extractLaravelRoutes(tree, file.path); result.routes.push(...extractedRoutes); } + + // Extract ORM queries (Prisma, Supabase) + extractORMQueries(file.path, file.content, result.ormQueries); } }; @@ -1356,7 +1421,7 @@ const processFileGroup = ( /** Accumulated result across sub-batches */ let accumulated: ParseWorkerResult = { nodes: [], relationships: [], symbols: [], - imports: [], calls: [], assignments: [], heritage: [], routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0, + imports: [], calls: [], assignments: [], heritage: [], routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0, }; let cumulativeProcessed = 0; @@ -1372,6 +1437,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => { target.fetchCalls.push(...src.fetchCalls); target.decoratorRoutes.push(...src.decoratorRoutes); target.toolDefs.push(...src.toolDefs); + target.ormQueries.push(...src.ormQueries); target.constructorBindings.push(...src.constructorBindings); target.typeEnvBindings.push(...src.typeEnvBindings); for (const [lang, count] of Object.entries(src.skippedLanguages)) { @@ -1398,7 +1464,7 @@ parentPort!.on('message', (msg: any) => { if (msg && msg.type === 'flush') { parentPort!.postMessage({ type: 'result', data: accumulated }); // Reset for potential reuse - accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0 }; + accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0 }; cumulativeProcessed = 0; return; } diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index a01f5f026..5e61c9337 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -29,7 +29,7 @@ export const REL_TABLE_NAME = 'CodeRelation'; // Valid relation types // Note: WRAPS is reserved for future middleware graph traversal (not yet emitted) -export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'ACCESSES', 'OVERRIDES', 'MEMBER_OF', 'STEP_IN_PROCESS', 'HANDLES_ROUTE', 'FETCHES', 'HANDLES_TOOL', 'ENTRY_POINT_OF', 'WRAPS'] as const; +export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'ACCESSES', 'OVERRIDES', 'MEMBER_OF', 'STEP_IN_PROCESS', 'HANDLES_ROUTE', 'FETCHES', 'HANDLES_TOOL', 'ENTRY_POINT_OF', 'WRAPS', 'QUERIES'] as const; export type RelType = typeof REL_TYPES[number]; // ============================================================================ @@ -284,6 +284,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM Function TO \`Typedef\`, FROM Function TO \`Union\`, FROM Function TO \`Property\`, + FROM Function TO CodeElement, FROM Class TO Method, FROM Class TO Function, FROM Class TO Class, @@ -317,6 +318,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM Method TO Interface, FROM Method TO \`Constructor\`, FROM Method TO \`Property\`, + FROM Method TO CodeElement, FROM \`Template\` TO \`Template\`, FROM \`Template\` TO Function, FROM \`Template\` TO Method, diff --git a/gitnexus/src/core/tree-sitter/parser-loader.ts b/gitnexus/src/core/tree-sitter/parser-loader.ts index f7740ab6c..dc4158328 100644 --- a/gitnexus/src/core/tree-sitter/parser-loader.ts +++ b/gitnexus/src/core/tree-sitter/parser-loader.ts @@ -13,10 +13,12 @@ import Ruby from 'tree-sitter-ruby'; import { createRequire } from 'node:module'; import { SupportedLanguages } from '../../config/supported-languages.js'; -// tree-sitter-swift is an optionalDependency — may not be installed +// tree-sitter-swift and tree-sitter-dart are optionalDependencies — may not be installed const _require = createRequire(import.meta.url); let Swift: any = null; try { Swift = _require('tree-sitter-swift'); } catch {} +let Dart: any = null; +try { Dart = _require('tree-sitter-dart'); } catch {} // tree-sitter-kotlin is an optionalDependency — may not be installed let Kotlin: any = null; @@ -38,6 +40,7 @@ const languageMap: Record = { ...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}), [SupportedLanguages.PHP]: PHP.php_only, [SupportedLanguages.Ruby]: Ruby, + ...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}), ...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}), }; diff --git a/gitnexus/test/fixtures/orm-repo/src/client.ts b/gitnexus/test/fixtures/orm-repo/src/client.ts new file mode 100644 index 000000000..6b780197c --- /dev/null +++ b/gitnexus/test/fixtures/orm-repo/src/client.ts @@ -0,0 +1,3 @@ +import { createClient } from '@supabase/supabase-js'; + +export const supabase = createClient('https://example.supabase.co', 'anon-key'); diff --git a/gitnexus/test/fixtures/orm-repo/src/db.ts b/gitnexus/test/fixtures/orm-repo/src/db.ts new file mode 100644 index 000000000..9b6c4ce30 --- /dev/null +++ b/gitnexus/test/fixtures/orm-repo/src/db.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from '@prisma/client'; + +export const prisma = new PrismaClient(); diff --git a/gitnexus/test/fixtures/orm-repo/src/prisma-service.ts b/gitnexus/test/fixtures/orm-repo/src/prisma-service.ts new file mode 100644 index 000000000..923e958fa --- /dev/null +++ b/gitnexus/test/fixtures/orm-repo/src/prisma-service.ts @@ -0,0 +1,25 @@ +import { prisma } from './db'; + +export async function getUsers() { + return prisma.user.findMany({ where: { active: true } }); +} + +export async function createPost(title: string, userId: number) { + return prisma.post.create({ data: { title, authorId: userId } }); +} + +export async function getUserById(id: number) { + return prisma.user.findUnique({ where: { id } }); +} + +export async function updatePost(id: number, title: string) { + return prisma.post.update({ where: { id }, data: { title } }); +} + +export async function deletePost(id: number) { + return prisma.post.delete({ where: { id } }); +} + +export async function countUsers() { + return prisma.user.count(); +} diff --git a/gitnexus/test/fixtures/orm-repo/src/supabase-service.ts b/gitnexus/test/fixtures/orm-repo/src/supabase-service.ts new file mode 100644 index 000000000..ad2f9e6d0 --- /dev/null +++ b/gitnexus/test/fixtures/orm-repo/src/supabase-service.ts @@ -0,0 +1,17 @@ +import { supabase } from './client'; + +export async function getBookings() { + return supabase.from('bookings').select('*'); +} + +export async function createInterpreter(name: string) { + return supabase.from('interpreters').insert({ name, active: true }); +} + +export async function updateBooking(id: string, status: string) { + return supabase.from('bookings').update({ status }).eq('id', id); +} + +export async function deleteSession(id: string) { + return supabase.from('sessions').delete().eq('id', id); +} diff --git a/gitnexus/test/fixtures/sample-code/dart-advanced.dart b/gitnexus/test/fixtures/sample-code/dart-advanced.dart new file mode 100644 index 000000000..1d24363a0 --- /dev/null +++ b/gitnexus/test/fixtures/sample-code/dart-advanced.dart @@ -0,0 +1,139 @@ +// ── Imports and re-exports ────────────────────────────────────────────── +import 'dart:convert'; +import 'package:http/http.dart' as http; +export 'src/models.dart'; + +// ── Private symbols (should be filtered by exportChecker) ────────────── +void _internalSetup() { + print('internal'); +} + +class _PrivateCache { + final Map _data = {}; +} + +// ── Call sites: expression statement ──────────────────────────────────── +void expressionCalls() { + fetchUsers(); + processData(); +} + +// ── Call sites: return statement ──────────────────────────────────────── +String returnCall() { + return formatOutput(); +} + +// ── Call sites: variable assignment ───────────────────────────────────── +void assignmentCalls() { + var result = computeScore(); + final user = loadUser(); +} + +// ── Type resolution: explicit annotations ────────────────────────────── +void typedDeclarations() { + User admin = User('admin'); + User? maybeUser = null; + List names = []; +} + +// ── Type resolution: constructor inference ────────────────────────────── +void constructorInference() { + var dog = Dog('Rex', 'GSD'); + final repo = Repository(); + var named = Dog.unknown(); +} + +// ── Type resolution: for-loop ────────────────────────────────────────── +void loopTypes(List users) { + for (var user in users) { + print(user); + } + for (User u in users) { + print(u); + } +} + +// ── Flutter widget pattern ───────────────────────────────────────────── +class UserWidget extends StatelessWidget { + final String username; + + const UserWidget({required this.username}); + + @override + Widget build(BuildContext context) { + return Text(username); + } +} + +class CounterPage extends StatefulWidget { + @override + State createState() => _CounterPageState(); +} + +class _CounterPageState extends State { + int _count = 0; + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Text('$_count'); + } +} + +// ── Framework detection patterns ─────────────────────────────────────── +class UserBloc extends Bloc { + @override + void onEvent(UserEvent event) {} +} + +class SettingsNotifier extends ChangeNotifier { + void toggle() { + notifyListeners(); + } +} + +// ── Stub types for compilation (not real Flutter) ────────────────────── +class StatelessWidget {} +class StatefulWidget {} +class State { + void initState() {} + void dispose() {} +} +class Widget {} +class BuildContext {} +class Text extends Widget { + Text(String text); +} +class Bloc { + void onEvent(E event) {} +} +class ChangeNotifier { + void notifyListeners() {} +} +class User { + final String name; + User(this.name); +} +class Dog { + Dog(String name, String breed); + factory Dog.unknown() => Dog('?', '?'); +} +class Repository {} +class UserEvent {} +class UserState {} + +// ── Helper functions for call extraction tests ───────────────────────── +List fetchUsers() => []; +String processData() => ''; +String formatOutput() => ''; +int computeScore() => 0; +User loadUser() => User('test'); diff --git a/gitnexus/test/fixtures/sample-code/simple.dart b/gitnexus/test/fixtures/sample-code/simple.dart new file mode 100644 index 000000000..efdfa5a30 --- /dev/null +++ b/gitnexus/test/fixtures/sample-code/simple.dart @@ -0,0 +1,121 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; + +// Top-level function +String greet(String name) { + return 'Hello, $name!'; +} + +// Top-level typedef +typedef JsonMap = Map; +typedef Callback = void Function(int value); + +// Enum +enum Status { + active, + inactive, + pending; + + String get label => name.toUpperCase(); +} + +// Class with extends and implements +class Animal { + final String name; + + Animal(this.name); + + String speak() => 'I am $name'; +} + +abstract class Describable { + String describe(); +} + +class Dog extends Animal implements Describable { + final String breed; + + Dog(super.name, this.breed); + + // Factory constructor + factory Dog.unknown() { + return Dog('Unknown', 'Mixed'); + } + + @override + String speak() => 'Woof! I am $name'; + + @override + String describe() => 'Dog($name, $breed)'; + + // Getter + String get info => '$name - $breed'; + + // Setter + set nickname(String value) { + print('Nickname set to $value'); + } +} + +// Mixin +mixin Swimming { + void swim() { + print('Swimming!'); + } +} + +mixin Flying { + void fly() { + print('Flying!'); + } +} + +// Class with mixin +class Duck extends Animal with Swimming, Flying { + Duck(super.name); +} + +// Extension +extension StringExtension on String { + String capitalize() { + if (isEmpty) return this; + return '${this[0].toUpperCase()}${substring(1)}'; + } +} + +// Generic class +class Repository { + final List _items = []; + + void add(T item) { + _items.add(item); + } + + T? findFirst(bool Function(T) predicate) { + for (final item in _items) { + if (predicate(item)) return item; + } + return null; + } +} + +// Private function (starts with _) +void _privateHelper() { + print('I am private'); +} + +// Async function +Future fetchData(String url) async { + final response = await http.get(Uri.parse(url)); + return response.body; +} + +// Top-level const +const String appName = 'MyApp'; + +void main() { + final dog = Dog('Rex', 'German Shepherd'); + dog.speak(); + print(greet('World')); +} diff --git a/gitnexus/test/integration/orm-dataflow.test.ts b/gitnexus/test/integration/orm-dataflow.test.ts new file mode 100644 index 000000000..b8769d5ec --- /dev/null +++ b/gitnexus/test/integration/orm-dataflow.test.ts @@ -0,0 +1,80 @@ +/** + * Integration Tests: ORM Dataflow Detection (Prisma + Supabase) + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; + +const ORM_REPO = path.resolve(__dirname, '..', 'fixtures', 'orm-repo'); + +describe('ORM dataflow detection', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(ORM_REPO, () => {}); + }, 60000); + + it('creates QUERIES edges for Prisma calls', () => { + const queryEdges: { source: string; target: string; reason: string }[] = []; + for (const rel of result.graph.iterRelationships()) { + if (rel.type === 'QUERIES') { + const source = result.graph.getNode(rel.sourceId); + const target = result.graph.getNode(rel.targetId); + if (source && target) { + queryEdges.push({ + source: source.properties.filePath || source.properties.name, + target: target.properties.name, + reason: rel.reason ?? '', + }); + } + } + } + const prismaEdges = queryEdges.filter(e => e.source.includes('prisma-service')); + const prismaModels = [...new Set(prismaEdges.map(e => e.target))]; + expect(prismaModels).toContain('user'); + expect(prismaModels).toContain('post'); + const reasons = prismaEdges.map(e => e.reason); + expect(reasons.some(r => r.includes('prisma-findMany'))).toBe(true); + expect(reasons.some(r => r.includes('prisma-create'))).toBe(true); + }); + + it('creates QUERIES edges for Supabase calls', () => { + const queryEdges: { source: string; target: string; reason: string }[] = []; + for (const rel of result.graph.iterRelationships()) { + if (rel.type === 'QUERIES') { + const source = result.graph.getNode(rel.sourceId); + const target = result.graph.getNode(rel.targetId); + if (source && target) { + queryEdges.push({ + source: source.properties.filePath || source.properties.name, + target: target.properties.name, + reason: rel.reason ?? '', + }); + } + } + } + const supabaseEdges = queryEdges.filter(e => e.source.includes('supabase-service')); + const supabaseModels = [...new Set(supabaseEdges.map(e => e.target))]; + expect(supabaseModels).toContain('bookings'); + expect(supabaseModels).toContain('interpreters'); + expect(supabaseModels).toContain('sessions'); + const reasons = supabaseEdges.map(e => e.reason); + expect(reasons.some(r => r.includes('supabase-select'))).toBe(true); + expect(reasons.some(r => r.includes('supabase-insert'))).toBe(true); + }); + + it('creates CodeElement nodes for ORM models', () => { + const codeElements: string[] = []; + result.graph.forEachNode(n => { + if (n.label === 'CodeElement' && n.properties.description?.includes('model/table')) { + codeElements.push(n.properties.name); + } + }); + expect(codeElements).toContain('user'); + expect(codeElements).toContain('post'); + expect(codeElements).toContain('bookings'); + expect(codeElements).toContain('interpreters'); + expect(codeElements).toContain('sessions'); + }); +}); diff --git a/gitnexus/test/integration/tree-sitter-languages.test.ts b/gitnexus/test/integration/tree-sitter-languages.test.ts index 1d857a744..2227cea47 100644 --- a/gitnexus/test/integration/tree-sitter-languages.test.ts +++ b/gitnexus/test/integration/tree-sitter-languages.test.ts @@ -422,6 +422,211 @@ describe('Tree-sitter multi-language parsing', () => { }); }); + describe('Dart', () => { + const dartQueries = () => getProvider(SupportedLanguages.Dart).treeSitterQueries; + + function loadDartOrSkip() { + return loadLanguage(SupportedLanguages.Dart).catch(() => null); + } + + // ── Definition extraction ────────────────────────────────────────── + + it('parses classes, functions, mixins, enums, and type aliases', async () => { + if (!(await loadDartOrSkip())) return; + const { matches } = parseAndQuery(parser, readFixture('simple.dart'), dartQueries()); + const defs = extractDefinitions(matches); + const defTypes = defs.map(d => d.type); + const defNames = defs.map(d => d.name); + + expect(defTypes).toContain('definition.class'); + expect(defTypes).toContain('definition.function'); + expect(defTypes).toContain('definition.method'); + expect(defTypes).toContain('definition.enum'); + expect(defTypes).toContain('definition.trait'); // mixin + expect(defTypes).toContain('definition.type'); // typedef + expect(defTypes).toContain('definition.constructor'); + expect(defTypes).toContain('definition.property'); // getter/setter + + expect(defNames).toContain('Animal'); + expect(defNames).toContain('Dog'); + expect(defNames).toContain('greet'); + expect(defNames).toContain('Swimming'); + expect(defNames).toContain('Status'); + expect(defNames).toContain('main'); + expect(defNames).toContain('StringExtension'); + }); + + it('captures factory constructor variant name (not class name)', async () => { + if (!(await loadDartOrSkip())) return; + const { matches } = parseAndQuery(parser, readFixture('simple.dart'), dartQueries()); + const defs = extractDefinitions(matches); + const constructors = defs.filter(d => d.type === 'definition.constructor'); + expect(constructors.length).toBeGreaterThan(0); + // factory Dog.unknown() should capture 'unknown', not 'Dog' + const constructorNames = constructors.map(c => c.name); + expect(constructorNames).toContain('unknown'); + expect(constructorNames).not.toContain('Dog'); + }); + + it('captures getter and setter as definition.property', async () => { + if (!(await loadDartOrSkip())) return; + const { matches } = parseAndQuery(parser, readFixture('simple.dart'), dartQueries()); + const defs = extractDefinitions(matches); + const props = defs.filter(d => d.type === 'definition.property'); + expect(props.map(p => p.name)).toContain('info'); // getter + expect(props.map(p => p.name)).toContain('nickname'); // setter + }); + + it('captures typedef names without duplicates', async () => { + if (!(await loadDartOrSkip())) return; + const { matches } = parseAndQuery(parser, readFixture('simple.dart'), dartQueries()); + const defs = extractDefinitions(matches); + const typedefs = defs.filter(d => d.type === 'definition.type'); + const typedefNames = typedefs.map(d => d.name); + expect(typedefNames).toContain('JsonMap'); + expect(typedefNames).toContain('Callback'); + // Should not capture RHS types as names + expect(typedefNames).not.toContain('Map'); + expect(typedefNames).not.toContain('Function'); + }); + + // ── Export detection (private underscore convention) ──────────────── + + it('filters private symbols via underscore convention', async () => { + if (!(await loadDartOrSkip())) return; + const { matches } = parseAndQuery(parser, readFixture('simple.dart'), dartQueries()); + const defs = extractDefinitions(matches); + const { dartExportChecker } = await import('../../src/core/ingestion/export-detection.js'); + const publicNames = defs.filter(d => dartExportChecker(null as any, d.name)).map(d => d.name); + const privateNames = defs.filter(d => !dartExportChecker(null as any, d.name)).map(d => d.name); + + expect(publicNames).toContain('Animal'); + expect(publicNames).toContain('greet'); + expect(privateNames).toContain('_privateHelper'); + }); + + // ── Import extraction ────────────────────────────────────────────── + + it('extracts imports', async () => { + if (!(await loadDartOrSkip())) return; + const { matches } = parseAndQuery(parser, readFixture('simple.dart'), dartQueries()); + + const imports: string[] = []; + for (const match of matches) { + for (const capture of match.captures) { + if (capture.name === 'import.source') imports.push(capture.node.text); + } + } + expect(imports.length).toBe(3); + }); + + it('extracts re-exports', async () => { + if (!(await loadDartOrSkip())) return; + const { matches } = parseAndQuery(parser, readFixture('dart-advanced.dart'), dartQueries()); + + const imports: string[] = []; + for (const match of matches) { + for (const capture of match.captures) { + if (capture.name === 'import.source') imports.push(capture.node.text); + } + } + // 2 imports + 1 re-export = 3 import.source captures + expect(imports.length).toBe(3); + }); + + // ── Heritage extraction ──────────────────────────────────────────── + + it('extracts heritage (extends, implements, with)', async () => { + if (!(await loadDartOrSkip())) return; + const { matches } = parseAndQuery(parser, readFixture('simple.dart'), dartQueries()); + + const heritage: { class: string; parent: string }[] = []; + for (const match of matches) { + const captures: Record = {}; + for (const c of match.captures) captures[c.name] = c.node.text; + if (captures['heritage.extends']) { + heritage.push({ class: captures['heritage.class'], parent: captures['heritage.extends'] }); + } + if (captures['heritage.implements']) { + heritage.push({ class: captures['heritage.class'], parent: captures['heritage.implements'] }); + } + if (captures['heritage.trait']) { + heritage.push({ class: captures['heritage.class'], parent: captures['heritage.trait'] }); + } + } + + const pairs = heritage.map(h => `${h.class}->${h.parent}`); + expect(pairs).toContain('Dog->Animal'); + expect(pairs).toContain('Dog->Describable'); + expect(pairs).toContain('Duck->Animal'); + expect(pairs).toContain('Duck->Swimming'); + expect(pairs).toContain('Duck->Flying'); + }); + + // ── Call extraction ──────────────────────────────────────────────── + + it('extracts calls in expression statements', async () => { + if (!(await loadDartOrSkip())) return; + const { matches } = parseAndQuery(parser, readFixture('dart-advanced.dart'), dartQueries()); + + const callNames: string[] = []; + for (const match of matches) { + for (const capture of match.captures) { + if (capture.name === 'call.name') callNames.push(capture.node.text); + } + } + expect(callNames).toContain('fetchUsers'); + expect(callNames).toContain('processData'); + }); + + it('extracts calls in return statements', async () => { + if (!(await loadDartOrSkip())) return; + const { matches } = parseAndQuery(parser, readFixture('dart-advanced.dart'), dartQueries()); + + const callNames: string[] = []; + for (const match of matches) { + for (const capture of match.captures) { + if (capture.name === 'call.name') callNames.push(capture.node.text); + } + } + expect(callNames).toContain('formatOutput'); + }); + + it('extracts calls in variable assignments', async () => { + if (!(await loadDartOrSkip())) return; + const { matches } = parseAndQuery(parser, readFixture('dart-advanced.dart'), dartQueries()); + + const callNames: string[] = []; + for (const match of matches) { + for (const capture of match.captures) { + if (capture.name === 'call.name') callNames.push(capture.node.text); + } + } + expect(callNames).toContain('computeScore'); + expect(callNames).toContain('loadUser'); + }); + + // ── Framework detection (path-based) ─────────────────────────────── + + it('detects Flutter framework from file paths', async () => { + const { detectFrameworkFromPath } = await import('../../src/core/ingestion/framework-detection.js'); + + expect(detectFrameworkFromPath('lib/main.dart')?.framework).toBe('flutter'); + expect(detectFrameworkFromPath('lib/app.dart')?.framework).toBe('flutter'); + expect(detectFrameworkFromPath('lib/screens/home.dart')?.framework).toBe('flutter'); + expect(detectFrameworkFromPath('lib/pages/login.dart')?.framework).toBe('flutter'); + expect(detectFrameworkFromPath('lib/widgets/button.dart')?.framework).toBe('flutter'); + expect(detectFrameworkFromPath('lib/bloc/user_bloc.dart')?.framework).toBe('flutter'); + expect(detectFrameworkFromPath('lib/services/api.dart')?.framework).toBe('flutter'); + expect(detectFrameworkFromPath('lib/routes/app_router.dart')?.framework).toBe('flutter'); + + // main.dart gets highest boost + expect(detectFrameworkFromPath('lib/main.dart')?.entryPointMultiplier).toBe(3.0); + // widgets get lowest + expect(detectFrameworkFromPath('lib/widgets/button.dart')?.entryPointMultiplier).toBe(1.5); + }); + }); + describe('cross-language assertions', () => { it('all supported languages produce at least one definition from fixtures', async () => { const langFixtures: [SupportedLanguages, string, string?][] = [ @@ -435,6 +640,7 @@ describe('Tree-sitter multi-language parsing', () => { [SupportedLanguages.CSharp, 'simple.cs'], [SupportedLanguages.Rust, 'simple.rs'], [SupportedLanguages.PHP, 'simple.php'], + // Dart and Swift are excluded — they are optionalDependencies that may not be installed ]; for (const [lang, fixture, filePath] of langFixtures) { diff --git a/gitnexus/test/unit/dart-import-resolver.test.ts b/gitnexus/test/unit/dart-import-resolver.test.ts new file mode 100644 index 000000000..e62b7b1ca --- /dev/null +++ b/gitnexus/test/unit/dart-import-resolver.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { resolveDartImport } from '../../src/core/ingestion/import-resolvers/dart.js'; +import type { ResolveCtx } from '../../src/core/ingestion/import-resolvers/types.js'; + +function makeCtx(files: string[]): ResolveCtx { + const allFileList = files; + const normalizedFileList = files.map(f => f.toLowerCase()); + return { + allFilePaths: new Set(files), + allFileList, + normalizedFileList, + index: undefined as any, + resolveCache: new Map(), + configs: { + tsconfigPaths: null, + goModule: null, + composerConfig: null, + swiftPackageConfig: null, + csharpConfigs: [], + }, + }; +} + +describe('Dart import resolver', () => { + describe('dart: SDK imports', () => { + it('skips dart:async', () => { + const result = resolveDartImport("'dart:async'", 'lib/main.dart', makeCtx([])); + expect(result).toBeNull(); + }); + + it('skips dart:io', () => { + const result = resolveDartImport("'dart:io'", 'lib/main.dart', makeCtx([])); + expect(result).toBeNull(); + }); + }); + + describe('package: imports', () => { + it('resolves local package import to lib/', () => { + const ctx = makeCtx(['lib/models/user.dart', 'lib/main.dart']); + const result = resolveDartImport("'package:my_app/models/user.dart'", 'lib/main.dart', ctx); + expect(result).toEqual({ kind: 'files', files: ['lib/models/user.dart'] }); + }); + + it('returns null for external package imports', () => { + const ctx = makeCtx(['lib/main.dart']); + const result = resolveDartImport("'package:http/http.dart'", 'lib/main.dart', ctx); + expect(result).toBeNull(); + }); + + it('returns null for malformed package import (no slash)', () => { + const result = resolveDartImport("'package:http'", 'lib/main.dart', makeCtx([])); + expect(result).toBeNull(); + }); + }); + + describe('relative imports', () => { + it('resolves relative import via standard resolver', () => { + const ctx = makeCtx(['lib/models/user.dart', 'lib/main.dart']); + const result = resolveDartImport("'models/user.dart'", 'lib/main.dart', ctx); + // resolveStandard handles relative path resolution + // The exact result depends on the standard resolver — we just check it doesn't crash + expect(result === null || result?.kind === 'files').toBe(true); + }); + }); + + describe('quote stripping', () => { + it('strips single quotes', () => { + const ctx = makeCtx([]); + const result = resolveDartImport("'dart:core'", 'lib/main.dart', ctx); + expect(result).toBeNull(); // dart: is skipped, proving quotes were stripped + }); + + it('strips double quotes', () => { + const ctx = makeCtx([]); + const result = resolveDartImport('"dart:core"', 'lib/main.dart', ctx); + expect(result).toBeNull(); + }); + }); +}); diff --git a/gitnexus/test/unit/dart-type-extractor.test.ts b/gitnexus/test/unit/dart-type-extractor.test.ts new file mode 100644 index 000000000..48cc6bbc0 --- /dev/null +++ b/gitnexus/test/unit/dart-type-extractor.test.ts @@ -0,0 +1,393 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import Parser from 'tree-sitter'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; +import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js'; +import { typeConfig } from '../../src/core/ingestion/type-extractors/dart.js'; +import { findChild } from '../../src/core/ingestion/utils/ast-helpers.js'; + +function loadDartOrSkip() { + return loadLanguage(SupportedLanguages.Dart).catch(() => null); +} + +function parseAndFindNodes(parser: Parser, code: string, nodeType: string) { + const tree = parser.parse(code); + const results: any[] = []; + function walk(node: any) { + if (node.type === nodeType) results.push(node); + for (let i = 0; i < node.namedChildCount; i++) walk(node.namedChild(i)); + } + walk(tree.rootNode); + return results; +} + +describe('Dart type extractor', () => { + let parser: Parser; + + beforeAll(async () => { + parser = await loadParser(); + if (!(await loadDartOrSkip())) return; + }); + + describe('Tier 0: explicit type annotations', () => { + it('extracts type from typed variable declaration', async () => { + if (!(await loadDartOrSkip())) return; + const env = new Map(); + const nodes = parseAndFindNodes(parser, 'void f() { User admin = User("x"); }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + typeConfig.extractDeclaration(nodes[0], env); + expect(env.get('admin')).toBe('User'); + }); + + it('extracts type from nullable declaration', async () => { + if (!(await loadDartOrSkip())) return; + const env = new Map(); + const nodes = parseAndFindNodes(parser, 'void f() { User? maybeUser = null; }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + typeConfig.extractDeclaration(nodes[0], env); + expect(env.get('maybeUser')).toBe('User'); + }); + + it('skips dynamic type', async () => { + if (!(await loadDartOrSkip())) return; + const env = new Map(); + const nodes = parseAndFindNodes(parser, 'void f() { dynamic x = 1; }', 'initialized_variable_definition'); + if (nodes.length > 0) { + typeConfig.extractDeclaration(nodes[0], env); + expect(env.has('x')).toBe(false); + } + }); + }); + + describe('Tier 0: parameter extraction', () => { + it('extracts typed parameter', async () => { + if (!(await loadDartOrSkip())) return; + const env = new Map(); + const tree = parser.parse('void greet(String name, int age) {}'); + // Find formal_parameter nodes + const params: any[] = []; + function walk(node: any) { + if (node.type === 'formal_parameter') params.push(node); + for (let i = 0; i < node.namedChildCount; i++) walk(node.namedChild(i)); + } + walk(tree.rootNode); + for (const p of params) { + typeConfig.extractParameter(p, env); + } + expect(env.get('name')).toBe('String'); + }); + }); + + describe('Tier 1: constructor inference', () => { + it('infers type from direct constructor call', async () => { + if (!(await loadDartOrSkip())) return; + const env = new Map(); + const classNames = new Set(['User']); + const nodes = parseAndFindNodes(parser, 'void f() { var user = User("x"); }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + typeConfig.extractInitializer!(nodes[0], env, classNames); + expect(env.get('user')).toBe('User'); + }); + + it('infers type from named constructor', async () => { + if (!(await loadDartOrSkip())) return; + const env = new Map(); + const classNames = new Set(['Dog']); + const nodes = parseAndFindNodes(parser, 'void f() { var d = Dog.unknown(); }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + typeConfig.extractInitializer!(nodes[0], env, classNames); + expect(env.get('d')).toBe('Dog'); + }); + + it('does not infer when callee is not a known class', async () => { + if (!(await loadDartOrSkip())) return; + const env = new Map(); + const classNames = new Set(); + const nodes = parseAndFindNodes(parser, 'void f() { var x = getUser(); }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + typeConfig.extractInitializer!(nodes[0], env, classNames); + expect(env.has('x')).toBe(false); + }); + + it('skips if explicit type present', async () => { + if (!(await loadDartOrSkip())) return; + const env = new Map(); + const classNames = new Set(['User']); + const nodes = parseAndFindNodes(parser, 'void f() { User user = User("x"); }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + typeConfig.extractInitializer!(nodes[0], env, classNames); + // Should not set — Tier 0 handles this + expect(env.has('user')).toBe(false); + }); + }); + + describe('constructor binding scanner', () => { + it('scans direct constructor call', async () => { + if (!(await loadDartOrSkip())) return; + const nodes = parseAndFindNodes(parser, 'void f() { var user = User(); }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + const result = typeConfig.scanConstructorBinding!(nodes[0]); + expect(result).toEqual({ varName: 'user', calleeName: 'User' }); + }); + + it('scans qualified call (method on receiver)', async () => { + if (!(await loadDartOrSkip())) return; + const nodes = parseAndFindNodes(parser, 'void f() { var u = svc.getUser(); }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + const result = typeConfig.scanConstructorBinding!(nodes[0]); + expect(result?.varName).toBe('u'); + expect(result?.calleeName).toBe('getUser'); + }); + + it('returns undefined for non-call assignment', async () => { + if (!(await loadDartOrSkip())) return; + const nodes = parseAndFindNodes(parser, 'void f() { var x = y; }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + const result = typeConfig.scanConstructorBinding!(nodes[0]); + expect(result).toBeUndefined(); + }); + }); + + describe('virtual dispatch detection', () => { + it('detects constructor type for virtual dispatch', async () => { + if (!(await loadDartOrSkip())) return; + const classNames = new Set(['Dog']); + const nodes = parseAndFindNodes(parser, 'void f() { var d = Dog(); }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + const result = typeConfig.detectConstructorType!(nodes[0], classNames); + expect(result).toBe('Dog'); + }); + }); + + describe('literal type inference', () => { + it('infers int literal', async () => { + if (!(await loadDartOrSkip())) return; + const tree = parser.parse('void f() { var x = 42; }'); + const nodes: any[] = []; + function walk(n: any) { if (n.type.includes('integer_literal')) nodes.push(n); for (let i = 0; i < n.namedChildCount; i++) walk(n.namedChild(i)); } + walk(tree.rootNode); + if (nodes.length > 0) { + expect(typeConfig.inferLiteralType!(nodes[0])).toBe('int'); + } + }); + + it('infers string literal', async () => { + if (!(await loadDartOrSkip())) return; + const tree = parser.parse('void f() { var x = "hello"; }'); + const nodes: any[] = []; + function walk(n: any) { if (n.type === 'string_literal') nodes.push(n); for (let i = 0; i < n.namedChildCount; i++) walk(n.namedChild(i)); } + walk(tree.rootNode); + if (nodes.length > 0) { + expect(typeConfig.inferLiteralType!(nodes[0])).toBe('String'); + } + }); + + it('infers bool literal', async () => { + if (!(await loadDartOrSkip())) return; + const tree = parser.parse('void f() { var x = true; }'); + const nodes: any[] = []; + function walk(n: any) { if (n.type === 'true' || n.type === 'false') nodes.push(n); for (let i = 0; i < n.childCount; i++) { const c = n.child(i); if (c) walk(c); } } + walk(tree.rootNode); + if (nodes.length > 0) { + expect(typeConfig.inferLiteralType!(nodes[0])).toBe('bool'); + } + }); + }); + + describe('Tier 2: pending assignment extraction', () => { + it('extracts copy assignment', async () => { + if (!(await loadDartOrSkip())) return; + const nodes = parseAndFindNodes(parser, 'void f() { var copy = original; }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + const result = typeConfig.extractPendingAssignment!(nodes[0], new Map()); + expect(result).toEqual({ kind: 'copy', lhs: 'copy', rhs: 'original' }); + }); + + it('extracts field access', async () => { + if (!(await loadDartOrSkip())) return; + const nodes = parseAndFindNodes(parser, 'void f() { var n = user.name; }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + const result = typeConfig.extractPendingAssignment!(nodes[0], new Map()); + expect(result).toEqual({ kind: 'fieldAccess', lhs: 'n', receiver: 'user', field: 'name' }); + }); + + it('extracts call result', async () => { + if (!(await loadDartOrSkip())) return; + const nodes = parseAndFindNodes(parser, 'void f() { var u = getUser(); }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + const result = typeConfig.extractPendingAssignment!(nodes[0], new Map()); + expect(result).toEqual({ kind: 'callResult', lhs: 'u', callee: 'getUser' }); + }); + + it('extracts method call result', async () => { + if (!(await loadDartOrSkip())) return; + const nodes = parseAndFindNodes(parser, 'void f() { var r = svc.fetch(); }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + const result = typeConfig.extractPendingAssignment!(nodes[0], new Map()); + expect(result).toEqual({ kind: 'methodCallResult', lhs: 'r', receiver: 'svc', method: 'fetch' }); + }); + + it('skips when lhs already in scope', async () => { + if (!(await loadDartOrSkip())) return; + const scope = new Map([['x', 'String']]); + const nodes = parseAndFindNodes(parser, 'void f() { var x = y; }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + const result = typeConfig.extractPendingAssignment!(nodes[0], scope); + expect(result).toBeUndefined(); + }); + + it('extracts call result from await expression', async () => { + if (!(await loadDartOrSkip())) return; + const nodes = parseAndFindNodes(parser, 'void f() async { var user = await getUser(); }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + const result = typeConfig.extractPendingAssignment!(nodes[0], new Map()); + expect(result).toEqual({ kind: 'callResult', lhs: 'user', callee: 'getUser' }); + }); + + it('extracts method call result from await expression', async () => { + if (!(await loadDartOrSkip())) return; + const nodes = parseAndFindNodes(parser, 'void f() async { var user = await svc.fetch(); }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + const result = typeConfig.extractPendingAssignment!(nodes[0], new Map()); + expect(result).toEqual({ kind: 'methodCallResult', lhs: 'user', receiver: 'svc', method: 'fetch' }); + }); + }); + + describe('for-loop element type resolution', () => { + it('extracts type from explicit for-loop annotation', async () => { + if (!(await loadDartOrSkip())) return; + const nodes = parseAndFindNodes(parser, 'void f(List users) { for (User u in users) {} }', 'for_statement'); + expect(nodes.length).toBeGreaterThan(0); + const scopeEnv = new Map(); + const ctx = { + scopeEnv, + declarationTypeNodes: new Map(), + scope: 'test@0', + returnTypeLookup: { + lookupReturnType: () => undefined, + lookupRawReturnType: () => undefined, + }, + }; + typeConfig.extractForLoopBinding!(nodes[0], ctx); + expect(scopeEnv.get('u')).toBe('User'); + }); + + it('infers element type from call iterable return type', async () => { + if (!(await loadDartOrSkip())) return; + const nodes = parseAndFindNodes(parser, 'void f() { for (var u in getUsers()) {} }', 'for_statement'); + expect(nodes.length).toBeGreaterThan(0); + const scopeEnv = new Map(); + const ctx = { + scopeEnv, + declarationTypeNodes: new Map(), + scope: 'test@0', + returnTypeLookup: { + lookupReturnType: (name: string) => name === 'getUsers' ? 'User' : undefined, + lookupRawReturnType: (name: string) => name === 'getUsers' ? 'List' : undefined, + }, + }; + typeConfig.extractForLoopBinding!(nodes[0], ctx); + expect(scopeEnv.get('u')).toBe('User'); + }); + + it('skips non-for_statement nodes', async () => { + if (!(await loadDartOrSkip())) return; + const nodes = parseAndFindNodes(parser, 'void f() { var x = 1; }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + const scopeEnv = new Map(); + const ctx = { + scopeEnv, + declarationTypeNodes: new Map(), + scope: 'test@0', + returnTypeLookup: { lookupReturnType: () => undefined, lookupRawReturnType: () => undefined }, + }; + typeConfig.extractForLoopBinding!(nodes[0], ctx); + expect(scopeEnv.size).toBe(0); + }); + }); + + describe('virtual dispatch — named constructor', () => { + it('detects constructor type for named constructor', async () => { + if (!(await loadDartOrSkip())) return; + const classNames = new Set(['Dog']); + const nodes = parseAndFindNodes(parser, 'void f() { var d = Dog.unknown(); }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + const result = typeConfig.detectConstructorType!(nodes[0], classNames); + expect(result).toBe('Dog'); + }); + + it('returns undefined when callee is not a known class', async () => { + if (!(await loadDartOrSkip())) return; + const classNames = new Set(); + const nodes = parseAndFindNodes(parser, 'void f() { var x = getUser(); }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + const result = typeConfig.detectConstructorType!(nodes[0], classNames); + expect(result).toBeUndefined(); + }); + }); + + describe('literal type inference — full coverage', () => { + it('infers double literal', async () => { + if (!(await loadDartOrSkip())) return; + const tree = parser.parse('void f() { var x = 3.14; }'); + const nodes: any[] = []; + function walk(n: any) { if (n.type.includes('floating_point')) nodes.push(n); for (let i = 0; i < n.namedChildCount; i++) walk(n.namedChild(i)); } + walk(tree.rootNode); + if (nodes.length > 0) expect(typeConfig.inferLiteralType!(nodes[0])).toBe('double'); + }); + + it('infers false literal', async () => { + if (!(await loadDartOrSkip())) return; + const tree = parser.parse('void f() { var x = false; }'); + const nodes: any[] = []; + function walk(n: any) { if (n.type === 'false') nodes.push(n); for (let i = 0; i < n.childCount; i++) { const c = n.child(i); if (c) walk(c); } } + walk(tree.rootNode); + if (nodes.length > 0) expect(typeConfig.inferLiteralType!(nodes[0])).toBe('bool'); + }); + + it('infers null literal', async () => { + if (!(await loadDartOrSkip())) return; + const tree = parser.parse('void f() { var x = null; }'); + const nodes: any[] = []; + function walk(n: any) { if (n.type === 'null_literal') nodes.push(n); for (let i = 0; i < n.namedChildCount; i++) walk(n.namedChild(i)); } + walk(tree.rootNode); + if (nodes.length > 0) expect(typeConfig.inferLiteralType!(nodes[0])).toBe('null'); + }); + + it('returns undefined for unknown node type', async () => { + if (!(await loadDartOrSkip())) return; + expect(typeConfig.inferLiteralType!({ type: 'identifier' } as any)).toBeUndefined(); + }); + }); + + describe('generic and const declarations', () => { + it('extracts outer type from generic declaration', async () => { + if (!(await loadDartOrSkip())) return; + const env = new Map(); + const nodes = parseAndFindNodes(parser, 'void f() { List names = []; }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + typeConfig.extractDeclaration(nodes[0], env); + expect(env.get('names')).toBe('List'); + }); + + it('infers type from generic constructor call', async () => { + if (!(await loadDartOrSkip())) return; + const env = new Map(); + const classNames = new Set(['Repository']); + const nodes = parseAndFindNodes(parser, 'void f() { var repo = Repository(); }', 'initialized_variable_definition'); + expect(nodes.length).toBeGreaterThan(0); + typeConfig.extractInitializer!(nodes[0], env, classNames); + expect(env.get('repo')).toBe('Repository'); + }); + + it('infers type from const constructor call', async () => { + if (!(await loadDartOrSkip())) return; + const env = new Map(); + const classNames = new Set(['Config']); + const nodes = parseAndFindNodes(parser, 'void f() { const config = Config(); }', 'initialized_variable_definition'); + if (nodes.length > 0) { + typeConfig.extractInitializer!(nodes[0], env, classNames); + expect(env.get('config')).toBe('Config'); + } + }); + }); +});